Skip to main content

proof_engine/animation/
sprite_anim.rs

1//! Sprite Animation System — frame-by-frame ASCII art animation.
2//!
3//! Provides a `SpriteAnimator` that drives frame-based animations for entities
4//! in the Chaos RPG. Each animation is a sequence of `SpriteFrame`s, where each
5//! frame is a collection of positioned, colored glyphs forming the ASCII art.
6//!
7//! # Architecture
8//!
9//! ```text
10//! SpriteAnimator
11//!   ├─ animations: HashMap<String, SpriteAnimation>
12//!   ├─ current: String (active animation name)
13//!   ├─ frame_index: usize
14//!   ├─ frame_timer: f32
15//!   └─ state_machine: AnimationStateMachine
16//!         ├─ states: HashMap<String, AnimState>
17//!         ├─ transitions: Vec<AnimTransition>
18//!         └─ params: HashMap<String, f32>
19//! ```
20//!
21//! # Pre-built Animations
22//!
23//! The module includes ready-to-use animations for:
24//! - Player: idle, attack, cast, hurt, defend
25//! - Enemy: idle, attack, death
26//! - Bosses: unique idle and attack per boss type
27
28use glam::{Vec2, Vec4};
29use std::collections::HashMap;
30
31// ── FrameGlyph ──────────────────────────────────────────────────────────────
32
33/// A single glyph within a sprite frame, positioned relative to entity center.
34#[derive(Debug, Clone)]
35pub struct FrameGlyph {
36    pub character: char,
37    /// Offset from entity center in world units.
38    pub offset: Vec2,
39    /// RGBA color.
40    pub color: Vec4,
41    /// Emission intensity (0 = none, 1+ = glows).
42    pub emission: f32,
43    /// Scale multiplier (1.0 = normal).
44    pub scale: f32,
45}
46
47impl FrameGlyph {
48    pub fn new(ch: char, offset: Vec2, color: Vec4) -> Self {
49        Self { character: ch, offset, color, emission: 0.0, scale: 1.0 }
50    }
51
52    pub fn colored(ch: char, x: f32, y: f32, r: f32, g: f32, b: f32) -> Self {
53        Self::new(ch, Vec2::new(x, y), Vec4::new(r, g, b, 1.0))
54    }
55
56    pub fn white(ch: char, x: f32, y: f32) -> Self {
57        Self::colored(ch, x, y, 1.0, 1.0, 1.0)
58    }
59
60    pub fn with_emission(mut self, e: f32) -> Self { self.emission = e; self }
61    pub fn with_scale(mut self, s: f32) -> Self { self.scale = s; self }
62}
63
64// ── SpriteFrame ─────────────────────────────────────────────────────────────
65
66/// A single frame of a sprite animation — the complete ASCII art for one pose.
67#[derive(Debug, Clone)]
68pub struct SpriteFrame {
69    /// All glyphs making up this frame.
70    pub glyphs: Vec<FrameGlyph>,
71    /// Optional per-frame event tag (e.g. "hit", "cast_release").
72    pub event: Option<String>,
73    /// Optional per-frame duration override (overrides animation default).
74    pub duration_override: Option<f32>,
75}
76
77impl SpriteFrame {
78    pub fn new(glyphs: Vec<FrameGlyph>) -> Self {
79        Self { glyphs, event: None, duration_override: None }
80    }
81
82    pub fn with_event(mut self, event: impl Into<String>) -> Self {
83        self.event = Some(event.into());
84        self
85    }
86
87    pub fn with_duration(mut self, d: f32) -> Self {
88        self.duration_override = Some(d);
89        self
90    }
91
92    /// Build a frame from a grid string. Each non-space char becomes a glyph.
93    /// Lines are centered vertically; chars are spaced 1.0 apart horizontally.
94    pub fn from_ascii(art: &str, color: Vec4) -> Self {
95        let lines: Vec<&str> = art.lines().collect();
96        let height = lines.len() as f32;
97        let mut glyphs = Vec::new();
98
99        for (row, line) in lines.iter().enumerate() {
100            let width = line.len() as f32;
101            for (col, ch) in line.chars().enumerate() {
102                if ch == ' ' { continue; }
103                let x = col as f32 - width * 0.5;
104                let y = -(row as f32 - height * 0.5); // y-up
105                glyphs.push(FrameGlyph::new(ch, Vec2::new(x, y), color));
106            }
107        }
108
109        Self::new(glyphs)
110    }
111}
112
113// ── LoopMode ────────────────────────────────────────────────────────────────
114
115/// How an animation repeats.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum LoopMode {
118    /// Play once and stop on the last frame.
119    Once,
120    /// Loop from beginning after reaching the end.
121    Loop,
122    /// Play forward, then backward, then forward, etc.
123    PingPong,
124    /// Play once and signal completion (for state machine transitions).
125    OnceAndDone,
126}
127
128// ── SpriteAnimation ─────────────────────────────────────────────────────────
129
130/// A named animation consisting of ordered frames.
131#[derive(Debug, Clone)]
132pub struct SpriteAnimation {
133    pub name: String,
134    pub frames: Vec<SpriteFrame>,
135    /// Default seconds per frame.
136    pub frame_duration: f32,
137    pub loop_mode: LoopMode,
138    /// Speed multiplier (1.0 = normal, 2.0 = double speed).
139    pub speed: f32,
140}
141
142impl SpriteAnimation {
143    pub fn new(name: impl Into<String>, frames: Vec<SpriteFrame>, frame_duration: f32, loop_mode: LoopMode) -> Self {
144        Self {
145            name: name.into(),
146            frames,
147            frame_duration,
148            loop_mode,
149            speed: 1.0,
150        }
151    }
152
153    pub fn with_speed(mut self, s: f32) -> Self { self.speed = s; self }
154
155    /// Total duration of one play-through in seconds.
156    pub fn total_duration(&self) -> f32 {
157        let mut total = 0.0;
158        for frame in &self.frames {
159            total += frame.duration_override.unwrap_or(self.frame_duration);
160        }
161        total / self.speed.max(0.01)
162    }
163
164    /// Number of frames.
165    pub fn frame_count(&self) -> usize { self.frames.len() }
166
167    /// Duration of a specific frame.
168    pub fn frame_time(&self, index: usize) -> f32 {
169        self.frames.get(index)
170            .and_then(|f| f.duration_override)
171            .unwrap_or(self.frame_duration) / self.speed.max(0.01)
172    }
173}
174
175// ── Animation State Machine ─────────────────────────────────────────────────
176
177/// A state in the animation state machine.
178#[derive(Debug, Clone)]
179pub struct AnimState {
180    pub name: String,
181    /// Which animation to play in this state.
182    pub animation: String,
183    /// Speed multiplier for this state.
184    pub speed: f32,
185}
186
187impl AnimState {
188    pub fn new(name: impl Into<String>, animation: impl Into<String>) -> Self {
189        Self { name: name.into(), animation: animation.into(), speed: 1.0 }
190    }
191
192    pub fn with_speed(mut self, s: f32) -> Self { self.speed = s; self }
193}
194
195/// Condition for transitioning between animation states.
196#[derive(Debug, Clone)]
197pub enum AnimCondition {
198    /// Fires when a named parameter exceeds a threshold.
199    ParamGt { param: String, value: f32 },
200    /// Fires when a named parameter is below a threshold.
201    ParamLt { param: String, value: f32 },
202    /// Fires when a named trigger is set (consumed on transition).
203    Trigger(String),
204    /// Fires when the current animation has completed (LoopMode::Once/OnceAndDone).
205    AnimationDone,
206    /// Always true.
207    Always,
208}
209
210impl AnimCondition {
211    pub fn trigger(name: impl Into<String>) -> Self { Self::Trigger(name.into()) }
212    pub fn param_gt(name: impl Into<String>, v: f32) -> Self {
213        Self::ParamGt { param: name.into(), value: v }
214    }
215    pub fn param_lt(name: impl Into<String>, v: f32) -> Self {
216        Self::ParamLt { param: name.into(), value: v }
217    }
218}
219
220/// A transition between animation states.
221#[derive(Debug, Clone)]
222pub struct AnimTransition {
223    pub from: String,
224    pub to: String,
225    pub condition: AnimCondition,
226    /// Crossfade duration in seconds (0 = instant).
227    pub blend_time: f32,
228}
229
230impl AnimTransition {
231    pub fn new(from: impl Into<String>, to: impl Into<String>, condition: AnimCondition) -> Self {
232        Self {
233            from: from.into(),
234            to: to.into(),
235            condition,
236            blend_time: 0.0,
237        }
238    }
239
240    pub fn with_blend(mut self, t: f32) -> Self { self.blend_time = t; self }
241}
242
243/// Simple animation state machine.
244pub struct AnimationStateMachine {
245    pub states: HashMap<String, AnimState>,
246    pub transitions: Vec<AnimTransition>,
247    pub params: HashMap<String, f32>,
248    pub triggers: HashMap<String, bool>,
249    pub current_state: Option<String>,
250}
251
252impl AnimationStateMachine {
253    pub fn new() -> Self {
254        Self {
255            states: HashMap::new(),
256            transitions: Vec::new(),
257            params: HashMap::new(),
258            triggers: HashMap::new(),
259            current_state: None,
260        }
261    }
262
263    pub fn add_state(&mut self, state: AnimState) {
264        self.states.insert(state.name.clone(), state);
265    }
266
267    pub fn add_transition(&mut self, transition: AnimTransition) {
268        self.transitions.push(transition);
269    }
270
271    pub fn set_param(&mut self, name: &str, value: f32) {
272        self.params.insert(name.to_owned(), value);
273    }
274
275    pub fn set_trigger(&mut self, name: &str) {
276        self.triggers.insert(name.to_owned(), true);
277    }
278
279    pub fn start(&mut self, state: &str) {
280        self.current_state = Some(state.to_owned());
281    }
282
283    /// Evaluate transitions. Returns the new animation name if state changed.
284    pub fn evaluate(&mut self, animation_done: bool) -> Option<String> {
285        let current = self.current_state.as_ref()?;
286        let current = current.clone();
287
288        for trans in &self.transitions {
289            if trans.from != current && trans.from != "*" { continue; }
290
291            let satisfied = match &trans.condition {
292                AnimCondition::ParamGt { param, value } => {
293                    self.params.get(param).copied().unwrap_or(0.0) > *value
294                }
295                AnimCondition::ParamLt { param, value } => {
296                    self.params.get(param).copied().unwrap_or(0.0) < *value
297                }
298                AnimCondition::Trigger(name) => {
299                    self.triggers.get(name).copied().unwrap_or(false)
300                }
301                AnimCondition::AnimationDone => animation_done,
302                AnimCondition::Always => true,
303            };
304
305            if satisfied {
306                // Consume trigger
307                if let AnimCondition::Trigger(name) = &trans.condition {
308                    self.triggers.insert(name.clone(), false);
309                }
310
311                let new_state = trans.to.clone();
312                let anim = self.states.get(&new_state)
313                    .map(|s| s.animation.clone());
314                self.current_state = Some(new_state);
315                return anim;
316            }
317        }
318
319        None
320    }
321
322    /// Current animation name from the active state.
323    pub fn current_animation(&self) -> Option<&str> {
324        let state_name = self.current_state.as_ref()?;
325        self.states.get(state_name).map(|s| s.animation.as_str())
326    }
327}
328
329impl Default for AnimationStateMachine {
330    fn default() -> Self { Self::new() }
331}
332
333// ── SpriteAnimator ──────────────────────────────────────────────────────────
334
335/// Events emitted by the animator during playback.
336#[derive(Debug, Clone)]
337pub struct AnimEvent {
338    pub animation: String,
339    pub frame_index: usize,
340    pub tag: String,
341}
342
343/// Drives frame-by-frame sprite animations with an optional state machine.
344pub struct SpriteAnimator {
345    pub animations: HashMap<String, SpriteAnimation>,
346    current: String,
347    frame_index: usize,
348    frame_timer: f32,
349    playing: bool,
350    /// True if the animation finished this frame (for Once/OnceAndDone).
351    finished: bool,
352    /// PingPong direction: true = forward, false = backward.
353    ping_pong_forward: bool,
354    /// Optional state machine for automatic transitions.
355    pub state_machine: Option<AnimationStateMachine>,
356    /// Events fired since last drain.
357    pending_events: Vec<AnimEvent>,
358}
359
360impl SpriteAnimator {
361    pub fn new() -> Self {
362        Self {
363            animations: HashMap::new(),
364            current: String::new(),
365            frame_index: 0,
366            frame_timer: 0.0,
367            playing: false,
368            finished: false,
369            ping_pong_forward: true,
370            state_machine: None,
371            pending_events: Vec::new(),
372        }
373    }
374
375    /// Add an animation to the library.
376    pub fn add_animation(&mut self, anim: SpriteAnimation) {
377        self.animations.insert(anim.name.clone(), anim);
378    }
379
380    /// Play a named animation from the beginning.
381    pub fn play(&mut self, name: &str) {
382        if self.animations.contains_key(name) {
383            self.current = name.to_owned();
384            self.frame_index = 0;
385            self.frame_timer = 0.0;
386            self.playing = true;
387            self.finished = false;
388            self.ping_pong_forward = true;
389        }
390    }
391
392    /// Play only if not already playing this animation (avoids restart).
393    pub fn play_if_different(&mut self, name: &str) {
394        if self.current != name {
395            self.play(name);
396        }
397    }
398
399    /// Stop playback (freeze on current frame).
400    pub fn stop(&mut self) { self.playing = false; }
401
402    /// Resume playback.
403    pub fn resume(&mut self) { self.playing = true; }
404
405    /// Whether the current animation has finished (Once/OnceAndDone).
406    pub fn is_finished(&self) -> bool { self.finished }
407
408    /// Whether the animator is currently playing.
409    pub fn is_playing(&self) -> bool { self.playing }
410
411    /// Current animation name.
412    pub fn current_animation(&self) -> &str { &self.current }
413
414    /// Current frame index.
415    pub fn current_frame_index(&self) -> usize { self.frame_index }
416
417    /// Advance the animation by `dt` seconds.
418    pub fn tick(&mut self, dt: f32) {
419        self.finished = false;
420
421        // State machine evaluation
422        if let Some(ref mut sm) = self.state_machine {
423            if let Some(new_anim) = sm.evaluate(self.finished) {
424                self.play(&new_anim);
425            }
426        }
427
428        if !self.playing { return; }
429
430        let anim = match self.animations.get(&self.current) {
431            Some(a) => a.clone(), // clone to avoid borrow issues
432            None => return,
433        };
434
435        if anim.frames.is_empty() { return; }
436
437        let frame_dur = anim.frame_time(self.frame_index);
438        self.frame_timer += dt;
439
440        while self.frame_timer >= frame_dur && frame_dur > 0.0 {
441            self.frame_timer -= frame_dur;
442
443            // Fire frame event
444            if let Some(ref event) = anim.frames[self.frame_index].event {
445                self.pending_events.push(AnimEvent {
446                    animation: self.current.clone(),
447                    frame_index: self.frame_index,
448                    tag: event.clone(),
449                });
450            }
451
452            // Advance frame
453            match anim.loop_mode {
454                LoopMode::Loop => {
455                    self.frame_index = (self.frame_index + 1) % anim.frames.len();
456                }
457                LoopMode::Once => {
458                    if self.frame_index + 1 < anim.frames.len() {
459                        self.frame_index += 1;
460                    } else {
461                        self.playing = false;
462                        self.finished = true;
463                    }
464                }
465                LoopMode::OnceAndDone => {
466                    if self.frame_index + 1 < anim.frames.len() {
467                        self.frame_index += 1;
468                    } else {
469                        self.playing = false;
470                        self.finished = true;
471                    }
472                }
473                LoopMode::PingPong => {
474                    if self.ping_pong_forward {
475                        if self.frame_index + 1 < anim.frames.len() {
476                            self.frame_index += 1;
477                        } else {
478                            self.ping_pong_forward = false;
479                            if self.frame_index > 0 {
480                                self.frame_index -= 1;
481                            }
482                        }
483                    } else {
484                        if self.frame_index > 0 {
485                            self.frame_index -= 1;
486                        } else {
487                            self.ping_pong_forward = true;
488                            self.frame_index += 1;
489                        }
490                    }
491                }
492            }
493
494            // Re-check frame_dur for new frame
495            break;
496        }
497    }
498
499    /// Get the current frame's glyphs. Returns empty slice if no animation.
500    pub fn current_glyphs(&self) -> &[FrameGlyph] {
501        self.animations.get(&self.current)
502            .and_then(|a| a.frames.get(self.frame_index))
503            .map(|f| f.glyphs.as_slice())
504            .unwrap_or(&[])
505    }
506
507    /// Get the current SpriteFrame if one exists.
508    pub fn current_frame(&self) -> Option<&SpriteFrame> {
509        self.animations.get(&self.current)
510            .and_then(|a| a.frames.get(self.frame_index))
511    }
512
513    /// Drain all pending events.
514    pub fn drain_events(&mut self) -> Vec<AnimEvent> {
515        std::mem::take(&mut self.pending_events)
516    }
517}
518
519impl Default for SpriteAnimator {
520    fn default() -> Self { Self::new() }
521}
522
523// ═══════════════════════════════════════════════════════════════════════════
524// Pre-built animations for the Chaos RPG
525// ═══════════════════════════════════════════════════════════════════════════
526
527/// Pre-built animation library for the Chaos RPG.
528pub struct AnimationLibrary;
529
530impl AnimationLibrary {
531    // ── Color constants ─────────────────────────────────────────────────
532
533    fn white() -> Vec4 { Vec4::new(1.0, 1.0, 1.0, 1.0) }
534    fn red()   -> Vec4 { Vec4::new(1.0, 0.3, 0.2, 1.0) }
535    fn blue()  -> Vec4 { Vec4::new(0.3, 0.5, 1.0, 1.0) }
536    fn gold()  -> Vec4 { Vec4::new(1.0, 0.85, 0.3, 1.0) }
537    fn green() -> Vec4 { Vec4::new(0.3, 1.0, 0.4, 1.0) }
538    fn gray()  -> Vec4 { Vec4::new(0.5, 0.5, 0.5, 1.0) }
539    fn dark()  -> Vec4 { Vec4::new(0.2, 0.2, 0.2, 0.5) }
540    fn purple() -> Vec4 { Vec4::new(0.7, 0.2, 1.0, 1.0) }
541    fn cyan()  -> Vec4 { Vec4::new(0.2, 0.9, 1.0, 1.0) }
542    fn orange() -> Vec4 { Vec4::new(1.0, 0.5, 0.1, 1.0) }
543
544    // ── Player Animations ───────────────────────────────────────────────
545
546    /// Player idle: 2-frame breathing cycle.
547    pub fn player_idle() -> SpriteAnimation {
548        let c = Self::white();
549        let frame1 = SpriteFrame::new(vec![
550            FrameGlyph::white('O', 0.0, 2.0),       // head
551            FrameGlyph::white('|', 0.0, 1.0),        // torso
552            FrameGlyph::white('/', -1.0, 1.0),       // left arm
553            FrameGlyph::white('\\', 1.0, 1.0),       // right arm
554            FrameGlyph::white('|', 0.0, 0.0),        // waist
555            FrameGlyph::white('/', -0.5, -1.0),      // left leg
556            FrameGlyph::white('\\', 0.5, -1.0),      // right leg
557        ]);
558
559        // Slightly expanded (breathing in)
560        let frame2 = SpriteFrame::new(vec![
561            FrameGlyph::white('O', 0.0, 2.1),
562            FrameGlyph::white('|', 0.0, 1.0),
563            FrameGlyph::white('/', -1.1, 1.1),
564            FrameGlyph::white('\\', 1.1, 1.1),
565            FrameGlyph::white('|', 0.0, 0.0),
566            FrameGlyph::white('/', -0.5, -1.0),
567            FrameGlyph::white('\\', 0.5, -1.0),
568        ]);
569
570        SpriteAnimation::new("player_idle", vec![frame1, frame2], 0.6, LoopMode::PingPong)
571    }
572
573    /// Player attack: 4-frame swing animation.
574    pub fn player_attack() -> SpriteAnimation {
575        // Frame 1: Wind up — arm raised
576        let f1 = SpriteFrame::new(vec![
577            FrameGlyph::white('O', 0.0, 2.0),
578            FrameGlyph::white('|', 0.0, 1.0),
579            FrameGlyph::white('/', -1.0, 1.0),
580            FrameGlyph::colored('\\', 1.0, 2.0, 1.0, 0.9, 0.3),  // arm raised
581            FrameGlyph::colored('/', 1.5, 2.5, 0.8, 0.8, 0.8),   // weapon up
582            FrameGlyph::white('|', 0.0, 0.0),
583            FrameGlyph::white('/', -0.5, -1.0),
584            FrameGlyph::white('\\', 0.5, -1.0),
585        ]);
586
587        // Frame 2: Swing — arm forward, weapon arc
588        let f2 = SpriteFrame::new(vec![
589            FrameGlyph::white('O', 0.0, 2.0),
590            FrameGlyph::white('|', 0.0, 1.0),
591            FrameGlyph::white('/', -1.0, 1.0),
592            FrameGlyph::colored('-', 1.5, 1.0, 1.0, 0.9, 0.3),   // arm extended
593            FrameGlyph::colored('>', 2.5, 1.0, 1.0, 0.7, 0.2).with_emission(0.5), // weapon strike
594            FrameGlyph::white('|', 0.0, 0.0),
595            FrameGlyph::white('/', -0.5, -1.0),
596            FrameGlyph::white('\\', 0.5, -1.0),
597        ]).with_event("hit");
598
599        // Frame 3: Follow through
600        let f3 = SpriteFrame::new(vec![
601            FrameGlyph::white('O', 0.0, 2.0),
602            FrameGlyph::white('|', 0.0, 1.0),
603            FrameGlyph::white('/', -1.0, 1.0),
604            FrameGlyph::colored('\\', 1.5, 0.0, 1.0, 0.9, 0.3),   // arm down
605            FrameGlyph::colored('\\', 2.0, -0.5, 0.8, 0.8, 0.8),  // weapon low
606            FrameGlyph::white('|', 0.0, 0.0),
607            FrameGlyph::white('/', -0.5, -1.0),
608            FrameGlyph::white('\\', 0.5, -1.0),
609        ]);
610
611        // Frame 4: Return to ready
612        let f4 = SpriteFrame::new(vec![
613            FrameGlyph::white('O', 0.0, 2.0),
614            FrameGlyph::white('|', 0.0, 1.0),
615            FrameGlyph::white('/', -1.0, 1.0),
616            FrameGlyph::white('\\', 1.0, 1.0),
617            FrameGlyph::white('|', 0.0, 0.0),
618            FrameGlyph::white('/', -0.5, -1.0),
619            FrameGlyph::white('\\', 0.5, -1.0),
620        ]);
621
622        SpriteAnimation::new("player_attack", vec![f1, f2, f3, f4], 0.08, LoopMode::OnceAndDone)
623    }
624
625    /// Player cast: 3-frame spell casting.
626    pub fn player_cast() -> SpriteAnimation {
627        // Frame 1: Arms raise
628        let f1 = SpriteFrame::new(vec![
629            FrameGlyph::white('O', 0.0, 2.0),
630            FrameGlyph::white('|', 0.0, 1.0),
631            FrameGlyph::colored('/', -1.0, 2.0, 0.3, 0.5, 1.0),   // left arm up
632            FrameGlyph::colored('\\', 1.0, 2.0, 0.3, 0.5, 1.0),   // right arm up
633            FrameGlyph::white('|', 0.0, 0.0),
634            FrameGlyph::white('/', -0.5, -1.0),
635            FrameGlyph::white('\\', 0.5, -1.0),
636        ]);
637
638        // Frame 2: Glow brightens
639        let f2 = SpriteFrame::new(vec![
640            FrameGlyph::white('O', 0.0, 2.0),
641            FrameGlyph::white('|', 0.0, 1.0),
642            FrameGlyph::colored('/', -1.0, 2.0, 0.3, 0.5, 1.0),
643            FrameGlyph::colored('\\', 1.0, 2.0, 0.3, 0.5, 1.0),
644            FrameGlyph::colored('*', 0.0, 2.5, 0.5, 0.7, 1.0).with_emission(1.0), // glow
645            FrameGlyph::colored('·', -0.5, 2.8, 0.4, 0.6, 1.0).with_emission(0.6),
646            FrameGlyph::colored('·', 0.5, 2.8, 0.4, 0.6, 1.0).with_emission(0.6),
647            FrameGlyph::white('|', 0.0, 0.0),
648            FrameGlyph::white('/', -0.5, -1.0),
649            FrameGlyph::white('\\', 0.5, -1.0),
650        ]);
651
652        // Frame 3: Release
653        let f3 = SpriteFrame::new(vec![
654            FrameGlyph::white('O', 0.0, 2.0),
655            FrameGlyph::white('|', 0.0, 1.0),
656            FrameGlyph::colored('-', -1.5, 1.5, 0.3, 0.5, 1.0),   // arms thrust forward
657            FrameGlyph::colored('-', 1.5, 1.5, 0.3, 0.5, 1.0),
658            FrameGlyph::colored('★', 0.0, 3.0, 0.5, 0.8, 1.0).with_emission(1.5), // spell release
659            FrameGlyph::white('|', 0.0, 0.0),
660            FrameGlyph::white('/', -0.5, -1.0),
661            FrameGlyph::white('\\', 0.5, -1.0),
662        ]).with_event("cast_release");
663
664        SpriteAnimation::new("player_cast", vec![f1, f2, f3], 0.12, LoopMode::OnceAndDone)
665    }
666
667    /// Player hurt: 2-frame recoil.
668    pub fn player_hurt() -> SpriteAnimation {
669        // Frame 1: Lean back
670        let f1 = SpriteFrame::new(vec![
671            FrameGlyph::colored('O', -0.3, 2.1, 1.0, 0.5, 0.5),
672            FrameGlyph::colored('\\', -0.2, 1.0, 1.0, 0.5, 0.5),
673            FrameGlyph::colored('/', -1.3, 0.8, 1.0, 0.5, 0.5),
674            FrameGlyph::colored('\\', 0.8, 0.8, 1.0, 0.5, 0.5),
675            FrameGlyph::colored('|', -0.1, 0.0, 1.0, 0.5, 0.5),
676            FrameGlyph::colored('/', -0.6, -1.0, 1.0, 0.5, 0.5),
677            FrameGlyph::colored('\\', 0.4, -1.0, 1.0, 0.5, 0.5),
678        ]);
679
680        // Frame 2: Recovery
681        let f2 = SpriteFrame::new(vec![
682            FrameGlyph::white('O', 0.0, 2.0),
683            FrameGlyph::white('|', 0.0, 1.0),
684            FrameGlyph::white('/', -1.0, 1.0),
685            FrameGlyph::white('\\', 1.0, 1.0),
686            FrameGlyph::white('|', 0.0, 0.0),
687            FrameGlyph::white('/', -0.5, -1.0),
688            FrameGlyph::white('\\', 0.5, -1.0),
689        ]);
690
691        SpriteAnimation::new("player_hurt", vec![f1, f2], 0.15, LoopMode::OnceAndDone)
692    }
693
694    /// Player defend: 2-frame block.
695    pub fn player_defend() -> SpriteAnimation {
696        // Frame 1: Arms cross (blocking)
697        let f1 = SpriteFrame::new(vec![
698            FrameGlyph::white('O', 0.0, 2.0),
699            FrameGlyph::white('|', 0.0, 1.0),
700            FrameGlyph::colored('X', 0.0, 1.5, 0.7, 0.9, 1.0),  // crossed arms
701            FrameGlyph::colored('[', -0.5, 1.5, 0.5, 0.5, 0.6),  // shield left
702            FrameGlyph::colored(']', 0.5, 1.5, 0.5, 0.5, 0.6),   // shield right
703            FrameGlyph::white('|', 0.0, 0.0),
704            FrameGlyph::white('/', -0.5, -1.0),
705            FrameGlyph::white('\\', 0.5, -1.0),
706        ]);
707
708        // Frame 2: Hold (slight shimmer)
709        let f2 = SpriteFrame::new(vec![
710            FrameGlyph::white('O', 0.0, 2.0),
711            FrameGlyph::white('|', 0.0, 1.0),
712            FrameGlyph::colored('X', 0.0, 1.5, 0.8, 1.0, 1.0).with_emission(0.3),
713            FrameGlyph::colored('[', -0.5, 1.5, 0.6, 0.6, 0.7),
714            FrameGlyph::colored(']', 0.5, 1.5, 0.6, 0.6, 0.7),
715            FrameGlyph::white('|', 0.0, 0.0),
716            FrameGlyph::white('/', -0.5, -1.0),
717            FrameGlyph::white('\\', 0.5, -1.0),
718        ]);
719
720        SpriteAnimation::new("player_defend", vec![f1, f2], 0.3, LoopMode::Loop)
721    }
722
723    // ── Enemy Animations ────────────────────────────────────────────────
724
725    /// Enemy idle: 2-frame shift.
726    pub fn enemy_idle() -> SpriteAnimation {
727        let c = Self::red();
728        let f1 = SpriteFrame::new(vec![
729            FrameGlyph::colored('▼', 0.0, 2.0, 1.0, 0.3, 0.2),
730            FrameGlyph::colored('█', 0.0, 1.0, 0.8, 0.2, 0.1),
731            FrameGlyph::colored('/', -1.0, 0.5, 0.8, 0.2, 0.1),
732            FrameGlyph::colored('\\', 1.0, 0.5, 0.8, 0.2, 0.1),
733            FrameGlyph::colored('▲', -0.5, -0.5, 0.6, 0.15, 0.1),
734            FrameGlyph::colored('▲', 0.5, -0.5, 0.6, 0.15, 0.1),
735        ]);
736
737        let f2 = SpriteFrame::new(vec![
738            FrameGlyph::colored('▼', 0.2, 2.0, 1.0, 0.3, 0.2),
739            FrameGlyph::colored('█', 0.2, 1.0, 0.8, 0.2, 0.1),
740            FrameGlyph::colored('/', -0.8, 0.5, 0.8, 0.2, 0.1),
741            FrameGlyph::colored('\\', 1.2, 0.5, 0.8, 0.2, 0.1),
742            FrameGlyph::colored('▲', -0.3, -0.5, 0.6, 0.15, 0.1),
743            FrameGlyph::colored('▲', 0.7, -0.5, 0.6, 0.15, 0.1),
744        ]);
745
746        SpriteAnimation::new("enemy_idle", vec![f1, f2], 0.5, LoopMode::PingPong)
747    }
748
749    /// Enemy attack: 3-frame lunge.
750    pub fn enemy_attack() -> SpriteAnimation {
751        // Lean forward
752        let f1 = SpriteFrame::new(vec![
753            FrameGlyph::colored('▼', 0.5, 2.0, 1.0, 0.3, 0.2),
754            FrameGlyph::colored('█', 0.3, 1.0, 0.8, 0.2, 0.1),
755            FrameGlyph::colored('/', -0.5, 0.8, 0.8, 0.2, 0.1),
756            FrameGlyph::colored('-', 1.5, 1.0, 1.0, 0.3, 0.2),
757            FrameGlyph::colored('▲', -0.3, -0.5, 0.6, 0.15, 0.1),
758            FrameGlyph::colored('▲', 0.5, -0.5, 0.6, 0.15, 0.1),
759        ]);
760
761        // Strike
762        let f2 = SpriteFrame::new(vec![
763            FrameGlyph::colored('▼', 1.0, 1.8, 1.0, 0.4, 0.2),
764            FrameGlyph::colored('█', 0.5, 1.0, 0.8, 0.2, 0.1),
765            FrameGlyph::colored('/', -0.3, 0.8, 0.8, 0.2, 0.1),
766            FrameGlyph::colored('>', 2.0, 1.0, 1.0, 0.5, 0.2).with_emission(0.8),
767            FrameGlyph::colored('▲', -0.1, -0.5, 0.6, 0.15, 0.1),
768            FrameGlyph::colored('▲', 0.7, -0.5, 0.6, 0.15, 0.1),
769        ]).with_event("hit");
770
771        // Return
772        let f3 = SpriteFrame::new(vec![
773            FrameGlyph::colored('▼', 0.0, 2.0, 1.0, 0.3, 0.2),
774            FrameGlyph::colored('█', 0.0, 1.0, 0.8, 0.2, 0.1),
775            FrameGlyph::colored('/', -1.0, 0.5, 0.8, 0.2, 0.1),
776            FrameGlyph::colored('\\', 1.0, 0.5, 0.8, 0.2, 0.1),
777            FrameGlyph::colored('▲', -0.5, -0.5, 0.6, 0.15, 0.1),
778            FrameGlyph::colored('▲', 0.5, -0.5, 0.6, 0.15, 0.1),
779        ]);
780
781        SpriteAnimation::new("enemy_attack", vec![f1, f2, f3], 0.1, LoopMode::OnceAndDone)
782    }
783
784    /// Enemy death: 4-frame dissolution.
785    pub fn enemy_death() -> SpriteAnimation {
786        // Intact
787        let f1 = SpriteFrame::new(vec![
788            FrameGlyph::colored('▼', 0.0, 2.0, 1.0, 0.3, 0.2),
789            FrameGlyph::colored('█', 0.0, 1.0, 0.8, 0.2, 0.1),
790            FrameGlyph::colored('/', -1.0, 0.5, 0.8, 0.2, 0.1),
791            FrameGlyph::colored('\\', 1.0, 0.5, 0.8, 0.2, 0.1),
792            FrameGlyph::colored('▲', -0.5, -0.5, 0.6, 0.15, 0.1),
793            FrameGlyph::colored('▲', 0.5, -0.5, 0.6, 0.15, 0.1),
794        ]);
795
796        // Cracking
797        let f2 = SpriteFrame::new(vec![
798            FrameGlyph::colored('▼', 0.1, 2.0, 0.8, 0.3, 0.2),
799            FrameGlyph::colored('░', 0.0, 1.0, 0.7, 0.2, 0.1),
800            FrameGlyph::colored('/', -1.2, 0.3, 0.6, 0.15, 0.1),
801            FrameGlyph::colored('\\', 1.2, 0.3, 0.6, 0.15, 0.1),
802            FrameGlyph::colored('·', -0.5, -0.5, 0.5, 0.1, 0.1),
803            FrameGlyph::colored('·', 0.5, -0.5, 0.5, 0.1, 0.1),
804        ]);
805
806        // Scattering
807        let f3 = SpriteFrame::new(vec![
808            FrameGlyph::colored('·', 0.3, 2.3, 0.5, 0.2, 0.15),
809            FrameGlyph::colored('░', -0.2, 1.2, 0.4, 0.15, 0.1),
810            FrameGlyph::colored('·', -1.5, 0.1, 0.3, 0.1, 0.05),
811            FrameGlyph::colored('·', 1.5, 0.1, 0.3, 0.1, 0.05),
812            FrameGlyph::colored('·', 0.0, -0.8, 0.2, 0.05, 0.05),
813        ]);
814
815        // Gone
816        let f4 = SpriteFrame::new(vec![
817            FrameGlyph::colored('·', 0.5, 2.5, 0.2, 0.1, 0.1),
818            FrameGlyph::colored('·', -0.5, 0.5, 0.15, 0.05, 0.05),
819        ]).with_event("death_complete");
820
821        SpriteAnimation::new("enemy_death", vec![f1, f2, f3, f4], 0.2, LoopMode::OnceAndDone)
822    }
823
824    // ── Boss Animations ─────────────────────────────────────────────────
825
826    /// Get idle animation for a boss by name.
827    pub fn boss_idle(boss_name: &str) -> SpriteAnimation {
828        match boss_name {
829            "Mirror" => Self::boss_mirror_idle(),
830            "Null" => Self::boss_null_idle(),
831            "Committee" => Self::boss_committee_idle(),
832            "FibonacciHydra" => Self::boss_hydra_idle(),
833            "Eigenstate" => Self::boss_eigenstate_idle(),
834            "Ouroboros" => Self::boss_ouroboros_idle(),
835            "AlgorithmReborn" => Self::boss_algorithm_idle(),
836            "ChaosWeaver" => Self::boss_chaos_weaver_idle(),
837            "VoidSerpent" => Self::boss_void_serpent_idle(),
838            "PrimeFactorial" => Self::boss_prime_idle(),
839            _ => Self::enemy_idle(),
840        }
841    }
842
843    /// Get attack animation for a boss by name.
844    pub fn boss_attack(boss_name: &str) -> SpriteAnimation {
845        match boss_name {
846            "Mirror" => Self::boss_mirror_attack(),
847            "Null" => Self::boss_null_attack(),
848            "Committee" => Self::boss_committee_attack(),
849            "FibonacciHydra" => Self::boss_hydra_attack(),
850            "Eigenstate" => Self::boss_eigenstate_attack(),
851            "Ouroboros" => Self::boss_ouroboros_attack(),
852            "AlgorithmReborn" => Self::boss_algorithm_attack(),
853            "ChaosWeaver" => Self::boss_chaos_weaver_attack(),
854            "VoidSerpent" => Self::boss_void_serpent_attack(),
855            "PrimeFactorial" => Self::boss_prime_attack(),
856            _ => Self::enemy_attack(),
857        }
858    }
859
860    // ── Individual boss idle animations ─────────────────────────────────
861
862    fn boss_mirror_idle() -> SpriteAnimation {
863        let f1 = SpriteFrame::new(vec![
864            FrameGlyph::colored('◇', 0.0, 3.0, 0.8, 0.9, 1.0).with_emission(0.5),
865            FrameGlyph::colored('│', 0.0, 2.0, 0.7, 0.8, 0.9),
866            FrameGlyph::colored('◇', -1.0, 1.0, 0.6, 0.7, 0.8),
867            FrameGlyph::colored('◇', 1.0, 1.0, 0.6, 0.7, 0.8),
868            FrameGlyph::colored('│', 0.0, 0.0, 0.7, 0.8, 0.9),
869            FrameGlyph::colored('△', -0.5, -1.0, 0.5, 0.6, 0.7),
870            FrameGlyph::colored('△', 0.5, -1.0, 0.5, 0.6, 0.7),
871        ]);
872        let f2 = SpriteFrame::new(vec![
873            FrameGlyph::colored('◆', 0.0, 3.0, 0.9, 1.0, 1.0).with_emission(0.8),
874            FrameGlyph::colored('│', 0.0, 2.0, 0.8, 0.9, 1.0),
875            FrameGlyph::colored('◆', -1.0, 1.0, 0.7, 0.8, 0.9),
876            FrameGlyph::colored('◆', 1.0, 1.0, 0.7, 0.8, 0.9),
877            FrameGlyph::colored('│', 0.0, 0.0, 0.8, 0.9, 1.0),
878            FrameGlyph::colored('△', -0.5, -1.0, 0.6, 0.7, 0.8),
879            FrameGlyph::colored('△', 0.5, -1.0, 0.6, 0.7, 0.8),
880        ]);
881        SpriteAnimation::new("boss_mirror_idle", vec![f1, f2], 0.7, LoopMode::PingPong)
882    }
883
884    fn boss_null_idle() -> SpriteAnimation {
885        let f1 = SpriteFrame::new(vec![
886            FrameGlyph::colored('∅', 0.0, 3.0, 0.3, 0.3, 0.3).with_emission(0.3),
887            FrameGlyph::colored('█', 0.0, 2.0, 0.1, 0.1, 0.1),
888            FrameGlyph::colored('░', -1.0, 1.0, 0.2, 0.2, 0.2),
889            FrameGlyph::colored('░', 1.0, 1.0, 0.2, 0.2, 0.2),
890            FrameGlyph::colored('▓', 0.0, 0.0, 0.15, 0.15, 0.15),
891        ]);
892        let f2 = SpriteFrame::new(vec![
893            FrameGlyph::colored('∅', 0.0, 3.0, 0.2, 0.2, 0.2),
894            FrameGlyph::colored('░', 0.0, 2.0, 0.08, 0.08, 0.08),
895            FrameGlyph::colored(' ', -1.0, 1.0, 0.0, 0.0, 0.0),
896            FrameGlyph::colored('░', 1.0, 1.0, 0.15, 0.15, 0.15),
897            FrameGlyph::colored('▒', 0.0, 0.0, 0.1, 0.1, 0.1),
898        ]);
899        SpriteAnimation::new("boss_null_idle", vec![f1, f2], 0.8, LoopMode::PingPong)
900    }
901
902    fn boss_committee_idle() -> SpriteAnimation {
903        let f1 = SpriteFrame::new(vec![
904            // Five heads in a row
905            FrameGlyph::colored('☻', -2.0, 2.0, 1.0, 0.8, 0.3),
906            FrameGlyph::colored('☻', -1.0, 2.0, 0.3, 1.0, 0.4),
907            FrameGlyph::colored('☻', 0.0, 2.5, 1.0, 0.3, 0.3),  // center judge raised
908            FrameGlyph::colored('☻', 1.0, 2.0, 0.3, 0.5, 1.0),
909            FrameGlyph::colored('☻', 2.0, 2.0, 0.8, 0.3, 1.0),
910            FrameGlyph::colored('═', -2.0, 1.0, 0.5, 0.4, 0.2),
911            FrameGlyph::colored('═', -1.0, 1.0, 0.5, 0.4, 0.2),
912            FrameGlyph::colored('═', 0.0, 1.0, 0.5, 0.4, 0.2),
913            FrameGlyph::colored('═', 1.0, 1.0, 0.5, 0.4, 0.2),
914            FrameGlyph::colored('═', 2.0, 1.0, 0.5, 0.4, 0.2),
915        ]);
916        let f2 = SpriteFrame::new(vec![
917            FrameGlyph::colored('☻', -2.0, 2.1, 1.0, 0.8, 0.3),
918            FrameGlyph::colored('☻', -1.0, 1.9, 0.3, 1.0, 0.4),
919            FrameGlyph::colored('☻', 0.0, 2.5, 1.0, 0.3, 0.3),
920            FrameGlyph::colored('☻', 1.0, 2.1, 0.3, 0.5, 1.0),
921            FrameGlyph::colored('☻', 2.0, 1.9, 0.8, 0.3, 1.0),
922            FrameGlyph::colored('═', -2.0, 1.0, 0.5, 0.4, 0.2),
923            FrameGlyph::colored('═', -1.0, 1.0, 0.5, 0.4, 0.2),
924            FrameGlyph::colored('═', 0.0, 1.0, 0.5, 0.4, 0.2),
925            FrameGlyph::colored('═', 1.0, 1.0, 0.5, 0.4, 0.2),
926            FrameGlyph::colored('═', 2.0, 1.0, 0.5, 0.4, 0.2),
927        ]);
928        SpriteAnimation::new("boss_committee_idle", vec![f1, f2], 0.6, LoopMode::PingPong)
929    }
930
931    fn boss_hydra_idle() -> SpriteAnimation {
932        let f1 = SpriteFrame::new(vec![
933            FrameGlyph::colored('◆', -1.0, 3.0, 0.2, 0.8, 0.3),
934            FrameGlyph::colored('◆', 1.0, 3.0, 0.2, 0.8, 0.3),
935            FrameGlyph::colored('\\', -0.5, 2.0, 0.15, 0.6, 0.2),
936            FrameGlyph::colored('/', 0.5, 2.0, 0.15, 0.6, 0.2),
937            FrameGlyph::colored('█', 0.0, 1.0, 0.1, 0.5, 0.15),
938            FrameGlyph::colored('▲', 0.0, -0.5, 0.08, 0.4, 0.1),
939        ]);
940        let f2 = SpriteFrame::new(vec![
941            FrameGlyph::colored('◆', -1.2, 3.2, 0.2, 0.8, 0.3),
942            FrameGlyph::colored('◆', 1.2, 2.8, 0.2, 0.8, 0.3),
943            FrameGlyph::colored('\\', -0.6, 2.1, 0.15, 0.6, 0.2),
944            FrameGlyph::colored('/', 0.6, 1.9, 0.15, 0.6, 0.2),
945            FrameGlyph::colored('█', 0.0, 1.0, 0.1, 0.5, 0.15),
946            FrameGlyph::colored('▲', 0.0, -0.5, 0.08, 0.4, 0.1),
947        ]);
948        SpriteAnimation::new("boss_hydra_idle", vec![f1, f2], 0.5, LoopMode::PingPong)
949    }
950
951    fn boss_eigenstate_idle() -> SpriteAnimation {
952        // Quantum superposition: alternates between two different forms
953        let f1 = SpriteFrame::new(vec![
954            FrameGlyph::colored('ψ', 0.0, 3.0, 0.5, 0.2, 1.0).with_emission(0.6),
955            FrameGlyph::colored('|', 0.0, 2.0, 0.4, 0.15, 0.8),
956            FrameGlyph::colored('◇', -1.0, 1.5, 0.3, 0.1, 0.7),
957            FrameGlyph::colored('◇', 1.0, 1.5, 0.3, 0.1, 0.7),
958            FrameGlyph::colored('▽', 0.0, 0.0, 0.2, 0.1, 0.6),
959        ]);
960        let f2 = SpriteFrame::new(vec![
961            FrameGlyph::colored('φ', 0.0, 3.0, 1.0, 0.2, 0.5).with_emission(0.6),
962            FrameGlyph::colored('│', 0.0, 2.0, 0.8, 0.15, 0.4),
963            FrameGlyph::colored('◆', -1.0, 1.5, 0.7, 0.1, 0.3),
964            FrameGlyph::colored('◆', 1.0, 1.5, 0.7, 0.1, 0.3),
965            FrameGlyph::colored('△', 0.0, 0.0, 0.6, 0.1, 0.2),
966        ]);
967        SpriteAnimation::new("boss_eigenstate_idle", vec![f1, f2], 0.3, LoopMode::PingPong)
968    }
969
970    fn boss_ouroboros_idle() -> SpriteAnimation {
971        let f1 = SpriteFrame::new(vec![
972            FrameGlyph::colored('◆', 0.0, 2.0, 0.2, 0.8, 0.5).with_emission(0.4),
973            FrameGlyph::colored('~', 1.0, 1.5, 0.15, 0.6, 0.4),
974            FrameGlyph::colored('~', 1.5, 0.5, 0.15, 0.6, 0.4),
975            FrameGlyph::colored('~', 1.0, -0.5, 0.15, 0.6, 0.4),
976            FrameGlyph::colored('~', 0.0, -1.0, 0.15, 0.6, 0.4),
977            FrameGlyph::colored('~', -1.0, -0.5, 0.15, 0.6, 0.4),
978            FrameGlyph::colored('~', -1.5, 0.5, 0.15, 0.6, 0.4),
979            FrameGlyph::colored('~', -1.0, 1.5, 0.15, 0.6, 0.4),
980        ]);
981        let f2 = SpriteFrame::new(vec![
982            FrameGlyph::colored('◆', 1.0, 1.5, 0.2, 0.8, 0.5).with_emission(0.4),
983            FrameGlyph::colored('~', 1.5, 0.5, 0.15, 0.6, 0.4),
984            FrameGlyph::colored('~', 1.0, -0.5, 0.15, 0.6, 0.4),
985            FrameGlyph::colored('~', 0.0, -1.0, 0.15, 0.6, 0.4),
986            FrameGlyph::colored('~', -1.0, -0.5, 0.15, 0.6, 0.4),
987            FrameGlyph::colored('~', -1.5, 0.5, 0.15, 0.6, 0.4),
988            FrameGlyph::colored('~', -1.0, 1.5, 0.15, 0.6, 0.4),
989            FrameGlyph::colored('~', 0.0, 2.0, 0.15, 0.6, 0.4),
990        ]);
991        SpriteAnimation::new("boss_ouroboros_idle", vec![f1, f2], 0.4, LoopMode::Loop)
992    }
993
994    fn boss_algorithm_idle() -> SpriteAnimation {
995        let f1 = SpriteFrame::new(vec![
996            FrameGlyph::colored('Σ', 0.0, 3.0, 0.2, 1.0, 0.8).with_emission(0.7),
997            FrameGlyph::colored('█', 0.0, 2.0, 0.1, 0.6, 0.5),
998            FrameGlyph::colored('0', -1.5, 1.0, 0.0, 0.4, 0.3),
999            FrameGlyph::colored('1', 1.5, 1.0, 0.0, 0.4, 0.3),
1000            FrameGlyph::colored('λ', -0.5, 0.0, 0.0, 0.3, 0.25),
1001            FrameGlyph::colored('λ', 0.5, 0.0, 0.0, 0.3, 0.25),
1002        ]);
1003        let f2 = SpriteFrame::new(vec![
1004            FrameGlyph::colored('Σ', 0.0, 3.0, 0.3, 1.0, 0.9).with_emission(0.9),
1005            FrameGlyph::colored('█', 0.0, 2.0, 0.15, 0.7, 0.6),
1006            FrameGlyph::colored('1', -1.5, 1.0, 0.0, 0.5, 0.4),
1007            FrameGlyph::colored('0', 1.5, 1.0, 0.0, 0.5, 0.4),
1008            FrameGlyph::colored('λ', -0.5, 0.0, 0.0, 0.35, 0.3),
1009            FrameGlyph::colored('λ', 0.5, 0.0, 0.0, 0.35, 0.3),
1010        ]);
1011        SpriteAnimation::new("boss_algorithm_idle", vec![f1, f2], 0.5, LoopMode::PingPong)
1012    }
1013
1014    fn boss_chaos_weaver_idle() -> SpriteAnimation {
1015        let f1 = SpriteFrame::new(vec![
1016            FrameGlyph::colored('∞', 0.0, 3.0, 1.0, 0.2, 0.8).with_emission(0.8),
1017            FrameGlyph::colored('▓', 0.0, 2.0, 0.8, 0.1, 0.6),
1018            FrameGlyph::colored('~', -1.5, 1.5, 0.6, 0.1, 0.5),
1019            FrameGlyph::colored('~', 1.5, 1.5, 0.6, 0.1, 0.5),
1020            FrameGlyph::colored('▲', -0.5, 0.0, 0.5, 0.05, 0.4),
1021            FrameGlyph::colored('▲', 0.5, 0.0, 0.5, 0.05, 0.4),
1022        ]);
1023        let f2 = SpriteFrame::new(vec![
1024            FrameGlyph::colored('∞', 0.0, 3.0, 0.8, 0.3, 1.0).with_emission(1.0),
1025            FrameGlyph::colored('▓', 0.0, 2.0, 0.6, 0.2, 0.8),
1026            FrameGlyph::colored('~', -1.8, 1.2, 0.5, 0.15, 0.6),
1027            FrameGlyph::colored('~', 1.8, 1.8, 0.5, 0.15, 0.6),
1028            FrameGlyph::colored('▲', -0.5, 0.0, 0.4, 0.1, 0.5),
1029            FrameGlyph::colored('▲', 0.5, 0.0, 0.4, 0.1, 0.5),
1030        ]);
1031        SpriteAnimation::new("boss_chaos_weaver_idle", vec![f1, f2], 0.35, LoopMode::PingPong)
1032    }
1033
1034    fn boss_void_serpent_idle() -> SpriteAnimation {
1035        let f1 = SpriteFrame::new(vec![
1036            FrameGlyph::colored('◆', 0.0, 3.0, 0.1, 0.0, 0.3).with_emission(0.3),
1037            FrameGlyph::colored('S', 0.5, 2.0, 0.08, 0.0, 0.25),
1038            FrameGlyph::colored('S', -0.5, 1.0, 0.08, 0.0, 0.25),
1039            FrameGlyph::colored('S', 0.5, 0.0, 0.08, 0.0, 0.25),
1040            FrameGlyph::colored('▲', 0.0, -1.0, 0.06, 0.0, 0.2),
1041        ]);
1042        let f2 = SpriteFrame::new(vec![
1043            FrameGlyph::colored('◆', 0.3, 3.0, 0.15, 0.0, 0.4).with_emission(0.4),
1044            FrameGlyph::colored('S', -0.3, 2.0, 0.1, 0.0, 0.3),
1045            FrameGlyph::colored('S', 0.3, 1.0, 0.1, 0.0, 0.3),
1046            FrameGlyph::colored('S', -0.3, 0.0, 0.1, 0.0, 0.3),
1047            FrameGlyph::colored('▲', 0.0, -1.0, 0.08, 0.0, 0.25),
1048        ]);
1049        SpriteAnimation::new("boss_void_serpent_idle", vec![f1, f2], 0.45, LoopMode::PingPong)
1050    }
1051
1052    fn boss_prime_idle() -> SpriteAnimation {
1053        let f1 = SpriteFrame::new(vec![
1054            FrameGlyph::colored('π', 0.0, 3.0, 1.0, 0.85, 0.3).with_emission(0.5),
1055            FrameGlyph::colored('█', 0.0, 2.0, 0.8, 0.7, 0.2),
1056            FrameGlyph::colored('2', -1.5, 1.0, 0.7, 0.6, 0.15),
1057            FrameGlyph::colored('3', 1.5, 1.0, 0.7, 0.6, 0.15),
1058            FrameGlyph::colored('▲', -0.5, 0.0, 0.6, 0.5, 0.1),
1059            FrameGlyph::colored('▲', 0.5, 0.0, 0.6, 0.5, 0.1),
1060        ]);
1061        let f2 = SpriteFrame::new(vec![
1062            FrameGlyph::colored('π', 0.0, 3.0, 1.0, 0.9, 0.4).with_emission(0.7),
1063            FrameGlyph::colored('█', 0.0, 2.0, 0.85, 0.75, 0.25),
1064            FrameGlyph::colored('5', -1.5, 1.0, 0.75, 0.65, 0.2),
1065            FrameGlyph::colored('7', 1.5, 1.0, 0.75, 0.65, 0.2),
1066            FrameGlyph::colored('▲', -0.5, 0.0, 0.65, 0.55, 0.15),
1067            FrameGlyph::colored('▲', 0.5, 0.0, 0.65, 0.55, 0.15),
1068        ]);
1069        SpriteAnimation::new("boss_prime_idle", vec![f1, f2], 0.6, LoopMode::PingPong)
1070    }
1071
1072    // ── Individual boss attack animations ───────────────────────────────
1073
1074    fn boss_mirror_attack() -> SpriteAnimation {
1075        let f1 = SpriteFrame::new(vec![
1076            FrameGlyph::colored('◇', 0.0, 3.0, 1.0, 1.0, 1.0).with_emission(1.0),
1077            FrameGlyph::colored('│', 0.0, 2.0, 0.9, 0.9, 1.0),
1078            FrameGlyph::colored('>', 2.0, 2.0, 1.0, 1.0, 1.0).with_emission(0.8),
1079        ]);
1080        let f2 = SpriteFrame::new(vec![
1081            FrameGlyph::colored('◆', 0.0, 3.0, 1.0, 1.0, 1.0).with_emission(1.5),
1082            FrameGlyph::colored('─', 1.0, 2.0, 1.0, 1.0, 1.0),
1083            FrameGlyph::colored('─', 2.0, 2.0, 1.0, 1.0, 1.0),
1084            FrameGlyph::colored('★', 3.0, 2.0, 1.0, 1.0, 1.0).with_emission(1.2),
1085        ]).with_event("hit");
1086        let f3 = SpriteFrame::new(vec![
1087            FrameGlyph::colored('◇', 0.0, 3.0, 0.8, 0.9, 1.0).with_emission(0.5),
1088            FrameGlyph::colored('│', 0.0, 2.0, 0.7, 0.8, 0.9),
1089            FrameGlyph::colored('◇', -1.0, 1.0, 0.6, 0.7, 0.8),
1090            FrameGlyph::colored('◇', 1.0, 1.0, 0.6, 0.7, 0.8),
1091        ]);
1092        SpriteAnimation::new("boss_mirror_attack", vec![f1, f2, f3], 0.1, LoopMode::OnceAndDone)
1093    }
1094
1095    fn boss_null_attack() -> SpriteAnimation {
1096        let f1 = SpriteFrame::new(vec![
1097            FrameGlyph::colored('∅', 0.0, 3.0, 0.5, 0.5, 0.5).with_emission(0.8),
1098            FrameGlyph::colored('█', 0.0, 2.0, 0.2, 0.2, 0.2),
1099        ]);
1100        let f2 = SpriteFrame::new(vec![
1101            FrameGlyph::colored('∅', 0.0, 3.0, 0.1, 0.1, 0.1).with_emission(1.5),
1102            FrameGlyph::colored(' ', 0.0, 2.0, 0.0, 0.0, 0.0),
1103            FrameGlyph::colored(' ', 1.0, 2.0, 0.0, 0.0, 0.0),
1104            FrameGlyph::colored(' ', 2.0, 2.0, 0.0, 0.0, 0.0),
1105        ]).with_event("erase");
1106        let f3 = SpriteFrame::new(vec![
1107            FrameGlyph::colored('∅', 0.0, 3.0, 0.3, 0.3, 0.3).with_emission(0.3),
1108            FrameGlyph::colored('█', 0.0, 2.0, 0.1, 0.1, 0.1),
1109            FrameGlyph::colored('░', -1.0, 1.0, 0.2, 0.2, 0.2),
1110            FrameGlyph::colored('░', 1.0, 1.0, 0.2, 0.2, 0.2),
1111        ]);
1112        SpriteAnimation::new("boss_null_attack", vec![f1, f2, f3], 0.12, LoopMode::OnceAndDone)
1113    }
1114
1115    fn boss_committee_attack() -> SpriteAnimation {
1116        let f1 = SpriteFrame::new(vec![
1117            FrameGlyph::colored('☻', -2.0, 2.0, 1.0, 0.0, 0.0), // voting red
1118            FrameGlyph::colored('☻', -1.0, 2.0, 1.0, 0.0, 0.0),
1119            FrameGlyph::colored('☻', 0.0, 2.5, 1.0, 0.0, 0.0).with_emission(0.5),
1120            FrameGlyph::colored('☻', 1.0, 2.0, 0.0, 1.0, 0.0), // dissent
1121            FrameGlyph::colored('☻', 2.0, 2.0, 1.0, 0.0, 0.0),
1122            FrameGlyph::colored('═', -2.0, 1.0, 0.8, 0.2, 0.2),
1123            FrameGlyph::colored('═', 0.0, 1.0, 0.8, 0.2, 0.2),
1124            FrameGlyph::colored('═', 2.0, 1.0, 0.8, 0.2, 0.2),
1125        ]);
1126        let f2 = SpriteFrame::new(vec![
1127            FrameGlyph::colored('!', -2.0, 3.0, 1.0, 0.3, 0.2),
1128            FrameGlyph::colored('!', -1.0, 3.0, 1.0, 0.3, 0.2),
1129            FrameGlyph::colored('!', 0.0, 3.5, 1.0, 0.5, 0.3).with_emission(1.0),
1130            FrameGlyph::colored('?', 1.0, 3.0, 0.3, 1.0, 0.3),
1131            FrameGlyph::colored('!', 2.0, 3.0, 1.0, 0.3, 0.2),
1132        ]).with_event("verdict");
1133        SpriteAnimation::new("boss_committee_attack", vec![f1, f2], 0.15, LoopMode::OnceAndDone)
1134    }
1135
1136    fn boss_hydra_attack() -> SpriteAnimation {
1137        let f1 = SpriteFrame::new(vec![
1138            FrameGlyph::colored('◆', -1.5, 3.5, 0.2, 0.9, 0.3),
1139            FrameGlyph::colored('◆', 1.5, 3.5, 0.2, 0.9, 0.3),
1140            FrameGlyph::colored('>', -0.5, 3.0, 0.3, 1.0, 0.4).with_emission(0.5),
1141            FrameGlyph::colored('>', 0.5, 3.0, 0.3, 1.0, 0.4).with_emission(0.5),
1142            FrameGlyph::colored('█', 0.0, 1.0, 0.1, 0.5, 0.15),
1143        ]);
1144        let f2 = SpriteFrame::new(vec![
1145            FrameGlyph::colored('>', -0.5, 4.0, 0.4, 1.0, 0.5).with_emission(1.0),
1146            FrameGlyph::colored('>', 0.5, 4.0, 0.4, 1.0, 0.5).with_emission(1.0),
1147            FrameGlyph::colored('*', 0.0, 4.5, 0.5, 1.0, 0.6).with_emission(1.2),
1148            FrameGlyph::colored('█', 0.0, 1.0, 0.1, 0.5, 0.15),
1149        ]).with_event("bite");
1150        SpriteAnimation::new("boss_hydra_attack", vec![f1, f2], 0.12, LoopMode::OnceAndDone)
1151    }
1152
1153    fn boss_eigenstate_attack() -> SpriteAnimation {
1154        // Collapses into one form then strikes
1155        let f1 = SpriteFrame::new(vec![
1156            FrameGlyph::colored('ψ', 0.0, 3.0, 1.0, 0.5, 1.0).with_emission(1.2),
1157            FrameGlyph::colored('φ', 0.2, 3.0, 0.5, 0.2, 1.0).with_emission(0.8),
1158        ]);
1159        let f2 = SpriteFrame::new(vec![
1160            FrameGlyph::colored('Ψ', 0.0, 3.0, 1.0, 0.2, 1.0).with_emission(2.0),
1161            FrameGlyph::colored('─', 1.0, 3.0, 0.8, 0.1, 0.8),
1162            FrameGlyph::colored('─', 2.0, 3.0, 0.6, 0.1, 0.6),
1163            FrameGlyph::colored('★', 3.0, 3.0, 1.0, 0.3, 1.0).with_emission(1.5),
1164        ]).with_event("collapse");
1165        SpriteAnimation::new("boss_eigenstate_attack", vec![f1, f2], 0.12, LoopMode::OnceAndDone)
1166    }
1167
1168    fn boss_ouroboros_attack() -> SpriteAnimation {
1169        let f1 = SpriteFrame::new(vec![
1170            FrameGlyph::colored('◆', 0.0, 2.0, 0.3, 1.0, 0.6).with_emission(0.8),
1171            FrameGlyph::colored('O', 0.0, 0.5, 0.2, 0.8, 0.5).with_emission(1.0),
1172        ]);
1173        let f2 = SpriteFrame::new(vec![
1174            FrameGlyph::colored('◆', 0.0, 2.0, 0.5, 1.0, 0.8).with_emission(1.5),
1175            FrameGlyph::colored('∞', 0.0, 0.5, 0.4, 1.0, 0.7).with_emission(1.5),
1176            FrameGlyph::colored('~', 2.0, 0.5, 0.3, 0.8, 0.5),
1177            FrameGlyph::colored('~', -2.0, 0.5, 0.3, 0.8, 0.5),
1178        ]).with_event("reverse");
1179        SpriteAnimation::new("boss_ouroboros_attack", vec![f1, f2], 0.15, LoopMode::OnceAndDone)
1180    }
1181
1182    fn boss_algorithm_attack() -> SpriteAnimation {
1183        let f1 = SpriteFrame::new(vec![
1184            FrameGlyph::colored('Σ', 0.0, 3.0, 0.4, 1.0, 0.9).with_emission(1.0),
1185            FrameGlyph::colored('█', 0.0, 2.0, 0.2, 0.7, 0.6),
1186            FrameGlyph::colored('>', 1.0, 2.0, 0.3, 0.9, 0.8),
1187        ]);
1188        let f2 = SpriteFrame::new(vec![
1189            FrameGlyph::colored('Σ', 0.0, 3.0, 0.5, 1.0, 1.0).with_emission(1.5),
1190            FrameGlyph::colored('>', 1.5, 2.5, 0.4, 1.0, 0.9).with_emission(0.8),
1191            FrameGlyph::colored('>', 2.5, 2.0, 0.4, 1.0, 0.9).with_emission(0.8),
1192            FrameGlyph::colored('>', 3.5, 1.5, 0.4, 1.0, 0.9).with_emission(0.8),
1193        ]).with_event("predict");
1194        let f3 = SpriteFrame::new(vec![
1195            FrameGlyph::colored('Σ', 0.0, 3.0, 0.3, 0.8, 0.7).with_emission(0.5),
1196            FrameGlyph::colored('█', 0.0, 2.0, 0.15, 0.6, 0.5),
1197        ]);
1198        SpriteAnimation::new("boss_algorithm_attack", vec![f1, f2, f3], 0.1, LoopMode::OnceAndDone)
1199    }
1200
1201    fn boss_chaos_weaver_attack() -> SpriteAnimation {
1202        let f1 = SpriteFrame::new(vec![
1203            FrameGlyph::colored('∞', 0.0, 3.0, 1.0, 0.3, 1.0).with_emission(1.5),
1204            FrameGlyph::colored('~', -1.0, 2.0, 0.8, 0.2, 0.8),
1205            FrameGlyph::colored('~', 1.0, 2.0, 0.8, 0.2, 0.8),
1206        ]);
1207        let f2 = SpriteFrame::new(vec![
1208            FrameGlyph::colored('∞', 0.0, 3.0, 1.0, 0.5, 1.0).with_emission(2.0),
1209            FrameGlyph::colored('★', -2.0, 1.0, 1.0, 0.2, 0.8).with_emission(1.0),
1210            FrameGlyph::colored('★', 2.0, 1.0, 1.0, 0.2, 0.8).with_emission(1.0),
1211            FrameGlyph::colored('★', 0.0, -1.0, 1.0, 0.2, 0.8).with_emission(1.0),
1212        ]).with_event("warp");
1213        SpriteAnimation::new("boss_chaos_weaver_attack", vec![f1, f2], 0.12, LoopMode::OnceAndDone)
1214    }
1215
1216    fn boss_void_serpent_attack() -> SpriteAnimation {
1217        let f1 = SpriteFrame::new(vec![
1218            FrameGlyph::colored('◆', 0.0, 3.0, 0.2, 0.0, 0.5).with_emission(0.5),
1219            FrameGlyph::colored('O', 0.0, 4.0, 0.1, 0.0, 0.4).with_emission(0.8), // mouth opens
1220        ]);
1221        let f2 = SpriteFrame::new(vec![
1222            FrameGlyph::colored('◆', 0.0, 3.0, 0.3, 0.0, 0.6).with_emission(1.0),
1223            FrameGlyph::colored('O', 0.0, 4.5, 0.0, 0.0, 0.0).with_emission(2.0), // void consume
1224            FrameGlyph::colored('·', 1.0, 4.0, 0.1, 0.0, 0.3),
1225            FrameGlyph::colored('·', -1.0, 4.0, 0.1, 0.0, 0.3),
1226        ]).with_event("consume");
1227        SpriteAnimation::new("boss_void_serpent_attack", vec![f1, f2], 0.15, LoopMode::OnceAndDone)
1228    }
1229
1230    fn boss_prime_attack() -> SpriteAnimation {
1231        let f1 = SpriteFrame::new(vec![
1232            FrameGlyph::colored('π', 0.0, 3.0, 1.0, 0.9, 0.5).with_emission(1.0),
1233            FrameGlyph::colored('!', 0.0, 4.0, 1.0, 0.85, 0.3),
1234        ]);
1235        let f2 = SpriteFrame::new(vec![
1236            FrameGlyph::colored('π', 0.0, 3.0, 1.0, 1.0, 0.6).with_emission(1.5),
1237            FrameGlyph::colored('1', -1.0, 4.0, 1.0, 0.9, 0.4).with_emission(0.5),
1238            FrameGlyph::colored('3', 0.0, 4.5, 1.0, 0.9, 0.4).with_emission(0.5),
1239            FrameGlyph::colored('7', 1.0, 4.0, 1.0, 0.9, 0.4).with_emission(0.5),
1240        ]).with_event("calculate");
1241        SpriteAnimation::new("boss_prime_attack", vec![f1, f2], 0.12, LoopMode::OnceAndDone)
1242    }
1243
1244    // ── Full animation set builder ──────────────────────────────────────
1245
1246    /// Build a complete SpriteAnimator with all player animations and state machine.
1247    pub fn player_animator() -> SpriteAnimator {
1248        let mut animator = SpriteAnimator::new();
1249        animator.add_animation(Self::player_idle());
1250        animator.add_animation(Self::player_attack());
1251        animator.add_animation(Self::player_cast());
1252        animator.add_animation(Self::player_hurt());
1253        animator.add_animation(Self::player_defend());
1254
1255        // State machine
1256        let mut sm = AnimationStateMachine::new();
1257        sm.add_state(AnimState::new("idle", "player_idle"));
1258        sm.add_state(AnimState::new("attack", "player_attack"));
1259        sm.add_state(AnimState::new("cast", "player_cast"));
1260        sm.add_state(AnimState::new("hurt", "player_hurt"));
1261        sm.add_state(AnimState::new("defend", "player_defend"));
1262
1263        sm.add_transition(AnimTransition::new("idle", "attack", AnimCondition::trigger("attack")));
1264        sm.add_transition(AnimTransition::new("idle", "cast", AnimCondition::trigger("cast")));
1265        sm.add_transition(AnimTransition::new("idle", "defend", AnimCondition::trigger("defend")));
1266        sm.add_transition(AnimTransition::new("*", "hurt", AnimCondition::trigger("hurt")));
1267        sm.add_transition(AnimTransition::new("attack", "idle", AnimCondition::AnimationDone));
1268        sm.add_transition(AnimTransition::new("cast", "idle", AnimCondition::AnimationDone));
1269        sm.add_transition(AnimTransition::new("hurt", "idle", AnimCondition::AnimationDone));
1270        sm.add_transition(AnimTransition::new("defend", "idle", AnimCondition::trigger("release_defend")));
1271
1272        sm.start("idle");
1273        animator.state_machine = Some(sm);
1274        animator.play("player_idle");
1275
1276        animator
1277    }
1278
1279    /// Build a standard enemy animator.
1280    pub fn enemy_animator() -> SpriteAnimator {
1281        let mut animator = SpriteAnimator::new();
1282        animator.add_animation(Self::enemy_idle());
1283        animator.add_animation(Self::enemy_attack());
1284        animator.add_animation(Self::enemy_death());
1285
1286        let mut sm = AnimationStateMachine::new();
1287        sm.add_state(AnimState::new("idle", "enemy_idle"));
1288        sm.add_state(AnimState::new("attack", "enemy_attack"));
1289        sm.add_state(AnimState::new("death", "enemy_death"));
1290
1291        sm.add_transition(AnimTransition::new("idle", "attack", AnimCondition::trigger("attack")));
1292        sm.add_transition(AnimTransition::new("attack", "idle", AnimCondition::AnimationDone));
1293        sm.add_transition(AnimTransition::new("*", "death", AnimCondition::trigger("death")));
1294
1295        sm.start("idle");
1296        animator.state_machine = Some(sm);
1297        animator.play("enemy_idle");
1298
1299        animator
1300    }
1301
1302    /// Build a boss animator for a specific boss type.
1303    pub fn boss_animator(boss_name: &str) -> SpriteAnimator {
1304        let mut animator = SpriteAnimator::new();
1305        let idle = Self::boss_idle(boss_name);
1306        let attack = Self::boss_attack(boss_name);
1307        let idle_name = idle.name.clone();
1308        let attack_name = attack.name.clone();
1309
1310        animator.add_animation(idle);
1311        animator.add_animation(attack);
1312        animator.add_animation(Self::enemy_death()); // shared death animation
1313
1314        let mut sm = AnimationStateMachine::new();
1315        sm.add_state(AnimState::new("idle", &idle_name));
1316        sm.add_state(AnimState::new("attack", &attack_name));
1317        sm.add_state(AnimState::new("death", "enemy_death"));
1318
1319        sm.add_transition(AnimTransition::new("idle", "attack", AnimCondition::trigger("attack")));
1320        sm.add_transition(AnimTransition::new("attack", "idle", AnimCondition::AnimationDone));
1321        sm.add_transition(AnimTransition::new("*", "death", AnimCondition::trigger("death")));
1322
1323        sm.start("idle");
1324        animator.state_machine = Some(sm);
1325        animator.play(&idle_name);
1326
1327        animator
1328    }
1329}
1330
1331// ── Tests ───────────────────────────────────────────────────────────────────
1332
1333#[cfg(test)]
1334mod tests {
1335    use super::*;
1336
1337    #[test]
1338    fn frame_from_ascii() {
1339        let frame = SpriteFrame::from_ascii("AB\nCD", Vec4::ONE);
1340        assert_eq!(frame.glyphs.len(), 4);
1341    }
1342
1343    #[test]
1344    fn loop_mode_cycles() {
1345        let mut animator = SpriteAnimator::new();
1346        animator.add_animation(SpriteAnimation::new(
1347            "test",
1348            vec![
1349                SpriteFrame::new(vec![FrameGlyph::white('A', 0.0, 0.0)]),
1350                SpriteFrame::new(vec![FrameGlyph::white('B', 0.0, 0.0)]),
1351                SpriteFrame::new(vec![FrameGlyph::white('C', 0.0, 0.0)]),
1352            ],
1353            0.1,
1354            LoopMode::Loop,
1355        ));
1356        animator.play("test");
1357
1358        // After 3 frames we should loop back
1359        animator.tick(0.1);
1360        assert_eq!(animator.current_frame_index(), 1);
1361        animator.tick(0.1);
1362        assert_eq!(animator.current_frame_index(), 2);
1363        animator.tick(0.1);
1364        assert_eq!(animator.current_frame_index(), 0); // looped
1365    }
1366
1367    #[test]
1368    fn once_mode_stops() {
1369        let mut animator = SpriteAnimator::new();
1370        animator.add_animation(SpriteAnimation::new(
1371            "test",
1372            vec![
1373                SpriteFrame::new(vec![FrameGlyph::white('A', 0.0, 0.0)]),
1374                SpriteFrame::new(vec![FrameGlyph::white('B', 0.0, 0.0)]),
1375            ],
1376            0.1,
1377            LoopMode::Once,
1378        ));
1379        animator.play("test");
1380        animator.tick(0.1); // frame 1
1381        animator.tick(0.1); // should stop
1382        assert!(animator.is_finished());
1383        assert!(!animator.is_playing());
1384    }
1385
1386    #[test]
1387    fn frame_events_fire() {
1388        let mut animator = SpriteAnimator::new();
1389        animator.add_animation(SpriteAnimation::new(
1390            "test",
1391            vec![
1392                SpriteFrame::new(vec![FrameGlyph::white('A', 0.0, 0.0)]).with_event("start"),
1393                SpriteFrame::new(vec![FrameGlyph::white('B', 0.0, 0.0)]).with_event("hit"),
1394            ],
1395            0.1,
1396            LoopMode::Once,
1397        ));
1398        animator.play("test");
1399        animator.tick(0.1); // advances past frame 0, fires "start"
1400        let events = animator.drain_events();
1401        assert_eq!(events.len(), 1);
1402        assert_eq!(events[0].tag, "start");
1403    }
1404
1405    #[test]
1406    fn player_animator_builds() {
1407        let animator = AnimationLibrary::player_animator();
1408        assert!(animator.animations.contains_key("player_idle"));
1409        assert!(animator.animations.contains_key("player_attack"));
1410        assert!(animator.animations.contains_key("player_cast"));
1411        assert!(animator.animations.contains_key("player_hurt"));
1412        assert!(animator.animations.contains_key("player_defend"));
1413        assert!(animator.is_playing());
1414    }
1415
1416    #[test]
1417    fn enemy_animator_builds() {
1418        let animator = AnimationLibrary::enemy_animator();
1419        assert!(animator.animations.contains_key("enemy_idle"));
1420        assert!(animator.animations.contains_key("enemy_attack"));
1421        assert!(animator.animations.contains_key("enemy_death"));
1422    }
1423
1424    #[test]
1425    fn all_boss_animators_build() {
1426        let bosses = [
1427            "Mirror", "Null", "Committee", "FibonacciHydra", "Eigenstate",
1428            "Ouroboros", "AlgorithmReborn", "ChaosWeaver", "VoidSerpent", "PrimeFactorial",
1429        ];
1430        for boss in &bosses {
1431            let animator = AnimationLibrary::boss_animator(boss);
1432            assert!(animator.animations.len() >= 3, "Boss {} has <3 animations", boss);
1433        }
1434    }
1435
1436    #[test]
1437    fn state_machine_transitions() {
1438        let mut sm = AnimationStateMachine::new();
1439        sm.add_state(AnimState::new("idle", "idle_anim"));
1440        sm.add_state(AnimState::new("attack", "attack_anim"));
1441        sm.add_transition(AnimTransition::new("idle", "attack", AnimCondition::trigger("attack")));
1442        sm.add_transition(AnimTransition::new("attack", "idle", AnimCondition::AnimationDone));
1443        sm.start("idle");
1444
1445        // No trigger → no transition
1446        assert!(sm.evaluate(false).is_none());
1447
1448        // Set trigger → transition fires
1449        sm.set_trigger("attack");
1450        let result = sm.evaluate(false);
1451        assert_eq!(result, Some("attack_anim".to_string()));
1452        assert_eq!(sm.current_state.as_deref(), Some("attack"));
1453
1454        // Trigger consumed
1455        assert!(!sm.triggers.get("attack").copied().unwrap_or(false));
1456
1457        // AnimationDone → back to idle
1458        let result = sm.evaluate(true);
1459        assert_eq!(result, Some("idle_anim".to_string()));
1460    }
1461
1462    #[test]
1463    fn animation_duration() {
1464        let anim = SpriteAnimation::new(
1465            "test",
1466            vec![
1467                SpriteFrame::new(vec![]).with_duration(0.2),
1468                SpriteFrame::new(vec![]).with_duration(0.3),
1469                SpriteFrame::new(vec![]),
1470            ],
1471            0.1,
1472            LoopMode::Loop,
1473        );
1474        // 0.2 + 0.3 + 0.1 (default) = 0.6
1475        assert!((anim.total_duration() - 0.6).abs() < 1e-6);
1476    }
1477
1478    #[test]
1479    fn pingpong_mode() {
1480        let mut animator = SpriteAnimator::new();
1481        animator.add_animation(SpriteAnimation::new(
1482            "pp",
1483            vec![
1484                SpriteFrame::new(vec![FrameGlyph::white('A', 0.0, 0.0)]),
1485                SpriteFrame::new(vec![FrameGlyph::white('B', 0.0, 0.0)]),
1486                SpriteFrame::new(vec![FrameGlyph::white('C', 0.0, 0.0)]),
1487            ],
1488            0.1,
1489            LoopMode::PingPong,
1490        ));
1491        animator.play("pp");
1492
1493        // Forward: 0 → 1 → 2
1494        animator.tick(0.1);
1495        assert_eq!(animator.current_frame_index(), 1);
1496        animator.tick(0.1);
1497        assert_eq!(animator.current_frame_index(), 2);
1498        // Backward: 2 → 1
1499        animator.tick(0.1);
1500        assert_eq!(animator.current_frame_index(), 1);
1501        // Backward: 1 → 0
1502        animator.tick(0.1);
1503        assert_eq!(animator.current_frame_index(), 0);
1504        // Forward again: 0 → 1
1505        animator.tick(0.1);
1506        assert_eq!(animator.current_frame_index(), 1);
1507    }
1508}