Skip to main content

proof_engine/game/
animation.rs

1//! Chaos RPG Animation State Machines, Blend Trees, and IK
2//!
3//! Wires the Proof Engine animation infrastructure into the Chaos RPG game loop.
4//! Every entity is an amorphous cluster of glyphs — animation here means driving
5//! per-glyph transforms (position offsets, scale, rotation, emission) through
6//! state machines, blend trees, and simplified inverse kinematics.
7
8use std::collections::HashMap;
9use std::f32::consts::{PI, TAU};
10
11use glam::Vec3;
12
13use crate::entity::EntityId;
14
15// ─────────────────────────────────────────────────────────────────────────────
16// Glyph Transform Output
17// ─────────────────────────────────────────────────────────────────────────────
18
19/// Transform applied to a single glyph within an entity.
20#[derive(Debug, Clone, Copy)]
21pub struct GlyphTransform {
22    /// Additive position offset (world units).
23    pub position_offset: Vec3,
24    /// Multiplicative scale factor (1.0 = identity).
25    pub scale: f32,
26    /// Rotation around Z axis (radians).
27    pub rotation_z: f32,
28    /// Emission multiplier (glow intensity, 0.0 = none).
29    pub emission: f32,
30}
31
32impl Default for GlyphTransform {
33    fn default() -> Self {
34        Self {
35            position_offset: Vec3::ZERO,
36            scale: 1.0,
37            rotation_z: 0.0,
38            emission: 0.0,
39        }
40    }
41}
42
43impl GlyphTransform {
44    /// Linearly interpolate between two transforms.
45    pub fn lerp(a: &GlyphTransform, b: &GlyphTransform, t: f32) -> Self {
46        Self {
47            position_offset: a.position_offset + (b.position_offset - a.position_offset) * t,
48            scale: a.scale + (b.scale - a.scale) * t,
49            rotation_z: a.rotation_z + (b.rotation_z - a.rotation_z) * t,
50            emission: a.emission + (b.emission - a.emission) * t,
51        }
52    }
53}
54
55// ─────────────────────────────────────────────────────────────────────────────
56// Animation Pose
57// ─────────────────────────────────────────────────────────────────────────────
58
59/// A snapshot of per-glyph transforms for an entire entity.
60#[derive(Debug, Clone)]
61pub struct AnimPose {
62    pub transforms: Vec<GlyphTransform>,
63}
64
65impl AnimPose {
66    pub fn identity(count: usize) -> Self {
67        Self {
68            transforms: vec![GlyphTransform::default(); count],
69        }
70    }
71
72    /// Blend two poses by weight `t` (0.0 = self, 1.0 = other).
73    pub fn blend(&self, other: &AnimPose, t: f32) -> Self {
74        let len = self.transforms.len().min(other.transforms.len());
75        let mut transforms = Vec::with_capacity(len);
76        for i in 0..len {
77            transforms.push(GlyphTransform::lerp(&self.transforms[i], &other.transforms[i], t));
78        }
79        Self { transforms }
80    }
81}
82
83// ─────────────────────────────────────────────────────────────────────────────
84// Blend Curves
85// ─────────────────────────────────────────────────────────────────────────────
86
87/// Easing function applied during state transitions.
88#[derive(Debug, Clone, Copy, PartialEq)]
89pub enum BlendCurve {
90    Linear,
91    EaseIn,
92    EaseOut,
93    EaseInOut,
94}
95
96impl BlendCurve {
97    /// Evaluate the curve at normalized time `t` in [0, 1].
98    pub fn evaluate(&self, t: f32) -> f32 {
99        let t = t.clamp(0.0, 1.0);
100        match self {
101            BlendCurve::Linear => t,
102            BlendCurve::EaseIn => t * t,
103            BlendCurve::EaseOut => 1.0 - (1.0 - t) * (1.0 - t),
104            BlendCurve::EaseInOut => {
105                if t < 0.5 {
106                    2.0 * t * t
107                } else {
108                    1.0 - (-2.0 * t + 2.0).powi(2) / 2.0
109                }
110            }
111        }
112    }
113}
114
115// ═════════════════════════════════════════════════════════════════════════════
116// PLAYER ANIMATION
117// ═════════════════════════════════════════════════════════════════════════════
118
119/// All possible player animation states in the Chaos RPG.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
121pub enum PlayerAnimState {
122    Idle,
123    Walk,
124    Attack,
125    HeavyAttack,
126    Cast,
127    Defend,
128    Hurt,
129    Flee,
130    Channel,
131    Dodge,
132    Interact,
133}
134
135impl PlayerAnimState {
136    /// Duration hint for non-looping states (seconds). Returns None for looping states.
137    pub fn fixed_duration(&self) -> Option<f32> {
138        match self {
139            PlayerAnimState::Attack => Some(0.3),
140            PlayerAnimState::HeavyAttack => Some(1.0),  // 0.5 windup + 0.3 swing + 0.2 follow
141            PlayerAnimState::Cast => Some(0.75),         // 0.5-1.0 depending on spell
142            PlayerAnimState::Hurt => Some(0.2),
143            PlayerAnimState::Dodge => Some(0.25),
144            PlayerAnimState::Interact => Some(0.5),
145            _ => None, // looping states
146        }
147    }
148}
149
150/// Sub-phase for HeavyAttack timing.
151#[derive(Debug, Clone, Copy, PartialEq)]
152enum HeavyAttackPhase {
153    Windup,     // 0.0..0.5s
154    Swing,      // 0.5..0.8s
155    FollowThru, // 0.8..1.0s
156}
157
158/// Sub-phase for Cast timing.
159#[derive(Debug, Clone, Copy, PartialEq)]
160enum CastPhase {
161    Raise,    // arms rise
162    Hold,     // sustained glow
163    Release,  // return
164}
165
166// ─────────────────────────────────────────────────────────────────────────────
167// Player Animation Controller
168// ─────────────────────────────────────────────────────────────────────────────
169
170/// Drives per-glyph transforms for a player entity based on the current state.
171pub struct PlayerAnimController {
172    pub current_state: PlayerAnimState,
173    pub prev_state: PlayerAnimState,
174    /// Blend factor between prev and current (0 = fully prev, 1 = fully current).
175    pub blend_factor: f32,
176    /// Total time for the current transition (seconds).
177    pub transition_time: f32,
178    /// Elapsed time in the current state.
179    pub state_timer: f32,
180    /// Elapsed time within the transition (counts up to transition_time).
181    transition_elapsed: f32,
182    /// Whether we are mid-transition.
183    in_transition: bool,
184    /// Blend curve for the active transition.
185    transition_curve: BlendCurve,
186    /// Number of glyphs in the entity.
187    glyph_count: usize,
188    /// Direction toward the current damage source (for Hurt recoil).
189    pub damage_direction: Vec3,
190    /// Direction of movement (for Dodge squash-stretch).
191    pub move_direction: Vec3,
192    /// Movement speed parameter [0, 1] for idle/walk blend.
193    pub movement_speed: f32,
194    /// IK targets active on this controller.
195    pub ik_targets: HashMap<String, IKTarget>,
196}
197
198impl PlayerAnimController {
199    pub fn new(glyph_count: usize) -> Self {
200        Self {
201            current_state: PlayerAnimState::Idle,
202            prev_state: PlayerAnimState::Idle,
203            blend_factor: 1.0,
204            transition_time: 0.0,
205            state_timer: 0.0,
206            transition_elapsed: 0.0,
207            in_transition: false,
208            transition_curve: BlendCurve::Linear,
209            glyph_count,
210            damage_direction: Vec3::NEG_X,
211            move_direction: Vec3::X,
212            movement_speed: 0.0,
213            ik_targets: HashMap::new(),
214        }
215    }
216
217    /// Attempt to transition to a new state. Returns true if the transition was accepted.
218    pub fn transition_to(&mut self, new_state: PlayerAnimState) -> bool {
219        if new_state == self.current_state && !self.in_transition {
220            return false;
221        }
222        let (duration, curve) = transition_params(self.current_state, new_state);
223        self.prev_state = self.current_state;
224        self.current_state = new_state;
225        self.transition_time = duration;
226        self.transition_elapsed = 0.0;
227        self.blend_factor = 0.0;
228        self.in_transition = true;
229        self.transition_curve = curve;
230        self.state_timer = 0.0;
231        true
232    }
233
234    /// Advance the controller by `dt` seconds and return the resulting pose.
235    pub fn update(&mut self, dt: f32) -> AnimPose {
236        self.state_timer += dt;
237
238        // Advance transition blend
239        if self.in_transition {
240            self.transition_elapsed += dt;
241            if self.transition_elapsed >= self.transition_time {
242                self.blend_factor = 1.0;
243                self.in_transition = false;
244            } else {
245                let raw = self.transition_elapsed / self.transition_time.max(0.001);
246                self.blend_factor = self.transition_curve.evaluate(raw);
247            }
248        }
249
250        // Auto-return to idle when a fixed-duration state expires
251        if !self.in_transition {
252            if let Some(dur) = self.current_state.fixed_duration() {
253                if self.state_timer >= dur {
254                    self.transition_to(PlayerAnimState::Idle);
255                }
256            }
257        }
258
259        // Evaluate poses
260        if self.in_transition {
261            let prev_pose = self.evaluate_state(self.prev_state);
262            let curr_pose = self.evaluate_state(self.current_state);
263            prev_pose.blend(&curr_pose, self.blend_factor)
264        } else {
265            self.evaluate_state(self.current_state)
266        }
267    }
268
269    /// Evaluate the pose for a given state at the current state_timer.
270    fn evaluate_state(&self, state: PlayerAnimState) -> AnimPose {
271        let t = self.state_timer;
272        let n = self.glyph_count;
273        match state {
274            PlayerAnimState::Idle => self.pose_idle(t, n),
275            PlayerAnimState::Walk => self.pose_walk(t, n),
276            PlayerAnimState::Attack => self.pose_attack(t, n),
277            PlayerAnimState::HeavyAttack => self.pose_heavy_attack(t, n),
278            PlayerAnimState::Cast => self.pose_cast(t, n),
279            PlayerAnimState::Defend => self.pose_defend(t, n),
280            PlayerAnimState::Hurt => self.pose_hurt(t, n),
281            PlayerAnimState::Flee => self.pose_flee(t, n),
282            PlayerAnimState::Channel => self.pose_channel(t, n),
283            PlayerAnimState::Dodge => self.pose_dodge(t, n),
284            PlayerAnimState::Interact => self.pose_interact(t, n),
285        }
286    }
287
288    // ── State Pose Evaluators ────────────────────────────────────────────────
289
290    /// Idle: gentle breathing (sine on scale 0.98-1.02) and slight Y bob (0.3 Hz, 2px).
291    fn pose_idle(&self, t: f32, n: usize) -> AnimPose {
292        let mut transforms = Vec::with_capacity(n);
293        let breath_freq = 0.3;
294        let breath_scale_amp = 0.02; // +-0.02 around 1.0
295        let bob_amp = 2.0;           // pixels
296
297        for i in 0..n {
298            let phase = i as f32 * 0.1; // slight per-glyph offset for organic feel
299            let breath = (TAU * breath_freq * t + phase).sin();
300            let scale = 1.0 + breath * breath_scale_amp;
301            let y_bob = breath * bob_amp;
302            transforms.push(GlyphTransform {
303                position_offset: Vec3::new(0.0, y_bob, 0.0),
304                scale,
305                rotation_z: 0.0,
306                emission: 0.0,
307            });
308        }
309        AnimPose { transforms }
310    }
311
312    /// Walk: increased bob (4px, 1.5 Hz), arm glyphs swing left-right.
313    fn pose_walk(&self, t: f32, n: usize) -> AnimPose {
314        let mut transforms = Vec::with_capacity(n);
315        let bob_freq = 1.5;
316        let bob_amp = 4.0;
317        let arm_swing_amp = 3.0; // pixels of X offset
318
319        for i in 0..n {
320            let phase = i as f32 * 0.15;
321            let bob = (TAU * bob_freq * t + phase).sin();
322            let y_bob = bob * bob_amp;
323
324            // Arm swing: odd-indexed glyphs swing opposite to even-indexed
325            let arm_phase = if i % 2 == 0 { 0.0 } else { PI };
326            let x_swing = (TAU * bob_freq * t + arm_phase).sin() * arm_swing_amp;
327
328            // Slight forward lean
329            let lean = 0.02; // radians
330
331            transforms.push(GlyphTransform {
332                position_offset: Vec3::new(x_swing, y_bob, 0.0),
333                scale: 1.0,
334                rotation_z: lean,
335                emission: 0.0,
336            });
337        }
338        AnimPose { transforms }
339    }
340
341    /// Attack: weapon arm swings toward target, body leans forward 5-10 degrees, 0.3s.
342    fn pose_attack(&self, t: f32, n: usize) -> AnimPose {
343        let mut transforms = Vec::with_capacity(n);
344        let duration = 0.3;
345        let progress = (t / duration).clamp(0.0, 1.0);
346
347        // Attack arc: fast swing peaking at ~60% through
348        let swing_curve = if progress < 0.6 {
349            (progress / 0.6 * PI * 0.5).sin()
350        } else {
351            ((1.0 - progress) / 0.4 * PI * 0.5).sin()
352        };
353
354        let lean_angle = swing_curve * 0.17; // ~10 degrees max
355        let forward_push = swing_curve * 3.0; // pixels
356
357        for i in 0..n {
358            // Weapon arm glyphs (upper indices) swing more aggressively
359            let arm_factor = if i >= n / 2 { 1.5 } else { 0.5 };
360            let swing_offset = swing_curve * 6.0 * arm_factor;
361
362            transforms.push(GlyphTransform {
363                position_offset: Vec3::new(swing_offset, -forward_push * 0.3, 0.0),
364                scale: 1.0 + swing_curve * 0.03,
365                rotation_z: lean_angle * arm_factor,
366                emission: swing_curve * 0.3,
367            });
368        }
369        AnimPose { transforms }
370    }
371
372    /// HeavyAttack: 0.5s windup, 0.3s swing, 0.2s follow-through (1.0s total).
373    fn pose_heavy_attack(&self, t: f32, n: usize) -> AnimPose {
374        let mut transforms = Vec::with_capacity(n);
375
376        let (phase, phase_progress) = if t < 0.5 {
377            (HeavyAttackPhase::Windup, t / 0.5)
378        } else if t < 0.8 {
379            (HeavyAttackPhase::Swing, (t - 0.5) / 0.3)
380        } else {
381            (HeavyAttackPhase::FollowThru, ((t - 0.8) / 0.2).clamp(0.0, 1.0))
382        };
383
384        for i in 0..n {
385            let arm_factor = if i >= n / 2 { 1.8 } else { 0.6 };
386            let (offset, scale, rot, emit) = match phase {
387                HeavyAttackPhase::Windup => {
388                    // Arm pulls back
389                    let pull = (phase_progress * PI * 0.5).sin();
390                    let x = -pull * 5.0 * arm_factor;
391                    let lean = -pull * 0.05;
392                    (Vec3::new(x, pull * 1.5, 0.0), 1.0 + pull * 0.02, lean, 0.0)
393                }
394                HeavyAttackPhase::Swing => {
395                    // Big forward arc
396                    let swing = (phase_progress * PI * 0.5).sin();
397                    let x = swing * 10.0 * arm_factor;
398                    let lean = swing * 0.22; // ~12 degrees
399                    (Vec3::new(x, -swing * 3.0, 0.0), 1.0 + swing * 0.05, lean, swing * 0.6)
400                }
401                HeavyAttackPhase::FollowThru => {
402                    // Gradual recovery
403                    let recover = 1.0 - phase_progress;
404                    let x = recover * 4.0 * arm_factor;
405                    let lean = recover * 0.1;
406                    (Vec3::new(x, -recover * 1.0, 0.0), 1.0, lean, recover * 0.2)
407                }
408            };
409            transforms.push(GlyphTransform {
410                position_offset: offset,
411                scale,
412                rotation_z: rot,
413                emission: emit,
414            });
415        }
416        AnimPose { transforms }
417    }
418
419    /// Cast: staff/hands rise upward (IK above head), body straightens, glow on hands.
420    fn pose_cast(&self, t: f32, n: usize) -> AnimPose {
421        let mut transforms = Vec::with_capacity(n);
422        let duration = 0.75;
423        let progress = (t / duration).clamp(0.0, 1.0);
424
425        let (cast_phase, phase_t) = if progress < 0.4 {
426            (CastPhase::Raise, progress / 0.4)
427        } else if progress < 0.8 {
428            (CastPhase::Hold, (progress - 0.4) / 0.4)
429        } else {
430            (CastPhase::Release, (progress - 0.8) / 0.2)
431        };
432
433        for i in 0..n {
434            let is_hand = i >= n * 2 / 3; // upper glyphs are "hands"
435            let (offset, scale, rot, emit) = match cast_phase {
436                CastPhase::Raise => {
437                    let rise = (phase_t * PI * 0.5).sin();
438                    let y_up = if is_hand { rise * 8.0 } else { rise * 1.0 };
439                    let glow = if is_hand { rise * 0.8 } else { 0.0 };
440                    (Vec3::new(0.0, y_up, 0.0), 1.0 + rise * 0.01, -rise * 0.03, glow)
441                }
442                CastPhase::Hold => {
443                    let pulse = (TAU * 3.0 * phase_t).sin() * 0.3 + 0.7;
444                    let y_up = if is_hand { 8.0 } else { 1.0 };
445                    let glow = if is_hand { pulse } else { pulse * 0.1 };
446                    (Vec3::new(0.0, y_up, 0.0), 1.0 + 0.01, -0.03, glow)
447                }
448                CastPhase::Release => {
449                    let drop = 1.0 - phase_t;
450                    let y_up = if is_hand { drop * 8.0 } else { drop * 1.0 };
451                    let glow = if is_hand { drop * 0.5 } else { 0.0 };
452                    (Vec3::new(0.0, y_up, 0.0), 1.0, -drop * 0.03, glow)
453                }
454            };
455            transforms.push(GlyphTransform {
456                position_offset: offset,
457                scale,
458                rotation_z: rot,
459                emission: emit,
460            });
461        }
462        AnimPose { transforms }
463    }
464
465    /// Defend: arms cross / shield raise (IK to chest center), body hunches, scale reduced.
466    fn pose_defend(&self, t: f32, n: usize) -> AnimPose {
467        let mut transforms = Vec::with_capacity(n);
468        // Sustain at full defend after 0.15s snap
469        let snap = (t / 0.15).clamp(0.0, 1.0);
470        let defend = snap;
471
472        for i in 0..n {
473            let is_arm = i >= n / 2;
474            // Arms converge toward center
475            let center_pull = if is_arm {
476                let base_x = if i % 2 == 0 { 3.0 } else { -3.0 };
477                Vec3::new(-base_x * defend, -defend * 2.0, 0.0)
478            } else {
479                Vec3::new(0.0, -defend * 1.0, 0.0)
480            };
481            // Defensive crouch: scale slightly reduced
482            let crouch_scale = 1.0 - defend * 0.05;
483            // Body hunches forward
484            let hunch = defend * 0.08;
485
486            transforms.push(GlyphTransform {
487                position_offset: center_pull,
488                scale: crouch_scale,
489                rotation_z: hunch,
490                emission: 0.0,
491            });
492        }
493        AnimPose { transforms }
494    }
495
496    /// Hurt: recoil backward, arms spread, 0.2s stagger, shake on all glyphs.
497    fn pose_hurt(&self, t: f32, n: usize) -> AnimPose {
498        let mut transforms = Vec::with_capacity(n);
499        let duration = 0.2;
500        let progress = (t / duration).clamp(0.0, 1.0);
501        // Sharp recoil then recovery
502        let recoil = if progress < 0.3 {
503            progress / 0.3
504        } else {
505            1.0 - (progress - 0.3) / 0.7
506        };
507
508        let recoil_dir = self.damage_direction.normalize_or_zero();
509
510        for i in 0..n {
511            let phase = i as f32 * 2.7;
512            // Shake: high-frequency noise
513            let shake_x = (t * 40.0 + phase).sin() * recoil * 2.0;
514            let shake_y = (t * 37.0 + phase * 1.3).sin() * recoil * 2.0;
515
516            // Recoil away from damage
517            let recoil_offset = recoil_dir * recoil * -5.0;
518
519            // Arms spread outward
520            let spread = if i >= n / 2 {
521                let side = if i % 2 == 0 { 1.0 } else { -1.0 };
522                Vec3::new(side * recoil * 3.0, 0.0, 0.0)
523            } else {
524                Vec3::ZERO
525            };
526
527            transforms.push(GlyphTransform {
528                position_offset: recoil_offset + spread + Vec3::new(shake_x, shake_y, 0.0),
529                scale: 1.0 + recoil * 0.04,
530                rotation_z: (t * 30.0 + phase).sin() * recoil * 0.05,
531                emission: recoil * 0.4,
532            });
533        }
534        AnimPose { transforms }
535    }
536
537    /// Flee: 180-degree turn, maxed bob, speed lines implied via high emission trail.
538    fn pose_flee(&self, t: f32, n: usize) -> AnimPose {
539        let mut transforms = Vec::with_capacity(n);
540        let bob_freq = 3.0; // maxed frequency
541        let bob_amp = 5.0;
542
543        // 180-degree rotation over first 0.3s
544        let turn_progress = (t / 0.3).clamp(0.0, 1.0);
545        let rotation = turn_progress * PI;
546
547        for i in 0..n {
548            let phase = i as f32 * 0.2;
549            let bob = (TAU * bob_freq * t + phase).sin();
550            let y_bob = bob * bob_amp;
551
552            // Speed trail: trailing glyphs get more emission
553            let trail_emission = (i as f32 / n.max(1) as f32) * 0.6;
554
555            transforms.push(GlyphTransform {
556                position_offset: Vec3::new(0.0, y_bob, 0.0),
557                scale: 1.0,
558                rotation_z: rotation,
559                emission: trail_emission,
560            });
561        }
562        AnimPose { transforms }
563    }
564
565    /// Channel: sustained cast pose with pulsing emission and rotating rune particles.
566    fn pose_channel(&self, t: f32, n: usize) -> AnimPose {
567        let mut transforms = Vec::with_capacity(n);
568        let pulse_freq = 2.0;
569
570        for i in 0..n {
571            let is_hand = i >= n * 2 / 3;
572            let phase = i as f32 * TAU / n.max(1) as f32;
573
574            // Hands stay raised
575            let y_up = if is_hand { 7.0 } else { 0.5 };
576
577            // Pulsing glow
578            let pulse = ((TAU * pulse_freq * t + phase).sin() * 0.5 + 0.5).clamp(0.0, 1.0);
579            let glow = if is_hand { pulse * 0.9 } else { pulse * 0.15 };
580
581            // Rotating rune orbit for hand glyphs
582            let orbit_offset = if is_hand {
583                let orbit_angle = t * TAU * 0.5 + phase;
584                Vec3::new(orbit_angle.cos() * 1.5, y_up + orbit_angle.sin() * 1.5, 0.0)
585            } else {
586                Vec3::new(0.0, y_up, 0.0)
587            };
588
589            transforms.push(GlyphTransform {
590                position_offset: orbit_offset,
591                scale: 1.0 + pulse * 0.02,
592                rotation_z: if is_hand { t * 0.5 } else { 0.0 },
593                emission: glow,
594            });
595        }
596        AnimPose { transforms }
597    }
598
599    /// Dodge: quick lateral movement with squash-stretch.
600    fn pose_dodge(&self, t: f32, n: usize) -> AnimPose {
601        let mut transforms = Vec::with_capacity(n);
602        let duration = 0.25;
603        let progress = (t / duration).clamp(0.0, 1.0);
604
605        // Bell-curve lateral displacement
606        let lateral = (progress * PI).sin() * 12.0;
607        let move_dir = self.move_direction.normalize_or_zero();
608
609        // Squash-stretch: compress in movement direction, expand perpendicular
610        let squash = if progress < 0.5 {
611            progress / 0.5
612        } else {
613            1.0 - (progress - 0.5) / 0.5
614        };
615
616        for i in 0..n {
617            let offset = move_dir * lateral;
618            // Squash in movement axis, stretch perpendicular
619            let scale_x = 1.0 - squash * 0.15;
620            let scale_y = 1.0 + squash * 0.15;
621            // Approximate as average scale (glyph-level squash)
622            let avg_scale = (scale_x + scale_y) * 0.5;
623
624            transforms.push(GlyphTransform {
625                position_offset: offset,
626                scale: avg_scale,
627                rotation_z: squash * 0.1 * if i % 2 == 0 { 1.0 } else { -1.0 },
628                emission: squash * 0.2,
629            });
630        }
631        AnimPose { transforms }
632    }
633
634    /// Interact: gentle reach forward and slight lean.
635    fn pose_interact(&self, t: f32, n: usize) -> AnimPose {
636        let mut transforms = Vec::with_capacity(n);
637        let duration = 0.5;
638        let progress = (t / duration).clamp(0.0, 1.0);
639        let reach = (progress * PI).sin();
640
641        for i in 0..n {
642            let is_arm = i >= n / 2;
643            let forward = if is_arm { reach * 4.0 } else { reach * 1.0 };
644            let lean = reach * 0.06;
645
646            transforms.push(GlyphTransform {
647                position_offset: Vec3::new(forward, -reach * 0.5, 0.0),
648                scale: 1.0,
649                rotation_z: lean,
650                emission: 0.0,
651            });
652        }
653        AnimPose { transforms }
654    }
655}
656
657// ─────────────────────────────────────────────────────────────────────────────
658// Transition Table
659// ─────────────────────────────────────────────────────────────────────────────
660
661/// Defines a transition between two animation states.
662#[derive(Debug, Clone)]
663pub struct AnimTransitionDef {
664    pub from_state: PlayerAnimState,
665    pub to_state: PlayerAnimState,
666    pub duration: f32,
667    pub blend_curve: BlendCurve,
668}
669
670/// Lookup transition parameters for a given (from, to) pair.
671/// Falls back to defaults when no explicit rule exists.
672fn transition_params(from: PlayerAnimState, to: PlayerAnimState) -> (f32, BlendCurve) {
673    // Any → Hurt is instant reaction
674    if to == PlayerAnimState::Hurt {
675        return (0.05, BlendCurve::Linear);
676    }
677
678    match (from, to) {
679        (PlayerAnimState::Idle, PlayerAnimState::Walk)   => (0.2, BlendCurve::EaseInOut),
680        (PlayerAnimState::Walk, PlayerAnimState::Idle)   => (0.2, BlendCurve::EaseInOut),
681        (PlayerAnimState::Walk, PlayerAnimState::Attack)  => (0.1, BlendCurve::EaseIn),
682        (PlayerAnimState::Idle, PlayerAnimState::Attack)  => (0.1, BlendCurve::EaseIn),
683        (PlayerAnimState::Attack, PlayerAnimState::Idle)  => (0.3, BlendCurve::EaseOut),
684        (PlayerAnimState::HeavyAttack, PlayerAnimState::Idle) => (0.4, BlendCurve::EaseOut),
685        (PlayerAnimState::Hurt, PlayerAnimState::Idle)    => (0.4, BlendCurve::EaseOut),
686        (PlayerAnimState::Idle, PlayerAnimState::Cast)    => (0.3, BlendCurve::EaseIn),
687        (PlayerAnimState::Cast, PlayerAnimState::Idle)    => (0.2, BlendCurve::EaseOut),
688        (PlayerAnimState::Idle, PlayerAnimState::Channel) => (0.3, BlendCurve::EaseIn),
689        (PlayerAnimState::Channel, PlayerAnimState::Idle) => (0.25, BlendCurve::EaseOut),
690        (PlayerAnimState::Idle, PlayerAnimState::Defend)  => (0.15, BlendCurve::EaseIn),
691        (PlayerAnimState::Defend, PlayerAnimState::Idle)  => (0.2, BlendCurve::EaseOut),
692        (PlayerAnimState::Idle, PlayerAnimState::Flee)    => (0.15, BlendCurve::EaseIn),
693        (PlayerAnimState::Flee, PlayerAnimState::Idle)    => (0.3, BlendCurve::EaseOut),
694        (PlayerAnimState::Idle, PlayerAnimState::Dodge)   => (0.05, BlendCurve::Linear),
695        (PlayerAnimState::Dodge, PlayerAnimState::Idle)   => (0.15, BlendCurve::EaseOut),
696        (PlayerAnimState::Walk, PlayerAnimState::Dodge)   => (0.05, BlendCurve::Linear),
697        (PlayerAnimState::Idle, PlayerAnimState::Interact) => (0.2, BlendCurve::EaseInOut),
698        (PlayerAnimState::Interact, PlayerAnimState::Idle) => (0.2, BlendCurve::EaseOut),
699        _ => (0.2, BlendCurve::EaseInOut), // default fallback
700    }
701}
702
703/// Return the full transition table as a Vec (useful for inspection/serialization).
704pub fn build_transition_table() -> Vec<AnimTransitionDef> {
705    let entries: &[(PlayerAnimState, PlayerAnimState, f32, BlendCurve)] = &[
706        (PlayerAnimState::Idle, PlayerAnimState::Walk,   0.2,  BlendCurve::EaseInOut),
707        (PlayerAnimState::Walk, PlayerAnimState::Attack,  0.1,  BlendCurve::EaseIn),
708        (PlayerAnimState::Attack, PlayerAnimState::Idle,  0.3,  BlendCurve::EaseOut),
709        (PlayerAnimState::Idle, PlayerAnimState::Cast,    0.3,  BlendCurve::EaseIn),
710        (PlayerAnimState::Cast, PlayerAnimState::Idle,    0.2,  BlendCurve::EaseOut),
711        (PlayerAnimState::Hurt, PlayerAnimState::Idle,    0.4,  BlendCurve::EaseOut),
712        (PlayerAnimState::Idle, PlayerAnimState::Defend,  0.15, BlendCurve::EaseIn),
713        (PlayerAnimState::Defend, PlayerAnimState::Idle,  0.2,  BlendCurve::EaseOut),
714        (PlayerAnimState::Idle, PlayerAnimState::Flee,    0.15, BlendCurve::EaseIn),
715        (PlayerAnimState::Idle, PlayerAnimState::Channel, 0.3,  BlendCurve::EaseIn),
716        (PlayerAnimState::Idle, PlayerAnimState::Dodge,   0.05, BlendCurve::Linear),
717        (PlayerAnimState::Idle, PlayerAnimState::Interact,0.2,  BlendCurve::EaseInOut),
718    ];
719    entries
720        .iter()
721        .map(|&(from, to, dur, curve)| AnimTransitionDef {
722            from_state: from,
723            to_state: to,
724            duration: dur,
725            blend_curve: curve,
726        })
727        .collect()
728}
729
730// ═════════════════════════════════════════════════════════════════════════════
731// BLEND TREES
732// ═════════════════════════════════════════════════════════════════════════════
733
734/// A 1D blend tree: interpolates between poses based on a single float parameter.
735pub struct BlendTree1D {
736    /// Current parameter value [0, 1].
737    pub parameter: f32,
738    /// Sorted entries: (parameter_value, pose).
739    entries: Vec<(f32, AnimPose)>,
740}
741
742impl BlendTree1D {
743    pub fn new() -> Self {
744        Self {
745            parameter: 0.0,
746            entries: Vec::new(),
747        }
748    }
749
750    /// Add a pose at a given parameter value. Entries should be added in order.
751    pub fn add_entry(&mut self, param: f32, pose: AnimPose) {
752        self.entries.push((param, pose));
753        self.entries.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
754    }
755
756    /// Evaluate the blend tree at the current parameter value.
757    pub fn evaluate(&self) -> AnimPose {
758        if self.entries.is_empty() {
759            return AnimPose::identity(0);
760        }
761        if self.entries.len() == 1 {
762            return self.entries[0].1.clone();
763        }
764
765        let p = self.parameter;
766
767        // Below first entry
768        if p <= self.entries[0].0 {
769            return self.entries[0].1.clone();
770        }
771        // Above last entry
772        if p >= self.entries.last().unwrap().0 {
773            return self.entries.last().unwrap().1.clone();
774        }
775
776        // Find the two bracketing entries
777        for i in 0..self.entries.len() - 1 {
778            let (p0, ref pose0) = self.entries[i];
779            let (p1, ref pose1) = self.entries[i + 1];
780            if p >= p0 && p <= p1 {
781                let range = p1 - p0;
782                let t = if range.abs() < 1e-6 { 0.0 } else { (p - p0) / range };
783                return pose0.blend(pose1, t);
784            }
785        }
786
787        self.entries.last().unwrap().1.clone()
788    }
789}
790
791/// IdleWalkBlend: parameter = movement_speed [0, 1], blends idle bob into walk bob.
792pub struct IdleWalkBlend {
793    pub tree: BlendTree1D,
794}
795
796impl IdleWalkBlend {
797    pub fn new(glyph_count: usize) -> Self {
798        let ctrl = PlayerAnimController::new(glyph_count);
799        let idle_pose = ctrl.pose_idle(0.0, glyph_count);
800        let walk_pose = ctrl.pose_walk(0.0, glyph_count);
801
802        let mut tree = BlendTree1D::new();
803        tree.add_entry(0.0, idle_pose);
804        tree.add_entry(1.0, walk_pose);
805        Self { tree }
806    }
807
808    pub fn evaluate(&mut self, speed: f32) -> AnimPose {
809        self.tree.parameter = speed.clamp(0.0, 1.0);
810        self.tree.evaluate()
811    }
812}
813
814/// AttackPowerBlend: parameter = force_stat [0, 1], blends light swing into heavy swing.
815pub struct AttackPowerBlend {
816    pub tree: BlendTree1D,
817}
818
819impl AttackPowerBlend {
820    pub fn new(glyph_count: usize) -> Self {
821        let ctrl = PlayerAnimController::new(glyph_count);
822        let light = ctrl.pose_attack(0.15, glyph_count);  // mid-attack snapshot
823        let heavy = ctrl.pose_heavy_attack(0.65, glyph_count); // mid-swing snapshot
824
825        let mut tree = BlendTree1D::new();
826        tree.add_entry(0.0, light);
827        tree.add_entry(1.0, heavy);
828        Self { tree }
829    }
830
831    pub fn evaluate(&mut self, force: f32) -> AnimPose {
832        self.tree.parameter = force.clamp(0.0, 1.0);
833        self.tree.evaluate()
834    }
835}
836
837/// DamageReactionBlend: parameter = damage_fraction [0, 1], small → large recoil.
838pub struct DamageReactionBlend {
839    pub tree: BlendTree1D,
840}
841
842impl DamageReactionBlend {
843    pub fn new(glyph_count: usize) -> Self {
844        // Build two snapshots at different recoil intensities
845        let mut ctrl_small = PlayerAnimController::new(glyph_count);
846        ctrl_small.damage_direction = Vec3::NEG_X;
847        let small_recoil = ctrl_small.pose_hurt(0.06, glyph_count); // early = small
848
849        let mut ctrl_large = PlayerAnimController::new(glyph_count);
850        ctrl_large.damage_direction = Vec3::NEG_X;
851        let large_recoil = ctrl_large.pose_hurt(0.06, glyph_count);
852        // Scale up the large recoil manually
853        let large_recoil = AnimPose {
854            transforms: large_recoil
855                .transforms
856                .into_iter()
857                .map(|mut gt| {
858                    gt.position_offset *= 2.5;
859                    gt.emission *= 2.0;
860                    gt
861                })
862                .collect(),
863        };
864
865        let mut tree = BlendTree1D::new();
866        tree.add_entry(0.0, small_recoil);
867        tree.add_entry(1.0, large_recoil);
868        Self { tree }
869    }
870
871    pub fn evaluate(&mut self, damage_fraction: f32) -> AnimPose {
872        self.tree.parameter = damage_fraction.clamp(0.0, 1.0);
873        self.tree.evaluate()
874    }
875}
876
877/// CastIntensityBlend: parameter = mana_cost_fraction, small cast → large cast.
878pub struct CastIntensityBlend {
879    pub tree: BlendTree1D,
880}
881
882impl CastIntensityBlend {
883    pub fn new(glyph_count: usize) -> Self {
884        let ctrl = PlayerAnimController::new(glyph_count);
885        let small_cast = ctrl.pose_cast(0.3, glyph_count);
886        let large_cast_raw = ctrl.pose_cast(0.3, glyph_count);
887        let large_cast = AnimPose {
888            transforms: large_cast_raw
889                .transforms
890                .into_iter()
891                .map(|mut gt| {
892                    gt.position_offset *= 1.5;
893                    gt.emission *= 2.5;
894                    gt.scale += 0.05;
895                    gt
896                })
897                .collect(),
898        };
899
900        let mut tree = BlendTree1D::new();
901        tree.add_entry(0.0, small_cast);
902        tree.add_entry(1.0, large_cast);
903        Self { tree }
904    }
905
906    pub fn evaluate(&mut self, mana_fraction: f32) -> AnimPose {
907        self.tree.parameter = mana_fraction.clamp(0.0, 1.0);
908        self.tree.evaluate()
909    }
910}
911
912// ═════════════════════════════════════════════════════════════════════════════
913// INVERSE KINEMATICS (simplified 2-bone for glyphs)
914// ═════════════════════════════════════════════════════════════════════════════
915
916/// A simplified 2-bone IK chain operating on glyph positions.
917#[derive(Debug, Clone)]
918pub struct IKChain {
919    pub root_pos: Vec3,
920    pub mid_pos: Vec3,
921    pub end_pos: Vec3,
922    /// Bone lengths: [root→mid, mid→end].
923    pub lengths: [f32; 2],
924}
925
926impl IKChain {
927    pub fn new(root: Vec3, mid: Vec3, end: Vec3) -> Self {
928        let l0 = (mid - root).length();
929        let l1 = (end - mid).length();
930        Self {
931            root_pos: root,
932            mid_pos: mid,
933            end_pos: end,
934            lengths: [l0, l1],
935        }
936    }
937
938    /// Total reach of the chain.
939    pub fn total_length(&self) -> f32 {
940        self.lengths[0] + self.lengths[1]
941    }
942}
943
944/// Analytical two-bone IK solver.
945///
946/// Positions the mid and end joints so that `end` reaches as close to `target`
947/// as possible. Uses the law of cosines for the elbow angle.
948pub fn solve_two_bone(chain: &mut IKChain, target: Vec3) {
949    let l0 = chain.lengths[0];
950    let l1 = chain.lengths[1];
951    let total = l0 + l1;
952
953    let root = chain.root_pos;
954    let to_target = target - root;
955    let dist = to_target.length().max(0.001);
956
957    // Clamp target to reachable distance
958    let dist_clamped = dist.min(total - 0.001).max((l0 - l1).abs() + 0.001);
959    let direction = to_target.normalize_or_zero();
960    let clamped_target = root + direction * dist_clamped;
961
962    // Law of cosines: angle at root
963    let cos_angle = ((l0 * l0 + dist_clamped * dist_clamped - l1 * l1) / (2.0 * l0 * dist_clamped))
964        .clamp(-1.0, 1.0);
965    let angle = cos_angle.acos();
966
967    // Construct a perpendicular axis for the elbow bend.
968    // Default to Y-up if direction is nearly vertical.
969    let up = if direction.dot(Vec3::Y).abs() > 0.99 {
970        Vec3::Z
971    } else {
972        Vec3::Y
973    };
974    let side = direction.cross(up).normalize_or_zero();
975    let bend_axis = side.cross(direction).normalize_or_zero();
976
977    // Rotate direction by angle around bend_axis to get mid position
978    let cos_a = angle.cos();
979    let sin_a = angle.sin();
980    let mid_dir = direction * cos_a + bend_axis * sin_a;
981    chain.mid_pos = root + mid_dir * l0;
982
983    // End position: from mid toward clamped target, at length l1
984    let mid_to_target = (clamped_target - chain.mid_pos).normalize_or_zero();
985    chain.end_pos = chain.mid_pos + mid_to_target * l1;
986}
987
988/// What an IK chain is targeting.
989#[derive(Debug, Clone)]
990pub enum IKTarget {
991    /// Track a specific entity (e.g., current enemy).
992    Enemy(EntityId),
993    /// Fixed world position.
994    Position(Vec3),
995    /// Offset relative to the owning entity's position.
996    Offset(Vec3),
997    /// Orient toward a direction (head/eye tracking).
998    LookDirection(Vec3),
999    /// No target (chain relaxes to rest pose).
1000    None,
1001}
1002
1003/// Named IK chain for a specific limb/purpose.
1004#[derive(Debug, Clone)]
1005pub struct IKLimb {
1006    pub name: String,
1007    pub chain: IKChain,
1008    pub target: IKTarget,
1009    /// Glyph indices that this IK chain controls: [root_glyph, mid_glyph, end_glyph].
1010    pub glyph_indices: [usize; 3],
1011    /// Blend weight for this IK [0, 1]. 0 = animation only, 1 = full IK.
1012    pub weight: f32,
1013}
1014
1015impl IKLimb {
1016    pub fn new(name: &str, glyph_indices: [usize; 3], rest_positions: [Vec3; 3]) -> Self {
1017        Self {
1018            name: name.to_string(),
1019            chain: IKChain::new(rest_positions[0], rest_positions[1], rest_positions[2]),
1020            target: IKTarget::None,
1021            glyph_indices,
1022            weight: 1.0,
1023        }
1024    }
1025
1026    /// Solve IK and return position offsets for the three controlled glyphs.
1027    pub fn solve(&mut self, entity_pos: Vec3, entities: &HashMap<EntityId, Vec3>) -> [Vec3; 3] {
1028        let world_target = match &self.target {
1029            IKTarget::Enemy(id) => {
1030                entities.get(id).copied().unwrap_or(entity_pos + Vec3::X * 5.0)
1031            }
1032            IKTarget::Position(p) => *p,
1033            IKTarget::Offset(o) => entity_pos + *o,
1034            IKTarget::LookDirection(dir) => entity_pos + dir.normalize_or_zero() * 10.0,
1035            IKTarget::None => {
1036                // Relax to rest — no offset
1037                return [Vec3::ZERO; 3];
1038            }
1039        };
1040
1041        solve_two_bone(&mut self.chain, world_target);
1042
1043        // Return offsets relative to entity position (these get blended with animation)
1044        let offsets = [
1045            (self.chain.root_pos - entity_pos) * self.weight,
1046            (self.chain.mid_pos - entity_pos) * self.weight,
1047            (self.chain.end_pos - entity_pos) * self.weight,
1048        ];
1049        offsets
1050    }
1051}
1052
1053// ── Game-specific IK target factories ────────────────────────────────────────
1054
1055/// Create a weapon-arm IK limb that aims at the current target enemy.
1056pub fn weapon_arm_ik(glyph_indices: [usize; 3], rest: [Vec3; 3]) -> IKLimb {
1057    let mut limb = IKLimb::new("weapon_arm", glyph_indices, rest);
1058    limb.weight = 0.8;
1059    limb
1060}
1061
1062/// Create a look-at IK limb for head/eye orientation.
1063pub fn look_at_ik(glyph_indices: [usize; 3], rest: [Vec3; 3]) -> IKLimb {
1064    let mut limb = IKLimb::new("look_at", glyph_indices, rest);
1065    limb.weight = 0.6;
1066    limb
1067}
1068
1069/// Create a staff-aim IK limb for mage casting.
1070pub fn staff_aim_ik(glyph_indices: [usize; 3], rest: [Vec3; 3]) -> IKLimb {
1071    let mut limb = IKLimb::new("staff_aim", glyph_indices, rest);
1072    limb.weight = 0.9;
1073    limb
1074}
1075
1076/// Create a shield IK limb that positions between player and threat.
1077pub fn shield_ik(glyph_indices: [usize; 3], rest: [Vec3; 3]) -> IKLimb {
1078    let mut limb = IKLimb::new("shield", glyph_indices, rest);
1079    limb.weight = 1.0;
1080    limb
1081}
1082
1083// ═════════════════════════════════════════════════════════════════════════════
1084// ENEMY ANIMATION
1085// ═════════════════════════════════════════════════════════════════════════════
1086
1087/// Base enemy animation states.
1088#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1089pub enum EnemyAnimState {
1090    Idle,
1091    Approach,
1092    Attack,
1093    Hurt,
1094    Die,
1095    Special,
1096}
1097
1098/// Simpler animation controller for enemies.
1099pub struct EnemyAnimController {
1100    pub current_state: EnemyAnimState,
1101    pub prev_state: EnemyAnimState,
1102    pub blend_factor: f32,
1103    pub transition_time: f32,
1104    pub state_timer: f32,
1105    transition_elapsed: f32,
1106    in_transition: bool,
1107    transition_curve: BlendCurve,
1108    glyph_count: usize,
1109    /// Direction the enemy is approaching from.
1110    pub approach_direction: Vec3,
1111    /// Death dissolution progress [0, 1].
1112    pub death_progress: f32,
1113}
1114
1115impl EnemyAnimController {
1116    pub fn new(glyph_count: usize) -> Self {
1117        Self {
1118            current_state: EnemyAnimState::Idle,
1119            prev_state: EnemyAnimState::Idle,
1120            blend_factor: 1.0,
1121            transition_time: 0.0,
1122            state_timer: 0.0,
1123            transition_elapsed: 0.0,
1124            in_transition: false,
1125            transition_curve: BlendCurve::Linear,
1126            glyph_count,
1127            approach_direction: Vec3::X,
1128            death_progress: 0.0,
1129        }
1130    }
1131
1132    pub fn transition_to(&mut self, new_state: EnemyAnimState) -> bool {
1133        if new_state == self.current_state && !self.in_transition {
1134            return false;
1135        }
1136        let (duration, curve) = enemy_transition_params(self.current_state, new_state);
1137        self.prev_state = self.current_state;
1138        self.current_state = new_state;
1139        self.transition_time = duration;
1140        self.transition_elapsed = 0.0;
1141        self.blend_factor = 0.0;
1142        self.in_transition = true;
1143        self.transition_curve = curve;
1144        self.state_timer = 0.0;
1145        true
1146    }
1147
1148    pub fn update(&mut self, dt: f32) -> AnimPose {
1149        self.state_timer += dt;
1150
1151        if self.in_transition {
1152            self.transition_elapsed += dt;
1153            if self.transition_elapsed >= self.transition_time {
1154                self.blend_factor = 1.0;
1155                self.in_transition = false;
1156            } else {
1157                let raw = self.transition_elapsed / self.transition_time.max(0.001);
1158                self.blend_factor = self.transition_curve.evaluate(raw);
1159            }
1160        }
1161
1162        if self.in_transition {
1163            let prev = self.evaluate_state(self.prev_state);
1164            let curr = self.evaluate_state(self.current_state);
1165            prev.blend(&curr, self.blend_factor)
1166        } else {
1167            self.evaluate_state(self.current_state)
1168        }
1169    }
1170
1171    fn evaluate_state(&self, state: EnemyAnimState) -> AnimPose {
1172        let t = self.state_timer;
1173        let n = self.glyph_count;
1174        match state {
1175            EnemyAnimState::Idle => self.enemy_idle(t, n),
1176            EnemyAnimState::Approach => self.enemy_approach(t, n),
1177            EnemyAnimState::Attack => self.enemy_attack(t, n),
1178            EnemyAnimState::Hurt => self.enemy_hurt(t, n),
1179            EnemyAnimState::Die => self.enemy_die(t, n),
1180            EnemyAnimState::Special => self.enemy_special(t, n),
1181        }
1182    }
1183
1184    fn enemy_idle(&self, t: f32, n: usize) -> AnimPose {
1185        let mut transforms = Vec::with_capacity(n);
1186        for i in 0..n {
1187            let phase = i as f32 * 0.3;
1188            let bob = (TAU * 0.4 * t + phase).sin();
1189            transforms.push(GlyphTransform {
1190                position_offset: Vec3::new(0.0, bob * 1.5, 0.0),
1191                scale: 1.0 + bob * 0.01,
1192                rotation_z: 0.0,
1193                emission: 0.05,
1194            });
1195        }
1196        AnimPose { transforms }
1197    }
1198
1199    fn enemy_approach(&self, t: f32, n: usize) -> AnimPose {
1200        let mut transforms = Vec::with_capacity(n);
1201        let dir = self.approach_direction.normalize_or_zero();
1202        for i in 0..n {
1203            let phase = i as f32 * 0.25;
1204            let bob = (TAU * 1.2 * t + phase).sin();
1205            let lean = 0.1;
1206            transforms.push(GlyphTransform {
1207                position_offset: Vec3::new(dir.x * 2.0, bob * 3.0, 0.0),
1208                scale: 1.0,
1209                rotation_z: lean,
1210                emission: 0.1,
1211            });
1212        }
1213        AnimPose { transforms }
1214    }
1215
1216    fn enemy_attack(&self, t: f32, n: usize) -> AnimPose {
1217        let mut transforms = Vec::with_capacity(n);
1218        let progress = (t / 0.4).clamp(0.0, 1.0);
1219        let swing = (progress * PI).sin();
1220        for i in 0..n {
1221            let factor = if i >= n / 2 { 1.5 } else { 0.7 };
1222            transforms.push(GlyphTransform {
1223                position_offset: Vec3::new(swing * 5.0 * factor, -swing * 2.0, 0.0),
1224                scale: 1.0 + swing * 0.04,
1225                rotation_z: swing * 0.15 * factor,
1226                emission: swing * 0.5,
1227            });
1228        }
1229        AnimPose { transforms }
1230    }
1231
1232    fn enemy_hurt(&self, t: f32, n: usize) -> AnimPose {
1233        let mut transforms = Vec::with_capacity(n);
1234        let recoil = (1.0 - (t / 0.3).clamp(0.0, 1.0)).max(0.0);
1235        for i in 0..n {
1236            let phase = i as f32 * 3.1;
1237            let shake_x = (t * 35.0 + phase).sin() * recoil * 2.5;
1238            let shake_y = (t * 31.0 + phase).cos() * recoil * 2.5;
1239            transforms.push(GlyphTransform {
1240                position_offset: Vec3::new(shake_x - recoil * 3.0, shake_y, 0.0),
1241                scale: 1.0 + recoil * 0.03,
1242                rotation_z: (t * 25.0 + phase).sin() * recoil * 0.06,
1243                emission: recoil * 0.6,
1244            });
1245        }
1246        AnimPose { transforms }
1247    }
1248
1249    fn enemy_die(&self, t: f32, n: usize) -> AnimPose {
1250        let mut transforms = Vec::with_capacity(n);
1251        let progress = (t / 1.5).clamp(0.0, 1.0);
1252        self.death_progress;
1253
1254        for i in 0..n {
1255            let phase = i as f32 * 1.618;
1256            // Outward scatter
1257            let angle = phase * TAU;
1258            let scatter = progress * progress * 8.0;
1259            let x = angle.cos() * scatter;
1260            let y = angle.sin() * scatter + progress * 3.0; // float upward
1261            // Fade out via scale
1262            let fade_scale = (1.0 - progress).max(0.0);
1263            transforms.push(GlyphTransform {
1264                position_offset: Vec3::new(x, y, 0.0),
1265                scale: fade_scale,
1266                rotation_z: progress * TAU * 0.5 * if i % 2 == 0 { 1.0 } else { -1.0 },
1267                emission: (1.0 - progress) * 0.8,
1268            });
1269        }
1270        AnimPose { transforms }
1271    }
1272
1273    fn enemy_special(&self, t: f32, n: usize) -> AnimPose {
1274        // Generic special: pulsing glow + scale oscillation
1275        let mut transforms = Vec::with_capacity(n);
1276        let pulse = (TAU * 2.0 * t).sin() * 0.5 + 0.5;
1277        for i in 0..n {
1278            let phase = i as f32 * TAU / n.max(1) as f32;
1279            let orbit = (t * TAU * 0.3 + phase).sin() * 2.0;
1280            transforms.push(GlyphTransform {
1281                position_offset: Vec3::new(orbit, (t * TAU * 0.3 + phase).cos() * 2.0, 0.0),
1282                scale: 1.0 + pulse * 0.08,
1283                rotation_z: t * 0.2,
1284                emission: pulse * 0.7,
1285            });
1286        }
1287        AnimPose { transforms }
1288    }
1289}
1290
1291fn enemy_transition_params(from: EnemyAnimState, to: EnemyAnimState) -> (f32, BlendCurve) {
1292    if to == EnemyAnimState::Hurt {
1293        return (0.05, BlendCurve::Linear);
1294    }
1295    if to == EnemyAnimState::Die {
1296        return (0.1, BlendCurve::EaseIn);
1297    }
1298    match (from, to) {
1299        (EnemyAnimState::Idle, EnemyAnimState::Approach) => (0.2, BlendCurve::EaseInOut),
1300        (EnemyAnimState::Approach, EnemyAnimState::Attack) => (0.1, BlendCurve::EaseIn),
1301        (EnemyAnimState::Attack, EnemyAnimState::Idle) => (0.3, BlendCurve::EaseOut),
1302        (EnemyAnimState::Hurt, EnemyAnimState::Idle) => (0.35, BlendCurve::EaseOut),
1303        (EnemyAnimState::Idle, EnemyAnimState::Special) => (0.25, BlendCurve::EaseIn),
1304        _ => (0.2, BlendCurve::EaseInOut),
1305    }
1306}
1307
1308// ═════════════════════════════════════════════════════════════════════════════
1309// BOSS ANIMATION STATES
1310// ═════════════════════════════════════════════════════════════════════════════
1311
1312/// Hydra boss: can split and reform.
1313#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1314pub enum HydraAnimState {
1315    Normal,
1316    /// Dissolve current form and spawn two smaller copies.
1317    Splitting,
1318    /// Two copies merge back into one.
1319    Reforming,
1320}
1321
1322/// Committee boss: judges that vote on player fate.
1323#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1324pub enum CommitteeAnimState {
1325    Normal,
1326    /// Judges light up sequentially (0.5s each).
1327    Voting,
1328    /// Final verdict — all judges glow simultaneously.
1329    Verdict,
1330}
1331
1332/// Algorithm boss: multi-phase encounter with entity reorganization.
1333#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1334pub enum AlgorithmAnimState {
1335    Phase1,
1336    /// Full entity reorganization over 2 seconds.
1337    PhaseTransition,
1338    Phase2,
1339    Phase3,
1340}
1341
1342/// Per-boss animation controller wrapping EnemyAnimController with boss-specific logic.
1343pub struct BossAnimController {
1344    pub base: EnemyAnimController,
1345    pub boss_state: BossState,
1346    /// For Hydra: split progress [0, 1].
1347    pub split_progress: f32,
1348    /// For Committee: which judge is currently lit (0-based index).
1349    pub voting_index: usize,
1350    /// For Committee: total number of judges.
1351    pub judge_count: usize,
1352    /// For Committee: timer within current vote step.
1353    pub vote_timer: f32,
1354    /// For Algorithm: phase transition progress [0, 1].
1355    pub phase_transition_progress: f32,
1356}
1357
1358/// Unified boss state enum.
1359#[derive(Debug, Clone, Copy, PartialEq)]
1360pub enum BossState {
1361    Hydra(HydraAnimState),
1362    Committee(CommitteeAnimState),
1363    Algorithm(AlgorithmAnimState),
1364}
1365
1366impl BossAnimController {
1367    pub fn new_hydra(glyph_count: usize) -> Self {
1368        Self {
1369            base: EnemyAnimController::new(glyph_count),
1370            boss_state: BossState::Hydra(HydraAnimState::Normal),
1371            split_progress: 0.0,
1372            voting_index: 0,
1373            judge_count: 5,
1374            vote_timer: 0.0,
1375            phase_transition_progress: 0.0,
1376        }
1377    }
1378
1379    pub fn new_committee(glyph_count: usize, judge_count: usize) -> Self {
1380        Self {
1381            base: EnemyAnimController::new(glyph_count),
1382            boss_state: BossState::Committee(CommitteeAnimState::Normal),
1383            split_progress: 0.0,
1384            voting_index: 0,
1385            judge_count,
1386            vote_timer: 0.0,
1387            phase_transition_progress: 0.0,
1388        }
1389    }
1390
1391    pub fn new_algorithm(glyph_count: usize) -> Self {
1392        Self {
1393            base: EnemyAnimController::new(glyph_count),
1394            boss_state: BossState::Algorithm(AlgorithmAnimState::Phase1),
1395            split_progress: 0.0,
1396            voting_index: 0,
1397            judge_count: 0,
1398            vote_timer: 0.0,
1399            phase_transition_progress: 0.0,
1400        }
1401    }
1402
1403    pub fn update(&mut self, dt: f32) -> AnimPose {
1404        let base_pose = self.base.update(dt);
1405        let n = base_pose.transforms.len();
1406
1407        match self.boss_state {
1408            BossState::Hydra(state) => self.apply_hydra(state, &base_pose, dt, n),
1409            BossState::Committee(state) => self.apply_committee(state, &base_pose, dt, n),
1410            BossState::Algorithm(state) => self.apply_algorithm(state, &base_pose, dt, n),
1411        }
1412    }
1413
1414    fn apply_hydra(&mut self, state: HydraAnimState, base: &AnimPose, dt: f32, n: usize) -> AnimPose {
1415        match state {
1416            HydraAnimState::Normal => base.clone(),
1417            HydraAnimState::Splitting => {
1418                self.split_progress = (self.split_progress + dt / 1.0).min(1.0);
1419                let p = self.split_progress;
1420                let mut transforms = Vec::with_capacity(n);
1421                for i in 0..n {
1422                    let side = if i < n / 2 { -1.0 } else { 1.0 };
1423                    let spread = p * 8.0 * side;
1424                    let dissolve_scale = 1.0 - p * 0.3;
1425                    let mut gt = base.transforms.get(i).copied().unwrap_or_default();
1426                    gt.position_offset.x += spread;
1427                    gt.scale *= dissolve_scale;
1428                    gt.emission += p * 0.5;
1429                    transforms.push(gt);
1430                }
1431                AnimPose { transforms }
1432            }
1433            HydraAnimState::Reforming => {
1434                self.split_progress = (self.split_progress - dt / 1.5).max(0.0);
1435                let p = self.split_progress;
1436                let mut transforms = Vec::with_capacity(n);
1437                for i in 0..n {
1438                    let side = if i < n / 2 { -1.0 } else { 1.0 };
1439                    let spread = p * 8.0 * side;
1440                    let mut gt = base.transforms.get(i).copied().unwrap_or_default();
1441                    gt.position_offset.x += spread;
1442                    gt.emission += p * 0.3;
1443                    transforms.push(gt);
1444                }
1445                AnimPose { transforms }
1446            }
1447        }
1448    }
1449
1450    fn apply_committee(
1451        &mut self,
1452        state: CommitteeAnimState,
1453        base: &AnimPose,
1454        dt: f32,
1455        n: usize,
1456    ) -> AnimPose {
1457        match state {
1458            CommitteeAnimState::Normal => base.clone(),
1459            CommitteeAnimState::Voting => {
1460                self.vote_timer += dt;
1461                if self.vote_timer >= 0.5 {
1462                    self.vote_timer -= 0.5;
1463                    self.voting_index += 1;
1464                    if self.voting_index >= self.judge_count {
1465                        self.boss_state = BossState::Committee(CommitteeAnimState::Verdict);
1466                        self.voting_index = 0;
1467                    }
1468                }
1469                let glyphs_per_judge = n / self.judge_count.max(1);
1470                let mut transforms = Vec::with_capacity(n);
1471                for i in 0..n {
1472                    let judge_idx = i / glyphs_per_judge.max(1);
1473                    let mut gt = base.transforms.get(i).copied().unwrap_or_default();
1474                    if judge_idx == self.voting_index {
1475                        gt.emission += 0.8;
1476                        gt.scale += 0.05;
1477                    }
1478                    transforms.push(gt);
1479                }
1480                AnimPose { transforms }
1481            }
1482            CommitteeAnimState::Verdict => {
1483                let mut transforms = Vec::with_capacity(n);
1484                let t = self.base.state_timer;
1485                let flash = (TAU * 4.0 * t).sin() * 0.5 + 0.5;
1486                for i in 0..n {
1487                    let mut gt = base.transforms.get(i).copied().unwrap_or_default();
1488                    gt.emission += flash;
1489                    gt.scale += flash * 0.03;
1490                    transforms.push(gt);
1491                }
1492                AnimPose { transforms }
1493            }
1494        }
1495    }
1496
1497    fn apply_algorithm(
1498        &mut self,
1499        state: AlgorithmAnimState,
1500        base: &AnimPose,
1501        dt: f32,
1502        n: usize,
1503    ) -> AnimPose {
1504        match state {
1505            AlgorithmAnimState::Phase1 | AlgorithmAnimState::Phase2 | AlgorithmAnimState::Phase3 => {
1506                base.clone()
1507            }
1508            AlgorithmAnimState::PhaseTransition => {
1509                self.phase_transition_progress =
1510                    (self.phase_transition_progress + dt / 2.0).min(1.0);
1511                let p = self.phase_transition_progress;
1512                let mut transforms = Vec::with_capacity(n);
1513                for i in 0..n {
1514                    let phase = i as f32 * TAU / n.max(1) as f32;
1515                    let chaos = (p * PI).sin(); // peaks at midpoint
1516                    let orbit_r = chaos * 6.0;
1517                    let angle = phase + p * TAU * 2.0;
1518                    let mut gt = base.transforms.get(i).copied().unwrap_or_default();
1519                    gt.position_offset += Vec3::new(
1520                        angle.cos() * orbit_r,
1521                        angle.sin() * orbit_r,
1522                        0.0,
1523                    );
1524                    gt.rotation_z += p * TAU;
1525                    gt.emission += chaos * 0.7;
1526                    gt.scale = gt.scale * (1.0 - chaos * 0.2);
1527                    transforms.push(gt);
1528                }
1529                AnimPose { transforms }
1530            }
1531        }
1532    }
1533}
1534
1535// ═════════════════════════════════════════════════════════════════════════════
1536// ANIMATION MANAGER
1537// ═════════════════════════════════════════════════════════════════════════════
1538
1539/// Centralized animation manager owning all active controllers.
1540pub struct AnimationManager {
1541    /// Player controllers keyed by entity ID.
1542    pub player_controllers: HashMap<EntityId, PlayerAnimController>,
1543    /// Enemy controllers keyed by entity ID.
1544    pub enemy_controllers: HashMap<EntityId, EnemyAnimController>,
1545    /// Boss controllers keyed by entity ID.
1546    pub boss_controllers: HashMap<EntityId, BossAnimController>,
1547    /// IK limbs keyed by (entity_id, limb_name).
1548    pub ik_limbs: HashMap<(EntityId, String), IKLimb>,
1549    /// Cached output transforms per entity.
1550    output_cache: HashMap<EntityId, Vec<GlyphTransform>>,
1551    /// Known entity positions (updated externally).
1552    pub entity_positions: HashMap<EntityId, Vec3>,
1553}
1554
1555impl AnimationManager {
1556    pub fn new() -> Self {
1557        Self {
1558            player_controllers: HashMap::new(),
1559            enemy_controllers: HashMap::new(),
1560            boss_controllers: HashMap::new(),
1561            ik_limbs: HashMap::new(),
1562            output_cache: HashMap::new(),
1563            entity_positions: HashMap::new(),
1564        }
1565    }
1566
1567    /// Register a player entity for animation.
1568    pub fn register_player(&mut self, entity_id: EntityId, glyph_count: usize) {
1569        self.player_controllers
1570            .insert(entity_id, PlayerAnimController::new(glyph_count));
1571    }
1572
1573    /// Register an enemy entity for animation.
1574    pub fn register_enemy(&mut self, entity_id: EntityId, glyph_count: usize) {
1575        self.enemy_controllers
1576            .insert(entity_id, EnemyAnimController::new(glyph_count));
1577    }
1578
1579    /// Register a boss entity.
1580    pub fn register_boss(&mut self, entity_id: EntityId, controller: BossAnimController) {
1581        self.boss_controllers.insert(entity_id, controller);
1582    }
1583
1584    /// Register an IK limb for an entity.
1585    pub fn register_ik_limb(&mut self, entity_id: EntityId, limb: IKLimb) {
1586        self.ik_limbs
1587            .insert((entity_id, limb.name.clone()), limb);
1588    }
1589
1590    /// Tick all animation controllers and update the output cache.
1591    pub fn update(&mut self, dt: f32) {
1592        self.output_cache.clear();
1593
1594        // Update player controllers
1595        let player_ids: Vec<EntityId> = self.player_controllers.keys().copied().collect();
1596        for id in player_ids {
1597            if let Some(ctrl) = self.player_controllers.get_mut(&id) {
1598                let pose = ctrl.update(dt);
1599                self.output_cache.insert(id, pose.transforms);
1600            }
1601        }
1602
1603        // Update enemy controllers
1604        let enemy_ids: Vec<EntityId> = self.enemy_controllers.keys().copied().collect();
1605        for id in enemy_ids {
1606            if let Some(ctrl) = self.enemy_controllers.get_mut(&id) {
1607                let pose = ctrl.update(dt);
1608                self.output_cache.insert(id, pose.transforms);
1609            }
1610        }
1611
1612        // Update boss controllers
1613        let boss_ids: Vec<EntityId> = self.boss_controllers.keys().copied().collect();
1614        for id in boss_ids {
1615            if let Some(ctrl) = self.boss_controllers.get_mut(&id) {
1616                let pose = ctrl.update(dt);
1617                self.output_cache.insert(id, pose.transforms);
1618            }
1619        }
1620
1621        // Apply IK on top of animation results
1622        let ik_keys: Vec<(EntityId, String)> = self.ik_limbs.keys().cloned().collect();
1623        for (entity_id, limb_name) in ik_keys {
1624            if let Some(limb) = self.ik_limbs.get_mut(&(entity_id, limb_name)) {
1625                let entity_pos = self.entity_positions.get(&entity_id).copied().unwrap_or(Vec3::ZERO);
1626                let offsets = limb.solve(entity_pos, &self.entity_positions);
1627                // Apply IK offsets to the cached transforms
1628                if let Some(transforms) = self.output_cache.get_mut(&entity_id) {
1629                    for (slot, &glyph_idx) in limb.glyph_indices.iter().enumerate() {
1630                        if glyph_idx < transforms.len() {
1631                            let ik_blend = limb.weight;
1632                            transforms[glyph_idx].position_offset = transforms[glyph_idx]
1633                                .position_offset
1634                                * (1.0 - ik_blend)
1635                                + offsets[slot] * ik_blend;
1636                        }
1637                    }
1638                }
1639            }
1640        }
1641    }
1642
1643    /// Trigger a state transition on a player entity.
1644    pub fn trigger_player_state(&mut self, entity_id: EntityId, new_state: PlayerAnimState) -> bool {
1645        if let Some(ctrl) = self.player_controllers.get_mut(&entity_id) {
1646            ctrl.transition_to(new_state)
1647        } else {
1648            false
1649        }
1650    }
1651
1652    /// Trigger a state transition on an enemy entity.
1653    pub fn trigger_enemy_state(&mut self, entity_id: EntityId, new_state: EnemyAnimState) -> bool {
1654        if let Some(ctrl) = self.enemy_controllers.get_mut(&entity_id) {
1655            ctrl.transition_to(new_state)
1656        } else {
1657            false
1658        }
1659    }
1660
1661    /// Set an IK target for a named limb on an entity.
1662    pub fn set_ik_target(&mut self, entity_id: EntityId, chain_name: &str, target: IKTarget) {
1663        if let Some(limb) = self.ik_limbs.get_mut(&(entity_id, chain_name.to_string())) {
1664            limb.target = target;
1665        }
1666    }
1667
1668    /// Get the output transforms for an entity (after update).
1669    pub fn get_glyph_transforms(&self, entity_id: EntityId) -> Vec<GlyphTransform> {
1670        self.output_cache
1671            .get(&entity_id)
1672            .cloned()
1673            .unwrap_or_default()
1674    }
1675
1676    /// Remove all controllers for a despawned entity.
1677    pub fn remove_entity(&mut self, entity_id: EntityId) {
1678        self.player_controllers.remove(&entity_id);
1679        self.enemy_controllers.remove(&entity_id);
1680        self.boss_controllers.remove(&entity_id);
1681        self.output_cache.remove(&entity_id);
1682        self.entity_positions.remove(&entity_id);
1683        // Remove IK limbs for this entity
1684        self.ik_limbs.retain(|(id, _), _| *id != entity_id);
1685    }
1686}
1687
1688impl Default for AnimationManager {
1689    fn default() -> Self {
1690        Self::new()
1691    }
1692}
1693
1694// ═════════════════════════════════════════════════════════════════════════════
1695// TESTS
1696// ═════════════════════════════════════════════════════════════════════════════
1697
1698#[cfg(test)]
1699mod tests {
1700    use super::*;
1701
1702    // ── GlyphTransform ───────────────────────────────────────────────────────
1703
1704    #[test]
1705    fn glyph_transform_default_is_identity() {
1706        let gt = GlyphTransform::default();
1707        assert_eq!(gt.position_offset, Vec3::ZERO);
1708        assert!((gt.scale - 1.0).abs() < 1e-6);
1709        assert!((gt.rotation_z).abs() < 1e-6);
1710        assert!((gt.emission).abs() < 1e-6);
1711    }
1712
1713    #[test]
1714    fn glyph_transform_lerp_midpoint() {
1715        let a = GlyphTransform {
1716            position_offset: Vec3::ZERO,
1717            scale: 1.0,
1718            rotation_z: 0.0,
1719            emission: 0.0,
1720        };
1721        let b = GlyphTransform {
1722            position_offset: Vec3::new(10.0, 0.0, 0.0),
1723            scale: 2.0,
1724            rotation_z: 1.0,
1725            emission: 1.0,
1726        };
1727        let mid = GlyphTransform::lerp(&a, &b, 0.5);
1728        assert!((mid.position_offset.x - 5.0).abs() < 1e-6);
1729        assert!((mid.scale - 1.5).abs() < 1e-6);
1730        assert!((mid.rotation_z - 0.5).abs() < 1e-6);
1731        assert!((mid.emission - 0.5).abs() < 1e-6);
1732    }
1733
1734    // ── BlendCurve ───────────────────────────────────────────────────────────
1735
1736    #[test]
1737    fn blend_curve_endpoints() {
1738        for curve in &[BlendCurve::Linear, BlendCurve::EaseIn, BlendCurve::EaseOut, BlendCurve::EaseInOut] {
1739            let v0 = curve.evaluate(0.0);
1740            let v1 = curve.evaluate(1.0);
1741            assert!(v0.abs() < 1e-6, "curve {curve:?} at t=0 should be ~0, got {v0}");
1742            assert!((v1 - 1.0).abs() < 1e-6, "curve {curve:?} at t=1 should be ~1, got {v1}");
1743        }
1744    }
1745
1746    #[test]
1747    fn blend_curve_monotonic() {
1748        for curve in &[BlendCurve::Linear, BlendCurve::EaseIn, BlendCurve::EaseOut, BlendCurve::EaseInOut] {
1749            let mut prev = 0.0f32;
1750            for step in 1..=20 {
1751                let t = step as f32 / 20.0;
1752                let v = curve.evaluate(t);
1753                assert!(v >= prev - 1e-6, "curve {curve:?} not monotonic at t={t}");
1754                prev = v;
1755            }
1756        }
1757    }
1758
1759    // ── Player Animation States ──────────────────────────────────────────────
1760
1761    #[test]
1762    fn player_idle_produces_correct_glyph_count() {
1763        let ctrl = PlayerAnimController::new(9);
1764        let pose = ctrl.pose_idle(0.5, 9);
1765        assert_eq!(pose.transforms.len(), 9);
1766    }
1767
1768    #[test]
1769    fn player_idle_breathing_range() {
1770        let ctrl = PlayerAnimController::new(1);
1771        // Sample over one full cycle
1772        let mut min_scale = f32::MAX;
1773        let mut max_scale = f32::MIN;
1774        for step in 0..100 {
1775            let t = step as f32 / 100.0 * (1.0 / 0.3); // one full cycle at 0.3Hz
1776            let pose = ctrl.pose_idle(t, 1);
1777            let s = pose.transforms[0].scale;
1778            min_scale = min_scale.min(s);
1779            max_scale = max_scale.max(s);
1780        }
1781        assert!(min_scale >= 0.97, "min scale {min_scale} should be >= 0.97");
1782        assert!(max_scale <= 1.03, "max scale {max_scale} should be <= 1.03");
1783    }
1784
1785    #[test]
1786    fn player_walk_higher_bob_than_idle() {
1787        let ctrl = PlayerAnimController::new(4);
1788        let idle = ctrl.pose_idle(0.25, 4);
1789        let walk = ctrl.pose_walk(0.25, 4);
1790        // Walk bob amplitude is higher
1791        let idle_max_y: f32 = idle.transforms.iter().map(|t| t.position_offset.y.abs()).fold(0.0f32, f32::max);
1792        let walk_max_y: f32 = walk.transforms.iter().map(|t| t.position_offset.y.abs()).fold(0.0f32, f32::max);
1793        // Walk should generally have bigger displacement (across a representative sample)
1794        // This is a statistical test so we check the amplitudes
1795        assert!(walk_max_y >= idle_max_y * 0.5, "walk bob should be comparable or larger than idle");
1796    }
1797
1798    #[test]
1799    fn player_attack_has_emission() {
1800        let ctrl = PlayerAnimController::new(6);
1801        let pose = ctrl.pose_attack(0.15, 6); // mid-attack
1802        let total_emission: f32 = pose.transforms.iter().map(|t| t.emission).sum();
1803        assert!(total_emission > 0.0, "attack should have emission glow");
1804    }
1805
1806    #[test]
1807    fn player_heavy_attack_phases() {
1808        let ctrl = PlayerAnimController::new(4);
1809        // Windup phase: arm should pull back (negative X for arm glyphs)
1810        let windup = ctrl.pose_heavy_attack(0.25, 4);
1811        // Swing phase: big forward movement
1812        let swing = ctrl.pose_heavy_attack(0.65, 4);
1813        let swing_emission: f32 = swing.transforms.iter().map(|t| t.emission).sum();
1814        assert!(swing_emission > 0.0, "heavy attack swing should have emission");
1815        // Follow-through should be calmer
1816        let follow = ctrl.pose_heavy_attack(0.9, 4);
1817        let follow_emission: f32 = follow.transforms.iter().map(|t| t.emission).sum();
1818        assert!(follow_emission < swing_emission, "follow-through emission should be less than swing");
1819    }
1820
1821    #[test]
1822    fn player_cast_hands_rise() {
1823        let ctrl = PlayerAnimController::new(9);
1824        let pose = ctrl.pose_cast(0.3, 9); // during raise phase
1825        // Hand glyphs (upper indices) should have positive Y offset
1826        let hand_y: f32 = pose.transforms[8].position_offset.y;
1827        let body_y: f32 = pose.transforms[0].position_offset.y;
1828        assert!(hand_y > body_y, "hand glyphs should rise higher than body glyphs");
1829    }
1830
1831    #[test]
1832    fn player_hurt_has_recoil() {
1833        let mut ctrl = PlayerAnimController::new(4);
1834        ctrl.damage_direction = Vec3::new(-1.0, 0.0, 0.0);
1835        let pose = ctrl.pose_hurt(0.05, 4); // early = peak recoil
1836        let avg_x: f32 = pose.transforms.iter().map(|t| t.position_offset.x).sum::<f32>() / 4.0;
1837        // Recoil should push away from damage (positive X since damage from -X)
1838        assert!(avg_x > 0.0, "hurt recoil should push away from damage source");
1839    }
1840
1841    #[test]
1842    fn player_defend_reduces_scale() {
1843        let ctrl = PlayerAnimController::new(6);
1844        let pose = ctrl.pose_defend(0.5, 6); // fully defended
1845        for gt in &pose.transforms {
1846            assert!(gt.scale < 1.0, "defend should reduce scale (crouch), got {}", gt.scale);
1847        }
1848    }
1849
1850    #[test]
1851    fn player_dodge_has_lateral_movement() {
1852        let mut ctrl = PlayerAnimController::new(4);
1853        ctrl.move_direction = Vec3::X;
1854        let pose = ctrl.pose_dodge(0.125, 4); // peak lateral
1855        let avg_x: f32 = pose.transforms.iter().map(|t| t.position_offset.x).sum::<f32>() / 4.0;
1856        assert!(avg_x.abs() > 1.0, "dodge should have significant lateral movement");
1857    }
1858
1859    // ── State Transitions ────────────────────────────────────────────────────
1860
1861    #[test]
1862    fn player_transition_idle_to_walk() {
1863        let mut ctrl = PlayerAnimController::new(4);
1864        assert!(ctrl.transition_to(PlayerAnimState::Walk));
1865        assert_eq!(ctrl.current_state, PlayerAnimState::Walk);
1866        assert_eq!(ctrl.prev_state, PlayerAnimState::Idle);
1867        assert!(ctrl.in_transition);
1868    }
1869
1870    #[test]
1871    fn player_transition_same_state_rejected() {
1872        let mut ctrl = PlayerAnimController::new(4);
1873        assert!(!ctrl.transition_to(PlayerAnimState::Idle));
1874    }
1875
1876    #[test]
1877    fn player_transition_blend_completes() {
1878        let mut ctrl = PlayerAnimController::new(4);
1879        ctrl.transition_to(PlayerAnimState::Walk);
1880        // Tick past transition time (0.2s for Idle→Walk)
1881        for _ in 0..20 {
1882            ctrl.update(0.016);
1883        }
1884        assert!(!ctrl.in_transition);
1885        assert!((ctrl.blend_factor - 1.0).abs() < 1e-6);
1886    }
1887
1888    #[test]
1889    fn player_hurt_transition_is_fast() {
1890        let (dur, curve) = transition_params(PlayerAnimState::Walk, PlayerAnimState::Hurt);
1891        assert!((dur - 0.05).abs() < 1e-6, "Any→Hurt should be 0.05s, got {dur}");
1892        assert_eq!(curve, BlendCurve::Linear);
1893    }
1894
1895    #[test]
1896    fn player_auto_return_to_idle_after_attack() {
1897        let mut ctrl = PlayerAnimController::new(4);
1898        ctrl.transition_to(PlayerAnimState::Attack);
1899        // Tick past attack duration (0.3s) + transition time
1900        for _ in 0..50 {
1901            ctrl.update(0.016);
1902        }
1903        // Should have auto-transitioned back to idle
1904        assert_eq!(ctrl.current_state, PlayerAnimState::Idle);
1905    }
1906
1907    // ── Blend Trees ──────────────────────────────────────────────────────────
1908
1909    #[test]
1910    fn blend_tree_1d_single_entry() {
1911        let mut tree = BlendTree1D::new();
1912        tree.add_entry(0.5, AnimPose::identity(3));
1913        tree.parameter = 0.0;
1914        let pose = tree.evaluate();
1915        assert_eq!(pose.transforms.len(), 3);
1916    }
1917
1918    #[test]
1919    fn blend_tree_1d_interpolation() {
1920        let pose_a = AnimPose {
1921            transforms: vec![GlyphTransform {
1922                position_offset: Vec3::ZERO,
1923                scale: 1.0,
1924                rotation_z: 0.0,
1925                emission: 0.0,
1926            }],
1927        };
1928        let pose_b = AnimPose {
1929            transforms: vec![GlyphTransform {
1930                position_offset: Vec3::new(10.0, 0.0, 0.0),
1931                scale: 2.0,
1932                rotation_z: 0.0,
1933                emission: 1.0,
1934            }],
1935        };
1936
1937        let mut tree = BlendTree1D::new();
1938        tree.add_entry(0.0, pose_a);
1939        tree.add_entry(1.0, pose_b);
1940
1941        tree.parameter = 0.5;
1942        let result = tree.evaluate();
1943        assert!((result.transforms[0].position_offset.x - 5.0).abs() < 1e-6);
1944        assert!((result.transforms[0].scale - 1.5).abs() < 1e-6);
1945    }
1946
1947    #[test]
1948    fn blend_tree_1d_clamp_below() {
1949        let pose = AnimPose::identity(2);
1950        let mut tree = BlendTree1D::new();
1951        tree.add_entry(0.3, pose.clone());
1952        tree.add_entry(0.7, AnimPose::identity(2));
1953
1954        tree.parameter = 0.0; // below first entry
1955        let result = tree.evaluate();
1956        assert_eq!(result.transforms.len(), 2);
1957    }
1958
1959    #[test]
1960    fn idle_walk_blend_at_zero_is_idle() {
1961        let mut blend = IdleWalkBlend::new(4);
1962        let pose = blend.evaluate(0.0);
1963        assert_eq!(pose.transforms.len(), 4);
1964    }
1965
1966    #[test]
1967    fn attack_power_blend_range() {
1968        let mut blend = AttackPowerBlend::new(6);
1969        let light = blend.evaluate(0.0);
1970        let heavy = blend.evaluate(1.0);
1971        // Heavy should have more emission/displacement
1972        let light_emit: f32 = light.transforms.iter().map(|t| t.emission).sum();
1973        let heavy_emit: f32 = heavy.transforms.iter().map(|t| t.emission).sum();
1974        assert!(heavy_emit >= light_emit, "heavy attack should have >= emission");
1975    }
1976
1977    #[test]
1978    fn damage_reaction_blend_scales() {
1979        let mut blend = DamageReactionBlend::new(4);
1980        let small = blend.evaluate(0.0);
1981        let large = blend.evaluate(1.0);
1982        let small_displacement: f32 = small.transforms.iter().map(|t| t.position_offset.length()).sum();
1983        let large_displacement: f32 = large.transforms.iter().map(|t| t.position_offset.length()).sum();
1984        assert!(large_displacement > small_displacement, "large damage should have more displacement");
1985    }
1986
1987    #[test]
1988    fn cast_intensity_blend_emission() {
1989        let mut blend = CastIntensityBlend::new(9);
1990        let small = blend.evaluate(0.0);
1991        let large = blend.evaluate(1.0);
1992        let small_emit: f32 = small.transforms.iter().map(|t| t.emission).sum();
1993        let large_emit: f32 = large.transforms.iter().map(|t| t.emission).sum();
1994        assert!(large_emit > small_emit, "large cast should have more emission");
1995    }
1996
1997    // ── IK System ────────────────────────────────────────────────────────────
1998
1999    #[test]
2000    fn ik_chain_construction() {
2001        let chain = IKChain::new(Vec3::ZERO, Vec3::new(3.0, 0.0, 0.0), Vec3::new(5.0, 0.0, 0.0));
2002        assert!((chain.lengths[0] - 3.0).abs() < 1e-6);
2003        assert!((chain.lengths[1] - 2.0).abs() < 1e-6);
2004        assert!((chain.total_length() - 5.0).abs() < 1e-6);
2005    }
2006
2007    #[test]
2008    fn ik_solve_reaches_target_within_range() {
2009        let mut chain = IKChain::new(
2010            Vec3::ZERO,
2011            Vec3::new(3.0, 0.0, 0.0),
2012            Vec3::new(5.0, 0.0, 0.0),
2013        );
2014        let target = Vec3::new(4.0, 1.0, 0.0);
2015        solve_two_bone(&mut chain, target);
2016
2017        let end_dist = (chain.end_pos - target).length();
2018        assert!(end_dist < 0.5, "IK end should be near target, dist = {end_dist}");
2019
2020        // Verify bone lengths are preserved
2021        let bone0_len = (chain.mid_pos - chain.root_pos).length();
2022        let bone1_len = (chain.end_pos - chain.mid_pos).length();
2023        assert!((bone0_len - 3.0).abs() < 0.1, "bone0 length should be ~3, got {bone0_len}");
2024        assert!((bone1_len - 2.0).abs() < 0.1, "bone1 length should be ~2, got {bone1_len}");
2025    }
2026
2027    #[test]
2028    fn ik_solve_unreachable_target_extends() {
2029        let mut chain = IKChain::new(
2030            Vec3::ZERO,
2031            Vec3::new(3.0, 0.0, 0.0),
2032            Vec3::new(5.0, 0.0, 0.0),
2033        );
2034        // Target beyond reach
2035        let target = Vec3::new(100.0, 0.0, 0.0);
2036        solve_two_bone(&mut chain, target);
2037
2038        // Should extend toward target as far as possible
2039        assert!(chain.end_pos.x > 3.0, "should extend toward target");
2040    }
2041
2042    #[test]
2043    fn ik_limb_no_target_returns_zero_offsets() {
2044        let mut limb = IKLimb::new(
2045            "test",
2046            [0, 1, 2],
2047            [Vec3::ZERO, Vec3::new(2.0, 0.0, 0.0), Vec3::new(4.0, 0.0, 0.0)],
2048        );
2049        limb.target = IKTarget::None;
2050        let offsets = limb.solve(Vec3::ZERO, &HashMap::new());
2051        for o in &offsets {
2052            assert!(o.length() < 1e-6, "None target should give zero offsets");
2053        }
2054    }
2055
2056    #[test]
2057    fn ik_limb_position_target() {
2058        let mut limb = IKLimb::new(
2059            "arm",
2060            [0, 1, 2],
2061            [Vec3::ZERO, Vec3::new(2.0, 0.0, 0.0), Vec3::new(4.0, 0.0, 0.0)],
2062        );
2063        limb.target = IKTarget::Position(Vec3::new(3.0, 1.0, 0.0));
2064        let offsets = limb.solve(Vec3::ZERO, &HashMap::new());
2065        // End effector offset should be non-zero toward target
2066        assert!(offsets[2].length() > 0.1, "IK should produce non-zero offset for position target");
2067    }
2068
2069    // ── Enemy Animation ──────────────────────────────────────────────────────
2070
2071    #[test]
2072    fn enemy_idle_glyph_count() {
2073        let ctrl = EnemyAnimController::new(6);
2074        let pose = ctrl.enemy_idle(0.5, 6);
2075        assert_eq!(pose.transforms.len(), 6);
2076    }
2077
2078    #[test]
2079    fn enemy_transition_to_attack() {
2080        let mut ctrl = EnemyAnimController::new(6);
2081        assert!(ctrl.transition_to(EnemyAnimState::Approach));
2082        assert_eq!(ctrl.current_state, EnemyAnimState::Approach);
2083    }
2084
2085    #[test]
2086    fn enemy_die_fades_out() {
2087        let ctrl = EnemyAnimController::new(4);
2088        let early = ctrl.enemy_die(0.1, 4);
2089        let late = ctrl.enemy_die(1.4, 4);
2090        let early_scale: f32 = early.transforms.iter().map(|t| t.scale).sum();
2091        let late_scale: f32 = late.transforms.iter().map(|t| t.scale).sum();
2092        assert!(late_scale < early_scale, "dying entity should fade out over time");
2093    }
2094
2095    #[test]
2096    fn enemy_hurt_transition_is_fast() {
2097        let (dur, _) = enemy_transition_params(EnemyAnimState::Idle, EnemyAnimState::Hurt);
2098        assert!((dur - 0.05).abs() < 1e-6);
2099    }
2100
2101    // ── Boss Animation ───────────────────────────────────────────────────────
2102
2103    #[test]
2104    fn boss_hydra_splitting_spreads_glyphs() {
2105        let mut boss = BossAnimController::new_hydra(8);
2106        boss.boss_state = BossState::Hydra(HydraAnimState::Splitting);
2107        let pose1 = boss.update(0.01);
2108        // Advance splitting
2109        for _ in 0..50 {
2110            boss.update(0.016);
2111        }
2112        let pose2 = boss.update(0.016);
2113        // Spread should increase
2114        let spread1: f32 = pose1.transforms.iter().map(|t| t.position_offset.x.abs()).sum();
2115        let spread2: f32 = pose2.transforms.iter().map(|t| t.position_offset.x.abs()).sum();
2116        assert!(spread2 > spread1, "splitting should spread glyphs apart");
2117    }
2118
2119    #[test]
2120    fn boss_committee_voting_advances() {
2121        let mut boss = BossAnimController::new_committee(10, 5);
2122        boss.boss_state = BossState::Committee(CommitteeAnimState::Voting);
2123        boss.voting_index = 0;
2124        boss.vote_timer = 0.0;
2125
2126        // Tick past one vote cycle (0.5s)
2127        for _ in 0..35 {
2128            boss.update(0.016);
2129        }
2130        assert!(boss.voting_index > 0 || matches!(boss.boss_state, BossState::Committee(CommitteeAnimState::Verdict)),
2131            "voting should advance index or reach verdict");
2132    }
2133
2134    #[test]
2135    fn boss_algorithm_phase_transition_chaos() {
2136        let mut boss = BossAnimController::new_algorithm(8);
2137        boss.boss_state = BossState::Algorithm(AlgorithmAnimState::PhaseTransition);
2138        boss.phase_transition_progress = 0.0;
2139
2140        // Tick to midpoint
2141        for _ in 0..60 {
2142            boss.update(0.016);
2143        }
2144        let pose = boss.update(0.016);
2145        let total_emission: f32 = pose.transforms.iter().map(|t| t.emission).sum();
2146        assert!(total_emission > 0.0, "phase transition should produce emission chaos");
2147    }
2148
2149    // ── Animation Manager ────────────────────────────────────────────────────
2150
2151    #[test]
2152    fn manager_register_and_update() {
2153        let mut mgr = AnimationManager::new();
2154        let player_id = EntityId(1);
2155        let enemy_id = EntityId(2);
2156        mgr.register_player(player_id, 9);
2157        mgr.register_enemy(enemy_id, 6);
2158        mgr.update(0.016);
2159
2160        let player_transforms = mgr.get_glyph_transforms(player_id);
2161        assert_eq!(player_transforms.len(), 9);
2162        let enemy_transforms = mgr.get_glyph_transforms(enemy_id);
2163        assert_eq!(enemy_transforms.len(), 6);
2164    }
2165
2166    #[test]
2167    fn manager_trigger_state() {
2168        let mut mgr = AnimationManager::new();
2169        let id = EntityId(1);
2170        mgr.register_player(id, 4);
2171        assert!(mgr.trigger_player_state(id, PlayerAnimState::Walk));
2172        assert!(!mgr.trigger_player_state(EntityId(999), PlayerAnimState::Walk)); // nonexistent
2173    }
2174
2175    #[test]
2176    fn manager_set_ik_target() {
2177        let mut mgr = AnimationManager::new();
2178        let id = EntityId(1);
2179        mgr.register_player(id, 6);
2180        let limb = weapon_arm_ik([0, 1, 2], [Vec3::ZERO, Vec3::X * 2.0, Vec3::X * 4.0]);
2181        mgr.register_ik_limb(id, limb);
2182        mgr.set_ik_target(id, "weapon_arm", IKTarget::Position(Vec3::new(3.0, 2.0, 0.0)));
2183        mgr.update(0.016);
2184        let transforms = mgr.get_glyph_transforms(id);
2185        assert_eq!(transforms.len(), 6);
2186    }
2187
2188    #[test]
2189    fn manager_remove_entity_cleans_up() {
2190        let mut mgr = AnimationManager::new();
2191        let id = EntityId(42);
2192        mgr.register_player(id, 4);
2193        mgr.update(0.016);
2194        assert!(!mgr.get_glyph_transforms(id).is_empty());
2195
2196        mgr.remove_entity(id);
2197        assert!(mgr.get_glyph_transforms(id).is_empty());
2198        assert!(!mgr.player_controllers.contains_key(&id));
2199    }
2200
2201    #[test]
2202    fn manager_boss_registration() {
2203        let mut mgr = AnimationManager::new();
2204        let id = EntityId(100);
2205        let boss = BossAnimController::new_hydra(12);
2206        mgr.register_boss(id, boss);
2207        mgr.update(0.016);
2208        let transforms = mgr.get_glyph_transforms(id);
2209        assert_eq!(transforms.len(), 12);
2210    }
2211
2212    // ── Transition Table ─────────────────────────────────────────────────────
2213
2214    #[test]
2215    fn transition_table_not_empty() {
2216        let table = build_transition_table();
2217        assert!(table.len() >= 10, "transition table should have at least 10 entries");
2218    }
2219
2220    #[test]
2221    fn transition_table_durations_positive() {
2222        let table = build_transition_table();
2223        for entry in &table {
2224            assert!(entry.duration > 0.0, "transition duration must be positive");
2225        }
2226    }
2227
2228    // ── Timing ───────────────────────────────────────────────────────────────
2229
2230    #[test]
2231    fn fixed_duration_states() {
2232        assert!(PlayerAnimState::Attack.fixed_duration().is_some());
2233        assert!(PlayerAnimState::HeavyAttack.fixed_duration().is_some());
2234        assert!(PlayerAnimState::Hurt.fixed_duration().is_some());
2235        assert!(PlayerAnimState::Idle.fixed_duration().is_none());
2236        assert!(PlayerAnimState::Walk.fixed_duration().is_none());
2237    }
2238
2239    #[test]
2240    fn pose_blend_preserves_length() {
2241        let a = AnimPose::identity(5);
2242        let b = AnimPose::identity(5);
2243        let blended = a.blend(&b, 0.5);
2244        assert_eq!(blended.transforms.len(), 5);
2245    }
2246
2247    #[test]
2248    fn ik_factory_functions() {
2249        let rest = [Vec3::ZERO, Vec3::X * 2.0, Vec3::X * 4.0];
2250        let indices = [0, 1, 2];
2251        let w = weapon_arm_ik(indices, rest);
2252        assert_eq!(w.name, "weapon_arm");
2253        assert!((w.weight - 0.8).abs() < 1e-6);
2254
2255        let l = look_at_ik(indices, rest);
2256        assert_eq!(l.name, "look_at");
2257
2258        let s = staff_aim_ik(indices, rest);
2259        assert_eq!(s.name, "staff_aim");
2260
2261        let sh = shield_ik(indices, rest);
2262        assert_eq!(sh.name, "shield");
2263        assert!((sh.weight - 1.0).abs() < 1e-6);
2264    }
2265}