morfeo 0.3.4 → 0.4.0

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,245 @@
1
+ // Piezas 3D para los formatos "construiste un X" y "escalada absurda" (un objeto que suma partes escena por escena),
2
+ // para multitudes ("30 000 hombres") y para la mano en primera persona que aprieta un control o sostiene algo.
3
+ // Todo depende de `t` (segundos) y de props; nada de useFrame.
4
+ //
5
+ // <Aparece t={t} en={cuando('ruedas')} desde="arriba"><Ruedas /></Aparece> (cae, rebota y suena por su cuenta)
6
+ // <Multitud3D t={t} n={400} ancho={16} fondo={10} colores={['#2F6FE0', '#F2B632']} destacar={[0, 7]} />
7
+ // <Mano3D t={t} pos={[0.5, -1.2, 3]} sostiene="control" aprieta={cuando('botón')} />
8
+ import React, {useMemo} from 'react';
9
+ import * as THREE from 'three';
10
+ import {azarN} from './ilustrado';
11
+ import {grupo, type Pieza, type V3} from './tres';
12
+
13
+ const clamp01 = (x: number) => Math.max(0, Math.min(1, x));
14
+ const suave = (x: number) => {
15
+ const c = clamp01(x);
16
+ return c * c * (3 - 2 * c);
17
+ };
18
+ /** Rebote al caer: llega en 0.55 del tramo, rebota dos veces y se queda. */
19
+ const caida = (k: number) => {
20
+ if (k >= 1) return 1;
21
+ if (k < 0.55) return (k / 0.55) ** 2;
22
+ const r = (k - 0.55) / 0.45;
23
+ return 1 - Math.abs(Math.sin(r * Math.PI * 2)) * (1 - r) * 0.12;
24
+ };
25
+
26
+ const Mate: React.FC<{c: string; r?: number}> = ({c, r = 0.75}) => <meshStandardMaterial color={c} roughness={r} flatShading />;
27
+
28
+ /**
29
+ * Una parte que llega en su momento: cae desde arriba con rebote (`desde: 'arriba'`), crece desde cero (`'crece'`),
30
+ * entra de lado (`'izq'`/`'der'`) o sube del suelo (`'abajo'`). Antes de `en` no se ve. `dur` en segundos, `altura` de caída.
31
+ */
32
+ export const Aparece: React.FC<Pieza & {t: number; en: number; desde?: 'arriba' | 'crece' | 'izq' | 'der' | 'abajo'; dur?: number; altura?: number; hasta?: number; children: React.ReactNode}> = ({t, en, desde = 'arriba', dur = 0.55, altura = 4, hasta, children, ...p}) => {
33
+ if (t < en) return null;
34
+ const k = clamp01((t - en) / dur);
35
+ const fuera = hasta != null ? suave((t - hasta) / 0.35) : 0;
36
+ if (fuera >= 1) return null;
37
+ const g = grupo(p);
38
+ let [x, y, z] = g.position;
39
+ let esc = g.scale * (1 - fuera);
40
+ if (desde === 'arriba') y += (1 - caida(k)) * altura;
41
+ else if (desde === 'abajo') y -= (1 - suave(k)) * altura;
42
+ else if (desde === 'izq') x -= (1 - suave(k)) * altura;
43
+ else if (desde === 'der') x += (1 - suave(k)) * altura;
44
+ else esc *= k < 1 ? Math.sin(suave(k) * Math.PI * 0.62) / Math.sin(Math.PI * 0.62) : 1;
45
+ // pequeño aplastamiento al tocar el suelo
46
+ const apl = desde === 'arriba' && k > 0.5 && k < 0.8 ? Math.sin(((k - 0.5) / 0.3) * Math.PI) * 0.12 : 0;
47
+ return (
48
+ <group position={[x, y, z]} rotation={g.rotation} scale={[esc * (1 + apl * 0.5), esc * (1 - apl), esc * (1 + apl * 0.5)]}>
49
+ {children}
50
+ </group>
51
+ );
52
+ };
53
+
54
+ /**
55
+ * Multitud de figuras simples (cuerpo + cabeza) en una rejilla con algo de desorden, en un solo `instancedMesh` (miles
56
+ * sin costo). `n`, `ancho`×`fondo` (unidades), `colores` del cuerpo, `destacar: [índices]` en `colorDestacado`, `brinca`
57
+ * para que salten. Aparece de adelante hacia atrás desde `en`.
58
+ */
59
+ export const Multitud3D: React.FC<Pieza & {t: number; n?: number; ancho?: number; fondo?: number; colores?: string[]; colorDestacado?: string; destacar?: number[]; brinca?: boolean; en?: number; altura?: number}> = ({t, n = 300, ancho = 14, fondo = 10, colores = ['#2F6FE0', '#3D7DF0', '#2A5FC8'], colorDestacado = '#F2B632', destacar = [], brinca = false, en = 0, altura = 1.1, ...p}) => {
60
+ const cols = Math.max(1, Math.round(Math.sqrt((n * ancho) / fondo)));
61
+ const filas = Math.ceil(n / cols);
62
+ const datos = useMemo(() => {
63
+ let semilla = n * 3 + 7;
64
+ const r = () => azarN(semilla++);
65
+ return Array.from({length: n}, (_, i) => {
66
+ const c = i % cols;
67
+ const f = Math.floor(i / cols);
68
+ const x = (c / Math.max(1, cols - 1) - 0.5) * ancho + (r() - 0.5) * (ancho / cols) * 0.5;
69
+ const z = (f / Math.max(1, filas - 1) - 0.5) * fondo + (r() - 0.5) * (fondo / filas) * 0.5;
70
+ return {x, z, fase: r() * 6.28, escala: 0.9 + r() * 0.2, color: destacar.includes(i) ? colorDestacado : colores[Math.floor(r() * colores.length)], fila: f};
71
+ });
72
+ }, [n, cols, filas, ancho, fondo, colores.join(), colorDestacado, destacar.join()]);
73
+ const cuerpo = useMemo(() => new THREE.CapsuleGeometry(0.22 * altura, 0.5 * altura, 3, 6), [altura]);
74
+ const cabeza = useMemo(() => new THREE.IcosahedronGeometry(0.2 * altura, 0), [altura]);
75
+ const m = useMemo(() => new THREE.Matrix4(), []);
76
+ const color = useMemo(() => new THREE.Color(), []);
77
+ const refC = React.useRef<THREE.InstancedMesh>(null);
78
+ const refH = React.useRef<THREE.InstancedMesh>(null);
79
+ // las matrices se escriben en cada render (depende de t), no en un efecto
80
+ const aplicar = () => {
81
+ if (!refC.current || !refH.current) return;
82
+ datos.forEach((d, i) => {
83
+ const k = clamp01((t - en - d.fila * 0.04) / 0.35);
84
+ const s = d.escala * (k < 1 ? caida(k) : 1);
85
+ const salto = brinca ? Math.max(0, Math.sin(t * 6 + d.fase)) * 0.25 : 0;
86
+ m.compose(new THREE.Vector3(d.x, (0.47 * altura + salto) * s, d.z), new THREE.Quaternion(), new THREE.Vector3(s, s, s));
87
+ refC.current!.setMatrixAt(i, m);
88
+ refC.current!.setColorAt(i, color.set(d.color));
89
+ m.compose(new THREE.Vector3(d.x, (1.05 * altura + salto) * s, d.z), new THREE.Quaternion(), new THREE.Vector3(s, s, s));
90
+ refH.current!.setMatrixAt(i, m);
91
+ refH.current!.setColorAt(i, color.set('#F2C9A4'));
92
+ });
93
+ refC.current.instanceMatrix.needsUpdate = true;
94
+ refH.current.instanceMatrix.needsUpdate = true;
95
+ if (refC.current.instanceColor) refC.current.instanceColor.needsUpdate = true;
96
+ if (refH.current.instanceColor) refH.current.instanceColor.needsUpdate = true;
97
+ };
98
+ React.useLayoutEffect(aplicar);
99
+ return (
100
+ <group {...grupo(p)}>
101
+ <instancedMesh ref={refC} args={[cuerpo, undefined, n]} castShadow>
102
+ <meshStandardMaterial roughness={0.7} flatShading />
103
+ </instancedMesh>
104
+ <instancedMesh ref={refH} args={[cabeza, undefined, n]} castShadow>
105
+ <meshStandardMaterial roughness={0.7} flatShading />
106
+ </instancedMesh>
107
+ </group>
108
+ );
109
+ };
110
+
111
+ /** Control remoto o de portón: caja oscura con botones. */
112
+ const Control: React.FC<{apretado: number}> = ({apretado}) => (
113
+ <group>
114
+ <mesh castShadow>
115
+ <boxGeometry args={[0.55, 0.95, 0.16]} />
116
+ <Mate c="#26282E" />
117
+ </mesh>
118
+ <mesh position={[0, 0.2, 0.09 - apretado * 0.03]}>
119
+ <cylinderGeometry args={[0.13, 0.13, 0.06, 16]} />
120
+ <meshStandardMaterial color="#E5484D" roughness={0.4} emissive="#E5484D" emissiveIntensity={apretado * 0.8} />
121
+ </mesh>
122
+ {[-0.13, 0.13].map((x) => (
123
+ <mesh key={x} position={[x, -0.15, 0.09]} rotation={[Math.PI / 2, 0, 0]}>
124
+ <cylinderGeometry args={[0.07, 0.07, 0.04, 12]} />
125
+ <Mate c="#D7D9DE" />
126
+ </mesh>
127
+ ))}
128
+ </group>
129
+ );
130
+
131
+ /** Teléfono: losa negra con pantalla encendida. */
132
+ const Movil: React.FC<{pantalla?: string}> = ({pantalla = '#3A6FF2'}) => (
133
+ <group>
134
+ <mesh castShadow>
135
+ <boxGeometry args={[0.55, 1.1, 0.08]} />
136
+ <Mate c="#111214" r={0.4} />
137
+ </mesh>
138
+ <mesh position={[0, 0, 0.045]}>
139
+ <planeGeometry args={[0.48, 1.0]} />
140
+ <meshStandardMaterial color={pantalla} emissive={pantalla} emissiveIntensity={0.7} />
141
+ </mesh>
142
+ </group>
143
+ );
144
+
145
+ /**
146
+ * Mano en primera persona (entra desde abajo del cuadro): palma, cuatro dedos que rodean lo que sostiene y pulgar que
147
+ * aprieta en `aprieta` (segundos). `sostiene: 'control' | 'movil' | 'nada'`, `piel`, `manga` (color de la manga).
148
+ * Ponla cerca de la cámara (2–4 u) y abajo del centro.
149
+ */
150
+ export const Mano3D: React.FC<Pieza & {t: number; sostiene?: 'control' | 'movil' | 'nada'; aprieta?: number; piel?: string; manga?: string; en?: number; pantalla?: string}> = ({t, sostiene = 'control', aprieta, piel = '#E8B48F', manga = '#2F6FE0', en = 0, pantalla, ...p}) => {
151
+ const entra = suave((t - en) / 0.45);
152
+ const dt = aprieta != null ? t - aprieta : -1;
153
+ const apretado = dt >= 0 && dt < 0.5 ? Math.sin((dt / 0.5) * Math.PI) : 0;
154
+ const g = grupo(p);
155
+ const [x, y, z] = g.position;
156
+ const tiembla = Math.sin(t * 2.1) * 0.01;
157
+ return (
158
+ <group position={[x, y - (1 - entra) * 1.6 + tiembla, z]} rotation={g.rotation} scale={g.scale}>
159
+ {/* manga y muñeca */}
160
+ <mesh position={[0, -0.95, -0.05]} rotation={[0.25, 0, 0]} castShadow>
161
+ <cylinderGeometry args={[0.24, 0.3, 0.9, 8]} />
162
+ <Mate c={manga} />
163
+ </mesh>
164
+ <mesh position={[0, -0.45, 0]} castShadow>
165
+ <cylinderGeometry args={[0.17, 0.2, 0.3, 8]} />
166
+ <Mate c={piel} />
167
+ </mesh>
168
+ {/* palma detrás del objeto */}
169
+ <mesh position={[0, -0.05, -0.14]} castShadow>
170
+ <boxGeometry args={[0.6, 0.62, 0.2]} />
171
+ <Mate c={piel} />
172
+ </mesh>
173
+ {/* lo que sostiene */}
174
+ <group position={[0, 0.15, 0]}>{sostiene === 'control' ? <Control apretado={apretado} /> : sostiene === 'movil' ? <Movil pantalla={pantalla} /> : null}</group>
175
+ {/* dedos que rodean por la derecha */}
176
+ {[0.22, 0.02, -0.18, -0.36].map((yy, i) => (
177
+ <mesh key={i} position={[0.33, yy + 0.1, 0.02]} rotation={[0, 0, 0.1]} castShadow>
178
+ <boxGeometry args={[0.14, 0.15, 0.24]} />
179
+ <Mate c={piel} />
180
+ </mesh>
181
+ ))}
182
+ {/* pulgar: baja sobre el botón al apretar */}
183
+ <group position={[-0.32, -0.05, 0.05]} rotation={[0, 0, -0.9 + apretado * 0.35]}>
184
+ <mesh position={[0, 0.25, 0.1 - apretado * 0.04]} castShadow>
185
+ <boxGeometry args={[0.16, 0.42, 0.16]} />
186
+ <Mate c={piel} />
187
+ </mesh>
188
+ </group>
189
+ </group>
190
+ );
191
+ };
192
+
193
+ /** Partes listas para armar objetos que crecen: rueda, motor y asiento (low-poly, se combinan con Aparece). */
194
+ export const Rueda: React.FC<Pieza & {radio?: number; t?: number; gira?: number; color?: string}> = ({radio = 0.35, t = 0, gira = 0, color = '#1E1F24', ...p}) => (
195
+ <group {...grupo(p)}>
196
+ <group rotation={[0, 0, -t * gira]}>
197
+ <mesh rotation={[Math.PI / 2, 0, 0]} castShadow>
198
+ <cylinderGeometry args={[radio, radio, radio * 0.6, 14]} />
199
+ <Mate c={color} r={0.9} />
200
+ </mesh>
201
+ <mesh rotation={[Math.PI / 2, 0, 0]} position={[0, 0, 0.01]}>
202
+ <cylinderGeometry args={[radio * 0.5, radio * 0.5, radio * 0.62, 8]} />
203
+ <Mate c="#B9BEC8" r={0.5} />
204
+ </mesh>
205
+ </group>
206
+ </group>
207
+ );
208
+
209
+ export const Motor: React.FC<Pieza & {t?: number; color?: string}> = ({t = 0, color = '#F2B632', ...p}) => {
210
+ const vibra = Math.sin(t * 60) * 0.01;
211
+ return (
212
+ <group {...grupo(p)} position={[(p.pos?.[0] ?? 0) + vibra, p.pos?.[1] ?? 0, p.pos?.[2] ?? 0]}>
213
+ <mesh castShadow>
214
+ <boxGeometry args={[0.7, 0.5, 0.5]} />
215
+ <Mate c={color} />
216
+ </mesh>
217
+ <mesh position={[0, 0.32, 0]} castShadow>
218
+ <boxGeometry args={[0.4, 0.14, 0.34]} />
219
+ <Mate c="#2A2C33" />
220
+ </mesh>
221
+ <mesh position={[0.42, 0.1, 0]} rotation={[0, 0, Math.PI / 2]} castShadow>
222
+ <cylinderGeometry args={[0.06, 0.06, 0.2, 8]} />
223
+ <Mate c="#6B7080" />
224
+ </mesh>
225
+ </group>
226
+ );
227
+ };
228
+
229
+ export const Asiento: React.FC<Pieza & {color?: string}> = ({color = '#E5484D', ...p}) => (
230
+ <group {...grupo(p)}>
231
+ <mesh position={[0, 0, 0]} castShadow>
232
+ <boxGeometry args={[0.6, 0.14, 0.55]} />
233
+ <Mate c={color} />
234
+ </mesh>
235
+ <mesh position={[0, 0.32, -0.24]} castShadow>
236
+ <boxGeometry args={[0.6, 0.6, 0.12]} />
237
+ <Mate c={color} />
238
+ </mesh>
239
+ <mesh position={[0, -0.25, 0]} castShadow>
240
+ <cylinderGeometry args={[0.05, 0.05, 0.4, 6]} />
241
+ <Mate c="#6B7080" />
242
+ </mesh>
243
+ </group>
244
+ );
245
+
@@ -12,3 +12,4 @@ export * from './mundo3d';
12
12
  export * from './datos';
13
13
  export * from './diorama';
14
14
  export {clamp, easeIO, easeOut, easeOutExpo, backOut, resorte, ruido, lerp, mezclaColor} from '../estilo';
15
+ export * from './armar';
@@ -53,6 +53,21 @@ const EscenaVista: React.FC<{e: Escena; t: number; tema: Tema; semilla: string;
53
53
  );
54
54
  };
55
55
 
56
+ /** Lugar (mundo) de la escena `i`: el suyo, o el de la última escena anterior que dijo uno, o el primero de `lugares`. */
57
+ export function lugarDe(d: Datos, i: number): string | null {
58
+ const nombres = Object.keys(d.lugares ?? {});
59
+ if (!nombres.length) return null;
60
+ for (let j = i; j >= 0; j--) {
61
+ const l = d.escenas[j]?.p.lugar;
62
+ if (typeof l === 'string' && d.lugares![l]) return l;
63
+ }
64
+ return d.mundo?.length ? null : nombres[0];
65
+ }
66
+ const capasDeLugar = (d: Datos, i: number) => {
67
+ const l = lugarDe(d, i);
68
+ return (l ? d.lugares![l] : d.mundo) ?? [];
69
+ };
70
+
56
71
  type Toma = {x: number; y: number; zoom: number; giro: number};
57
72
  const TOMA0: Toma = {x: 540, y: 960, zoom: 1, giro: 0};
58
73
 
@@ -62,8 +77,10 @@ const TOMA0: Toma = {x: 540, y: 960, zoom: 1, giro: 0};
62
77
  * Sin `toma`, la escena conserva la anterior. `corte: true` salta sin viaje.
63
78
  */
64
79
  export function camaraMundo(d: Datos, si: number, t: number): Toma {
80
+ // cada lugar tiene su propia cámara: al buscar la toma anterior no se cruza a otro lugar
65
81
  const destino = (i: number): Toma => {
66
- for (let j = i; j >= 0; j--) if (d.escenas[j].p.toma) return {...TOMA0, ...d.escenas[j].p.toma};
82
+ const l = lugarDe(d, i);
83
+ for (let j = i; j >= 0 && lugarDe(d, j) === l; j--) if (d.escenas[j].p.toma) return {...TOMA0, ...d.escenas[j].p.toma};
67
84
  return TOMA0;
68
85
  };
69
86
  const final = (i: number): Toma => {
@@ -76,7 +93,8 @@ export function camaraMundo(d: Datos, si: number, t: number): Toma {
76
93
  const tm = e.p.toma ?? {};
77
94
  const a = destino(si);
78
95
  const b = final(si);
79
- const viaje = tm.corte || si === 0 ? 0 : tm.duracion ?? Math.min(1.1, e.dur * 0.55);
96
+ const cambiaLugar = si > 0 && lugarDe(d, si) !== lugarDe(d, si - 1);
97
+ const viaje = tm.corte || si === 0 || cambiaLugar ? 0 : tm.duracion ?? Math.min(1.1, e.dur * 0.55);
80
98
  const kd = clamp((t - viaje * 0.6) / Math.max(0.01, e.dur - viaje * 0.6));
81
99
  const kq = tm.a ? easeIO(kd) : kd;
82
100
  const actual: Toma = {x: lerp(a.x, b.x, kq), y: lerp(a.y, b.y, kq), zoom: lerp(a.zoom, b.zoom, kq), giro: lerp(a.giro, b.giro, kq)};
@@ -99,7 +117,7 @@ const Mundo: React.FC<{d: Datos; si: number; t: number; T: number; tema: Tema}>
99
117
  const propias = capasDe(e.tipo, e.p, temaEsc, marca).filter((c: any) => c.enMundo);
100
118
  return (
101
119
  <AbsoluteFill style={{transform: `translate(540px, 960px) scale(${cam.zoom}) rotate(${cam.giro}deg) translate(${-cam.x}px, ${-cam.y}px)`, transformOrigin: '0 0'}}>
102
- <Capas capas={d.mundo as any} ctx={ctxMundo} />
120
+ <Capas capas={capasDeLugar(d, si) as any} ctx={ctxMundo} />
103
121
  {propias.length ? <Capas capas={propias} ctx={ctxEsc} /> : null}
104
122
  </AbsoluteFill>
105
123
  );
@@ -198,7 +216,9 @@ const RotuloSello: React.FC<{texto: string; t: number; tema: Tema; e: Escena}> =
198
216
  const w = ancho(limpio, tema.fuentes.titulo, tam, 900);
199
217
  if (w > 820) tam = Math.max(44, Math.floor((tam * 820) / w));
200
218
  } catch {}
201
- const y = e.p.rotuloY ?? tema.rotulo?.y ?? 1180;
219
+ // lejos de los subtítulos: si van a media altura, el sello se pone arriba de ellos
220
+ const S = tema.subtitulos;
221
+ const y = e.p.rotuloY ?? tema.rotulo?.y ?? (S.tipo !== 'ninguno' && Math.abs(S.y - 1180) < 260 ? S.y - 300 : 1180);
202
222
  return (
203
223
  <div style={{position: 'absolute', left: 0, width: W, top: y, display: 'flex', justifyContent: 'center', pointerEvents: 'none', opacity: clamp(caida * 4)}}>
204
224
  <div
@@ -238,7 +258,9 @@ const RotuloPaso: React.FC<{texto: string; t: number; tema: Tema; e: Escena}> =
238
258
  const k = easeIO(clamp(t / 0.35));
239
259
  const color = colorRotulo(e.p.rotuloColor ?? tema.rotulo?.color, tema.colores.acento);
240
260
  const tam = e.p.rotuloTam ?? tema.rotulo?.tam ?? 64;
241
- const y = e.p.rotuloY ?? tema.rotulo?.y ?? 250;
261
+ // si los subtítulos van arriba, el titular baja para no encimarse con ellos
262
+ const S = tema.subtitulos;
263
+ const y = e.p.rotuloY ?? tema.rotulo?.y ?? (S.tipo !== 'ninguno' && S.y < 600 ? S.y + 120 : 250);
242
264
  const n = e.p.rotuloPaso;
243
265
  return (
244
266
  <div style={{position: 'absolute', left: 56, right: 56, top: y, display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 24, pointerEvents: 'none'}}>
@@ -305,7 +327,8 @@ const RotuloPixel: React.FC<{texto: string; t: number; tema: Tema; e: Escena}> =
305
327
  const w = ancho(limpio, PIXEL, tam, 400);
306
328
  if (w > 900) tam = Math.max(30, Math.floor((tam * 900) / w));
307
329
  } catch {}
308
- const y = e.p.rotuloY ?? tema.rotulo?.y ?? 230;
330
+ const S = tema.subtitulos;
331
+ const y = e.p.rotuloY ?? tema.rotulo?.y ?? (S.tipo !== 'ninguno' && S.y < 600 ? S.y + 150 : 230);
309
332
  return (
310
333
  <div style={{position: 'absolute', left: 0, width: W, top: y, display: 'flex', justifyContent: 'center', pointerEvents: 'none'}}>
311
334
  <div
@@ -368,10 +391,12 @@ export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string,
368
391
  const e = d.escenas[si];
369
392
  const t = T - e.t0;
370
393
  const semilla = d.id;
371
- const conMundo = !!d.mundo?.length;
394
+ const conMundo = !!d.mundo?.length || Object.keys(d.lugares ?? {}).length > 0;
372
395
  const DUR = tema.movimiento?.transicion ?? TRANSICION;
373
- // con mundo, la transición es el viaje de la cámara: las capas de pantalla solo entran con su propia animación
374
- const trans = conMundo ? 'corte' : ((e.p.transicion ?? tema.transicion) as string);
396
+ // con mundo, la transición es el viaje de la cámara: las capas de pantalla solo entran con su propia animación.
397
+ // Al cambiar de lugar sí hay transición (la de la escena; corte si no dice), y el lugar anterior sale con ella.
398
+ const cambiaLugar = conMundo && si > 0 && lugarDe(d, si) !== lugarDe(d, si - 1);
399
+ const trans = conMundo ? (cambiaLugar ? ((e.p.transicion as string) ?? 'corte') : 'corte') : ((e.p.transicion ?? tema.transicion) as string);
375
400
  const enTransicion = si > 0 && t < DUR && trans !== 'corte';
376
401
  const lin = clamp(t / DUR);
377
402
  const k = easeOut(lin);
@@ -408,10 +433,19 @@ export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string,
408
433
  return (
409
434
  <AbsoluteFill style={{background: '#000', overflow: 'hidden'}}>
410
435
  <Modo d={d} si={si} t={t} T={T} claro={LOOKS_CLAROS.includes(d.estilo ?? '') || tema.colores.texto !== '#FFFFFF'}>
411
- {conMundo ? <Mundo d={d} si={si} t={t} T={T} tema={tema} /> : null}
436
+ {conMundo && enTransicion && prev ? (
437
+ <AbsoluteFill style={estiloPrevio}>
438
+ <Mundo d={d} si={si - 1} t={prev.dur + t} T={T} tema={tema} />
439
+ </AbsoluteFill>
440
+ ) : null}
441
+ {conMundo ? (
442
+ <AbsoluteFill style={enTransicion ? estiloActual : undefined}>
443
+ <Mundo d={d} si={si} t={t} T={T} tema={tema} />
444
+ </AbsoluteFill>
445
+ ) : null}
412
446
  {enTransicion && prev ? (
413
447
  <AbsoluteFill style={estiloPrevio}>
414
- <EscenaVista e={prev} t={prev.dur + t} tema={tema} semilla={semilla} />
448
+ <EscenaVista e={prev} t={prev.dur + t} tema={tema} semilla={semilla} sinFondo={conMundo} />
415
449
  </AbsoluteFill>
416
450
  ) : null}
417
451
  <AbsoluteFill style={estiloActual}>
@@ -6,6 +6,7 @@ import {Avatar, Icono, tamIcono, useMarca} from '../piezas';
6
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
+ import {HISTORIAS, Historias} from './historias';
9
10
  import {azar, cuando, formatoNumero, partesResaltadas, recurso, type CtxEscena} from './util';
10
11
  import {Particulas} from './fondo';
11
12
  import {Escena3D, Modelo3D} from './modelo3d';
@@ -280,6 +281,7 @@ const Contenido: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
280
281
  default:
281
282
  if (ANOTACIONES.includes(c.tipo)) return <Anotacion c={c} ctx={ctx} />;
282
283
  if (FICHAS.includes(c.tipo)) return <Fichas c={c} ctx={ctx} />;
284
+ if (HISTORIAS.includes(c.tipo)) return <Historias c={c} ctx={ctx} />;
283
285
  return <div style={{color: 'red', fontSize: 40, fontFamily: 'sans-serif', background: '#fff', padding: 10}}>capa desconocida: {c.tipo}</div>;
284
286
  }
285
287
  };