Skip to main content

proof_engine/tween/
game_tweens.rs

1//! Game-specific tween presets — ready-to-call functions that wire the tween
2//! engine to specific game events: menu navigation, combat, screen transitions,
3//! stat changes, level ups, death, victory, and more.
4//!
5//! Each function takes a `&mut TweenManager` and the minimum context needed
6//! (glyph IDs, bar IDs, positions, values), starts the relevant tweens, and
7//! returns the tween IDs for optional cancellation.
8
9use std::f32::consts::TAU;
10use glam::{Vec3, Vec4};
11
12use super::easing::Easing;
13use super::tween_manager::{TweenManager, TweenTarget, TweenId, BarId};
14use crate::glyph::GlyphId;
15
16// ═══════════════════════════════════════════════════════════════════════════
17// MENU
18// ═══════════════════════════════════════════════════════════════════════════
19
20/// Slide the menu cursor glyph from one Y position to another.
21pub fn menu_cursor_slide(
22    mgr: &mut TweenManager,
23    cursor_glyph: GlyphId,
24    from_y: f32,
25    to_y: f32,
26) -> TweenId {
27    mgr.cancel_glyph(cursor_glyph);
28    mgr.start(
29        TweenTarget::GlyphPositionY(cursor_glyph),
30        from_y, to_y, 0.1,
31        Easing::EaseOutCubic,
32    )
33}
34
35/// Pulse the cursor glyph's emission to indicate it's selected.
36pub fn menu_cursor_pulse(
37    mgr: &mut TweenManager,
38    cursor_glyph: GlyphId,
39) -> TweenId {
40    mgr.start(
41        TweenTarget::GlyphEmission(cursor_glyph),
42        0.3, 0.8, 0.6,
43        Easing::EaseInOutSine,
44    )
45}
46
47/// Menu selection confirm: flash bright then fade.
48pub fn menu_select_flash(
49    mgr: &mut TweenManager,
50    glyphs: &[GlyphId],
51) -> Vec<TweenId> {
52    let mut ids = Vec::new();
53    for &glyph in glyphs {
54        ids.push(mgr.start(
55            TweenTarget::GlyphEmission(glyph),
56            0.0, 2.0, 0.08,
57            Easing::EaseOutExpo,
58        ));
59        ids.push(mgr.start_delayed(
60            TweenTarget::GlyphEmission(glyph),
61            2.0, 0.0, 0.2, 0.08,
62            Easing::EaseInQuad,
63        ));
64    }
65    ids
66}
67
68/// Theme/screen crossfade: fade out old, swap, fade in new.
69pub fn screen_crossfade(
70    mgr: &mut TweenManager,
71    fade_duration: f32,
72    on_midpoint: impl FnOnce(&mut TweenManager) + Send + 'static,
73) -> TweenId {
74    mgr.start_with_callback(
75        TweenTarget::ScreenFade,
76        0.0, 1.0, fade_duration,
77        Easing::EaseInQuad,
78        move |mgr| {
79            on_midpoint(mgr);
80            mgr.start(
81                TweenTarget::ScreenFade,
82                1.0, 0.0, fade_duration,
83                Easing::EaseOutQuad,
84            );
85        },
86    )
87}
88
89/// Instant fade to black, hold, then fade in.
90pub fn screen_fade_through_black(
91    mgr: &mut TweenManager,
92    fade_out: f32,
93    hold: f32,
94    fade_in: f32,
95) -> TweenId {
96    mgr.start_with_callback(
97        TweenTarget::ScreenFade,
98        0.0, 1.0, fade_out,
99        Easing::EaseInQuad,
100        move |mgr| {
101            mgr.start_delayed(
102                TweenTarget::ScreenFade,
103                1.0, 0.0, fade_in, hold,
104                Easing::EaseOutQuad,
105            );
106        },
107    )
108}
109
110// ═══════════════════════════════════════════════════════════════════════════
111// HP / MP / BARS
112// ═══════════════════════════════════════════════════════════════════════════
113
114/// Smoothly animate an HP bar from current to new percentage.
115pub fn hp_bar_change(
116    mgr: &mut TweenManager,
117    bar: BarId,
118    from_pct: f32,
119    to_pct: f32,
120) -> TweenId {
121    mgr.start_tagged(
122        TweenTarget::BarFillPercent(bar),
123        from_pct, to_pct, 0.3,
124        Easing::EaseOutQuad,
125        "hp_bar",
126    )
127}
128
129/// Ghost bar (recent damage indicator): delays, then slides down.
130pub fn hp_ghost_bar(
131    mgr: &mut TweenManager,
132    bar: BarId,
133    from_pct: f32,
134    to_pct: f32,
135) -> TweenId {
136    mgr.start_delayed(
137        TweenTarget::BarGhostPercent(bar),
138        from_pct, to_pct, 0.5, 0.3,
139        Easing::EaseInQuad,
140    )
141}
142
143/// Full HP change: animate both fill and ghost bar.
144pub fn hp_bar_damage(
145    mgr: &mut TweenManager,
146    bar: BarId,
147    old_pct: f32,
148    new_pct: f32,
149) -> (TweenId, TweenId) {
150    let fill = hp_bar_change(mgr, bar, old_pct, new_pct);
151    let ghost = hp_ghost_bar(mgr, bar, old_pct, new_pct);
152    (fill, ghost)
153}
154
155/// MP bar smooth change (no ghost).
156pub fn mp_bar_change(
157    mgr: &mut TweenManager,
158    bar: BarId,
159    from_pct: f32,
160    to_pct: f32,
161) -> TweenId {
162    mgr.start_tagged(
163        TweenTarget::BarFillPercent(bar),
164        from_pct, to_pct, 0.25,
165        Easing::EaseOutCubic,
166        "mp_bar",
167    )
168}
169
170/// XP bar fill (slower, more satisfying).
171pub fn xp_bar_fill(
172    mgr: &mut TweenManager,
173    bar: BarId,
174    from_pct: f32,
175    to_pct: f32,
176) -> TweenId {
177    mgr.start(
178        TweenTarget::BarFillPercent(bar),
179        from_pct, to_pct, 0.8,
180        Easing::EaseOutBack,
181    )
182}
183
184// ═══════════════════════════════════════════════════════════════════════════
185// DAMAGE NUMBERS
186// ═══════════════════════════════════════════════════════════════════════════
187
188/// Spawn a damage number arc: rises, scales from big to normal, fades out.
189pub fn damage_number_arc(
190    mgr: &mut TweenManager,
191    glyph: GlyphId,
192    start_y: f32,
193    rise_height: f32,
194) -> Vec<TweenId> {
195    vec![
196        // Rise upward
197        mgr.start(
198            TweenTarget::GlyphPositionY(glyph),
199            start_y, start_y + rise_height, 1.0,
200            Easing::EaseOutQuad,
201        ),
202        // Fade out
203        mgr.start(
204            TweenTarget::GlyphAlpha(glyph),
205            1.0, 0.0, 1.0,
206            Easing::EaseInQuad,
207        ),
208        // Start big, spring to normal size
209        mgr.start(
210            TweenTarget::GlyphScale(glyph),
211            2.0, 1.0, 0.2,
212            Easing::Spring { stiffness: 8.0, damping: 0.4 },
213        ),
214    ]
215}
216
217/// Critical hit damage number: bigger arc, gold flash, longer duration.
218pub fn crit_damage_number(
219    mgr: &mut TweenManager,
220    glyph: GlyphId,
221    start_y: f32,
222    rise_height: f32,
223) -> Vec<TweenId> {
224    vec![
225        mgr.start(
226            TweenTarget::GlyphPositionY(glyph),
227            start_y, start_y + rise_height * 1.5, 1.2,
228            Easing::EaseOutQuad,
229        ),
230        mgr.start(
231            TweenTarget::GlyphAlpha(glyph),
232            1.0, 0.0, 1.2,
233            Easing::EaseInCubic,
234        ),
235        mgr.start(
236            TweenTarget::GlyphScale(glyph),
237            3.0, 1.2, 0.3,
238            Easing::Spring { stiffness: 6.0, damping: 0.3 },
239        ),
240        // Gold emission flash
241        mgr.start(
242            TweenTarget::GlyphEmission(glyph),
243            3.0, 0.5, 0.3,
244            Easing::EaseOutExpo,
245        ),
246    ]
247}
248
249/// Healing number: green, floats up gently.
250pub fn heal_number(
251    mgr: &mut TweenManager,
252    glyph: GlyphId,
253    start_y: f32,
254) -> Vec<TweenId> {
255    vec![
256        mgr.start(
257            TweenTarget::GlyphPositionY(glyph),
258            start_y, start_y + 2.0, 0.8,
259            Easing::EaseOutSine,
260        ),
261        mgr.start(
262            TweenTarget::GlyphAlpha(glyph),
263            1.0, 0.0, 0.8,
264            Easing::EaseInQuad,
265        ),
266        mgr.start(
267            TweenTarget::GlyphScale(glyph),
268            1.5, 1.0, 0.15,
269            Easing::EaseOutBack,
270        ),
271    ]
272}
273
274// ═══════════════════════════════════════════════════════════════════════════
275// STAT CHANGES
276// ═══════════════════════════════════════════════════════════════════════════
277
278/// Flash a stat glyph gold when a stat increases.
279pub fn stat_increase_flash(
280    mgr: &mut TweenManager,
281    glyph: GlyphId,
282) -> Vec<TweenId> {
283    vec![
284        // Flash bright
285        mgr.start(
286            TweenTarget::GlyphEmission(glyph),
287            0.0, 2.0, 0.1,
288            Easing::EaseOutQuad,
289        ),
290        // Fade back
291        mgr.start_delayed(
292            TweenTarget::GlyphEmission(glyph),
293            2.0, 0.0, 0.3, 0.1,
294            Easing::EaseInQuad,
295        ),
296        // Scale pop
297        mgr.start(
298            TweenTarget::GlyphScale(glyph),
299            1.0, 1.3, 0.08,
300            Easing::EaseOutQuad,
301        ),
302        mgr.start_delayed(
303            TweenTarget::GlyphScale(glyph),
304            1.3, 1.0, 0.15, 0.08,
305            Easing::EaseOutBack,
306        ),
307    ]
308}
309
310/// Flash red when a stat decreases.
311pub fn stat_decrease_flash(
312    mgr: &mut TweenManager,
313    glyph: GlyphId,
314) -> Vec<TweenId> {
315    vec![
316        mgr.start(
317            TweenTarget::GlyphColorR(glyph),
318            1.0, 1.0, 0.1,
319            Easing::Flash,
320        ),
321        mgr.start(
322            TweenTarget::GlyphEmission(glyph),
323            0.0, 1.5, 0.08,
324            Easing::EaseOutQuad,
325        ),
326        mgr.start_delayed(
327            TweenTarget::GlyphEmission(glyph),
328            1.5, 0.0, 0.2, 0.08,
329            Easing::EaseInQuad,
330        ),
331    ]
332}
333
334// ═══════════════════════════════════════════════════════════════════════════
335// STATUS EFFECTS
336// ═══════════════════════════════════════════════════════════════════════════
337
338/// Apply a status effect visual: tint + emission pulse.
339pub fn status_effect_apply(
340    mgr: &mut TweenManager,
341    entity_glyphs: &[GlyphId],
342    emission_color_intensity: f32,
343) -> Vec<TweenId> {
344    let mut ids = Vec::new();
345    for &glyph in entity_glyphs {
346        ids.push(mgr.start(
347            TweenTarget::GlyphEmission(glyph),
348            0.0, emission_color_intensity, 0.15,
349            Easing::EaseOutExpo,
350        ));
351        ids.push(mgr.start_delayed(
352            TweenTarget::GlyphEmission(glyph),
353            emission_color_intensity, 0.0, 0.3, 0.15,
354            Easing::EaseInQuad,
355        ));
356    }
357    ids
358}
359
360// ═══════════════════════════════════════════════════════════════════════════
361// LEVEL UP
362// ═══════════════════════════════════════════════════════════════════════════
363
364/// Level up pulse ring: expanding ring of glyphs radiating outward.
365///
366/// Returns the glyph IDs and tween IDs for the ring particles.
367/// The caller must spawn the glyphs before calling this.
368pub fn level_up_ring(
369    mgr: &mut TweenManager,
370    ring_glyphs: &[GlyphId],
371    center_x: f32,
372    center_y: f32,
373    max_radius: f32,
374) -> Vec<TweenId> {
375    let count = ring_glyphs.len();
376    let mut ids = Vec::new();
377
378    for (i, &glyph) in ring_glyphs.iter().enumerate() {
379        let angle = i as f32 * TAU / count as f32;
380        let target_x = center_x + angle.cos() * max_radius;
381        let target_y = center_y + angle.sin() * max_radius;
382
383        // Expand outward
384        ids.push(mgr.start(
385            TweenTarget::GlyphPositionX(glyph),
386            center_x, target_x, 0.5,
387            Easing::EaseOutQuad,
388        ));
389        ids.push(mgr.start(
390            TweenTarget::GlyphPositionY(glyph),
391            center_y, target_y, 0.5,
392            Easing::EaseOutQuad,
393        ));
394        // Fade out
395        ids.push(mgr.start(
396            TweenTarget::GlyphAlpha(glyph),
397            1.0, 0.0, 0.5,
398            Easing::EaseInQuad,
399        ));
400        // Bright emission
401        ids.push(mgr.start(
402            TweenTarget::GlyphEmission(glyph),
403            2.0, 0.0, 0.5,
404            Easing::EaseOutQuad,
405        ));
406    }
407
408    ids
409}
410
411/// Level up screen flash.
412pub fn level_up_screen_flash(mgr: &mut TweenManager) -> Vec<TweenId> {
413    vec![
414        mgr.start(TweenTarget::ScreenBloom, 0.5, 2.0, 0.15, Easing::EaseOutExpo),
415        mgr.start_delayed(TweenTarget::ScreenBloom, 2.0, 0.5, 0.4, 0.15, Easing::EaseInQuad),
416        mgr.start(TweenTarget::ScreenVignette, 0.0, 0.4, 0.1, Easing::EaseOutQuad),
417        mgr.start_delayed(TweenTarget::ScreenVignette, 0.4, 0.0, 0.3, 0.1, Easing::EaseInQuad),
418    ]
419}
420
421// ═══════════════════════════════════════════════════════════════════════════
422// COMBAT
423// ═══════════════════════════════════════════════════════════════════════════
424
425/// Entity hit recoil: brief scale squish + position bump.
426pub fn entity_hit_recoil(
427    mgr: &mut TweenManager,
428    entity_glyphs: &[GlyphId],
429    hit_direction_x: f32,
430) -> Vec<TweenId> {
431    let mut ids = Vec::new();
432    for &glyph in entity_glyphs {
433        // Scale squish
434        ids.push(mgr.start(
435            TweenTarget::GlyphScaleX(glyph),
436            1.0, 0.8, 0.05,
437            Easing::EaseOutQuad,
438        ));
439        ids.push(mgr.start_delayed(
440            TweenTarget::GlyphScaleX(glyph),
441            0.8, 1.0, 0.1, 0.05,
442            Easing::EaseOutBack,
443        ));
444        ids.push(mgr.start(
445            TweenTarget::GlyphScaleY(glyph),
446            1.0, 1.2, 0.05,
447            Easing::EaseOutQuad,
448        ));
449        ids.push(mgr.start_delayed(
450            TweenTarget::GlyphScaleY(glyph),
451            1.2, 1.0, 0.1, 0.05,
452            Easing::EaseOutBack,
453        ));
454        // Emission flash
455        ids.push(mgr.start(
456            TweenTarget::GlyphEmission(glyph),
457            0.0, 1.5, 0.05,
458            Easing::Flash,
459        ));
460        ids.push(mgr.start_delayed(
461            TweenTarget::GlyphEmission(glyph),
462            1.5, 0.0, 0.15, 0.05,
463            Easing::EaseInQuad,
464        ));
465    }
466    // Screen trauma
467    ids.push(mgr.start(TweenTarget::CameraTrauma, 0.0, 0.3, 0.1, Easing::Flash));
468    ids
469}
470
471/// Boss entrance: dramatic camera + screen effects.
472pub fn boss_entrance(mgr: &mut TweenManager) -> Vec<TweenId> {
473    vec![
474        // Vignette crush
475        mgr.start(TweenTarget::ScreenVignette, 0.0, 0.8, 0.3, Easing::EaseInQuad),
476        mgr.start_delayed(TweenTarget::ScreenVignette, 0.8, 0.2, 0.5, 0.3, Easing::EaseOutQuad),
477        // Chromatic aberration spike
478        mgr.start(TweenTarget::ScreenChromaticAberration, 0.0, 0.8, 0.2, Easing::EaseOutExpo),
479        mgr.start_delayed(TweenTarget::ScreenChromaticAberration, 0.8, 0.0, 0.4, 0.2, Easing::EaseInQuad),
480        // Desaturation
481        mgr.start(TweenTarget::ScreenSaturation, 1.0, 0.3, 0.3, Easing::EaseInQuad),
482        mgr.start_delayed(TweenTarget::ScreenSaturation, 0.3, 1.0, 0.5, 0.3, Easing::EaseOutQuad),
483        // Camera trauma
484        mgr.start(TweenTarget::CameraTrauma, 0.0, 0.5, 0.3, Easing::EaseOutExpo),
485    ]
486}
487
488/// Combat hit screen shake.
489pub fn combat_hit_shake(
490    mgr: &mut TweenManager,
491    intensity: f32,
492) -> TweenId {
493    mgr.start(
494        TweenTarget::CameraTrauma,
495        0.0, intensity, 0.08,
496        Easing::Flash,
497    )
498}
499
500// ═══════════════════════════════════════════════════════════════════════════
501// DEATH
502// ═══════════════════════════════════════════════════════════════════════════
503
504/// "YOU DIED" text scales up from 0 with spring easing.
505pub fn death_text_reveal(
506    mgr: &mut TweenManager,
507    text_glyphs: &[GlyphId],
508) -> Vec<TweenId> {
509    let mut ids = Vec::new();
510    for (i, &glyph) in text_glyphs.iter().enumerate() {
511        let delay = i as f32 * 0.03; // stagger each character
512        ids.push(mgr.start_delayed(
513            TweenTarget::GlyphScale(glyph),
514            0.0, 1.0, 0.4, delay,
515            Easing::Spring { stiffness: 6.0, damping: 0.35 },
516        ));
517        ids.push(mgr.start_delayed(
518            TweenTarget::GlyphAlpha(glyph),
519            0.0, 1.0, 0.2, delay,
520            Easing::EaseOutQuad,
521        ));
522    }
523    // Screen effects
524    ids.push(mgr.start(TweenTarget::ScreenSaturation, 1.0, 0.0, 1.5, Easing::EaseInQuad));
525    ids.push(mgr.start(TweenTarget::ScreenVignette, 0.0, 0.9, 1.0, Easing::EaseInCubic));
526    ids.push(mgr.start(TweenTarget::ScreenChromaticAberration, 0.0, 0.3, 0.5, Easing::EaseOutQuad));
527    ids
528}
529
530/// Entity death: glyphs scatter and fade.
531pub fn entity_death_scatter(
532    mgr: &mut TweenManager,
533    entity_glyphs: &[GlyphId],
534    entity_x: f32,
535    entity_y: f32,
536) -> Vec<TweenId> {
537    let mut ids = Vec::new();
538    let count = entity_glyphs.len();
539    for (i, &glyph) in entity_glyphs.iter().enumerate() {
540        let angle = i as f32 * TAU / count.max(1) as f32;
541        let dist = 3.0 + (i as f32 * 0.5);
542        ids.push(mgr.start(
543            TweenTarget::GlyphPositionX(glyph),
544            entity_x, entity_x + angle.cos() * dist, 0.6,
545            Easing::EaseOutQuad,
546        ));
547        ids.push(mgr.start(
548            TweenTarget::GlyphPositionY(glyph),
549            entity_y, entity_y + angle.sin() * dist, 0.6,
550            Easing::EaseOutQuad,
551        ));
552        ids.push(mgr.start(
553            TweenTarget::GlyphAlpha(glyph),
554            1.0, 0.0, 0.6,
555            Easing::EaseInQuad,
556        ));
557        ids.push(mgr.start(
558            TweenTarget::GlyphRotation(glyph),
559            0.0, TAU * (if i % 2 == 0 { 1.0 } else { -1.0 }), 0.6,
560            Easing::EaseOutQuad,
561        ));
562    }
563    ids
564}
565
566// ═══════════════════════════════════════════════════════════════════════════
567// VICTORY
568// ═══════════════════════════════════════════════════════════════════════════
569
570/// Score counter rolls up from 0 to final value.
571pub fn score_roll_up(
572    mgr: &mut TweenManager,
573    final_score: f32,
574    duration: f32,
575) -> TweenId {
576    mgr.start(
577        TweenTarget::Named("score_display".to_string()),
578        0.0, final_score, duration,
579        Easing::EaseOutCubic,
580    )
581}
582
583/// Victory text entrance: each character drops in with bounce.
584pub fn victory_text_drop(
585    mgr: &mut TweenManager,
586    text_glyphs: &[GlyphId],
587    target_y: f32,
588) -> Vec<TweenId> {
589    let mut ids = Vec::new();
590    for (i, &glyph) in text_glyphs.iter().enumerate() {
591        let delay = i as f32 * 0.05;
592        ids.push(mgr.start_delayed(
593            TweenTarget::GlyphPositionY(glyph),
594            target_y + 10.0, target_y, 0.4, delay,
595            Easing::EaseOutBounce,
596        ));
597        ids.push(mgr.start_delayed(
598            TweenTarget::GlyphAlpha(glyph),
599            0.0, 1.0, 0.1, delay,
600            Easing::EaseOutQuad,
601        ));
602        ids.push(mgr.start_delayed(
603            TweenTarget::GlyphEmission(glyph),
604            2.0, 0.3, 0.3, delay,
605            Easing::EaseOutQuad,
606        ));
607    }
608    // Bloom flash
609    ids.push(mgr.start(TweenTarget::ScreenBloom, 0.5, 3.0, 0.2, Easing::EaseOutExpo));
610    ids.push(mgr.start_delayed(TweenTarget::ScreenBloom, 3.0, 0.5, 0.5, 0.2, Easing::EaseInQuad));
611    ids
612}
613
614// ═══════════════════════════════════════════════════════════════════════════
615// CHARACTER CREATION / ASSEMBLY
616// ═══════════════════════════════════════════════════════════════════════════
617
618/// Assemble entity: glyphs fly in from scattered positions to formation targets.
619pub fn entity_assemble(
620    mgr: &mut TweenManager,
621    glyph_starts: &[(GlyphId, f32, f32)], // (glyph, start_x, start_y)
622    glyph_targets: &[(f32, f32)],          // (target_x, target_y)
623    stagger: f32,
624) -> Vec<TweenId> {
625    let mut ids = Vec::new();
626    for (i, ((glyph, sx, sy), (tx, ty))) in glyph_starts.iter().zip(glyph_targets.iter()).enumerate() {
627        let delay = i as f32 * stagger;
628        ids.push(mgr.start_delayed(
629            TweenTarget::GlyphPositionX(*glyph),
630            *sx, *tx, 0.3, delay,
631            Easing::EaseOutBack,
632        ));
633        ids.push(mgr.start_delayed(
634            TweenTarget::GlyphPositionY(*glyph),
635            *sy, *ty, 0.3, delay,
636            Easing::EaseOutBack,
637        ));
638        ids.push(mgr.start_delayed(
639            TweenTarget::GlyphAlpha(*glyph),
640            0.0, 1.0, 0.15, delay,
641            Easing::EaseOutQuad,
642        ));
643    }
644    ids
645}
646
647// ═══════════════════════════════════════════════════════════════════════════
648// CRAFTING
649// ═══════════════════════════════════════════════════════════════════════════
650
651/// Shatter: glyphs explode outward from a center point.
652pub fn crafting_shatter(
653    mgr: &mut TweenManager,
654    glyphs: &[GlyphId],
655    center_x: f32,
656    center_y: f32,
657) -> Vec<TweenId> {
658    entity_death_scatter(mgr, glyphs, center_x, center_y)
659}
660
661/// Forge: glyphs converge to center and flash.
662pub fn crafting_forge(
663    mgr: &mut TweenManager,
664    glyph_positions: &[(GlyphId, f32, f32)],
665    center_x: f32,
666    center_y: f32,
667) -> Vec<TweenId> {
668    let mut ids = Vec::new();
669    for (i, (glyph, sx, sy)) in glyph_positions.iter().enumerate() {
670        let delay = i as f32 * 0.02;
671        ids.push(mgr.start_delayed(
672            TweenTarget::GlyphPositionX(*glyph),
673            *sx, center_x, 0.3, delay,
674            Easing::EaseInQuad,
675        ));
676        ids.push(mgr.start_delayed(
677            TweenTarget::GlyphPositionY(*glyph),
678            *sy, center_y, 0.3, delay,
679            Easing::EaseInQuad,
680        ));
681    }
682    // Screen flash at convergence
683    ids.push(mgr.start_delayed(TweenTarget::ScreenBloom, 0.5, 3.0, 0.1, 0.3, Easing::Flash));
684    ids.push(mgr.start_delayed(TweenTarget::ScreenBloom, 3.0, 0.5, 0.3, 0.4, Easing::EaseInQuad));
685    ids.push(mgr.start_delayed(TweenTarget::CameraTrauma, 0.0, 0.2, 0.1, 0.3, Easing::Flash));
686    ids
687}
688
689// ═══════════════════════════════════════════════════════════════════════════
690// FLOOR NAVIGATION
691// ═══════════════════════════════════════════════════════════════════════════
692
693/// Slide the room cursor to a new position.
694pub fn room_cursor_slide(
695    mgr: &mut TweenManager,
696    cursor_glyph: GlyphId,
697    from_x: f32,
698    from_y: f32,
699    to_x: f32,
700    to_y: f32,
701) -> Vec<TweenId> {
702    mgr.cancel_glyph(cursor_glyph);
703    vec![
704        mgr.start(TweenTarget::GlyphPositionX(cursor_glyph), from_x, to_x, 0.15, Easing::EaseOutCubic),
705        mgr.start(TweenTarget::GlyphPositionY(cursor_glyph), from_y, to_y, 0.15, Easing::EaseOutCubic),
706    ]
707}
708
709/// Room transition: brief camera pan.
710pub fn room_transition_pan(
711    mgr: &mut TweenManager,
712    from_x: f32,
713    to_x: f32,
714    from_y: f32,
715    to_y: f32,
716) -> Vec<TweenId> {
717    mgr.cancel_tag("room_pan");
718    vec![
719        mgr.start_tagged(TweenTarget::CameraPositionX, from_x, to_x, 0.3, Easing::EaseInOutCubic, "room_pan"),
720        mgr.start_tagged(TweenTarget::CameraPositionY, from_y, to_y, 0.3, Easing::EaseInOutCubic, "room_pan"),
721    ]
722}
723
724// ═══════════════════════════════════════════════════════════════════════════
725// UTILITY
726// ═══════════════════════════════════════════════════════════════════════════
727
728/// Pulse a glyph's emission in a loop (for highlights, cursors, etc.).
729/// Returns the tween ID — cancel to stop pulsing.
730pub fn pulse_emission(
731    mgr: &mut TweenManager,
732    glyph: GlyphId,
733    min_emission: f32,
734    max_emission: f32,
735    period: f32,
736) -> TweenId {
737    // Use a yoyo tween for continuous pulsing.
738    let tween = super::Tween::new(min_emission, max_emission, period * 0.5, Easing::EaseInOutSine)
739        .with_repeat(-1, true);
740    let state = super::TweenState::new(tween);
741    mgr.push_raw(
742        TweenTarget::GlyphEmission(glyph),
743        state,
744        0.0,
745        Some("pulse".to_string()),
746        None,
747    )
748}
749
750/// Text typewriter reveal: stagger alpha on each glyph.
751pub fn typewriter_reveal(
752    mgr: &mut TweenManager,
753    glyphs: &[GlyphId],
754    chars_per_second: f32,
755) -> Vec<TweenId> {
756    let mut ids = Vec::new();
757    let interval = 1.0 / chars_per_second.max(0.01);
758    for (i, &glyph) in glyphs.iter().enumerate() {
759        let delay = i as f32 * interval;
760        ids.push(mgr.start_delayed(
761            TweenTarget::GlyphAlpha(glyph),
762            0.0, 1.0, 0.05, delay,
763            Easing::Step,
764        ));
765    }
766    ids
767}
768
769// ── Tests ───────────────────────────────────────────────────────────────────
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774
775    #[test]
776    fn damage_number_arc_creates_three_tweens() {
777        let mut mgr = TweenManager::new();
778        let glyph = GlyphId(42);
779        let ids = damage_number_arc(&mut mgr, glyph, 0.0, 3.0);
780        assert_eq!(ids.len(), 3);
781    }
782
783    #[test]
784    fn hp_bar_damage_creates_two_tweens() {
785        let mut mgr = TweenManager::new();
786        let bar = BarId(0);
787        let (fill, ghost) = hp_bar_damage(&mut mgr, bar, 1.0, 0.7);
788        assert!(mgr.is_active(fill));
789        assert!(mgr.is_active(ghost));
790    }
791
792    #[test]
793    fn level_up_ring_creates_four_per_glyph() {
794        let mut mgr = TweenManager::new();
795        let glyphs = vec![GlyphId(0), GlyphId(1), GlyphId(2)];
796        let ids = level_up_ring(&mut mgr, &glyphs, 0.0, 0.0, 5.0);
797        assert_eq!(ids.len(), 12); // 4 per glyph × 3 glyphs
798    }
799
800    #[test]
801    fn screen_crossfade_chains() {
802        let mut mgr = TweenManager::new();
803        screen_crossfade(&mut mgr, 0.2, |_| {});
804        mgr.tick(0.25); // complete first fade
805        assert!(mgr.active_count() >= 1, "Should have started fade-in");
806    }
807
808    #[test]
809    fn score_roll_up_named() {
810        let mut mgr = TweenManager::new();
811        score_roll_up(&mut mgr, 1000.0, 1.0);
812        mgr.tick(0.5);
813        let val = mgr.get_named("score_display");
814        assert!(val > 0.0 && val < 1000.0);
815    }
816}