tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
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}