morfeo 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,439 @@
1
+ // Personajes 2D planos (estilo de las series ilustradas): cuerpo, cara con expresiones, poses, parpadeo,
2
+ // respiración y boca que se mueve cuando hablan. Van DENTRO de un <svg>; el origen (0, 0) son los pies.
3
+ //
4
+ // <Persona2D x={700} y={1500} alto={520} t={t} pelo="#2B1B12" ropa="#2F6FE0" expresion="feliz" pose="señalar" />
5
+ // <Persona2D ... estados={[{en: cuando('miedo'), expresion: 'miedo'}, {en: cuando('corre'), pose: 'caminar'}]} />
6
+ // <Persona2D ... habla={[[cuando('hola'), cuando('fin')]]} /> (la boca se mueve en esos intervalos)
7
+ // <Persona2D ... silueta="#141A33" /> (figura negra, sin cara: multitudes, misterio)
8
+ //
9
+ // Todo depende de `t` (segundos): nada se anima solo.
10
+ import React from 'react';
11
+ import {azarN} from './ilustrado';
12
+
13
+ export type Expresion = 'neutral' | 'feliz' | 'triste' | 'enojado' | 'sorpresa' | 'miedo' | 'dormido' | 'pensativo';
14
+ export type Pose = 'parado' | 'caminar' | 'señalar' | 'brazosArriba' | 'sentado' | 'pensar' | 'cruzado';
15
+ export type Peinado = 'corto' | 'largo' | 'chongo' | 'rizado' | 'calvo' | 'cresta';
16
+ export type Accesorio = 'lentes' | 'sombrero' | 'gorra' | 'casco' | 'audifonos' | 'moño';
17
+
18
+ export const PIELES = ['#F6D2B4', '#E8B48F', '#C98E66', '#A86B45', '#7A4A2E', '#5A341F'];
19
+ export const PELOS = ['#2B1B12', '#4A2C17', '#7A4A22', '#C98A3A', '#E6C36B', '#9A9A9A', '#1A1A1A', '#B5452E'];
20
+ export const ROPAS = ['#2F6FE0', '#E04A4A', '#2FA36B', '#F2B632', '#8B5BD6', '#F07A3A', '#3A3F4B', '#E86AA6', '#25A8C9'];
21
+
22
+ export type Persona2DProps = {
23
+ x?: number;
24
+ y?: number;
25
+ /** Alto total en px (de los pies a la coronilla). */
26
+ alto?: number;
27
+ t?: number;
28
+ piel?: string;
29
+ pelo?: string;
30
+ peinado?: Peinado;
31
+ ropa?: string;
32
+ pantalon?: string;
33
+ zapatos?: string;
34
+ complexion?: 'delgada' | 'normal' | 'ancha';
35
+ accesorio?: Accesorio | null;
36
+ colorAccesorio?: string;
37
+ expresion?: Expresion;
38
+ pose?: Pose;
39
+ /** Cambios en el tiempo: [{en: segundos, expresion?, pose?}] (usa `cuando('palabra')` para calcular `en`). */
40
+ estados?: {en: number; expresion?: Expresion; pose?: Pose}[];
41
+ /** Intervalos [inicio, fin] en segundos en que habla (la boca se mueve). */
42
+ habla?: [number, number][];
43
+ /** Hacia dónde mira: -1 izquierda … 1 derecha. */
44
+ mirar?: number;
45
+ /** Voltear horizontalmente (mira hacia el otro lado). */
46
+ voltear?: boolean;
47
+ /** Toda la figura de un solo color, sin cara. */
48
+ silueta?: string;
49
+ /** Varía el parpadeo y el balanceo entre personajes. */
50
+ semilla?: number;
51
+ /** Rubor en las mejillas. */
52
+ rubor?: boolean;
53
+ };
54
+
55
+ const clamp01 = (x: number) => Math.max(0, Math.min(1, x));
56
+ const RAD = Math.PI / 180;
57
+
58
+ /** Ángulos de brazos y piernas por pose (grados). Brazo: [hombro hacia afuera, codo hacia adentro]. */
59
+ function poseDe(pose: Pose, t: number, fase: number) {
60
+ const vaiven = Math.sin(t * 1.6 + fase);
61
+ switch (pose) {
62
+ case 'caminar': {
63
+ const p = Math.sin(t * 7.5 + fase);
64
+ return {bi: [8 - p * 22, 10], bd: [8 + p * 22, 10], pi: p * 20, pd: -p * 20, sube: Math.abs(Math.cos(t * 7.5 + fase)) * 8, sentado: false};
65
+ }
66
+ case 'señalar':
67
+ return {bi: [8 + vaiven * 2, 6], bd: [100 + Math.sin(t * 3 + fase) * 4, 0], pi: 0, pd: 0, sube: 0, sentado: false};
68
+ case 'brazosArriba':
69
+ return {bi: [155 + Math.sin(t * 6 + fase) * 10, -10], bd: [155 + Math.sin(t * 6 + fase + 1.2) * 10, -10], pi: 0, pd: 0, sube: Math.abs(Math.sin(t * 6 + fase)) * 10, sentado: false};
70
+ case 'pensar':
71
+ return {bi: [20, 95], bd: [25, 150], pi: 0, pd: 0, sube: 0, sentado: false};
72
+ case 'cruzado':
73
+ return {bi: [22, 120], bd: [22, 120], pi: 0, pd: 0, sube: 0, sentado: false};
74
+ case 'sentado':
75
+ return {bi: [18, 55], bd: [18, 55], pi: 0, pd: 0, sube: 0, sentado: true};
76
+ default:
77
+ return {bi: [7 + vaiven * 2, 8], bd: [7 - vaiven * 2, 8], pi: 0, pd: 0, sube: 0, sentado: false};
78
+ }
79
+ }
80
+
81
+ /** Brazo de dos tramos con la mano al final. `lado` 1 = derecho (x positivo), -1 = izquierdo. */
82
+ const Brazo: React.FC<{lado: 1 | -1; hombro: number; codo: number; ancho: number; largo: number; ropa: string; piel: string}> = ({lado, hombro, codo, ancho, largo, ropa, piel}) => {
83
+ const a1 = -lado * hombro;
84
+ const a2 = lado * codo;
85
+ const l = largo / 2;
86
+ return (
87
+ <g transform={`rotate(${a1})`}>
88
+ <rect x={-ancho / 2} y={-ancho / 3} width={ancho} height={l + ancho / 3} rx={ancho / 2} fill={ropa} />
89
+ <g transform={`translate(0 ${l}) rotate(${a2})`}>
90
+ <rect x={-ancho / 2 + 2} y={-ancho / 3} width={ancho - 4} height={l + ancho / 3} rx={ancho / 2} fill={ropa} />
91
+ <circle cx={0} cy={l + 4} r={ancho * 0.55} fill={piel} />
92
+ </g>
93
+ </g>
94
+ );
95
+ };
96
+
97
+ /** Ojos, cejas, boca y mejillas según la expresión. */
98
+ const Cara: React.FC<{expresion: Expresion; t: number; habla: boolean; parpadea: boolean; mirar: number; rubor: boolean; enojo: number}> = ({expresion, t, habla, parpadea, mirar, rubor, enojo}) => {
99
+ const ex = 24;
100
+ const ey = -6;
101
+ const cerrados = expresion === 'dormido' || parpadea;
102
+ const px = mirar * 4.5;
103
+ const pupila = expresion === 'sorpresa' || expresion === 'miedo' ? 4.2 : 6.2;
104
+ // cejas: [inclinación, altura]
105
+ const ceja: Record<Expresion, [number, number]> = {neutral: [0, 0], feliz: [-6, -2], triste: [16, 2], enojado: [-24, 4], sorpresa: [0, -8], miedo: [18, -5], dormido: [0, 3], pensativo: [8, -3]};
106
+ const [inc, alt] = ceja[expresion];
107
+ const abre = habla ? 0.25 + 0.75 * Math.abs(Math.sin(t * 13)) * (0.6 + 0.4 * Math.abs(Math.sin(t * 5.3))) : 0;
108
+ let boca: React.ReactNode;
109
+ if (abre > 0.05 && expresion !== 'dormido') {
110
+ boca = <ellipse cx={0} cy={30} rx={11} ry={3 + abre * 10} fill="#5A1E1E" />;
111
+ } else {
112
+ switch (expresion) {
113
+ case 'feliz':
114
+ boca = <path d="M -14 24 Q 0 40 14 24 Z" fill="#5A1E1E" />;
115
+ break;
116
+ case 'triste':
117
+ boca = <path d="M -12 34 Q 0 24 12 34" stroke="#3A1A12" strokeWidth={4} fill="none" strokeLinecap="round" />;
118
+ break;
119
+ case 'enojado':
120
+ boca = <path d="M -13 32 Q 0 26 13 32" stroke="#3A1A12" strokeWidth={5} fill="none" strokeLinecap="round" />;
121
+ break;
122
+ case 'sorpresa':
123
+ boca = <ellipse cx={0} cy={31} rx={8} ry={10} fill="#5A1E1E" />;
124
+ break;
125
+ case 'miedo':
126
+ boca = <path d="M -14 32 q 3.5 -5 7 0 t 7 0 t 7 0 t 7 0" stroke="#3A1A12" strokeWidth={3.5} fill="none" strokeLinecap="round" />;
127
+ break;
128
+ case 'dormido':
129
+ boca = <ellipse cx={0} cy={30} rx={5} ry={4} fill="#5A1E1E" opacity={0.8} />;
130
+ break;
131
+ case 'pensativo':
132
+ boca = <path d="M -6 30 L 12 27" stroke="#3A1A12" strokeWidth={4} strokeLinecap="round" />;
133
+ break;
134
+ default:
135
+ boca = <path d="M -10 29 Q 0 34 10 29" stroke="#3A1A12" strokeWidth={4} fill="none" strokeLinecap="round" />;
136
+ }
137
+ }
138
+ return (
139
+ <g>
140
+ {[-1, 1].map((l) =>
141
+ cerrados ? (
142
+ <path key={l} d={`M ${l * ex - 11} ${ey} Q ${l * ex} ${ey + (expresion === 'feliz' ? -7 : 6)} ${l * ex + 11} ${ey}`} stroke="#2A1A12" strokeWidth={4} fill="none" strokeLinecap="round" />
143
+ ) : (
144
+ <g key={l}>
145
+ <ellipse cx={l * ex} cy={ey} rx={12.5} ry={expresion === 'sorpresa' || expresion === 'miedo' ? 15 : 13.5} fill="#fff" />
146
+ <circle cx={l * ex + px} cy={ey + 1} r={pupila} fill="#1E1410" />
147
+ <circle cx={l * ex + px + 2} cy={ey - 2} r={1.8} fill="#fff" />
148
+ </g>
149
+ ),
150
+ )}
151
+ {[-1, 1].map((l) => (
152
+ <path key={`c${l}`} d={`M ${l * ex - 12} ${ey - 20 + alt} L ${l * ex + 12} ${ey - 20 + alt}`} transform={`rotate(${l * inc} ${l * ex} ${ey - 20 + alt})`} stroke="#2A1A12" strokeWidth={5} strokeLinecap="round" />
153
+ ))}
154
+ {boca}
155
+ {rubor || expresion === 'feliz' || enojo > 0 ? (
156
+ <>
157
+ <ellipse cx={-36} cy={16} rx={10} ry={6} fill={enojo > 0 ? `rgba(225,60,40,${0.35 + 0.35 * enojo})` : 'rgba(235,120,110,0.35)'} />
158
+ <ellipse cx={36} cy={16} rx={10} ry={6} fill={enojo > 0 ? `rgba(225,60,40,${0.35 + 0.35 * enojo})` : 'rgba(235,120,110,0.35)'} />
159
+ </>
160
+ ) : null}
161
+ {expresion === 'miedo' || expresion === 'triste' ? <path d="M 50 -24 q -6 10 0 14 q 6 -4 0 -14 Z" fill="#7FC8FF" opacity={0.9} /> : null}
162
+ </g>
163
+ );
164
+ };
165
+
166
+ const Pelo: React.FC<{peinado: Peinado; color: string; r: number; capa: 'atras' | 'frente'}> = ({peinado, color, r, capa}) => {
167
+ if (peinado === 'calvo') return null;
168
+ if (capa === 'atras') {
169
+ if (peinado === 'largo') return <path d={`M ${-r - 6} -6 Q ${-r - 10} ${r * 1.6} ${-r * 0.4} ${r * 1.7} L ${r * 0.4} ${r * 1.7} Q ${r + 10} ${r * 1.6} ${r + 6} -6 Z`} fill={color} />;
170
+ if (peinado === 'chongo') return <circle cx={0} cy={-r - 14} r={r * 0.42} fill={color} />;
171
+ return null;
172
+ }
173
+ switch (peinado) {
174
+ case 'rizado':
175
+ return (
176
+ <g fill={color}>
177
+ {Array.from({length: 9}, (_, i) => {
178
+ const a = Math.PI + (i / 8) * Math.PI;
179
+ return <circle key={i} cx={Math.cos(a) * r * 0.92} cy={Math.sin(a) * r * 0.92 - 4} r={r * 0.3} />;
180
+ })}
181
+ </g>
182
+ );
183
+ case 'cresta':
184
+ return <path d={`M ${-r * 0.3} ${-r * 0.75} L 0 ${-r * 1.45} L ${r * 0.3} ${-r * 0.75} Z`} fill={color} />;
185
+ case 'largo':
186
+ return <path d={`M ${-r - 4} 0 Q ${-r} ${-r * 1.15} 0 ${-r * 1.08} Q ${r} ${-r * 1.15} ${r + 4} 0 Q ${r * 0.5} ${-r * 0.55} ${-r * 0.1} ${-r * 0.62} Q ${-r * 0.6} ${-r * 0.5} ${-r - 4} 0 Z`} fill={color} />;
187
+ default:
188
+ return <path d={`M ${-r - 3} -4 Q ${-r - 2} ${-r * 1.12} 0 ${-r * 1.06} Q ${r + 2} ${-r * 1.12} ${r + 3} -4 Q ${r * 0.7} ${-r * 0.6} ${r * 0.1} ${-r * 0.68} Q ${-r * 0.5} ${-r * 0.62} ${-r - 3} -4 Z`} fill={color} />;
189
+ }
190
+ };
191
+
192
+ const Accesorio: React.FC<{tipo: Accesorio; color?: string; r: number}> = ({tipo, color, r}) => {
193
+ switch (tipo) {
194
+ case 'lentes':
195
+ return (
196
+ <g stroke={color ?? '#1A1A1A'} strokeWidth={4.5} fill="rgba(255,255,255,0.12)">
197
+ <circle cx={-24} cy={-6} r={17} />
198
+ <circle cx={24} cy={-6} r={17} />
199
+ <path d="M -7 -8 Q 0 -13 7 -8" fill="none" />
200
+ </g>
201
+ );
202
+ case 'sombrero':
203
+ return (
204
+ <g fill={color ?? '#E3C27A'}>
205
+ <ellipse cx={0} cy={-r * 0.72} rx={r * 1.75} ry={r * 0.32} />
206
+ <path d={`M ${-r * 0.85} ${-r * 0.75} Q ${-r * 0.8} ${-r * 1.65} 0 ${-r * 1.6} Q ${r * 0.8} ${-r * 1.65} ${r * 0.85} ${-r * 0.75} Z`} />
207
+ <rect x={-r * 0.86} y={-r * 0.98} width={r * 1.72} height={r * 0.16} fill="#8A3B2A" />
208
+ </g>
209
+ );
210
+ case 'gorra':
211
+ return (
212
+ <g fill={color ?? '#E04A4A'}>
213
+ <path d={`M ${-r} ${-r * 0.45} Q ${-r} ${-r * 1.2} 0 ${-r * 1.18} Q ${r} ${-r * 1.2} ${r} ${-r * 0.45} Z`} />
214
+ <ellipse cx={r * 0.55} cy={-r * 0.45} rx={r * 0.85} ry={r * 0.16} />
215
+ </g>
216
+ );
217
+ case 'casco':
218
+ return (
219
+ <g fill={color ?? '#F5C518'}>
220
+ <path d={`M ${-r * 1.02} ${-r * 0.42} Q ${-r * 1.02} ${-r * 1.25} 0 ${-r * 1.22} Q ${r * 1.02} ${-r * 1.25} ${r * 1.02} ${-r * 0.42} Z`} />
221
+ <rect x={-r * 1.18} y={-r * 0.5} width={r * 2.36} height={r * 0.16} rx={r * 0.08} />
222
+ <rect x={-r * 0.1} y={-r * 1.22} width={r * 0.2} height={r * 0.72} fill="rgba(0,0,0,0.12)" />
223
+ </g>
224
+ );
225
+ case 'audifonos':
226
+ return (
227
+ <g fill={color ?? '#2A2A33'}>
228
+ <path d={`M ${-r * 1.05} 0 Q ${-r * 1.1} ${-r * 1.35} 0 ${-r * 1.32} Q ${r * 1.1} ${-r * 1.35} ${r * 1.05} 0`} stroke={color ?? '#2A2A33'} strokeWidth={9} fill="none" />
229
+ <rect x={-r * 1.2} y={-r * 0.25} width={r * 0.34} height={r * 0.6} rx={r * 0.12} />
230
+ <rect x={r * 0.86} y={-r * 0.25} width={r * 0.34} height={r * 0.6} rx={r * 0.12} />
231
+ </g>
232
+ );
233
+ case 'moño':
234
+ return (
235
+ <g fill={color ?? '#E86AA6'} transform={`translate(${r * 0.55} ${-r * 0.92})`}>
236
+ <path d="M 0 0 L -22 -14 L -22 14 Z" />
237
+ <path d="M 0 0 L 22 -14 L 22 14 Z" />
238
+ <circle r={7} />
239
+ </g>
240
+ );
241
+ }
242
+ return null;
243
+ };
244
+
245
+ /** Un personaje plano de cuerpo completo. Los pies quedan en (x, y). */
246
+ export const Persona2D: React.FC<Persona2DProps> = ({
247
+ x = 0,
248
+ y = 0,
249
+ alto = 480,
250
+ t = 0,
251
+ piel = PIELES[1],
252
+ pelo = PELOS[0],
253
+ peinado = 'corto',
254
+ ropa = ROPAS[0],
255
+ pantalon = '#2E3A59',
256
+ zapatos = '#2A2420',
257
+ complexion = 'normal',
258
+ accesorio = null,
259
+ colorAccesorio,
260
+ expresion: exp0 = 'neutral',
261
+ pose: pose0 = 'parado',
262
+ estados = [],
263
+ habla = [],
264
+ mirar = 0,
265
+ voltear = false,
266
+ silueta,
267
+ semilla = 0,
268
+ rubor = false,
269
+ }) => {
270
+ // estado actual y desde cuándo (para el pequeño rebote al cambiar)
271
+ let expresion = exp0;
272
+ let pose = pose0;
273
+ let desde = -99;
274
+ for (const e of [...estados].sort((a, b) => a.en - b.en)) {
275
+ if (t >= e.en) {
276
+ if (e.expresion) expresion = e.expresion;
277
+ if (e.pose) pose = e.pose;
278
+ desde = e.en;
279
+ }
280
+ }
281
+ const fase = azarN(semilla + 3) * 6.28;
282
+ const P = poseDe(pose, t, fase);
283
+ const k = alto / 400; // el cuerpo se dibuja en 400 unidades de alto
284
+ const ancho = complexion === 'ancha' ? 1.3 : complexion === 'delgada' ? 0.82 : 1;
285
+ const respira = 1 + 0.014 * Math.sin(t * 2.4 + fase);
286
+ const rebote = desde > -99 ? 1 + 0.06 * Math.exp(-(t - desde) * 9) * Math.cos((t - desde) * 26) : 1;
287
+ const periodo = 3.1 + azarN(semilla + 7) * 1.6;
288
+ const parpadea = (t + azarN(semilla + 11) * periodo) % periodo < 0.13;
289
+ const hablando = habla.some(([a, b]) => t >= a && t <= b);
290
+ const enojo = expresion === 'enojado' ? clamp01((t - desde) / 0.6) : 0;
291
+ const temblor = expresion === 'miedo' ? Math.sin(t * 40) * 1.6 : 0;
292
+ const cadera = P.sentado ? 70 : 112;
293
+ const torsoAlto = 140;
294
+ const hombroY = -(cadera + torsoAlto - 18);
295
+ const cabezaR = 64;
296
+ const cabezaY = -(cadera + torsoAlto + cabezaR - 6);
297
+ const tw = 118 * ancho;
298
+ const c = (col: string) => silueta ?? col;
299
+ return (
300
+ <g transform={`translate(${x + temblor} ${y}) scale(${(voltear ? -1 : 1) * k * rebote} ${k * rebote})`}>
301
+ {/* sombra de contacto */}
302
+ <ellipse cx={0} cy={4} rx={tw * 0.7} ry={12} fill="rgba(0,0,0,0.22)" />
303
+ <g transform={`translate(0 ${-P.sube})`}>
304
+ {/* piernas */}
305
+ {P.sentado ? (
306
+ <g fill={c(pantalon)}>
307
+ <rect x={-tw * 0.42} y={-cadera} width={tw * 0.36} height={cadera} rx={16} />
308
+ <rect x={tw * 0.06} y={-cadera} width={tw * 0.36} height={cadera} rx={16} />
309
+ <ellipse cx={-tw * 0.24} cy={-4} rx={26} ry={12} fill={c(zapatos)} />
310
+ <ellipse cx={tw * 0.24} cy={-4} rx={26} ry={12} fill={c(zapatos)} />
311
+ </g>
312
+ ) : (
313
+ [-1, 1].map((l) => (
314
+ <g key={l} transform={`translate(${l * tw * 0.22} ${-cadera}) rotate(${l < 0 ? P.pi : P.pd})`}>
315
+ <rect x={-tw * 0.17} y={0} width={tw * 0.34} height={cadera - 6} rx={14} fill={c(pantalon)} />
316
+ <ellipse cx={l * 6} cy={cadera - 6} rx={26} ry={12} fill={c(zapatos)} />
317
+ </g>
318
+ ))
319
+ )}
320
+ {/* brazo de atrás (izquierdo) */}
321
+ <g transform={`translate(${-tw / 2 + 6} ${hombroY})`}>
322
+ <Brazo lado={-1} hombro={P.bi[0]} codo={P.bi[1]} ancho={30 * Math.min(1.15, ancho)} largo={150} ropa={c(ropa)} piel={c(piel)} />
323
+ </g>
324
+ {/* torso */}
325
+ <g transform={`translate(0 ${-cadera}) scale(1 ${respira})`}>
326
+ <rect x={-tw / 2} y={-torsoAlto} width={tw} height={torsoAlto + 8} rx={tw * 0.32} fill={c(ropa)} />
327
+ {silueta ? null : <path d={`M ${-tw * 0.18} ${-torsoAlto + 2} Q 0 ${-torsoAlto + 26} ${tw * 0.18} ${-torsoAlto + 2}`} fill={piel} />}
328
+ </g>
329
+ {/* brazo de enfrente (derecho) */}
330
+ <g transform={`translate(${tw / 2 - 6} ${hombroY})`}>
331
+ <Brazo lado={1} hombro={P.bd[0]} codo={P.bd[1]} ancho={30 * Math.min(1.15, ancho)} largo={150} ropa={c(ropa)} piel={c(piel)} />
332
+ </g>
333
+ {/* cabeza */}
334
+ <g transform={`translate(0 ${cabezaY + (expresion === 'triste' ? 4 : 0)}) rotate(${pose === 'pensar' ? -6 : Math.sin(t * 1.3 + fase) * 1.5})`}>
335
+ {silueta ? null : <Pelo peinado={peinado} color={pelo} r={cabezaR} capa="atras" />}
336
+ <rect x={-14} y={cabezaR - 14} width={28} height={24} fill={c(piel)} />
337
+ <circle r={cabezaR} fill={c(piel)} />
338
+ {silueta ? null : (
339
+ <>
340
+ <ellipse cx={-cabezaR + 2} cy={4} rx={9} ry={13} fill={piel} />
341
+ <ellipse cx={cabezaR - 2} cy={4} rx={9} ry={13} fill={piel} />
342
+ <Cara expresion={expresion} t={t} habla={hablando} parpadea={parpadea} mirar={mirar} rubor={rubor} enojo={enojo} />
343
+ <Pelo peinado={peinado} color={pelo} r={cabezaR} capa="frente" />
344
+ {accesorio ? <Accesorio tipo={accesorio} color={colorAccesorio} r={cabezaR} /> : null}
345
+ </>
346
+ )}
347
+ </g>
348
+ </g>
349
+ </g>
350
+ );
351
+ };
352
+
353
+ /** Varias personas en filas (público, fila, pueblo): colores y alturas variados, de atrás hacia adelante. */
354
+ export const Multitud: React.FC<{
355
+ x?: number;
356
+ y?: number;
357
+ ancho?: number;
358
+ filas?: number;
359
+ porFila?: number;
360
+ alto?: number;
361
+ t?: number;
362
+ pose?: Pose;
363
+ expresion?: Expresion;
364
+ silueta?: string;
365
+ semilla?: number;
366
+ }> = ({x = 0, y = 0, ancho = 1000, filas = 2, porFila = 6, alto = 300, t = 0, pose = 'parado', expresion = 'neutral', silueta, semilla = 0}) => (
367
+ <g>
368
+ {Array.from({length: filas}, (_, f) => {
369
+ const fila = filas - 1 - f; // la de atrás primero
370
+ const escala = 1 - fila * 0.14;
371
+ return Array.from({length: porFila}, (_, i) => {
372
+ const s = semilla + fila * 31 + i * 7;
373
+ const px = x - ancho / 2 + ((i + 0.5 + (fila % 2) * 0.5) / porFila) * ancho + (azarN(s) - 0.5) * 30;
374
+ return (
375
+ <Persona2D
376
+ key={`${fila}-${i}`}
377
+ x={px}
378
+ y={y - fila * alto * 0.22}
379
+ alto={alto * escala * (0.88 + azarN(s + 1) * 0.24)}
380
+ t={t}
381
+ semilla={s}
382
+ piel={PIELES[Math.floor(azarN(s + 2) * PIELES.length)]}
383
+ pelo={PELOS[Math.floor(azarN(s + 3) * PELOS.length)]}
384
+ peinado={(['corto', 'largo', 'chongo', 'rizado', 'calvo'] as Peinado[])[Math.floor(azarN(s + 4) * 5)]}
385
+ ropa={ROPAS[Math.floor(azarN(s + 5) * ROPAS.length)]}
386
+ pose={pose}
387
+ expresion={expresion}
388
+ silueta={silueta ? (fila > 0 ? silueta : silueta) : undefined}
389
+ mirar={(azarN(s + 6) - 0.5) * 1.4}
390
+ />
391
+ );
392
+ });
393
+ })}
394
+ </g>
395
+ );
396
+
397
+ /**
398
+ * Globo de diálogo. (x, y) es la punta de la cola (la boca de quien habla); el globo queda arriba.
399
+ * `pensar` lo dibuja como nube de pensamiento. Aparece en `en` y se va en `hasta` (segundos).
400
+ */
401
+ export const Globo: React.FC<{x: number; y: number; texto: string; ancho?: number; tam?: number; lado?: 'izq' | 'der'; pensar?: boolean; t?: number; en?: number; hasta?: number; fondo?: string; color?: string}> = ({
402
+ x,
403
+ y,
404
+ texto,
405
+ ancho = 420,
406
+ tam = 34,
407
+ lado = 'der',
408
+ pensar = false,
409
+ t = 1,
410
+ en = 0,
411
+ hasta = Infinity,
412
+ fondo = '#FFFFFF',
413
+ color = '#141414',
414
+ }) => {
415
+ // entra en `en` y se va en `hasta` (se encoge en 0.25 s)
416
+ const k = Math.min(clamp01((t - en) / 0.3), clamp01((hasta - t) / 0.25));
417
+ if (k <= 0) return null;
418
+ const lineas = Math.max(1, Math.ceil((texto.length * tam * 0.55) / (ancho - 50)));
419
+ const alto = lineas * tam * 1.25 + 44;
420
+ const bx = lado === 'der' ? x - 40 : x - ancho + 40;
421
+ const by = y - 46 - alto;
422
+ const s = 0.6 + 0.4 * (1 - Math.pow(1 - k, 3)) + Math.sin(k * Math.PI) * 0.06;
423
+ return (
424
+ <g transform={`translate(${x} ${y}) scale(${s}) translate(${-x} ${-y})`} opacity={clamp01(k * 3)}>
425
+ {pensar ? (
426
+ <>
427
+ <circle cx={x} cy={y - 10} r={8} fill={fondo} />
428
+ <circle cx={x + (lado === 'der' ? 10 : -10)} cy={y - 32} r={13} fill={fondo} />
429
+ </>
430
+ ) : (
431
+ <path d={`M ${x} ${y} L ${x + (lado === 'der' ? 10 : -10)} ${by + alto - 4} L ${x + (lado === 'der' ? 46 : -46)} ${by + alto - 4} Z`} fill={fondo} />
432
+ )}
433
+ <rect x={bx} y={by} width={ancho} height={alto} rx={pensar ? alto / 2 : 28} fill={fondo} style={{filter: 'drop-shadow(0 6px 10px rgba(0,0,0,0.25))'}} />
434
+ <foreignObject x={bx + 20} y={by + 16} width={ancho - 40} height={alto - 28}>
435
+ <div style={{fontFamily: 'Montserrat, sans-serif', fontWeight: 800, fontSize: tam, lineHeight: 1.22, color, textAlign: 'center'}}>{texto}</div>
436
+ </foreignObject>
437
+ </g>
438
+ );
439
+ };
@@ -14,7 +14,7 @@ import {azarN} from './ilustrado';
14
14
 
15
15
  export {Objeto3D} from '../motor/objetos3d';
16
16
 
17
- type V3 = [number, number, number];
17
+ export type V3 = [number, number, number];
18
18
  const suave = (x: number) => {
19
19
  const c = Math.max(0, Math.min(1, x));
20
20
  return c * c * (3 - 2 * c);
@@ -26,7 +26,20 @@ const mezcla = (a: V3, b: V3, k: number): V3 => [a[0] + (b[0] - a[0]) * k, a[1]
26
26
  * escena; sin `a` se acerca un poco sola (`deriva`, 0.06 = 6 % del camino hacia lo que mira). `corte` salta sin viaje.
27
27
  */
28
28
  export type Toma3D = {pos: V3; mirar?: V3; fov?: number; a?: {pos?: V3; mirar?: V3; fov?: number}; viaje?: number; corte?: boolean; deriva?: number};
29
- type Estado = {pos: V3; mirar: V3; fov: number};
29
+ export type Estado = {pos: V3; mirar: V3; fov: number};
30
+
31
+ /**
32
+ * Dónde cae un punto 3D en la pantalla (1080×1920) con la cámara de este cuadro. Devuelve {x, y, visible}:
33
+ * `visible` es falso si el punto queda detrás de la cámara. Sirve para pegar etiquetas 2D a objetos 3D.
34
+ */
35
+ export function proyectar(estado: Estado, punto: V3, ancho = 1080, alto = 1920): {x: number; y: number; visible: boolean} {
36
+ const cam = new THREE.PerspectiveCamera(estado.fov, ancho / alto, 0.1, 400);
37
+ cam.position.set(...estado.pos);
38
+ cam.lookAt(...estado.mirar);
39
+ cam.updateMatrixWorld(true);
40
+ const v = new THREE.Vector3(...punto).project(cam);
41
+ return {x: ((v.x + 1) / 2) * ancho, y: ((1 - v.y) / 2) * alto, visible: v.z < 1 && v.z > -1};
42
+ }
30
43
 
31
44
  /** Cámara del cuadro actual. `tomas[i]` es la de la escena i+1; si falta, se conserva la anterior. */
32
45
  export function camara3D(tomas: (Toma3D | undefined | null)[], escena: number, tEscena: number, inicios?: number[]): Estado {
@@ -85,10 +98,104 @@ const Entorno: React.FC<{fuerza: number}> = ({fuerza}) => {
85
98
  return null;
86
99
  };
87
100
 
101
+ /** Luz del cielo según la hora (0–24): mediodía claro, atardecer naranja, noche azul con luna. */
102
+ type Cielo = {fondo: string; sol: string; fuerzaSol: number; arriba: string; abajo: string; fuerzaHemi: number; entorno: number; altura: number};
103
+ const CIELOS: [number, Cielo][] = [
104
+ [0, {fondo: '#121A33', sol: '#9DB4FF', fuerzaSol: 0.55, arriba: '#5A6FA8', abajo: '#1A1F2E', fuerzaHemi: 0.5, entorno: 0.18, altura: 0.6}],
105
+ [6, {fondo: '#F6C7A0', sol: '#FFB27A', fuerzaSol: 1.4, arriba: '#FFE1C2', abajo: '#9C8A70', fuerzaHemi: 0.8, entorno: 0.4, altura: 0.25}],
106
+ [9, {fondo: '#BFE3FF', sol: '#FFFFFF', fuerzaSol: 2.3, arriba: '#FFFFFF', abajo: '#B9A98A', fuerzaHemi: 1.05, entorno: 0.55, altura: 1}],
107
+ [16, {fondo: '#BFE3FF', sol: '#FFFFFF', fuerzaSol: 2.3, arriba: '#FFFFFF', abajo: '#B9A98A', fuerzaHemi: 1.05, entorno: 0.55, altura: 1}],
108
+ [18.5, {fondo: '#F4A26B', sol: '#FF8A3D', fuerzaSol: 1.8, arriba: '#FFD2A8', abajo: '#8A6A55', fuerzaHemi: 0.75, entorno: 0.38, altura: 0.22}],
109
+ [20.5, {fondo: '#2A2F55', sol: '#8FA4FF', fuerzaSol: 0.6, arriba: '#6474B8', abajo: '#20233A', fuerzaHemi: 0.55, entorno: 0.2, altura: 0.6}],
110
+ [24, {fondo: '#121A33', sol: '#9DB4FF', fuerzaSol: 0.55, arriba: '#5A6FA8', abajo: '#1A1F2E', fuerzaHemi: 0.5, entorno: 0.18, altura: 0.6}],
111
+ ];
112
+ const HORAS: Record<string, number> = {amanecer: 6.5, manana: 10, mañana: 10, dia: 12, día: 12, tarde: 16, atardecer: 18.5, noche: 22};
113
+ const colorMezcla = (a: string, b: string, k: number) => '#' + new THREE.Color(a).lerp(new THREE.Color(b), k).getHexString();
114
+ /** Cielo y luces para una hora (número 0–24 o 'dia', 'atardecer', 'noche'...). */
115
+ export function cieloDeHora(hora: number | string): Cielo {
116
+ const h = (((typeof hora === 'string' ? HORAS[hora] ?? 12 : hora) % 24) + 24) % 24;
117
+ let i = CIELOS.findIndex(([x]) => x > h);
118
+ if (i <= 0) i = CIELOS.length - 1;
119
+ const [h0, a] = CIELOS[i - 1];
120
+ const [h1, b] = CIELOS[i];
121
+ const k = suave((h - h0) / (h1 - h0));
122
+ const n = (x: number, y: number) => x + (y - x) * k;
123
+ return {fondo: colorMezcla(a.fondo, b.fondo, k), sol: colorMezcla(a.sol, b.sol, k), fuerzaSol: n(a.fuerzaSol, b.fuerzaSol), arriba: colorMezcla(a.arriba, b.arriba, k), abajo: colorMezcla(a.abajo, b.abajo, k), fuerzaHemi: n(a.fuerzaHemi, b.fuerzaHemi), entorno: n(a.entorno, b.entorno), altura: n(a.altura, b.altura)};
124
+ }
125
+
126
+ /** Activa el recorte por planos (para `Corte`, la vista de rayos X). */
127
+ const Recorte: React.FC = () => {
128
+ const {gl} = useThree();
129
+ useLayoutEffect(() => {
130
+ gl.localClippingEnabled = true;
131
+ }, [gl]);
132
+ return null;
133
+ };
134
+
135
+ /**
136
+ * Etiqueta pegada a un punto 3D: pastilla de color con una flecha que apunta al objeto. Sigue al objeto cuando la
137
+ * cámara se mueve. `escena` (1, 2...) la limita a esa escena; `en`/`hasta` son segundos desde que empieza.
138
+ */
139
+ export type Etiqueta3D = {
140
+ pos: V3;
141
+ texto: string;
142
+ escena?: number | number[];
143
+ en?: number;
144
+ hasta?: number;
145
+ color?: string;
146
+ colorTexto?: string;
147
+ /** Hacia dónde sale la pastilla desde el objeto. */
148
+ lado?: 'arriba' | 'abajo' | 'izq' | 'der';
149
+ /** Largo de la flecha (px). */
150
+ largo?: number;
151
+ tam?: number;
152
+ inclinar?: number;
153
+ };
154
+
155
+ const EtiquetasPantalla: React.FC<{etiquetas: Etiqueta3D[]; estado: Estado; escena: number; tEscena: number}> = ({etiquetas, estado, escena, tEscena}) => (
156
+ <>
157
+ {etiquetas.map((e, i) => {
158
+ const escenas = e.escena == null ? null : ([] as number[]).concat(e.escena);
159
+ if (escenas && !escenas.includes(escena)) return null;
160
+ const t = tEscena - (e.en ?? 0.2);
161
+ if (t < 0 || (e.hasta != null && tEscena > e.hasta)) return null;
162
+ const p = proyectar(estado, e.pos);
163
+ if (!p.visible) return null;
164
+ const lado = e.lado ?? 'arriba';
165
+ const largo = e.largo ?? 110;
166
+ const tam = e.tam ?? 40;
167
+ const k = Math.min(1, t / 0.35);
168
+ const pop = 1 - Math.exp(-7 * k) * Math.cos(k * 7.5);
169
+ const flecha = largo * Math.min(1, t / 0.25);
170
+ const [dx, dy] = lado === 'arriba' ? [0, -1] : lado === 'abajo' ? [0, 1] : lado === 'izq' ? [-1, 0] : [1, 0];
171
+ const fx = p.x + dx * flecha;
172
+ const fy = p.y + dy * flecha;
173
+ const color = e.color ?? '#2D7BFF';
174
+ const ang = (Math.atan2(-dy, -dx) * 180) / Math.PI;
175
+ const transform = lado === 'arriba' ? 'translate(-50%, -100%)' : lado === 'abajo' ? 'translate(-50%, 0)' : lado === 'izq' ? 'translate(-100%, -50%)' : 'translate(0, -50%)';
176
+ return (
177
+ <React.Fragment key={i}>
178
+ <svg width={1080} height={1920} style={{position: 'absolute', left: 0, top: 0, pointerEvents: 'none'}}>
179
+ <line x1={fx} y1={fy} x2={p.x - dx * 6} y2={p.y - dy * 6} stroke={color} strokeWidth={9} strokeLinecap="round" />
180
+ <g transform={`translate(${p.x} ${p.y}) rotate(${ang})`}>
181
+ <path d="M 0 0 L -30 -17 L -30 17 Z" fill={color} />
182
+ </g>
183
+ </svg>
184
+ <div style={{position: 'absolute', left: fx, top: fy, transform: `${transform} scale(${pop}) rotate(${e.inclinar ?? -3}deg)`, transformOrigin: 'center', background: color, color: e.colorTexto ?? '#FFFFFF', fontFamily: 'Montserrat, sans-serif', fontWeight: 900, fontSize: tam, padding: `${tam * 0.22}px ${tam * 0.5}px`, borderRadius: tam * 0.3, border: '4px solid rgba(255,255,255,0.95)', boxShadow: '0 8px 18px rgba(0,0,0,0.3)', whiteSpace: 'nowrap', letterSpacing: 0.5}}>
185
+ {e.texto}
186
+ </div>
187
+ </React.Fragment>
188
+ );
189
+ })}
190
+ </>
191
+ );
192
+
88
193
  /**
89
194
  * El lienzo del mundo 3D (1080×1920). `luz`:
90
195
  * 'dia' cielo claro, sol con sombras suaves (maquetas, cortes de terreno, low-poly)
91
196
  * 'calida' cuarto oscuro con un foco cálido desde arriba y su cono visible (mesa, dinero, objetos)
197
+ * `hora` (0–24 o 'atardecer', 'noche'...) cambia el cielo y el sol de la luz 'dia'; anímala para pasar de día a noche.
198
+ * `etiquetas`: pastillas con flecha pegadas a puntos 3D (siguen al objeto con la cámara).
92
199
  */
93
200
  export const Lienzo3D: React.FC<{
94
201
  tomas: (Toma3D | undefined | null)[];
@@ -96,25 +203,32 @@ export const Lienzo3D: React.FC<{
96
203
  tEscena?: number;
97
204
  inicios?: number[];
98
205
  luz?: 'dia' | 'calida';
206
+ hora?: number | string;
99
207
  fondo?: string;
100
208
  niebla?: [number, number];
101
209
  sol?: V3;
102
210
  foco?: V3;
211
+ etiquetas?: Etiqueta3D[];
212
+ /** Encima del 3D, en coordenadas de pantalla (texto, tarjetas). */
213
+ encima?: React.ReactNode;
103
214
  children: React.ReactNode;
104
- }> = ({tomas, escena = 1, tEscena = 0, inicios, luz = 'dia', fondo, niebla, sol = [9, 15, 8], foco = [0, 10, 1.5], children}) => {
215
+ }> = ({tomas, escena = 1, tEscena = 0, inicios, luz = 'dia', hora, fondo, niebla, sol = [9, 15, 8], foco = [0, 10, 1.5], etiquetas, encima, children}) => {
105
216
  const estado = camara3D(tomas, escena, tEscena, inicios);
106
- const color = fondo ?? (luz === 'dia' ? '#BFE3FF' : '#120A06');
217
+ const cielo = cieloDeHora(hora ?? 12);
218
+ const color = fondo ?? (luz === 'dia' ? cielo.fondo : '#120A06');
219
+ const posSol: V3 = [sol[0], sol[1] * cielo.altura + 1.5, sol[2]];
107
220
  return (
108
- <div style={{width: 1080, height: 1920}}>
221
+ <div style={{position: 'relative', width: 1080, height: 1920}}>
109
222
  <ThreeCanvas width={1080} height={1920} shadows gl={{antialias: true}} camera={{fov: estado.fov, position: estado.pos, near: 0.1, far: 400}}>
110
223
  <color attach="background" args={[color]} />
111
224
  {niebla ? <fog attach="fog" args={[color, niebla[0], niebla[1]]} /> : null}
112
225
  <Camara estado={estado} />
226
+ <Recorte />
113
227
  {luz === 'dia' ? (
114
228
  <>
115
- <Entorno fuerza={0.55} />
116
- <hemisphereLight args={['#ffffff', '#b9a98a', 1.05]} />
117
- <directionalLight position={sol} intensity={2.3} castShadow shadow-mapSize={[2048, 2048]} shadow-camera-left={-26} shadow-camera-right={26} shadow-camera-top={26} shadow-camera-bottom={-26} shadow-camera-far={90} shadow-bias={-0.0006} />
229
+ <Entorno fuerza={cielo.entorno} />
230
+ <hemisphereLight args={[cielo.arriba, cielo.abajo, cielo.fuerzaHemi]} />
231
+ <directionalLight position={posSol} color={cielo.sol} intensity={cielo.fuerzaSol} castShadow shadow-mapSize={[2048, 2048]} shadow-camera-left={-26} shadow-camera-right={26} shadow-camera-top={26} shadow-camera-bottom={-26} shadow-camera-far={90} shadow-bias={-0.0006} />
118
232
  </>
119
233
  ) : (
120
234
  <>
@@ -130,13 +244,15 @@ export const Lienzo3D: React.FC<{
130
244
  )}
131
245
  {children}
132
246
  </ThreeCanvas>
247
+ {etiquetas?.length ? <EtiquetasPantalla etiquetas={etiquetas} estado={estado} escena={escena} tEscena={tEscena} /> : null}
248
+ {encima}
133
249
  </div>
134
250
  );
135
251
  };
136
252
 
137
- type Pieza = {pos?: V3; rot?: V3; escala?: number};
253
+ export type Pieza = {pos?: V3; rot?: V3; escala?: number};
138
254
  const RAD = Math.PI / 180;
139
- const grupo = (p: Pieza) => ({position: p.pos ?? ([0, 0, 0] as V3), rotation: (p.rot ?? [0, 0, 0]).map((g) => g * RAD) as V3, scale: p.escala ?? 1});
255
+ export const grupo = (p: Pieza) => ({position: p.pos ?? ([0, 0, 0] as V3), rotation: (p.rot ?? [0, 0, 0]).map((g) => g * RAD) as V3, scale: p.escala ?? 1});
140
256
 
141
257
  /** Bloque de color mate: la pieza base para cajas, muros, banquetas, cualquier cosa. */
142
258
  export const Bloque: React.FC<Pieza & {tam: V3; color: string; rugosidad?: number; metal?: number; opacidad?: number; radio?: number}> = ({tam, color, rugosidad = 0.8, metal = 0, opacidad, ...p}) => (
@@ -129,26 +129,32 @@ const Rotulo: React.FC<{e: Escena; t: number; tema: Tema}> = ({e, t, tema}) => {
129
129
  const limpio = texto.replace(/\*/g, '');
130
130
  const fuente = tema.fuentes.titulo;
131
131
  // cabe en 940 px: se achica si hace falta (el ancho se mide con la fuente ya cargada)
132
- let tam = e.p.rotuloTam ?? 104;
132
+ let tam = e.p.rotuloTam ?? tema.rotulo?.tam ?? 104;
133
133
  try {
134
134
  const w = ancho(may ? limpio.toUpperCase() : limpio, fuente, tam, 900);
135
135
  if (w > 940) tam = Math.max(56, Math.floor((tam * 940) / w));
136
136
  } catch {}
137
137
  const claro = tema.colores.texto !== '#FFFFFF';
138
- const contorno = !claro && (S.contorno ?? 0) > 0;
138
+ const estilo = e.p.rotuloEstilo ?? tema.rotulo?.estilo ?? 'normal';
139
+ const contorno = estilo === 'contorno' || (estilo === 'normal' && !claro && (S.contorno ?? 0) > 0);
140
+ const grosor = estilo === 'contorno' ? Math.max(8, Math.round(tam * 0.11)) : S.contorno;
141
+ const pastilla = estilo === 'pastilla';
142
+ const inclinar = e.p.rotuloInclinar ?? tema.rotulo?.inclinar ?? (pastilla ? -3 : 0);
143
+ const y = tema.rotulo?.y ?? (abajo ? 1470 : 330);
139
144
  return (
140
- <div style={{position: 'absolute', left: 50, width: W - 100, top: abajo ? 1470 : 330, transform: `translate(0, -50%) scale(${0.86 + 0.14 * resorte(k, 0.3)})`, opacity: clamp(k * 3), textAlign: 'center', pointerEvents: 'none'}}>
145
+ <div style={{position: 'absolute', left: 50, width: W - 100, top: y, transform: `translate(0, -50%) scale(${0.86 + 0.14 * resorte(k, 0.3)}) rotate(${inclinar}deg)`, opacity: clamp(k * 3), textAlign: 'center', pointerEvents: 'none'}}>
141
146
  <span
142
147
  style={{
143
148
  fontFamily: fuente,
144
149
  fontWeight: 900,
145
- fontSize: tam,
150
+ fontSize: pastilla ? tam * 0.82 : tam,
146
151
  lineHeight: 1.05,
147
- color: tema.colores.texto,
152
+ color: pastilla || estilo === 'contorno' ? '#FFFFFF' : tema.colores.texto,
148
153
  textTransform: may ? 'uppercase' : undefined,
149
- WebkitTextStroke: contorno ? `${S.contorno}px #000` : undefined,
154
+ WebkitTextStroke: contorno ? `${grosor}px #1B1B1F` : undefined,
150
155
  paintOrder: 'stroke fill',
151
- textShadow: claro ? undefined : '0 6px 22px rgba(0,0,0,0.55)',
156
+ textShadow: pastilla ? undefined : estilo === 'contorno' ? '0 7px 0 rgba(0,0,0,0.28)' : claro ? undefined : '0 6px 22px rgba(0,0,0,0.55)',
157
+ ...(pastilla ? {display: 'inline-block', background: '#1E1F24', padding: `${tam * 0.12}px ${tam * 0.32}px`, borderRadius: tam * 0.22, boxShadow: '0 10px 24px rgba(0,0,0,0.3)', border: '4px solid rgba(255,255,255,0.9)'} : {}),
152
158
  }}
153
159
  >
154
160
  {partesResaltadas(texto).map(([p, r], i) => (