morfeo 0.0.0-stage → 0.1.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.
- package/LICENSE +22 -0
- package/README.md +210 -2
- package/assets/fuentes/Anton.ttf +0 -0
- package/assets/fuentes/JetBrainsMono.ttf +0 -0
- package/assets/fuentes/Montserrat.ttf +0 -0
- package/bin/morfeo.mjs +184 -0
- package/bin/postinstall.mjs +10 -0
- package/index.d.ts +246 -0
- package/lib/config.mjs +241 -0
- package/lib/entorno.mjs +93 -0
- package/lib/guion.mjs +309 -0
- package/lib/imagenes.mjs +99 -0
- package/lib/index.mjs +15 -0
- package/lib/instalar.mjs +193 -0
- package/lib/navegador.mjs +64 -0
- package/lib/personajes.mjs +94 -0
- package/lib/render.mjs +278 -0
- package/lib/voces.mjs +122 -0
- package/lib/whisper.mjs +45 -0
- package/package.json +84 -5
- package/plantilla/assets/.gitkeep +0 -0
- package/plantilla/env.example +7 -0
- package/plantilla/escenas/Barras.tsx +15 -0
- package/plantilla/gitignore +4 -0
- package/plantilla/guiones/cachalote.mjs +24 -0
- package/plantilla/guiones/tutorial.mjs +16 -0
- package/plantilla/morfeo.config.mjs +43 -0
- package/plantilla/package.json +13 -0
- package/plantilla/videos.mjs +25 -0
- package/python/musica.py +221 -0
- package/python/preparar.py +268 -0
- package/python/requirements.txt +5 -0
- package/remotion/Root.tsx +35 -0
- package/remotion/Short.tsx +278 -0
- package/remotion/escenas.tsx +430 -0
- package/remotion/estilo.ts +73 -0
- package/remotion/iconos.ts +326 -0
- package/remotion/index.ts +4 -0
- package/remotion/motor/Video.tsx +119 -0
- package/remotion/motor/capas.tsx +547 -0
- package/remotion/motor/fondo.tsx +149 -0
- package/remotion/motor/modelo3d.tsx +252 -0
- package/remotion/motor/plantillas.tsx +123 -0
- package/remotion/motor/registro.ts +11 -0
- package/remotion/motor/subtitulos.tsx +137 -0
- package/remotion/motor/tema.ts +202 -0
- package/remotion/motor/util.ts +66 -0
- package/remotion/motor/widgets.tsx +248 -0
- package/remotion/piezas.tsx +181 -0
- package/remotion/tipos.ts +43 -0
- package/remotion/tres/Capa3D.tsx +153 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Piezas reutilizables: texto con contorno, pastillas, íconos pixel, resplandores y avatar.
|
|
2
|
+
import React, {createContext, useContext} from 'react';
|
|
3
|
+
import {Img, staticFile} from 'remotion';
|
|
4
|
+
import {ANTON, MONO, MONT, NEGRO, ancho} from './estilo';
|
|
5
|
+
import {ICONOS} from './iconos';
|
|
6
|
+
import {MARCA_VACIA, type Marca} from './tipos';
|
|
7
|
+
|
|
8
|
+
export const MarcaContexto = createContext<Marca>(MARCA_VACIA);
|
|
9
|
+
export const useMarca = () => useContext(MarcaContexto);
|
|
10
|
+
|
|
11
|
+
type Fuente = 'anton' | 'mont' | 'mono';
|
|
12
|
+
const FAMILIA: Record<Fuente, string> = {anton: ANTON, mont: MONT, mono: MONO};
|
|
13
|
+
|
|
14
|
+
/** Texto centrado en (x, y) con contorno negro y sombra, como los títulos del canal. */
|
|
15
|
+
export const Texto: React.FC<{
|
|
16
|
+
x: number;
|
|
17
|
+
y: number;
|
|
18
|
+
s: string;
|
|
19
|
+
size: number;
|
|
20
|
+
color?: string;
|
|
21
|
+
stroke?: number;
|
|
22
|
+
escala?: number;
|
|
23
|
+
rot?: number; // grados, positivo = contra reloj (como el motor anterior)
|
|
24
|
+
fuente?: Fuente;
|
|
25
|
+
peso?: number;
|
|
26
|
+
sombra?: boolean;
|
|
27
|
+
ancla?: 'centro' | 'izquierda';
|
|
28
|
+
opacidad?: number;
|
|
29
|
+
}> = ({x, y, s, size, color = '#fff', stroke = 0, escala = 1, rot = 0, fuente = 'anton', peso, sombra = true, ancla = 'centro', opacidad = 1}) => {
|
|
30
|
+
if (escala <= 0.01) return null;
|
|
31
|
+
const tx = ancla === 'centro' ? '-50%' : '0';
|
|
32
|
+
return (
|
|
33
|
+
<div
|
|
34
|
+
style={{
|
|
35
|
+
position: 'absolute',
|
|
36
|
+
left: x,
|
|
37
|
+
top: y,
|
|
38
|
+
transform: `translate(${tx}, -50%) scale(${escala}) rotate(${-rot}deg)`,
|
|
39
|
+
transformOrigin: ancla === 'centro' ? 'center' : 'left center',
|
|
40
|
+
fontFamily: FAMILIA[fuente],
|
|
41
|
+
fontWeight: peso ?? (fuente === 'mont' ? 800 : fuente === 'mono' ? 700 : 400),
|
|
42
|
+
fontSize: size,
|
|
43
|
+
lineHeight: 1,
|
|
44
|
+
whiteSpace: 'pre',
|
|
45
|
+
color,
|
|
46
|
+
opacity: opacidad,
|
|
47
|
+
WebkitTextStroke: stroke ? `${stroke * 2}px ${NEGRO}` : undefined,
|
|
48
|
+
paintOrder: 'stroke fill',
|
|
49
|
+
filter: sombra ? 'drop-shadow(8px 10px 0 rgba(0,0,0,0.55))' : undefined,
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
{s}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Pastilla redonda con texto (etiquetas como "DATO CURIOSO"). */
|
|
58
|
+
export const Pastilla: React.FC<{cx: number; cy: number; s: string; size?: number; fondo: string; color?: string; padx?: number; pady?: number}> = ({
|
|
59
|
+
cx,
|
|
60
|
+
cy,
|
|
61
|
+
s,
|
|
62
|
+
size = 40,
|
|
63
|
+
fondo,
|
|
64
|
+
color = '#fff',
|
|
65
|
+
padx = 34,
|
|
66
|
+
pady = 16,
|
|
67
|
+
}) => {
|
|
68
|
+
const w = ancho(s, MONT, size, 900) + padx * 2;
|
|
69
|
+
const h = size + pady * 2;
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
style={{
|
|
73
|
+
position: 'absolute',
|
|
74
|
+
left: cx - w / 2,
|
|
75
|
+
top: cy - h / 2,
|
|
76
|
+
width: w,
|
|
77
|
+
height: h,
|
|
78
|
+
borderRadius: h,
|
|
79
|
+
background: fondo,
|
|
80
|
+
color,
|
|
81
|
+
fontFamily: MONT,
|
|
82
|
+
fontWeight: 900,
|
|
83
|
+
fontSize: size,
|
|
84
|
+
display: 'flex',
|
|
85
|
+
alignItems: 'center',
|
|
86
|
+
justifyContent: 'center',
|
|
87
|
+
whiteSpace: 'pre',
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
{s}
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Tamaño de cuadro para que un ícono quepa en una caja de `caja` px. */
|
|
96
|
+
export const tamIcono = (nombre: string, caja: number) => {
|
|
97
|
+
const g = (ICONOS[nombre] ?? ICONOS.question).grid;
|
|
98
|
+
return caja / Math.max(g[0].length, g.length * 1.15);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Ícono pixel art centrado en (cx, cy); `u` = tamaño de cada cuadro. */
|
|
102
|
+
export const Icono: React.FC<{nombre: string; cx: number; cy: number; u: number; color?: string; sombra?: boolean}> = ({nombre, cx, cy, u, color, sombra = true}) => {
|
|
103
|
+
if (u <= 0.05) return null;
|
|
104
|
+
const ic = ICONOS[nombre] ?? ICONOS.question;
|
|
105
|
+
const rows = ic.grid.length;
|
|
106
|
+
const cols = ic.grid[0].length;
|
|
107
|
+
const celdas: [number, number][] = [];
|
|
108
|
+
ic.grid.forEach((row, r) => [...row].forEach((ch, c) => ch === '1' && celdas.push([c, r])));
|
|
109
|
+
const w = cols * u + u * 0.3;
|
|
110
|
+
const h = rows * u + u * 0.3;
|
|
111
|
+
return (
|
|
112
|
+
<svg
|
|
113
|
+
style={{position: 'absolute', left: cx - (cols / 2) * u, top: cy - (rows / 2) * u, overflow: 'visible'}}
|
|
114
|
+
width={w}
|
|
115
|
+
height={h}
|
|
116
|
+
shapeRendering="crispEdges"
|
|
117
|
+
>
|
|
118
|
+
{sombra && celdas.map(([c, r]) => <rect key={`s${c}-${r}`} x={c * u + u * 0.22} y={r * u + u * 0.22} width={u + 0.5} height={u + 0.5} fill={NEGRO} />)}
|
|
119
|
+
{celdas.map(([c, r]) => (
|
|
120
|
+
<rect key={`${c}-${r}`} x={c * u} y={r * u} width={u + 0.5} height={u + 0.5} fill={color ?? ic.color} />
|
|
121
|
+
))}
|
|
122
|
+
</svg>
|
|
123
|
+
);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** Resplandor suave (elipse difuminada). */
|
|
127
|
+
export const Resplandor: React.FC<{cx: number; cy: number; rx: number; ry: number; color: string; fuerza: number}> = ({cx, cy, rx, ry, color, fuerza}) =>
|
|
128
|
+
fuerza <= 0 ? null : (
|
|
129
|
+
<div
|
|
130
|
+
style={{
|
|
131
|
+
position: 'absolute',
|
|
132
|
+
left: cx - rx * 1.6,
|
|
133
|
+
top: cy - ry * 1.6,
|
|
134
|
+
width: rx * 3.2,
|
|
135
|
+
height: ry * 3.2,
|
|
136
|
+
background: `radial-gradient(ellipse closest-side, ${color} 0%, ${color} 35%, transparent 100%)`,
|
|
137
|
+
opacity: fuerza,
|
|
138
|
+
}}
|
|
139
|
+
/>
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
/** Avatar redondo con aro blanco: el logo de la marca, o su inicial si no hay logo. */
|
|
143
|
+
export const Avatar: React.FC<{x: number; y: number; size: number; aro: number}> = ({x, y, size, aro}) => {
|
|
144
|
+
const marca = useMarca();
|
|
145
|
+
return (
|
|
146
|
+
<div
|
|
147
|
+
style={{
|
|
148
|
+
position: 'absolute',
|
|
149
|
+
left: x,
|
|
150
|
+
top: y,
|
|
151
|
+
width: size + aro * 2,
|
|
152
|
+
height: size + aro * 2,
|
|
153
|
+
borderRadius: '50%',
|
|
154
|
+
background: '#fff',
|
|
155
|
+
padding: aro,
|
|
156
|
+
boxSizing: 'border-box',
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
{marca.logo ? (
|
|
160
|
+
<Img src={staticFile(marca.logo)} style={{width: size, height: size, borderRadius: '50%', display: 'block', objectFit: 'cover'}} />
|
|
161
|
+
) : (
|
|
162
|
+
<div
|
|
163
|
+
style={{
|
|
164
|
+
width: size,
|
|
165
|
+
height: size,
|
|
166
|
+
borderRadius: '50%',
|
|
167
|
+
background: 'linear-gradient(#5A3CAA, #2A1650)',
|
|
168
|
+
color: '#fff',
|
|
169
|
+
fontFamily: ANTON,
|
|
170
|
+
fontSize: size * 0.55,
|
|
171
|
+
display: 'flex',
|
|
172
|
+
alignItems: 'center',
|
|
173
|
+
justifyContent: 'center',
|
|
174
|
+
}}
|
|
175
|
+
>
|
|
176
|
+
{(marca.nombre.trim()[0] || 'M').toUpperCase()}
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Formato de <publico>/v/<id>/datos.json (lo escribe python/preparar.py)
|
|
2
|
+
export type Fila = [string, string, number]; // [texto, color, tamaño]
|
|
3
|
+
|
|
4
|
+
export type Escena = {
|
|
5
|
+
tipo: string;
|
|
6
|
+
p: Record<string, any>; // los campos de la escena en videos.py (colores ya en #hex)
|
|
7
|
+
t0: number; // inicio de la escena en el video (s)
|
|
8
|
+
dur: number; // duración (s)
|
|
9
|
+
habla: [number, number]; // cuándo empieza y termina la voz, relativo a la escena (s)
|
|
10
|
+
ev: {at?: number; click?: number}; // momento de la palabra `at` / `click`, relativo a la escena (s)
|
|
11
|
+
palabras?: [string, number, number][]; // [palabra del subtítulo, inicio, fin], relativo a la escena (s)
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type Subtitulo = {a: number; b: number; w: [string, number, number][]};
|
|
15
|
+
|
|
16
|
+
export type Datos = {
|
|
17
|
+
id: string;
|
|
18
|
+
titulo: string;
|
|
19
|
+
indice: number;
|
|
20
|
+
duracion: number;
|
|
21
|
+
estilo?: string; // 'datos-curiosos' (clásico) o un estilo del motor de capas
|
|
22
|
+
tema?: Record<string, any>; // ajustes del estilo para este video
|
|
23
|
+
hud?: Record<string, any>[]; // capas fijas sobre todo el video (día, barra de progreso, hora...)
|
|
24
|
+
audio: string;
|
|
25
|
+
whisper: number;
|
|
26
|
+
escenas: Escena[];
|
|
27
|
+
subtitulos: Subtitulo[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Identidad del canal: sale en la etiqueta de arriba y en la escena "cta".
|
|
31
|
+
export type Marca = {
|
|
32
|
+
nombre: string; // "" = sin etiqueta
|
|
33
|
+
logo: string | null; // ruta dentro de la carpeta public (la copia morfeo), o null = inicial del nombre
|
|
34
|
+
redes: string; // línea pequeña bajo el botón de la escena "cta" ("" = nada)
|
|
35
|
+
seguir: string; // texto del botón de la escena "cta"
|
|
36
|
+
siguiendo: string; // texto del botón ya pulsado
|
|
37
|
+
etiqueta: string; // pastilla roja de la escena "title" (se cambia por video con `tag`)
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const MARCA_VACIA: Marca = {nombre: '', logo: null, redes: '', seguir: '+ SEGUIR', siguiendo: 'SIGUIENDO', etiqueta: 'DATO CURIOSO'};
|
|
41
|
+
|
|
42
|
+
// Props que recibe la composición: morfeo las pasa como inputProps
|
|
43
|
+
export type PropsMorfeo = {videos: Record<string, Datos>; marca: Marca; fuentes?: {familia: string; url: string}[]; estilos?: Record<string, any>};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Capa 3D con Three.js (vía @remotion/three): fondo de cubos flotando en profundidad
|
|
2
|
+
// e íconos pixel convertidos en voxels que giran y reciben luz.
|
|
3
|
+
// La cámara está calibrada para que en z = 0 una unidad sea un píxel de la pantalla:
|
|
4
|
+
// así los íconos se colocan con las mismas coordenadas (cx, cy) que el resto del diseño.
|
|
5
|
+
import {useThree} from '@react-three/fiber';
|
|
6
|
+
import {noise2D} from '@remotion/noise';
|
|
7
|
+
import {ThreeCanvas} from '@remotion/three';
|
|
8
|
+
import React, {useLayoutEffect, useMemo, useRef} from 'react';
|
|
9
|
+
import {random} from 'remotion';
|
|
10
|
+
import * as THREE from 'three';
|
|
11
|
+
import type {Icono3D} from '../escenas';
|
|
12
|
+
import {H, W, mezclaColor} from '../estilo';
|
|
13
|
+
import {ICONOS} from '../iconos';
|
|
14
|
+
|
|
15
|
+
const FOV = 30;
|
|
16
|
+
const DIST = H / 2 / Math.tan(((FOV / 2) * Math.PI) / 180);
|
|
17
|
+
const aMundo = (cx: number, cy: number): [number, number] => [cx - W / 2, H / 2 - cy];
|
|
18
|
+
// un objeto auxiliar para cada uso: compartirlo arrastraba escala y giro de un lado al otro
|
|
19
|
+
const auxVoxel = new THREE.Object3D();
|
|
20
|
+
const auxCubo = new THREE.Object3D();
|
|
21
|
+
|
|
22
|
+
// ---------- ícono voxel ----------
|
|
23
|
+
const celdasDe = (nombre: string) => {
|
|
24
|
+
const g = (ICONOS[nombre] ?? ICONOS.question).grid;
|
|
25
|
+
const out: [number, number][] = [];
|
|
26
|
+
g.forEach((row, r) => [...row].forEach((ch, c) => ch === '1' && out.push([c - g[0].length / 2 + 0.5, g.length / 2 - r - 0.5])));
|
|
27
|
+
return out;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const Voxel: React.FC<{ic: Icono3D; T: number}> = ({ic, T}) => {
|
|
31
|
+
const ref = useRef<THREE.InstancedMesh>(null);
|
|
32
|
+
const celdas = useMemo(() => celdasDe(ic.nombre), [ic.nombre]);
|
|
33
|
+
const color = ic.color ?? (ICONOS[ic.nombre] ?? ICONOS.question).color;
|
|
34
|
+
useLayoutEffect(() => {
|
|
35
|
+
const m = ref.current;
|
|
36
|
+
if (!m) return;
|
|
37
|
+
celdas.forEach(([x, y], i) => {
|
|
38
|
+
auxVoxel.position.set(x, y, 0);
|
|
39
|
+
auxVoxel.updateMatrix();
|
|
40
|
+
m.setMatrixAt(i, auxVoxel.matrix);
|
|
41
|
+
});
|
|
42
|
+
m.instanceMatrix.needsUpdate = true;
|
|
43
|
+
}, [celdas]);
|
|
44
|
+
const [x, y] = aMundo(ic.cx, ic.cy);
|
|
45
|
+
const fase = ic.fase ?? 0;
|
|
46
|
+
// balanceo suave + un giro completo al aparecer
|
|
47
|
+
const rotY = 0.38 * Math.sin(T * 1.6 + fase) + (ic.giro ?? 0) * Math.PI * 2;
|
|
48
|
+
const rotX = 0.14 * Math.sin(T * 1.1 + fase);
|
|
49
|
+
return (
|
|
50
|
+
<group position={[x, y, 0]} rotation={[rotX, rotY, 0]} scale={[ic.u, ic.u, ic.u]}>
|
|
51
|
+
<instancedMesh ref={ref} args={[undefined, undefined, celdas.length]} castShadow>
|
|
52
|
+
<boxGeometry args={[0.96, 0.96, 0.96]} />
|
|
53
|
+
<meshStandardMaterial color={color} roughness={0.45} metalness={0.05} emissive={color} emissiveIntensity={0.12} />
|
|
54
|
+
</instancedMesh>
|
|
55
|
+
</group>
|
|
56
|
+
);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// ---------- cubos de fondo ----------
|
|
60
|
+
const N_CUBOS = 46;
|
|
61
|
+
const CubosFondo: React.FC<{T: number; indice: number; glow: string}> = ({T, indice, glow}) => {
|
|
62
|
+
const ref = useRef<THREE.InstancedMesh>(null);
|
|
63
|
+
const datos = useMemo(
|
|
64
|
+
() =>
|
|
65
|
+
Array.from({length: N_CUBOS}, (_, i) => {
|
|
66
|
+
const r = (k: string) => random(`c3d${indice}-${i}-${k}`);
|
|
67
|
+
const z = -2600 * r('z') - 150; // profundidad
|
|
68
|
+
const escalaZ = (DIST - z) / DIST; // cuánto más grande se ve el mundo a esa distancia
|
|
69
|
+
return {
|
|
70
|
+
x: (r('x') - 0.5) * W * escalaZ * 1.1,
|
|
71
|
+
y0: (r('y') - 0.5) * H * escalaZ,
|
|
72
|
+
alto: H * escalaZ + 300,
|
|
73
|
+
z,
|
|
74
|
+
s: 14 + r('s') * 34,
|
|
75
|
+
v: 25 + r('v') * 70,
|
|
76
|
+
rx: r('rx') * 6,
|
|
77
|
+
ry: r('ry') * 6,
|
|
78
|
+
vr: 0.3 + r('vr') * 0.9,
|
|
79
|
+
c: Math.floor(r('c') * 4),
|
|
80
|
+
};
|
|
81
|
+
}),
|
|
82
|
+
[indice],
|
|
83
|
+
);
|
|
84
|
+
const colores = useMemo(() => [0.15, 0.35, 0.55, 0.75].map((k) => new THREE.Color(mezclaColor(glow, '#5A5A82', k))), [glow]);
|
|
85
|
+
useLayoutEffect(() => {
|
|
86
|
+
const m = ref.current;
|
|
87
|
+
if (!m) return;
|
|
88
|
+
datos.forEach((d, i) => {
|
|
89
|
+
const y = ((((d.y0 + d.v * T + d.alto / 2) % d.alto) + d.alto) % d.alto) - d.alto / 2;
|
|
90
|
+
auxCubo.position.set(d.x + 60 * noise2D(`c3d${indice}`, i * 0.37, T * 0.25), y, d.z);
|
|
91
|
+
auxCubo.rotation.set(d.rx + T * d.vr, d.ry + T * d.vr * 0.7, 0);
|
|
92
|
+
auxCubo.scale.setScalar(d.s);
|
|
93
|
+
auxCubo.updateMatrix();
|
|
94
|
+
m.setMatrixAt(i, auxCubo.matrix);
|
|
95
|
+
m.setColorAt(i, colores[d.c]);
|
|
96
|
+
});
|
|
97
|
+
m.instanceMatrix.needsUpdate = true;
|
|
98
|
+
if (m.instanceColor) m.instanceColor.needsUpdate = true;
|
|
99
|
+
});
|
|
100
|
+
return (
|
|
101
|
+
<instancedMesh ref={ref} args={[undefined, undefined, N_CUBOS]}>
|
|
102
|
+
<boxGeometry args={[1, 1, 1]} />
|
|
103
|
+
<meshStandardMaterial roughness={0.6} transparent opacity={0.85} />
|
|
104
|
+
</instancedMesh>
|
|
105
|
+
);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// La cámara de R3F solo toma `position` al crearse: aquí se mueve en cada fotograma.
|
|
109
|
+
const Camara: React.FC<{x: number; y: number}> = ({x, y}) => {
|
|
110
|
+
const camera = useThree((st) => st.camera);
|
|
111
|
+
useLayoutEffect(() => {
|
|
112
|
+
camera.position.set(x, y, DIST);
|
|
113
|
+
camera.lookAt(x * 0.3, y * 0.3, 0);
|
|
114
|
+
camera.updateProjectionMatrix();
|
|
115
|
+
});
|
|
116
|
+
return null;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// ---------- la capa completa ----------
|
|
120
|
+
export const Capa3D: React.FC<{
|
|
121
|
+
T: number;
|
|
122
|
+
indice: number;
|
|
123
|
+
glow: string;
|
|
124
|
+
niebla: string;
|
|
125
|
+
iconos: Icono3D[];
|
|
126
|
+
salida: number; // 0..1: la escena se va hacia arriba (misma salida que la capa 2D)
|
|
127
|
+
}> = ({T, indice, glow, niebla, iconos, salida}) => {
|
|
128
|
+
// la cámara se mece apenas, para que se sienta la profundidad
|
|
129
|
+
const cx = 40 * Math.sin(T * 0.35);
|
|
130
|
+
const cy = 30 * Math.sin(T * 0.27 + 1);
|
|
131
|
+
return (
|
|
132
|
+
<ThreeCanvas
|
|
133
|
+
width={W}
|
|
134
|
+
height={H}
|
|
135
|
+
style={{position: 'absolute', left: 0, top: 0}}
|
|
136
|
+
gl={{alpha: true, antialias: true, preserveDrawingBuffer: true}}
|
|
137
|
+
camera={{fov: FOV, near: 10, far: 9000, position: [0, 0, DIST]}}
|
|
138
|
+
onCreated={({gl}) => gl.setClearColor(0x000000, 0)}
|
|
139
|
+
>
|
|
140
|
+
<Camara x={cx} y={cy} />
|
|
141
|
+
<fog attach="fog" args={[niebla, DIST + 300, DIST + 2700]} />
|
|
142
|
+
<ambientLight intensity={0.9} />
|
|
143
|
+
<directionalLight position={[-600, 900, 1600]} intensity={2.4} />
|
|
144
|
+
<pointLight position={[0, 0, 900]} intensity={1.2} distance={4000} decay={0} color={glow} />
|
|
145
|
+
<CubosFondo T={T} indice={indice} glow={glow} />
|
|
146
|
+
<group position={[0, 70 * salida, 0]} scale={1 + 0.05 * salida}>
|
|
147
|
+
{iconos.map((ic, i) => (
|
|
148
|
+
<Voxel key={`${ic.nombre}-${i}`} ic={ic} T={T} />
|
|
149
|
+
))}
|
|
150
|
+
</group>
|
|
151
|
+
</ThreeCanvas>
|
|
152
|
+
);
|
|
153
|
+
};
|