morfeo 0.3.0 → 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);
@@ -250,9 +250,9 @@ export const Lienzo3D: React.FC<{
250
250
  );
251
251
  };
252
252
 
253
- type Pieza = {pos?: V3; rot?: V3; escala?: number};
253
+ export type Pieza = {pos?: V3; rot?: V3; escala?: number};
254
254
  const RAD = Math.PI / 180;
255
- 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});
256
256
 
257
257
  /** Bloque de color mate: la pieza base para cajas, muros, banquetas, cualquier cosa. */
258
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}) => (
@@ -3,7 +3,7 @@
3
3
  import React from 'react';
4
4
  import {Img, OffthreadVideo} from 'remotion';
5
5
  import {Avatar, Icono, tamIcono, useMarca} from '../piezas';
6
- import {H, W, backOut, clamp, easeIO, easeOut, easeOutExpo, lerp, resorte} from '../estilo';
6
+ import {H, W, ancho, backOut, clamp, easeIO, easeOut, easeOutExpo, lerp, resorte} from '../estilo';
7
7
  import {ANOTACIONES, ANOTACIONES_SIN_ENTRADA, Anotacion} from './anotaciones';
8
8
  import {FICHAS, Fichas} from './fichas';
9
9
  import {azar, cuando, formatoNumero, partesResaltadas, recurso, type CtxEscena} from './util';
@@ -284,6 +284,30 @@ const Contenido: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
284
284
  }
285
285
  };
286
286
 
287
+ /**
288
+ * Tamaño de letra que cabe: si la palabra más larga no entra en el ancho disponible, o el texto ocuparía más de
289
+ * `maxLineas` renglones (3 por defecto), se achica. `ajustar: false` lo apaga. Mide con la fuente ya cargada.
290
+ */
291
+ function tamQueCabe(c: Capa, texto: string, fuente: string, T: CtxEscena['tema']): number {
292
+ let tam: number = c.tam ?? 96;
293
+ if (c.ajustar === false || !texto) return tam;
294
+ const relleno = c.fondo ? 60 : 0;
295
+ const disponible = (typeof c.ancho === 'number' ? c.ancho : typeof c.maxAncho === 'number' ? c.maxAncho : 940) - relleno;
296
+ const peso = c.peso ?? (fuente === T.fuentes.titulo ? 800 : 600);
297
+ const limpio = texto.replace(/\*/g, '');
298
+ try {
299
+ const palabras = limpio.split(/\s+/).filter(Boolean);
300
+ const masLarga = Math.max(...palabras.map((w) => ancho(w, fuente, tam, peso)));
301
+ if (masLarga > disponible) tam *= disponible / masLarga;
302
+ const maxLineas = c.maxLineas ?? 3;
303
+ const lineas = limpio.split('\n').reduce((n, l) => n + Math.max(1, Math.ceil((ancho(l, fuente, tam, peso) * 1.08) / disponible)), 0);
304
+ if (lineas > maxLineas) tam *= Math.sqrt(maxLineas / lineas);
305
+ } catch {
306
+ // la fuente aún no está cargada (p. ej. en el editor): se queda el tamaño pedido
307
+ }
308
+ return Math.max(28, Math.floor(tam));
309
+ }
310
+
287
311
  const TextoCapa: React.FC<{c: Capa; ctx: CtxEscena; fuente: string}> = ({c, ctx, fuente}) => {
288
312
  const T = ctx.tema;
289
313
  const may = c.mayusculas ?? (fuente === T.fuentes.titulo && T.tituloMayusculas);
@@ -300,11 +324,12 @@ const TextoCapa: React.FC<{c: Capa; ctx: CtxEscena; fuente: string}> = ({c, ctx,
300
324
  const color = c.color ?? T.colores.texto;
301
325
  const acento = c.acento ?? T.colores.acento;
302
326
  const contorno = c.contorno ? `${c.contorno}px ${c.colorContorno ?? '#000'}` : undefined;
327
+ const tam = tamQueCabe(c, texto, fuente, T);
303
328
  return (
304
329
  <div
305
330
  style={{
306
331
  fontFamily: fuente,
307
- fontSize: c.tam ?? 96,
332
+ fontSize: tam,
308
333
  fontWeight: c.peso ?? (fuente === T.fuentes.titulo ? 800 : 600),
309
334
  lineHeight: c.interlineado ?? 1.05,
310
335
  color,