tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 333dca1d5fb473825c9128d402d4cc4e2a5176fc
llimphi-voxel/src/studio.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(3d): cosechar efectos genéricos de supay + refrescar voxel/director
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}