tawasuyu

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Definiciones

⎇ main llimphi-anim/src/lib.rs Rust · 758 líneas · 25.1 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

1//! `llimphi-anim` — máquina de estados de animación, estilo Rive, **clip-agnóstica**.
2//!
3//! Es el escalón sobre el playback lineal: en vez de "reproduce esta animación",
4//! modela "según estos *inputs*, en qué estado estoy y a cuál transiciono, con
5//! qué *blend*". Es lo que vuelve una animación *interactiva* (hover, progreso,
6//! triggers) en lugar de un loop fijo.
7//!
8//! ## Por qué clip-agnóstico
9//!
10//! El núcleo **no sabe de Lottie ni de vello**. Un clip es un [`ClipId`] (u32)
11//! con una duración conocida; la máquina secuencia y mezcla clips por id y
12//! tiempo, y emite un [`RenderFrame`] que dice *qué* renderizar (clip primario +
13//! un clip entrante con su mezcla 0..1 durante una transición). El consumidor
14//! mapea `ClipId → asset` y pinta. Hoy ese consumidor es `llimphi-lottie`;
15//! mañana puede ser un rig de huesos nativo o un tween de `llimphi-motion` — el
16//! mismo motor de estados sirve a todos.
17//!
18//! ## Modelo (espejo del de Rive)
19//!
20//! - **Inputs**: `bool`, `number`, `trigger` (los triggers se consumen en cada
21//!   `advance`).
22//! - **Estado**: un clip con velocidad y loop, más sus transiciones salientes.
23//! - **Transición**: destino + condiciones (AND) sobre inputs + duración de
24//!   blend. Las transiciones *any-state* se evalúan sin importar el estado
25//!   actual (típico para "fire trigger → ir a X desde donde sea").
26//! - **Condición**: `bool == v`, `number <cmp> v`, `trigger disparado`, o
27//!   `clip terminó` (para "cuando la animación acaba, pasa a la siguiente").
28//!
29//! ## Bucle
30//!
31//! El consumidor empuja inputs ([`Instance::set_bool`] etc.), llama
32//! [`Instance::advance`] con el `dt` del frame, y pinta [`Instance::render_frame`].
33//! Encaja directo en el bucle Elm de Llimphi vía `Handle::spawn_periodic`.
34
35#![forbid(unsafe_code)]
36
37pub mod constraint;
38pub mod physics;
39pub mod skel;
40
41use std::collections::{HashMap, HashSet};
42use std::sync::Arc;
43
44/// Índice de un estado dentro de la máquina (devuelto por `add_state`).
45pub type StateId = usize;
46
47/// Identificador opaco de un clip de animación. Lo interpreta el consumidor
48/// (p. ej. índice en un `Vec<LottieAsset>` o clave de un mapa).
49pub type ClipId = u32;
50
51/// Operador de comparación para condiciones sobre inputs numéricos.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Cmp {
54    Eq,
55    Ne,
56    Lt,
57    Le,
58    Gt,
59    Ge,
60}
61
62impl Cmp {
63    fn test(self, a: f64, b: f64) -> bool {
64        match self {
65            Cmp::Eq => a == b,
66            Cmp::Ne => a != b,
67            Cmp::Lt => a < b,
68            Cmp::Le => a <= b,
69            Cmp::Gt => a > b,
70            Cmp::Ge => a >= b,
71        }
72    }
73}
74
75/// Condición de guarda de una transición. Una transición dispara cuando **todas**
76/// sus condiciones se cumplen (AND).
77#[derive(Debug, Clone)]
78pub enum Condition {
79    /// El input booleano `name` vale `value` (default `false` si no está seteado).
80    Bool { name: String, value: bool },
81    /// El input numérico `name` comparado con `value` por `op` (default `0.0`).
82    Number { name: String, op: Cmp, value: f64 },
83    /// El trigger `name` fue disparado en este frame (se consume al `advance`).
84    Trigger { name: String },
85    /// El clip del estado actual terminó (sólo aplica a estados no-loop).
86    ClipDone,
87}
88
89impl Condition {
90    /// `bool == value`.
91    pub fn bool(name: impl Into<String>, value: bool) -> Self {
92        Condition::Bool {
93            name: name.into(),
94            value,
95        }
96    }
97    /// `number <op> value`.
98    pub fn number(name: impl Into<String>, op: Cmp, value: f64) -> Self {
99        Condition::Number {
100            name: name.into(),
101            op,
102            value,
103        }
104    }
105    /// trigger disparado.
106    pub fn trigger(name: impl Into<String>) -> Self {
107        Condition::Trigger { name: name.into() }
108    }
109    /// el clip actual terminó.
110    pub fn clip_done() -> Self {
111        Condition::ClipDone
112    }
113}
114
115/// Región sensible al puntero, en coordenadas **normalizadas** `0..1` sobre el
116/// rect donde se pinta la animación (origen arriba-izquierda). Resolución- e
117/// independiente del tamaño del clip: el consumidor mapea el puntero de pantalla
118/// a este espacio.
119#[derive(Debug, Clone, Copy, PartialEq)]
120pub enum Area {
121    /// Toda la superficie de la animación.
122    All,
123    /// Un rectángulo normalizado.
124    Rect { x: f64, y: f64, w: f64, h: f64 },
125}
126
127impl Area {
128    fn contains(&self, px: f64, py: f64) -> bool {
129        match self {
130            Area::All => (0.0..=1.0).contains(&px) && (0.0..=1.0).contains(&py),
131            Area::Rect { x, y, w, h } => {
132                px >= *x && px <= x + w && py >= *y && py <= y + h
133            }
134        }
135    }
136}
137
138/// Qué evento de puntero dispara un [`Listener`].
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum PointerTrigger {
141    /// El puntero entró al área.
142    Enter,
143    /// El puntero salió del área.
144    Exit,
145    /// Se presionó el botón con el puntero dentro del área.
146    Down,
147    /// Se soltó el botón con el puntero dentro del área.
148    Up,
149    /// El puntero se movió dentro del área (cada movimiento).
150    Move,
151}
152
153/// Qué le hace un [`Listener`] a los inputs cuando dispara.
154#[derive(Debug, Clone)]
155pub enum Action {
156    /// Setea un input booleano.
157    SetBool { name: String, value: bool },
158    /// Dispara un trigger.
159    Fire { name: String },
160}
161
162impl Action {
163    pub fn set_bool(name: impl Into<String>, value: bool) -> Self {
164        Action::SetBool {
165            name: name.into(),
166            value,
167        }
168    }
169    pub fn fire(name: impl Into<String>) -> Self {
170        Action::Fire { name: name.into() }
171    }
172}
173
174/// Un *listener* estilo Rive: cuando el puntero hace `trigger` sobre `area`,
175/// aplica `action` a los inputs. Es el puente puntero → máquina de estados
176/// (hover/click → bool/trigger → transición).
177#[derive(Debug, Clone)]
178pub struct Listener {
179    pub area: Area,
180    pub trigger: PointerTrigger,
181    pub action: Action,
182}
183
184/// Una transición saliente: a qué estado, bajo qué condiciones, con cuánto blend.
185#[derive(Debug, Clone)]
186pub struct Transition {
187    pub to: StateId,
188    pub conditions: Vec<Condition>,
189    /// Duración del crossfade hacia el estado destino, en segundos. `0.0` =
190    /// instantáneo (sin mezcla).
191    pub duration_secs: f64,
192}
193
194/// Un estado de la máquina: un clip con su velocidad y loop, más sus salidas.
195#[derive(Debug, Clone)]
196struct State {
197    name: String,
198    clip: ClipId,
199    speed: f64,
200    looping: bool,
201    transitions: Vec<Transition>,
202}
203
204/// La máquina de estados *definición* (inmutable, compartible). Construida con
205/// `new` + `add_state`/`transition`, congelada en un `Arc` por `instance`.
206#[derive(Debug, Clone, Default)]
207pub struct StateMachine {
208    states: Vec<State>,
209    /// Duración conocida de cada clip (segundos). La necesita la condición
210    /// `ClipDone` y el wrap de tiempo.
211    clip_durations: HashMap<ClipId, f64>,
212    /// Transiciones evaluadas desde *cualquier* estado.
213    any: Vec<Transition>,
214    /// Listeners de puntero (hover/click → inputs).
215    listeners: Vec<Listener>,
216    entry: StateId,
217}
218
219impl StateMachine {
220    pub fn new() -> Self {
221        Self::default()
222    }
223
224    /// Agrega un estado-clip. Devuelve su `StateId`. El primero agregado es el
225    /// estado de entrada por defecto (cambialo con [`set_entry`]).
226    ///
227    /// [`set_entry`]: StateMachine::set_entry
228    pub fn add_state(
229        &mut self,
230        name: impl Into<String>,
231        clip: ClipId,
232        speed: f64,
233        looping: bool,
234    ) -> StateId {
235        let id = self.states.len();
236        self.states.push(State {
237            name: name.into(),
238            clip,
239            speed,
240            looping,
241            transitions: Vec::new(),
242        });
243        id
244    }
245
246    /// Registra la duración (segundos) de un clip — necesaria para `ClipDone`.
247    pub fn set_clip_duration(&mut self, clip: ClipId, secs: f64) {
248        self.clip_durations.insert(clip, secs);
249    }
250
251    /// Fija el estado de entrada.
252    pub fn set_entry(&mut self, entry: StateId) {
253        self.entry = entry;
254    }
255
256    /// Agrega una transición `from → to` con condiciones y blend.
257    pub fn transition(
258        &mut self,
259        from: StateId,
260        to: StateId,
261        conditions: Vec<Condition>,
262        duration_secs: f64,
263    ) {
264        self.states[from].transitions.push(Transition {
265            to,
266            conditions,
267            duration_secs,
268        });
269    }
270
271    /// Agrega una transición *any-state* `* → to` (se evalúa desde cualquier
272    /// estado, antes que las salientes del estado actual).
273    pub fn transition_any(
274        &mut self,
275        to: StateId,
276        conditions: Vec<Condition>,
277        duration_secs: f64,
278    ) {
279        self.any.push(Transition {
280            to,
281            conditions,
282            duration_secs,
283        });
284    }
285
286    /// Agrega un listener de puntero: cuando el puntero hace `trigger` sobre
287    /// `area`, aplica `action` a los inputs (que luego rigen transiciones).
288    pub fn listener(&mut self, area: Area, trigger: PointerTrigger, action: Action) {
289        self.listeners.push(Listener {
290            area,
291            trigger,
292            action,
293        });
294    }
295
296    /// Crea una instancia ejecutable de esta máquina.
297    pub fn instance(self) -> Instance {
298        let entry = self.entry;
299        Instance {
300            machine: Arc::new(self),
301            inputs: Inputs::default(),
302            current: entry,
303            state_time: 0.0,
304            active: None,
305            last_pointer: None,
306        }
307    }
308
309    fn clip_of(&self, state: StateId) -> ClipId {
310        self.states[state].clip
311    }
312    fn speed_of(&self, state: StateId) -> f64 {
313        self.states[state].speed
314    }
315    fn duration_of_clip(&self, clip: ClipId) -> Option<f64> {
316        self.clip_durations.get(&clip).copied()
317    }
318}
319
320/// Estado de los inputs de una instancia. Booleanos y números persisten; los
321/// triggers se limpian al final de cada `advance`.
322#[derive(Debug, Clone, Default)]
323struct Inputs {
324    bools: HashMap<String, bool>,
325    numbers: HashMap<String, f64>,
326    triggers: HashSet<String>,
327}
328
329/// Una transición en curso: hacia dónde, su tiempo propio y cuánto blend lleva.
330#[derive(Debug, Clone)]
331struct Active {
332    to: StateId,
333    to_time: f64,
334    elapsed: f64,
335    duration: f64,
336}
337
338/// Instancia ejecutable: inputs vivos + estado actual + transición en curso.
339/// `Clone` barato más allá de los inputs (la definición va por `Arc`).
340#[derive(Debug, Clone)]
341pub struct Instance {
342    machine: Arc<StateMachine>,
343    inputs: Inputs,
344    current: StateId,
345    state_time: f64,
346    active: Option<Active>,
347    /// Última posición del puntero en coords normalizadas `0..1` (`None` =
348    /// fuera de la animación). La usan los listeners de `Down`/`Up` y la
349    /// detección de `Enter`/`Exit`.
350    last_pointer: Option<(f64, f64)>,
351}
352
353/// Una muestra de clip a renderizar: qué clip y en qué instante (segundos).
354#[derive(Debug, Clone, Copy, PartialEq)]
355pub struct ClipSample {
356    pub clip: ClipId,
357    pub time_secs: f64,
358}
359
360/// Lo que el consumidor debe pintar este frame: el clip primario y, si hay una
361/// transición en curso, el clip entrante con su mezcla (`0.0`=nada del entrante,
362/// `1.0`=todo el entrante).
363#[derive(Debug, Clone, Copy, PartialEq)]
364pub struct RenderFrame {
365    pub primary: ClipSample,
366    pub blend: Option<(ClipSample, f32)>,
367}
368
369impl Instance {
370    /// Setea un input booleano.
371    pub fn set_bool(&mut self, name: impl Into<String>, value: bool) {
372        self.inputs.bools.insert(name.into(), value);
373    }
374    /// Setea un input numérico.
375    pub fn set_number(&mut self, name: impl Into<String>, value: f64) {
376        self.inputs.numbers.insert(name.into(), value);
377    }
378    /// Dispara un trigger (se consume en el próximo `advance`).
379    pub fn fire(&mut self, name: impl Into<String>) {
380        self.inputs.triggers.insert(name.into());
381    }
382
383    /// Nombre del estado actual (de origen mientras hay una transición en curso).
384    pub fn current_state(&self) -> &str {
385        &self.machine.states[self.current].name
386    }
387    /// ¿Hay una transición (crossfade) en curso?
388    pub fn is_transitioning(&self) -> bool {
389        self.active.is_some()
390    }
391
392    /// ¿El clip del estado actual terminó? (`false` si el estado es loop o si no
393    /// se registró su duración.)
394    fn clip_done(&self) -> bool {
395        let st = &self.machine.states[self.current];
396        if st.looping {
397            return false;
398        }
399        match self.machine.duration_of_clip(st.clip) {
400            Some(dur) => self.state_time >= dur,
401            None => false,
402        }
403    }
404
405    fn cond_met(&self, c: &Condition, clip_done: bool) -> bool {
406        match c {
407            Condition::Bool { name, value } => {
408                self.inputs.bools.get(name).copied().unwrap_or(false) == *value
409            }
410            Condition::Number { name, op, value } => {
411                op.test(self.inputs.numbers.get(name).copied().unwrap_or(0.0), *value)
412            }
413            Condition::Trigger { name } => self.inputs.triggers.contains(name),
414            Condition::ClipDone => clip_done,
415        }
416    }
417
418    fn transition_ready(&self, t: &Transition, clip_done: bool) -> bool {
419        // Sin condiciones = nunca dispara sola (evita loops degenerados);
420        // las transiciones útiles siempre tienen al menos una guarda.
421        !t.conditions.is_empty() && t.conditions.iter().all(|c| self.cond_met(c, clip_done))
422    }
423
424    /// Busca la primera transición disparable: any-state primero, luego las
425    /// salientes del estado actual. No retorna self-transiciones (to == current).
426    fn pick_transition(&self, clip_done: bool) -> Option<Transition> {
427        self.machine
428            .any
429            .iter()
430            .chain(self.machine.states[self.current].transitions.iter())
431            .find(|t| t.to != self.current && self.transition_ready(t, clip_done))
432            .cloned()
433    }
434
435    fn begin(&mut self, t: Transition) {
436        if t.duration_secs <= 0.0 {
437            // Instantáneo: saltamos sin blend.
438            self.current = t.to;
439            self.state_time = 0.0;
440            self.active = None;
441        } else {
442            self.active = Some(Active {
443                to: t.to,
444                to_time: 0.0,
445                elapsed: 0.0,
446                duration: t.duration_secs,
447            });
448        }
449    }
450
451    /// Avanza la transición en curso (si la hay) por `dt` y la confirma cuando
452    /// el blend completa. Aislado para poder aplicarlo tanto a una transición
453    /// que ya venía como a una recién arrancada en este mismo frame.
454    fn step_active(&mut self, dt: f64) {
455        let Some(at) = self.active.as_ref() else {
456            return;
457        };
458        let to = at.to;
459        let speed = self.machine.speed_of(to);
460        let at = self.active.as_mut().unwrap();
461        at.to_time += dt * speed;
462        at.elapsed += dt;
463        if at.elapsed >= at.duration {
464            let to_time = at.to_time;
465            self.current = to;
466            self.state_time = to_time;
467            self.active = None;
468        }
469    }
470
471    fn apply_action(&mut self, action: &Action) {
472        match action {
473            Action::SetBool { name, value } => {
474                self.inputs.bools.insert(name.clone(), *value);
475            }
476            Action::Fire { name } => {
477                self.inputs.triggers.insert(name.clone());
478            }
479        }
480    }
481
482    /// Reporta la posición del puntero en coords **normalizadas** `0..1` sobre
483    /// el rect de la animación, o `None` si el puntero salió. Dispara los
484    /// listeners `Enter`/`Exit`/`Move` correspondientes y deja la posición
485    /// guardada para los de `Down`/`Up`.
486    pub fn pointer_move(&mut self, pos: Option<(f64, f64)>) {
487        let machine = self.machine.clone();
488        let was = self.last_pointer;
489        for l in &machine.listeners {
490            let was_in = was.map_or(false, |(x, y)| l.area.contains(x, y));
491            let now_in = pos.map_or(false, |(x, y)| l.area.contains(x, y));
492            let fire = match l.trigger {
493                PointerTrigger::Enter => !was_in && now_in,
494                PointerTrigger::Exit => was_in && !now_in,
495                PointerTrigger::Move => now_in,
496                _ => false,
497            };
498            if fire {
499                self.apply_action(&l.action);
500            }
501        }
502        self.last_pointer = pos;
503    }
504
505    /// Botón presionado: dispara los listeners `Down` cuya área contiene la
506    /// última posición conocida del puntero.
507    pub fn pointer_down(&mut self) {
508        self.pointer_button(PointerTrigger::Down);
509    }
510
511    /// Botón soltado: dispara los listeners `Up`.
512    pub fn pointer_up(&mut self) {
513        self.pointer_button(PointerTrigger::Up);
514    }
515
516    fn pointer_button(&mut self, which: PointerTrigger) {
517        let Some((px, py)) = self.last_pointer else {
518            return;
519        };
520        let machine = self.machine.clone();
521        for l in &machine.listeners {
522            if l.trigger == which && l.area.contains(px, py) {
523                self.apply_action(&l.action);
524            }
525        }
526    }
527
528    /// Avanza la máquina `dt` segundos: corre el tiempo del estado, avanza/
529    /// confirma la transición en curso, evalúa una nueva transición (y le aplica
530    /// el `dt` de este frame si arranca) y consume los triggers.
531    pub fn advance(&mut self, dt: f64) {
532        let dt = dt.max(0.0);
533        self.state_time += dt * self.machine.speed_of(self.current);
534
535        // Transición que ya venía de frames anteriores.
536        self.step_active(dt);
537
538        // Nueva transición sólo si no estamos mezclando (sin interrupción de
539        // blend en el MVP). Si arranca, le aplicamos el dt de este frame para
540        // que el blend progrese (y se complete si el dt es grande).
541        if self.active.is_none() {
542            let done = self.clip_done();
543            if let Some(t) = self.pick_transition(done) {
544                self.begin(t);
545                self.step_active(dt);
546            }
547        }
548
549        self.inputs.triggers.clear();
550    }
551
552    /// Qué pintar este frame. Durante una transición, `primary` es el estado de
553    /// origen y `blend` el entrante con su mezcla 0..1.
554    pub fn render_frame(&self) -> RenderFrame {
555        let primary = ClipSample {
556            clip: self.machine.clip_of(self.current),
557            time_secs: self.state_time,
558        };
559        let blend = self.active.as_ref().map(|at| {
560            let mix = (at.elapsed / at.duration).clamp(0.0, 1.0) as f32;
561            (
562                ClipSample {
563                    clip: self.machine.clip_of(at.to),
564                    time_secs: at.to_time,
565                },
566                mix,
567            )
568        });
569        RenderFrame { primary, blend }
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    const IDLE: ClipId = 0;
578    const WALK: ClipId = 1;
579    const JUMP: ClipId = 2;
580
581    /// idle ⇄ walk por un bool "moving", con blend de 0.2 s.
582    fn idle_walk() -> StateMachine {
583        let mut sm = StateMachine::new();
584        let idle = sm.add_state("idle", IDLE, 1.0, true);
585        let walk = sm.add_state("walk", WALK, 1.0, true);
586        sm.set_entry(idle);
587        sm.transition(idle, walk, vec![Condition::bool("moving", true)], 0.2);
588        sm.transition(walk, idle, vec![Condition::bool("moving", false)], 0.2);
589        sm
590    }
591
592    #[test]
593    fn arranca_en_entry() {
594        let inst = idle_walk().instance();
595        assert_eq!(inst.current_state(), "idle");
596        assert_eq!(inst.render_frame().primary.clip, IDLE);
597        assert!(inst.render_frame().blend.is_none());
598    }
599
600    #[test]
601    fn bool_dispara_transicion_con_blend() {
602        let mut inst = idle_walk().instance();
603        inst.set_bool("moving", true);
604        inst.advance(0.1); // arranca la transición (dur 0.2)
605        assert!(inst.is_transitioning(), "debería estar mezclando");
606        let rf = inst.render_frame();
607        assert_eq!(rf.primary.clip, IDLE); // origen
608        let (incoming, mix) = rf.blend.expect("hay blend");
609        assert_eq!(incoming.clip, WALK);
610        assert!((mix - 0.5).abs() < 1e-6, "0.1/0.2 = 0.5, fue {mix}");
611        // Completar el blend.
612        inst.advance(0.15);
613        assert!(!inst.is_transitioning());
614        assert_eq!(inst.current_state(), "walk");
615        assert!(inst.render_frame().blend.is_none());
616    }
617
618    #[test]
619    fn vuelve_cuando_el_bool_se_apaga() {
620        let mut inst = idle_walk().instance();
621        inst.set_bool("moving", true);
622        inst.advance(0.3); // idle→walk completo
623        assert_eq!(inst.current_state(), "walk");
624        inst.set_bool("moving", false);
625        inst.advance(0.3); // walk→idle completo
626        assert_eq!(inst.current_state(), "idle");
627    }
628
629    #[test]
630    fn trigger_es_de_un_solo_frame() {
631        let mut sm = idle_walk();
632        let jump = sm.add_state("jump", JUMP, 1.0, false);
633        sm.set_clip_duration(JUMP, 0.5);
634        // any-state: fire "jump" → jump (instantáneo)
635        sm.transition_any(jump, vec![Condition::trigger("jump")], 0.0);
636        // jump termina → vuelve a idle
637        sm.transition(jump, 0, vec![Condition::clip_done()], 0.0);
638        let mut inst = sm.instance();
639
640        inst.fire("jump");
641        inst.advance(0.016);
642        assert_eq!(inst.current_state(), "jump");
643        // El trigger se consumió: avanzar de nuevo no re-dispara nada raro.
644        inst.advance(0.016);
645        assert_eq!(inst.current_state(), "jump");
646        // Cuando el clip de 0.5 s termina, vuelve a idle.
647        inst.advance(0.5);
648        assert_eq!(inst.current_state(), "idle");
649    }
650
651    #[test]
652    fn condicion_numerica() {
653        let mut sm = StateMachine::new();
654        let slow = sm.add_state("slow", IDLE, 1.0, true);
655        let fast = sm.add_state("fast", WALK, 1.0, true);
656        sm.set_entry(slow);
657        sm.transition(
658            slow,
659            fast,
660            vec![Condition::number("speed", Cmp::Gt, 5.0)],
661            0.0,
662        );
663        let mut inst = sm.instance();
664        inst.set_number("speed", 3.0);
665        inst.advance(0.016);
666        assert_eq!(inst.current_state(), "slow");
667        inst.set_number("speed", 9.0);
668        inst.advance(0.016);
669        assert_eq!(inst.current_state(), "fast");
670    }
671
672    #[test]
673    fn transicion_instantanea_sin_blend() {
674        let mut sm = StateMachine::new();
675        let a = sm.add_state("a", IDLE, 1.0, true);
676        let b = sm.add_state("b", WALK, 1.0, true);
677        sm.set_entry(a);
678        sm.transition(a, b, vec![Condition::bool("go", true)], 0.0);
679        let mut inst = sm.instance();
680        inst.set_bool("go", true);
681        inst.advance(0.016);
682        assert!(!inst.is_transitioning());
683        assert_eq!(inst.current_state(), "b");
684    }
685
686    #[test]
687    fn hover_enter_exit_maneja_un_bool() {
688        // idle ⇄ walk por "moving"; hover sobre toda la animación setea moving.
689        let mut sm = idle_walk();
690        sm.listener(Area::All, PointerTrigger::Enter, Action::set_bool("moving", true));
691        sm.listener(Area::All, PointerTrigger::Exit, Action::set_bool("moving", false));
692        let mut inst = sm.instance();
693
694        // Puntero entra al centro → moving=true → transiciona a walk.
695        inst.pointer_move(Some((0.5, 0.5)));
696        inst.advance(0.3);
697        assert_eq!(inst.current_state(), "walk");
698
699        // Puntero sale → moving=false → vuelve a idle.
700        inst.pointer_move(None);
701        inst.advance(0.3);
702        assert_eq!(inst.current_state(), "idle");
703    }
704
705    #[test]
706    fn click_dispara_trigger_en_su_area() {
707        let mut sm = idle_walk();
708        let jump = sm.add_state("jump", JUMP, 1.0, false);
709        sm.set_clip_duration(JUMP, 0.5);
710        sm.transition_any(jump, vec![Condition::trigger("tap")], 0.0);
711        sm.transition(jump, 0, vec![Condition::clip_done()], 0.0);
712        // Sólo la mitad derecha responde al click.
713        sm.listener(
714            Area::Rect { x: 0.5, y: 0.0, w: 0.5, h: 1.0 },
715            PointerTrigger::Down,
716            Action::fire("tap"),
717        );
718        let mut inst = sm.instance();
719
720        // Click en la mitad IZQUIERDA: fuera del área, no dispara.
721        inst.pointer_move(Some((0.2, 0.5)));
722        inst.pointer_down();
723        inst.advance(0.016);
724        assert_eq!(inst.current_state(), "idle");
725
726        // Click en la mitad DERECHA: dispara "tap" → jump.
727        inst.pointer_move(Some((0.8, 0.5)));
728        inst.pointer_down();
729        inst.advance(0.016);
730        assert_eq!(inst.current_state(), "jump");
731    }
732
733    #[test]
734    fn move_fuera_del_area_no_dispara() {
735        let mut sm = idle_walk();
736        sm.listener(
737            Area::Rect { x: 0.0, y: 0.0, w: 0.4, h: 0.4 },
738            PointerTrigger::Enter,
739            Action::set_bool("moving", true),
740        );
741        let mut inst = sm.instance();
742        // Entra pero a una zona fuera del rect del listener.
743        inst.pointer_move(Some((0.9, 0.9)));
744        inst.advance(0.3);
745        assert_eq!(inst.current_state(), "idle", "fuera del área no debe disparar");
746    }
747
748    #[test]
749    fn la_velocidad_escala_el_tiempo() {
750        let mut sm = StateMachine::new();
751        let s = sm.add_state("s", IDLE, 2.0, true);
752        sm.set_entry(s);
753        let mut inst = sm.instance();
754        inst.advance(1.0);
755        // speed 2.0 → 1 s real = 2 s de clip.
756        assert!((inst.render_frame().primary.time_secs - 2.0).abs() < 1e-9);
757    }
758}