gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 484Markdown 258TOML 131RON 38118 Sutichaykuna

⎇ 333dca1d5fb473825c9128d402d4cc4e2a5176fc llimphi-voxel/src/studio.rs Rust · 526 siq'ikuna · 21.4 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(3d): cosechar efectos genéricos de supay + refrescar voxel/director 333dca1d5 · Sergio ·

1//! `studio` — el **modelo-documento del creador de mundos**: un [`Project`]
2//! agnóstico de UI que junta los *artefactos* (mundos, personajes, escenas) bajo
3//! nombre, para que **una interfaz** los cree/edite y la **IA** los emita o lea.
4//!
5//! Es contenido puro y **serializable** (RON para edición a mano / salida de la IA;
6//! postcard para la CAS): no toca GPU ni ventana. La studio app (o cualquier otra)
7//! lo carga, lo pinta con sus widgets y lo guarda. Cada artefacto referencia tipos
8//! que ya existen en este crate ([`WorldRecipe`], [`Age`]) — el `Project` sólo les
9//! pone nombre y los agrupa.
10
11use serde::{Deserialize, Serialize};
12
13use crate::actor::{Actor, Age, Clip};
14use crate::director::{ActorKey, ActorScript};
15use crate::worldgen::WorldRecipe;
16use llimphi_3d::glam::Vec3;
17use llimphi_3d::Camera3d;
18
19/// Dimensión por defecto de la grilla con la que el editor previsualiza un mundo
20/// (cúbica en XZ, alto = 0.4·lado, mínimo 48) — el mismo criterio que la app.
21pub const PREVIEW_DIM_XZ: u32 = 128;
22
23/// **Dirección del sol** de la escena (hacia el sol, sin normalizar). Una sola
24/// fuente de verdad: la usa el preview/export para iluminar (`voxel.sun_dir`) y el
25/// plano [`ShotKind::Backlight`] para saber adónde mirar. Sol algo bajo → luz
26/// rasante (claroscuro) y, en contraluz, **god rays** legibles.
27pub const SCENE_SUN: [f32; 3] = [0.5, 0.36, 0.45];
28
29/// Calcula el `dim` `[x, y, z]` de un mundo de lado `xz` (alto derivado).
30pub fn world_dim(xz: u32) -> [u32; 3] {
31    let dy = (xz * 4 / 10).max(48);
32    [xz, dy, xz]
33}
34
35/// Un **mundo nombrado** del proyecto: nombre + su [`WorldRecipe`].
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct NamedWorld {
38    pub name: String,
39    pub recipe: WorldRecipe,
40}
41
42impl NamedWorld {
43    pub fn new(name: impl Into<String>, recipe: WorldRecipe) -> Self {
44        Self { name: name.into(), recipe }
45    }
46}
47
48/// **Especificación serializable de un personaje**: lo que un editor/IA fija (edad
49/// + colores). Se materializa con [`to_actor`](Self::to_actor) en un [`Actor`]
50/// posable. Los colores son `[r, g, b]` en `[0,1]` (como [`Actor`]).
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct CharSpec {
53    pub name: String,
54    pub age: Age,
55    pub skin: [f32; 3],
56    pub shirt: [f32; 3],
57    pub pants: [f32; 3],
58}
59
60impl CharSpec {
61    /// Un personaje con la paleta por defecto de [`Actor::new`] a la edad dada.
62    pub fn new(name: impl Into<String>, age: Age) -> Self {
63        let a = Actor::new(Vec3::ZERO, 0.0);
64        Self { name: name.into(), age, skin: a.skin, shirt: a.shirt, pants: a.pants }
65    }
66
67    /// Materializa el spec en un [`Actor`] parado en `pos` mirando a `facing`.
68    pub fn to_actor(&self, pos: Vec3, facing: f32) -> Actor {
69        Actor::new(pos, facing)
70            .with_age(self.age)
71            .with_colors(self.skin, self.shirt, self.pants)
72    }
73}
74
75/// **Keyframe serializable** de un actor (espejo de [`ActorKey`]): dónde está en
76/// la grilla en `t`, y opcionalmente qué clip reproduce y hacia dónde mira.
77#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
78pub struct ActorKeySpec {
79    pub t: f32,
80    pub gx: f32,
81    pub gz: f32,
82    #[serde(default)]
83    pub clip: Option<Clip>,
84    #[serde(default)]
85    pub face: Option<f32>,
86}
87
88impl ActorKeySpec {
89    /// Compila a un [`ActorKey`] del director.
90    pub fn to_key(self) -> ActorKey {
91        let mut k = ActorKey::at(self.t, self.gx, self.gz);
92        if let Some(c) = self.clip {
93            k = k.play(c);
94        }
95        if let Some(f) = self.face {
96            k = k.facing(f);
97        }
98        k
99    }
100}
101
102/// **Actor de una escena**: qué personaje del proyecto lo interpreta (`character`,
103/// índice en [`Project::characters`]) y su guion de keyframes.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct ActorSpec {
106    pub character: usize,
107    pub keys: Vec<ActorKeySpec>,
108    /// **Tasa de cuadros propia** del actor (`None` = fluido/nativo). Con un valor
109    /// bajo (12–15) el actor se anima *en doses* (stop-motion): es el sello que
110    /// separa al Héroe del Avatar. Ver [`ActorScript::quantize`].
111    #[serde(default)]
112    pub frame_rate: Option<u32>,
113}
114
115impl ActorSpec {
116    /// Compila los keyframes a un [`ActorScript`] reproducible (con su tasa de
117    /// cuadros propia, si la tiene).
118    pub fn to_script(&self) -> ActorScript {
119        ActorScript::new(self.keys.iter().map(|k| k.to_key()).collect())
120            .with_frame_rate(self.frame_rate)
121    }
122}
123
124/// **Tipo de plano** de cámara: un encuadre cinematográfico de alto nivel que se
125/// resuelve contra el **centroide del reparto** (no contra `eye/target` crudos), así
126/// es trivial de elegir y de generar por IA. [`resolve`](Self::resolve) produce la
127/// [`Camera3d`] del frame.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129pub enum ShotKind {
130    /// Establecedor: lejos y alto, presenta la escena.
131    Establishing,
132    /// Primer plano: cerca, a la altura del pecho.
133    CloseUp,
134    /// Lateral: desde el costado.
135    Side,
136    /// Órbita: gira lento alrededor del reparto.
137    Orbit,
138    /// **Contraluz**: cámara del lado opuesto al sol, mirando hacia él con el reparto
139    /// a contraluz (silueta en el tercio bajo) y el disco solar en lo alto del cuadro
140    /// → rim light + **god rays** (haces volumétricos). El plano "héroe" del motor.
141    Backlight,
142}
143
144impl ShotKind {
145    /// Todos los planos (para ciclar en un editor).
146    pub const ALL: [ShotKind; 5] = [
147        ShotKind::Establishing,
148        ShotKind::CloseUp,
149        ShotKind::Side,
150        ShotKind::Orbit,
151        ShotKind::Backlight,
152    ];
153
154    /// Nombre legible (español).
155    pub fn label(self) -> &'static str {
156        match self {
157            ShotKind::Establishing => "establecedor",
158            ShotKind::CloseUp => "primer plano",
159            ShotKind::Side => "lateral",
160            ShotKind::Orbit => "órbita",
161            ShotKind::Backlight => "contraluz",
162        }
163    }
164
165    /// El plano siguiente (cicla).
166    pub fn next(self) -> ShotKind {
167        let i = ShotKind::ALL.iter().position(|&k| k == self).unwrap_or(0);
168        ShotKind::ALL[(i + 1) % ShotKind::ALL.len()]
169    }
170
171    /// Resuelve la cámara del plano: mira a `look` (centroide del reparto, ya
172    /// elevado a la altura del pecho), con el ojo según el tipo, a distancia base
173    /// `d` (escala con el tamaño del reparto). `t` (seg) anima la órbita.
174    pub fn resolve(self, look: Vec3, d: f32, t: f32) -> Camera3d {
175        // `(eye, fov, target)` por tipo. Casi todos miran al reparto (`look`); el
176        // contraluz sube el objetivo hacia el sol para meterlo en el cuadro.
177        let (eye, fov, target) = match self {
178            ShotKind::Establishing => {
179                (look + Vec3::new(-0.5 * d, 0.9 * d, -1.6 * d), 50.0, look)
180            }
181            ShotKind::CloseUp => (look + Vec3::new(0.25 * d, 0.45 * d, -0.85 * d), 40.0, look),
182            ShotKind::Side => (look + Vec3::new(1.35 * d, 0.4 * d, 0.15 * d), 46.0, look),
183            ShotKind::Orbit => {
184                let a = t * 0.6;
185                (look + Vec3::new(a.cos() * 1.3 * d, 0.6 * d, a.sin() * 1.3 * d), 48.0, look)
186            }
187            ShotKind::Backlight => {
188                // Detrás del reparto respecto al sol (horizontal anti-sol), apenas
189                // elevado; el objetivo sube hacia el cielo → la cámara mira hacia el
190                // sol con el reparto a contraluz en el tercio bajo.
191                let s = Vec3::new(SCENE_SUN[0], SCENE_SUN[1], SCENE_SUN[2]).normalize();
192                let back = Vec3::new(-s.x, 0.0, -s.z).normalize();
193                let eye = look + back * (1.2 * d) + Vec3::new(0.0, 0.15 * d, 0.0);
194                let target = look + Vec3::new(0.0, 0.55 * d, 0.0);
195                (eye, 55.0, target)
196            }
197        };
198        Camera3d { eye, target, fovy_rad: fov_f32_to_rad(fov), ..Camera3d::default() }
199    }
200}
201
202/// Grados → radianes (helper local para no depender de glam en el call site).
203fn fov_f32_to_rad(deg: f32) -> f32 {
204    deg * std::f32::consts::PI / 180.0
205}
206
207/// Un **plano** de la escena: el tipo de encuadre y desde qué instante (seg) está
208/// activo. El plano vigente en `t` es el último con `start ≤ t` (corte duro entre
209/// planos).
210#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
211pub struct ShotSpec {
212    pub start: f32,
213    pub kind: ShotKind,
214}
215
216/// **Especificación serializable de una escena**: el mundo de fondo (`world`,
217/// índice en [`Project::worlds`]), la duración, el reparto guionado y los **planos**
218/// de cámara. Es la versión editable/IA-emisible del [`Sequence`](crate::Sequence)
219/// del director; se compila con [`scripts`](Self::scripts) y se reproduce posando
220/// cada actor en `sample(t)` con la cámara del plano vigente.
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct SceneSpec {
223    pub name: String,
224    pub world: usize,
225    pub duration: f32,
226    pub actors: Vec<ActorSpec>,
227    #[serde(default)]
228    pub shots: Vec<ShotSpec>,
229    /// **Cámara en mano**: intensidad del temblor orgánico (`0` = trípode fijo,
230    /// look de dron; `~1` = respiración/pulso de camarógrafo). Ensucia la cámara
231    /// matemáticamente perfecta del motor — es el sello que mete al espectador en
232    /// el "barro" de la escena. Ver [`handheld_shake`].
233    #[serde(default)]
234    pub handheld: f32,
235}
236
237/// **Temblor de cámara en mano**, determinista (función pura de `t` → la peli sale
238/// reproducible cuadro a cuadro). Suma de senos en frecuencias inconmensurables:
239/// una **respiración** lenta (bob vertical) + un **micro-pulso** rápido en los tres
240/// ejes para el ojo, y una **deriva** aún más lenta para el objetivo (el encuadre
241/// flota, no sólo tiembla). `amt ≤ 0` → sin offset (trípode). Devuelve
242/// `(offset_ojo, offset_objetivo)` en unidades de mundo, escalado un poco con la
243/// distancia `d` del plano para que también respire en planos lejanos.
244pub fn handheld_shake(t: f32, amt: f32, d: f32) -> (Vec3, Vec3) {
245    if amt <= 0.0 {
246        return (Vec3::ZERO, Vec3::ZERO);
247    }
248    let scale = amt * (1.0 + d * 0.03);
249    // Respiración (bob) + micro-pulso por eje (fases y frecuencias dispares).
250    let breath = (t * 1.7).sin() * 0.6 + (t * 0.9).sin() * 0.4;
251    let jx = (t * 9.3).sin() * 0.5 + (t * 4.7 + 1.3).sin() * 0.5;
252    let jy = (t * 8.1 + 2.1).sin() * 0.5 + (t * 5.3 + 0.7).sin() * 0.5;
253    let jz = (t * 7.4 + 0.4).sin() * 0.5 + (t * 3.9 + 2.7).sin() * 0.5;
254    let eye = Vec3::new(jx * 0.10, breath * 0.12 + jy * 0.08, jz * 0.10) * scale;
255    // Deriva del objetivo: más lenta y desfasada → el cuadro "busca" al sujeto.
256    let tgt = Vec3::new(
257        (t * 1.3 + 0.5).sin() * 0.06,
258        (t * 1.1 + 1.9).sin() * 0.05,
259        0.0,
260    ) * scale;
261    (eye, tgt)
262}
263
264impl SceneSpec {
265    /// Los guiones de los actores, listos para `sample(t)`.
266    pub fn scripts(&self) -> Vec<ActorScript> {
267        self.actors.iter().map(|a| a.to_script()).collect()
268    }
269
270    /// Los **instantes (seg) que merecen un acento musical**: los cortes de cámara
271    /// (inicio de cada plano salvo el primero) y los **gestos** de los actores (keys
272    /// con un clip *emote*). Es lo que deja caer la banda sonora *sobre la acción*.
273    /// Ordenados, sin repetir (dos a menos de `EPS` se funden). Espeja
274    /// [`Sequence::beat_times`](crate::Sequence::beat_times).
275    pub fn beat_times(&self) -> Vec<f32> {
276        const EPS: f32 = 0.05;
277        let mut ts: Vec<f32> = Vec::new();
278        for s in self.shots.iter().skip(1) {
279            ts.push(s.start);
280        }
281        for a in &self.actors {
282            for k in &a.keys {
283                if k.clip.is_some_and(|c| c.is_emote()) {
284                    ts.push(k.t);
285                }
286            }
287        }
288        ts.retain(|&t| t >= 0.0 && t <= self.duration + EPS);
289        ts.sort_by(f32::total_cmp);
290        ts.dedup_by(|a, b| (*a - *b).abs() < EPS);
291        ts
292    }
293
294    /// La **cámara de la escena** en `t`: resuelve el plano vigente mirando a
295    /// `look` (centroide del reparto) a distancia `d`, y le suma el temblor de
296    /// **cámara en mano** ([`handheld_shake`]) según [`Self::handheld`]. Es el
297    /// único punto por el que deberían pasar el preview y el export para que el
298    /// sello de cámara salga igual en ambos.
299    pub fn camera_at(&self, look: Vec3, d: f32, t: f32) -> Camera3d {
300        let mut cam = self.active_shot(t).resolve(look, d, t);
301        let (eo, to) = handheld_shake(t, self.handheld, d);
302        cam.eye += eo;
303        cam.target += to;
304        cam
305    }
306
307    /// El plano vigente en `t` (el último con `start ≤ t`); `Establishing` si no
308    /// hay planos definidos.
309    pub fn active_shot(&self, t: f32) -> ShotKind {
310        self.shots
311            .iter()
312            .filter(|s| s.start <= t)
313            .last()
314            .map(|s| s.kind)
315            .unwrap_or(ShotKind::Establishing)
316    }
317
318    /// **Escena patrón "entran y saludan"**: `n` actores entran caminando por el
319    /// centro del mundo de izquierda a derecha, se giran y hacen `gesture`. Coords
320    /// de grilla (la altura del terreno se aplica al reproducir). La base tanto del
321    /// arranque como de la generación por IA.
322    pub fn walk_and_emote(
323        name: impl Into<String>,
324        world: usize,
325        n: usize,
326        gesture: Clip,
327        dim: [u32; 3],
328    ) -> Self {
329        use std::f32::consts::{FRAC_PI_2, PI};
330        let n = n.clamp(1, 5);
331        let margin = 18.0_f32;
332        let gx0 = margin;
333        let gx1 = (dim[0] as f32 - margin).max(gx0 + 1.0);
334        let cz = dim[2] as f32 * 0.5;
335        let (t_walk, t_turn, dur) = (2.6_f32, 3.0_f32, 5.6_f32);
336
337        let mut actors = Vec::with_capacity(n);
338        for i in 0..n {
339            let off = (i as f32 - (n as f32 - 1.0) / 2.0) * 3.0;
340            let gz = cz + off;
341            actors.push(ActorSpec {
342                character: i,
343                keys: vec![
344                    ActorKeySpec { t: 0.0, gx: gx0, gz, clip: None, face: None },
345                    ActorKeySpec { t: t_walk, gx: gx1, gz, clip: None, face: Some(FRAC_PI_2) },
346                    ActorKeySpec { t: t_turn, gx: gx1, gz, clip: Some(gesture), face: Some(PI) },
347                    ActorKeySpec { t: dur, gx: gx1, gz, clip: Some(gesture), face: Some(PI) },
348                ],
349                // El **Héroe** (primer actor) se anima en doses (12 fps): se mueve a
350                // tirones, pesado, contra los demás (Avatares) que van fluidos. Es el
351                // sello de animación, visible ya en la escena de arranque.
352                frame_rate: if i == 0 { Some(12) } else { None },
353            });
354        }
355        // Tres planos: establecedor, **contraluz** a mitad de la caminata (el reparto
356        // entra al sol → luce los god rays), y primer plano en el gesto.
357        let shots = vec![
358            ShotSpec { start: 0.0, kind: ShotKind::Establishing },
359            ShotSpec { start: t_walk * 0.5, kind: ShotKind::Backlight },
360            ShotSpec { start: t_turn, kind: ShotKind::CloseUp },
361        ];
362        // Cámara en mano suave por defecto: el sello se ve sin tener que pedirlo.
363        Self { name: name.into(), world, duration: dur, actors, shots, handheld: 0.7 }
364    }
365}
366
367/// El **proyecto**: la bolsa de artefactos del creador (mundos, personajes,
368/// escenas). Vacío por defecto; [`starter`](Self::starter) trae algo que tocar.
369#[derive(Debug, Clone, Default, Serialize, Deserialize)]
370pub struct Project {
371    pub worlds: Vec<NamedWorld>,
372    #[serde(default)]
373    pub characters: Vec<CharSpec>,
374    #[serde(default)]
375    pub scenes: Vec<SceneSpec>,
376}
377
378impl Project {
379    /// Proyecto de arranque: el desierto y la pradera, un trío de personajes
380    /// distinguibles y una escena demo (entran y saludan en el desierto).
381    pub fn starter() -> Self {
382        let characters = vec![
383            CharSpec { name: "rojo".into(), age: Age::Adult, skin: [0.90, 0.72, 0.58], shirt: [0.82, 0.28, 0.26], pants: [0.20, 0.20, 0.28] },
384            CharSpec { name: "azul".into(), age: Age::Adult, skin: [0.86, 0.68, 0.54], shirt: [0.22, 0.55, 0.78], pants: [0.18, 0.20, 0.24] },
385            CharSpec { name: "amarillo".into(), age: Age::Adult, skin: [0.92, 0.78, 0.62], shirt: [0.92, 0.80, 0.30], pants: [0.26, 0.22, 0.20] },
386        ];
387        let dim = world_dim(PREVIEW_DIM_XZ);
388        Self {
389            worlds: vec![
390                NamedWorld::new("desierto", WorldRecipe::desert(1337)),
391                NamedWorld::new("pradera", WorldRecipe::grassland(1337)),
392            ],
393            characters,
394            scenes: vec![SceneSpec::walk_and_emote("saludo en el desierto", 0, 3, Clip::Wave, dim)],
395        }
396    }
397
398    /// Agrega un mundo y devuelve su índice.
399    pub fn add_world(&mut self, w: NamedWorld) -> usize {
400        self.worlds.push(w);
401        self.worlds.len() - 1
402    }
403
404    /// Agrega una escena y devuelve su índice.
405    pub fn add_scene(&mut self, s: SceneSpec) -> usize {
406        self.scenes.push(s);
407        self.scenes.len() - 1
408    }
409
410    /// Personaje `i`, o uno por defecto si el índice se sale (escenas que piden
411    /// más actores que personajes hay).
412    pub fn character_or_default(&self, i: usize) -> CharSpec {
413        self.characters
414            .get(i)
415            .cloned()
416            .unwrap_or_else(|| CharSpec::new("actor", Age::Adult))
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn proyecto_round_trip_ron() {
426        let p = Project::starter();
427        let s = ron::ser::to_string(&p).expect("serializa a ron");
428        let back: Project = ron::from_str(&s).expect("deserializa de ron");
429        assert_eq!(back.worlds.len(), p.worlds.len());
430        assert_eq!(back.worlds[0].name, "desierto");
431        // La receta sobrevive el viaje (un parámetro de muestra).
432        assert!((back.worlds[0].recipe.base - p.worlds[0].recipe.base).abs() < 1e-6);
433    }
434
435    #[test]
436    fn charspec_se_materializa_con_la_edad() {
437        let spec = CharSpec::new("nene", Age::Baby);
438        let actor = spec.to_actor(Vec3::ZERO, 0.0);
439        assert_eq!(actor.age, Age::Baby);
440    }
441
442    #[test]
443    fn world_dim_minimo_48_de_alto() {
444        assert_eq!(world_dim(64)[1], 48); // 64*0.4=25.6 → clamp a 48
445        assert_eq!(world_dim(192)[1], 76);
446    }
447
448    #[test]
449    fn escena_round_trip_y_compila_a_guiones() {
450        let dim = world_dim(128);
451        let s = SceneSpec::walk_and_emote("demo", 0, 3, Clip::Wave, dim);
452        // RON ida y vuelta.
453        let txt = ron::ser::to_string(&s).expect("ron");
454        let back: SceneSpec = ron::from_str(&txt).expect("de-ron");
455        assert_eq!(back.actors.len(), 3);
456        // Compila a guiones reproducibles: a mitad de la caminata el actor se movió.
457        let scripts = back.scripts();
458        let start = scripts[0].sample(0.0);
459        let mid = scripts[0].sample(1.3);
460        assert!(mid.gx > start.gx, "el actor avanza en X: {} → {}", start.gx, mid.gx);
461    }
462
463    #[test]
464    fn plano_vigente_corta_en_el_tiempo() {
465        let dim = world_dim(128);
466        let s = SceneSpec::walk_and_emote("demo", 0, 2, Clip::Wave, dim);
467        // Arranca en establecedor; tras el giro (t≈3) pasa a primer plano.
468        assert_eq!(s.active_shot(0.5), ShotKind::Establishing);
469        assert_eq!(s.active_shot(3.5), ShotKind::CloseUp);
470        // El plano resuelve una cámara que mira al centroide.
471        let look = Vec3::new(10.0, 2.0, 10.0);
472        let cam = ShotKind::CloseUp.resolve(look, 9.0, 1.0);
473        assert_eq!(cam.target, look);
474        assert!((cam.eye - look).length() > 1.0, "el ojo está separado del objetivo");
475    }
476
477    #[test]
478    fn beats_son_cortes_y_gestos() {
479        let dim = world_dim(128);
480        let s = SceneSpec::walk_and_emote("demo", 0, 2, Clip::Wave, dim);
481        // walk_and_emote: corte de cámara a t_turn (3.0) + el gesto Wave a t_turn.
482        // Caen en el mismo instante → se funden en un solo beat.
483        let beats = s.beat_times();
484        assert!(!beats.is_empty(), "hay al menos un acento");
485        assert!(beats.iter().all(|&t| t >= 0.0 && t <= s.duration + 0.1));
486        assert!(beats.iter().any(|&t| (t - 3.0).abs() < 0.2), "acento cerca del gesto/corte");
487    }
488
489    #[test]
490    fn camara_en_mano_es_determinista_y_apagable() {
491        // amt=0 → trípode: sin offset, exactamente cero.
492        let (e0, t0) = handheld_shake(1.234, 0.0, 30.0);
493        assert_eq!(e0, Vec3::ZERO);
494        assert_eq!(t0, Vec3::ZERO);
495
496        // amt>0 → tiembla (offset no nulo) y es función pura de t (reproducible).
497        let (e1, _) = handheld_shake(1.234, 0.7, 30.0);
498        let (e2, _) = handheld_shake(1.234, 0.7, 30.0);
499        assert_eq!(e1, e2, "mismo t → mismo temblor (peli reproducible)");
500        assert!(e1.length() > 0.0, "con intensidad la cámara se mueve");
501        // Instantes distintos → temblor distinto (no está congelado).
502        let (e3, _) = handheld_shake(1.235, 0.7, 30.0);
503        assert!((e1 - e3).length() > 0.0, "el temblor evoluciona en el tiempo");
504    }
505
506    #[test]
507    fn frame_rate_del_heroe_viaja_al_guion() {
508        let dim = world_dim(128);
509        let s = SceneSpec::walk_and_emote("demo", 0, 3, Clip::Wave, dim);
510        // El primer actor (Héroe) anima en doses; los demás, fluidos.
511        assert_eq!(s.actors[0].frame_rate, Some(12));
512        assert_eq!(s.actors[1].frame_rate, None);
513        // Y sobrevive la compilación a guion.
514        assert_eq!(s.scripts()[0].frame_rate(), Some(12));
515        // Cámara en mano por defecto encendida.
516        assert!(s.handheld > 0.0);
517    }
518
519    #[test]
520    fn starter_trae_escena_y_personajes() {
521        let p = Project::starter();
522        assert_eq!(p.characters.len(), 3);
523        assert_eq!(p.scenes.len(), 1);
524        assert_eq!(p.scenes[0].world, 0);
525    }
526}