Skip to main content

dotzuki_engine/overworld/
presentation.rs

1//! Overworld presentation-state machines — pure, frame-counted animation
2//! logic for the visual effects classic JRPGs implement as blocking
3//! animation routines. The renderer reads these each frame; the game's
4//! update loop ticks them once per frame (freezing gameplay, mirroring the
5//! originals' busy-wait structure).
6//!
7//! Everything here is game-agnostic: states depend only on
8//! [`Direction`], injected tuning data (spin facing order, elevator shake
9//! parameters), and frame counters. Sound cues are returned as typed enums
10//! ([`TeleportSpinSfx`], [`EnterMapSpinSfx`], [`ElevatorShakeSfx`],
11//! [`ShipDepartureSfx`]); the game maps them to its own audio ids.
12//!
13//! Covered effects:
14//! - teleport / escape-item spin-out ([`TeleportSpinState`]) and the
15//!   matching arrival spin-in ([`EnterMapSpinState`])
16//! - elevator rumble ([`ElevatorShakeState`])
17//! - looping water/flower background-tile animation ([`TileAnimState`])
18//! - fishing-rod animation ([`FishingAnimState`])
19//! - boulder-push dust puff ([`BoulderDustState`])
20//! - ship-departure cutscene ([`ShipDepartureState`])
21//! - the all-white palette flash frame count ([`FLASH_WHITE_FRAMES`])
22
23use super::types::Direction;
24
25// ── Teleport/escape-item spin-out ─────────────────────────────────
26
27/// Phase of the leave-map spin animation.
28#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
29pub enum TeleportSpinPhase {
30    /// Spin in place: 16 spins with frame delays 16,15,…,1 (136 frames
31    /// total); a loop sound cue fires whenever the current spin's delay is
32    /// a multiple of 4 (spins 0, 4, 8, 12).
33    SpinInPlace,
34    /// Spin while moving up: 5 spin steps of 16px each (3-frame step
35    /// delay), a departure cue at the start.
36    SpinUp,
37    /// The extra 10-frame delay used when not standing on a warp pad.
38    Delay,
39    /// Animation finished; the caller starts the fade-out-to-white warp.
40    Done,
41}
42
43/// Total frames of the spin-in-place phase: 16+15+…+1.
44pub const SPIN_IN_PLACE_FRAMES: u16 = 136;
45/// Frames between spin-up steps.
46pub const SPIN_UP_STEP_DELAY: u16 = 3;
47/// Number of 16px spin-up steps.
48pub const SPIN_UP_STEPS: u16 = 5;
49/// Extra frames after the spin-up when not on a warp pad.
50pub const SPIN_POST_DELAY_FRAMES: u16 = 10;
51/// Pixels the player sprite rises per spin-up step.
52pub const SPIN_UP_STEP_PIXELS: i32 = 16;
53
54/// Sound cues [`TeleportSpinState::tick`] can request.
55#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
56pub enum TeleportSpinSfx {
57    /// The looping spin cue, fired on spins whose delay is a multiple of 4.
58    SpinLoop,
59    /// The departure cue, fired once at the start of the spin-up.
60    Rise,
61}
62
63/// State of the teleport / escape-item spin-out animation.
64///
65/// Frame-driven: constructed when the leave-warp is triggered, ticked once
66/// per frame by the update loop; the warp fade-out starts when
67/// [`Self::is_done`] becomes true.
68///
69/// `spin_order` is the facing cycle the animation rotates through, starting
70/// from the player's current facing (the classic order is
71/// `[Down, Left, Up, Right]`).
72#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
73pub struct TeleportSpinState {
74    /// Facing cycle, indexed by `(start_index + step) % 4`.
75    spin_order: [Direction; 4],
76    /// Index in `spin_order` of the facing shown at spin step 0 — the
77    /// player's facing when the animation started.
78    start_index: usize,
79    /// Elapsed frames within the whole animation.
80    frame: u16,
81    phase: TeleportSpinPhase,
82}
83
84impl TeleportSpinState {
85    pub fn new(current_facing: Direction, spin_order: [Direction; 4]) -> Self {
86        // The spin starts by showing the current facing, then advances
87        // through the cycle.
88        let start_index = spin_order
89            .iter()
90            .position(|&d| d == current_facing)
91            .unwrap_or(0);
92        Self {
93            spin_order,
94            start_index,
95            frame: 0,
96            phase: TeleportSpinPhase::SpinInPlace,
97        }
98    }
99
100    /// Spin-in-place index (0..=15) whose display window contains `frame`,
101    /// or None once the phase is over. Spin i is shown for `16 - i` frames.
102    fn spin_in_place_index(frame: u16) -> Option<usize> {
103        let mut start = 0u16;
104        for i in 0..16u16 {
105            let dur = 16 - i;
106            if frame < start + dur {
107                return Some(i as usize);
108            }
109            start += dur;
110        }
111        None
112    }
113
114    /// Advance one frame. Returns the sound cue to play this frame, if any
115    /// ([`TeleportSpinSfx::SpinLoop`] on spins whose delay is a multiple of
116    /// 4, [`TeleportSpinSfx::Rise`] at the start of the spin-up).
117    pub fn tick(&mut self) -> Option<TeleportSpinSfx> {
118        if self.phase == TeleportSpinPhase::Done {
119            return None;
120        }
121        let mut sfx = None;
122        match self.phase {
123            TeleportSpinPhase::SpinInPlace => {
124                // First frame of a spin whose delay (16 - i) is a multiple of 4.
125                if self.frame == 0
126                    || Self::spin_in_place_index(self.frame)
127                        != Self::spin_in_place_index(self.frame.wrapping_sub(1))
128                {
129                    if let Some(i) = Self::spin_in_place_index(self.frame) {
130                        if (16 - i) % 4 == 0 {
131                            sfx = Some(TeleportSpinSfx::SpinLoop);
132                        }
133                    }
134                }
135                if self.frame + 1 >= SPIN_IN_PLACE_FRAMES {
136                    self.phase = TeleportSpinPhase::SpinUp;
137                }
138            }
139            TeleportSpinPhase::SpinUp => {
140                if self.frame == SPIN_IN_PLACE_FRAMES {
141                    sfx = Some(TeleportSpinSfx::Rise);
142                }
143                // 5 steps: 4 with a 3-frame delay, the last ends immediately.
144                let spin_up_frames = (SPIN_UP_STEPS - 1) * (1 + SPIN_UP_STEP_DELAY) + 1;
145                if self.frame + 1 >= SPIN_IN_PLACE_FRAMES + spin_up_frames {
146                    self.phase = TeleportSpinPhase::Delay;
147                }
148            }
149            TeleportSpinPhase::Delay => {
150                let spin_up_frames = (SPIN_UP_STEPS - 1) * (1 + SPIN_UP_STEP_DELAY) + 1;
151                if self.frame + 1 >= SPIN_IN_PLACE_FRAMES + spin_up_frames + SPIN_POST_DELAY_FRAMES
152                {
153                    self.phase = TeleportSpinPhase::Done;
154                }
155            }
156            TeleportSpinPhase::Done => {}
157        }
158        self.frame += 1;
159        sfx
160    }
161
162    pub fn is_done(&self) -> bool {
163        self.phase == TeleportSpinPhase::Done
164    }
165
166    pub fn phase(&self) -> TeleportSpinPhase {
167        self.phase
168    }
169
170    /// Current player facing (the spin rotating through the facing cycle).
171    pub fn facing(&self) -> Direction {
172        let step = match self.phase {
173            TeleportSpinPhase::SpinInPlace => {
174                Self::spin_in_place_index(self.frame).unwrap_or(15)
175            }
176            TeleportSpinPhase::SpinUp => {
177                let f = self.frame - SPIN_IN_PLACE_FRAMES;
178                ((f / (1 + SPIN_UP_STEP_DELAY)) as usize).min((SPIN_UP_STEPS - 1) as usize) + 16
179            }
180            TeleportSpinPhase::Delay | TeleportSpinPhase::Done => 16 + (SPIN_UP_STEPS - 1) as usize,
181        };
182        self.spin_order[(self.start_index + step) % 4]
183    }
184
185    /// Vertical pixel offset of the player sprite (≤ 0; rises off screen
186    /// during the spin-up phase).
187    pub fn player_y_offset(&self) -> i32 {
188        match self.phase {
189            TeleportSpinPhase::SpinInPlace => 0,
190            TeleportSpinPhase::SpinUp => {
191                let f = self.frame - SPIN_IN_PLACE_FRAMES;
192                let step = ((f / (1 + SPIN_UP_STEP_DELAY)) as i32 + 1).min(SPIN_UP_STEPS as i32);
193                -step * SPIN_UP_STEP_PIXELS
194            }
195            TeleportSpinPhase::Delay | TeleportSpinPhase::Done => {
196                -(SPIN_UP_STEPS as i32) * SPIN_UP_STEP_PIXELS
197            }
198        }
199    }
200
201    /// Whether the player sprite is still on screen (by the last step the
202    /// sprite has risen fully above the visible area).
203    pub fn player_visible(&self) -> bool {
204        self.player_y_offset() > -(SPIN_UP_STEPS as i32) * SPIN_UP_STEP_PIXELS
205    }
206}
207
208// ── Arrival spin-in ───────────────────────────────────────────────
209
210/// Phase of the arrival spin, the counterpart of [`TeleportSpinState`]:
211/// after a teleport-class warp arrival, the player descends from off the
212/// top of the screen, then spins in place.
213#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
214pub enum EnterMapSpinPhase {
215    /// Spin while moving down: 5 spin steps of 16px each (3-frame step
216    /// delay), a descent cue at the start, an arrival cue after the last
217    /// step.
218    SpinDown,
219    /// Spin in place with delays 0,1,…,7 (8 spins, silent) — skipped when
220    /// the player arrives ON a warp pad or hole.
221    SpinInPlace,
222    /// Finished; the caller restores the saved facing and Y position.
223    Done,
224}
225
226/// Total frames of the spin-down phase: 5 spins with 3-frame delays between
227/// (the last step ends immediately).
228pub const ENTER_MAP_SPIN_DOWN_FRAMES: u16 =
229    (ENTER_MAP_SPIN_DOWN_STEPS - 1) * (1 + ENTER_MAP_SPIN_DOWN_STEP_DELAY) + 1;
230/// Number of 16px spin-down steps (from offscreen above to the standing
231/// position).
232pub const ENTER_MAP_SPIN_DOWN_STEPS: u16 = 5;
233/// Frames between spin-down steps.
234pub const ENTER_MAP_SPIN_DOWN_STEP_DELAY: u16 = 3;
235/// Pixels the player sprite descends per spin-down step.
236pub const ENTER_MAP_SPIN_DOWN_STEP_PIXELS: i32 = 16;
237/// Total frames of the arrival spin-in-place phase: 8 spins whose delays are
238/// 0,1,…,7 (8 + (1+2+…+7)).
239pub const ENTER_MAP_SPIN_IN_PLACE_FRAMES: u16 = 36;
240
241/// Sound cues [`EnterMapSpinState::tick`] can request.
242#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
243pub enum EnterMapSpinSfx {
244    /// Fired once at the start of the spin-down.
245    Descend,
246    /// Fired when the spin-down completes.
247    Land,
248}
249
250/// State of the arrival spin animation.
251///
252/// Frame-driven: constructed when a teleport-class warp is committed;
253/// ticked once per frame once the fade-in from white has completed (the
254/// player stays hidden during the fade). `spin_in_place` mirrors the
255/// standing-on-warp-pad check: arrivals on a warp pad/hole skip the final
256/// spin-in-place.
257#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
258pub struct EnterMapSpinState {
259    /// Facing cycle, indexed by `(start_index + step) % 4`.
260    spin_order: [Direction; 4],
261    /// Index in `spin_order` of the facing shown at spin step 0 — the
262    /// player's facing at the destination.
263    start_index: usize,
264    /// Elapsed frames within the whole animation.
265    frame: u16,
266    phase: EnterMapSpinPhase,
267    spin_in_place: bool,
268}
269
270impl EnterMapSpinState {
271    pub fn new(current_facing: Direction, spin_order: [Direction; 4], spin_in_place: bool) -> Self {
272        let start_index = spin_order
273            .iter()
274            .position(|&d| d == current_facing)
275            .unwrap_or(0);
276        Self {
277            spin_order,
278            start_index,
279            frame: 0,
280            phase: EnterMapSpinPhase::SpinDown,
281            spin_in_place,
282        }
283    }
284
285    /// Advance one frame. Returns the sound cue to play this frame, if any
286    /// ([`EnterMapSpinSfx::Descend`] at the start of the spin-down,
287    /// [`EnterMapSpinSfx::Land`] when it completes).
288    pub fn tick(&mut self) -> Option<EnterMapSpinSfx> {
289        if self.phase == EnterMapSpinPhase::Done {
290            return None;
291        }
292        let mut sfx = None;
293        match self.phase {
294            EnterMapSpinPhase::SpinDown => {
295                if self.frame == 0 {
296                    sfx = Some(EnterMapSpinSfx::Descend);
297                }
298                if self.frame + 1 >= ENTER_MAP_SPIN_DOWN_FRAMES {
299                    // Spin-down finished → land cue, then the spin-in-place
300                    // unless the player arrived on a warp pad or hole.
301                    sfx = Some(EnterMapSpinSfx::Land);
302                    self.phase = if self.spin_in_place {
303                        EnterMapSpinPhase::SpinInPlace
304                    } else {
305                        EnterMapSpinPhase::Done
306                    };
307                }
308            }
309            EnterMapSpinPhase::SpinInPlace => {
310                if self.frame + 1
311                    >= ENTER_MAP_SPIN_DOWN_FRAMES + ENTER_MAP_SPIN_IN_PLACE_FRAMES
312                {
313                    self.phase = EnterMapSpinPhase::Done;
314                }
315            }
316            EnterMapSpinPhase::Done => {}
317        }
318        self.frame += 1;
319        sfx
320    }
321
322    pub fn is_done(&self) -> bool {
323        self.phase == EnterMapSpinPhase::Done
324    }
325
326    pub fn phase(&self) -> EnterMapSpinPhase {
327        self.phase
328    }
329
330    /// Current player facing (the spin rotating through the facing cycle).
331    pub fn facing(&self) -> Direction {
332        let step = match self.phase {
333            EnterMapSpinPhase::SpinDown => {
334                (self.frame / (1 + ENTER_MAP_SPIN_DOWN_STEP_DELAY)) as usize
335            }
336            EnterMapSpinPhase::SpinInPlace => {
337                let f = self.frame - ENTER_MAP_SPIN_DOWN_FRAMES;
338                // 8 spins; spin i is shown for i+1 frames (delays 0..7).
339                let spin = (f as usize).min(ENTER_MAP_SPIN_IN_PLACE_FRAMES as usize - 1);
340                let mut start = 0usize;
341                let mut idx = 0usize;
342                for i in 0..8usize {
343                    let dur = i + 1;
344                    if spin < start + dur {
345                        idx = i;
346                        break;
347                    }
348                    start += dur;
349                }
350                ENTER_MAP_SPIN_DOWN_STEPS as usize + idx
351            }
352            // The animation ends by restoring the saved (destination) facing.
353            EnterMapSpinPhase::Done => 0,
354        };
355        self.spin_order[(self.start_index + step) % 4]
356    }
357
358    /// Vertical pixel offset of the player sprite (≤ 0; the player descends
359    /// from off the top of the screen into the standing position).
360    pub fn player_y_offset(&self) -> i32 {
361        match self.phase {
362            EnterMapSpinPhase::SpinDown => {
363                // Moves land on ticks 1, 5, 9, 13, 17 (a spin + 3-frame delay
364                // each): Y rises 16px per move, -80 → 0. Frame 0 is the
365                // pre-fade position (fully off the top).
366                if self.frame == 0 {
367                    -(ENTER_MAP_SPIN_DOWN_STEPS as i32) * ENTER_MAP_SPIN_DOWN_STEP_PIXELS
368                } else {
369                    let moves = ((self.frame - 1) / (1 + ENTER_MAP_SPIN_DOWN_STEP_DELAY) + 1)
370                        .min(ENTER_MAP_SPIN_DOWN_STEPS);
371                    -((ENTER_MAP_SPIN_DOWN_STEPS - moves) as i32)
372                        * ENTER_MAP_SPIN_DOWN_STEP_PIXELS
373                }
374            }
375            EnterMapSpinPhase::SpinInPlace | EnterMapSpinPhase::Done => 0,
376        }
377    }
378
379    /// Whether the player sprite is still off the top of the screen.
380    pub fn player_visible(&self) -> bool {
381        self.player_y_offset() > -(ENTER_MAP_SPIN_DOWN_STEPS as i32)
382            * ENTER_MAP_SPIN_DOWN_STEP_PIXELS
383    }
384}
385
386// ── Elevator shake ────────────────────────────────────────────────
387
388/// Tuning for the elevator rumble: how many up/down iterations and how far
389/// the background scrolls each iteration.
390#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
391pub struct ElevatorShakeParams {
392    /// Number of shake iterations (each lasts 2 frames).
393    pub iterations: u8,
394    /// Background scroll magnitude per iteration, in pixels.
395    pub pixel_offset: u8,
396}
397
398impl ElevatorShakeParams {
399    /// Total frames of the shake (2 frames per iteration).
400    pub const fn total_frames(&self) -> u16 {
401        self.iterations as u16 * 2
402    }
403}
404
405/// Sound cues [`ElevatorShakeState::tick`] can request.
406#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
407pub enum ElevatorShakeSfx {
408    /// The rattle, fired at the start of each 2-frame iteration.
409    Rattle,
410    /// The arrival "ding", fired on the final frame.
411    Arrive,
412}
413
414/// Frame-driven elevator rumble: scrolls the background up/down by
415/// ±`pixel_offset`, `iterations` × 2 frames, a rattle cue each iteration,
416/// then a single arrival ding.
417#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
418pub struct ElevatorShakeState {
419    /// Elapsed frames of the shake (0..params.total_frames()).
420    frame: u16,
421    params: ElevatorShakeParams,
422}
423
424impl ElevatorShakeState {
425    pub fn new(params: ElevatorShakeParams) -> Self {
426        Self { frame: 0, params }
427    }
428
429    pub fn params(&self) -> ElevatorShakeParams {
430        self.params
431    }
432
433    /// Total frames of the shake.
434    pub fn total_frames(&self) -> u16 {
435        self.params.total_frames()
436    }
437
438    /// Advance one frame. Returns [`ElevatorShakeSfx::Rattle`] at the start
439    /// of each 2-frame iteration and [`ElevatorShakeSfx::Arrive`] on the
440    /// final frame.
441    pub fn tick(&mut self) -> Option<ElevatorShakeSfx> {
442        if self.is_done() {
443            return None;
444        }
445        let sfx = if self.frame + 1 >= self.total_frames() {
446            Some(ElevatorShakeSfx::Arrive)
447        } else if self.frame % 2 == 0 {
448            Some(ElevatorShakeSfx::Rattle)
449        } else {
450            None
451        };
452        self.frame += 1;
453        sfx
454    }
455
456    pub fn is_done(&self) -> bool {
457        self.frame >= self.total_frames()
458    }
459
460    /// Current background scroll offset (±pixel_offset). The first
461    /// iteration scrolls negative.
462    pub fn offset_y(&self) -> i32 {
463        if self.is_done() {
464            return 0;
465        }
466        let iteration = self.frame / 2;
467        let px = self.params.pixel_offset as i32;
468        if iteration % 2 == 0 {
469            -px
470        } else {
471            px
472        }
473    }
474}
475
476// ── Water/flower tile animation ───────────────────────────────────
477
478/// Which background tiles a tileset animates (the classic
479/// water-rotation / flower-frame system).
480#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
481pub enum TileAnimKind {
482    /// No animated tiles.
483    None,
484    /// Water tiles only.
485    Water,
486    /// Water + flower tiles.
487    WaterFlower,
488}
489
490/// Per-frame water/flower background-tile animation.
491///
492/// - `counter1` increments every frame; at 20 the water tile rotates one
493///   pixel; at 21 ([`TileAnimKind::WaterFlower`] only) the flower tile
494///   advances and `counter1` resets. For water-only tilesets `counter1`
495///   resets right after the water update.
496/// - `counter2` increments on each water update (& 7); its bit 2 selects
497///   the water rotation direction (right for 4 updates, then left for 4),
498///   and `counter2 & 3` selects the flower frame (0/1 → 1, 2 → 2, 3 → 3).
499#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
500pub struct TileAnimState {
501    counter1: u8,
502    counter2: u8,
503    /// Net horizontal water-tile rotation in pixels (0..=4, back and forth).
504    water_shift: i8,
505    /// Flower frame (1..=3) selected by the last flower update, if any.
506    flower_frame: Option<u8>,
507    /// Animation kind for the current tileset (None = animations disabled).
508    kind: TileAnimKind,
509}
510
511impl TileAnimState {
512    pub fn new() -> Self {
513        Self {
514            counter1: 0,
515            counter2: 0,
516            water_shift: 0,
517            flower_frame: None,
518            kind: TileAnimKind::None,
519        }
520    }
521
522    /// Adopt a tileset's animation kind and reset `counter1` (`counter2`
523    /// and the accumulated water shift persist, matching the classic WRAM
524    /// behavior on map load).
525    pub fn set_tileset(&mut self, kind: TileAnimKind) {
526        self.kind = kind;
527        self.counter1 = 0;
528    }
529
530    /// Advance one frame. No-op when the tileset has no animated tiles.
531    pub fn tick(&mut self) {
532        if self.kind == TileAnimKind::None {
533            return;
534        }
535        self.counter1 = self.counter1.wrapping_add(1);
536        if self.counter1 < 20 {
537            return;
538        }
539        if self.counter1 == 21 {
540            // flower update
541            self.counter1 = 0;
542            self.flower_frame = Some(match self.counter2 & 3 {
543                0 | 1 => 1,
544                2 => 2,
545                _ => 3,
546            });
547            return;
548        }
549        // counter1 == 20: water update.
550        self.counter2 = (self.counter2 + 1) & 7;
551        // Shift the tile rows one pixel right (counter2 bit 2 clear) or
552        // left (set).
553        self.water_shift += if self.counter2 & 4 == 0 { 1 } else { -1 };
554        // Water-only tilesets reset the counter immediately; WaterFlower
555        // falls through to the flower frame on the next tick.
556        if self.kind == TileAnimKind::Water {
557            self.counter1 = 0;
558        }
559    }
560
561    /// Current horizontal rotation of the water tile in pixels (positive =
562    /// right). Sample source column `(x - shift) mod 8`.
563    pub fn water_shift(&self) -> i8 {
564        self.water_shift
565    }
566
567    /// Flower frame (1..=3) to display, or None before the first flower
568    /// update (the tileset's base flower tile shows).
569    pub fn flower_frame(&self) -> Option<u8> {
570        self.flower_frame
571    }
572
573    /// Animation kind for the current tileset.
574    pub fn kind(&self) -> TileAnimKind {
575        self.kind
576    }
577}
578
579impl Default for TileAnimState {
580    fn default() -> Self {
581        Self::new()
582    }
583}
584
585// ── Fishing rod animation ─────────────────────────────────────────
586
587/// Initial pause before the rod appears.
588pub const FISHING_CAST_DELAY_FRAMES: u16 = 10;
589/// Frames the rod stays out waiting for a bite.
590pub const FISHING_ROD_OUT_FRAMES: u16 = 100;
591/// Shake iterations on a bite.
592pub const FISHING_SHAKE_ITERATIONS: u16 = 10;
593/// Frames per shake iteration.
594pub const FISHING_SHAKE_STEP_FRAMES: u16 = 3;
595/// Frames the "!" emotion bubble stays up.
596pub const FISHING_BUBBLE_FRAMES: u16 = 60;
597
598/// Total frames of the whole animation: 10 + 100 + 30 + 60.
599pub const FISHING_ANIM_FRAMES: u16 = FISHING_CAST_DELAY_FRAMES
600    + FISHING_ROD_OUT_FRAMES
601    + FISHING_SHAKE_ITERATIONS * FISHING_SHAKE_STEP_FRAMES
602    + FISHING_BUBBLE_FRAMES;
603
604/// Phase of the player-side fishing rod animation.
605#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
606pub enum FishingAnimPhase {
607    /// Initial pause — nothing drawn yet (the rod OAM and the fishing pose
608    /// tiles are set up only after this delay).
609    CastDelay,
610    /// The rod is out and the player holds the fishing pose; the game's
611    /// bite roll decides the outcome at the end of this phase.
612    RodOut,
613    /// Bite only: shake iterations toggling the player sprite's and rod's
614    /// Y by ±1 px.
615    Shake,
616    /// Bite only: the "!" emotion bubble over the player. The rod is hidden
617    /// during this phase when the player faces up (so it does not overlap
618    /// the bubble), then unhidden.
619    Bubble,
620    /// Finished; the caller shows the result text.
621    Done,
622}
623
624/// Frame-driven state of the rod animation. Constructed when a rod use
625/// passes the game's eligibility gates; ticked once per frame by the update
626/// loop (which freezes gameplay while it runs); when [`Self::is_done`] the
627/// result text is queued.
628#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
629pub struct FishingAnimState {
630    /// Elapsed ticks (0 before the first `tick`).
631    frame: u16,
632    /// Player facing when the anim started (selects the rod OAM entry).
633    facing: Direction,
634    /// A bite plays the shake + bubble.
635    bite: bool,
636    phase: FishingAnimPhase,
637}
638
639impl FishingAnimState {
640    pub fn new(facing: Direction, bite: bool) -> Self {
641        Self {
642            frame: 0,
643            facing,
644            bite,
645            phase: FishingAnimPhase::CastDelay,
646        }
647    }
648
649    fn phase_for(frame: u16, bite: bool) -> FishingAnimPhase {
650        let rod_end = FISHING_CAST_DELAY_FRAMES + FISHING_ROD_OUT_FRAMES;
651        if frame < FISHING_CAST_DELAY_FRAMES {
652            FishingAnimPhase::CastDelay
653        } else if frame < rod_end {
654            FishingAnimPhase::RodOut
655        } else if !bite {
656            FishingAnimPhase::Done
657        } else if frame < rod_end + FISHING_SHAKE_ITERATIONS * FISHING_SHAKE_STEP_FRAMES {
658            FishingAnimPhase::Shake
659        } else if frame < rod_end
660            + FISHING_SHAKE_ITERATIONS * FISHING_SHAKE_STEP_FRAMES
661            + FISHING_BUBBLE_FRAMES
662        {
663            FishingAnimPhase::Bubble
664        } else {
665            FishingAnimPhase::Done
666        }
667    }
668
669    /// Advance one frame.
670    pub fn tick(&mut self) {
671        self.frame = self.frame.saturating_add(1);
672        self.phase = Self::phase_for(self.frame, self.bite);
673    }
674
675    pub fn phase(&self) -> FishingAnimPhase {
676        self.phase
677    }
678
679    pub fn is_done(&self) -> bool {
680        self.phase == FishingAnimPhase::Done
681    }
682
683    /// Facing captured at construction.
684    pub fn facing(&self) -> Direction {
685        self.facing
686    }
687
688    /// Whether the player is holding the fishing pose (pose + rod shown).
689    /// False during the initial delay and after the anim ends.
690    pub fn pose_active(&self) -> bool {
691        matches!(
692            self.phase,
693            FishingAnimPhase::RodOut | FishingAnimPhase::Shake | FishingAnimPhase::Bubble
694        )
695    }
696
697    /// Whether the rod OAM piece is drawn this frame. Hidden during the
698    /// bubble for the up-facing player, and not yet present during the
699    /// cast delay.
700    pub fn rod_visible(&self) -> bool {
701        self.pose_active()
702            && !(self.phase == FishingAnimPhase::Bubble && self.facing == Direction::Up)
703    }
704
705    /// Whether the "!" emotion bubble is displayed above the player.
706    pub fn bubble_active(&self) -> bool {
707        self.phase == FishingAnimPhase::Bubble
708    }
709
710    /// Vertical offset (±1 px) of the player sprite and rod during the
711    /// bite shake.
712    pub fn player_shake_offset(&self) -> i32 {
713        if self.phase != FishingAnimPhase::Shake {
714            return 0;
715        }
716        let shake_start = FISHING_CAST_DELAY_FRAMES + FISHING_ROD_OUT_FRAMES;
717        let iteration = (self.frame - shake_start) / FISHING_SHAKE_STEP_FRAMES;
718        if iteration % 2 == 0 { 1 } else { 0 }
719    }
720
721    /// The rod's OAM piece for `facing`, expressed as an OFFSET from the
722    /// player sprite's top-left. Returns `(dx, dy, tile index into the
723    /// rod sprite sheet, x_flip)`; the sheet's tiles are 0 (DOWN/UP) and
724    /// 1 (LEFT/RIGHT — X-flipped for RIGHT).
725    pub fn rod_piece(facing: Direction) -> (i32, i32, u8, bool) {
726        match facing {
727            Direction::Down => (20, 35, 0, false),
728            Direction::Up => (20, -12, 0, false),
729            Direction::Left => (0, 16, 1, false),
730            Direction::Right => (48, 16, 1, true),
731        }
732    }
733}
734
735// ── Boulder push dust ─────────────────────────────────────────────
736
737/// Number of animation steps of the boulder-dust puff.
738pub const BOULDER_DUST_STEPS: u8 = 8;
739/// Frames each dust step lasts.
740pub const BOULDER_DUST_STEP_FRAMES: u8 = 3;
741
742/// The 2×2 smoke-puff block kicked up when a pushed boulder slides one
743/// tile. The block is written once, anchored to the player's map tile at
744/// push time (the animation outlives the push lockout, so the anchor must
745/// not track the player afterward), then runs [`BOULDER_DUST_STEPS`] steps
746/// of [`BOULDER_DUST_STEP_FRAMES`] frames each. Every step the block drifts
747/// 1px against the boulder's slide direction and the sprite palette
748/// toggles, flashing two gray shades.
749#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
750pub struct BoulderDustState {
751    /// The push direction — the player's facing when the boulder moved
752    /// (also the dust's base offset and drift direction).
753    facing: Direction,
754    /// The player's map tile when the push started: the dust's world anchor.
755    anchor_x: u16,
756    anchor_y: u16,
757    /// Current animation step (0..BOULDER_DUST_STEPS; == STEPS once done).
758    step: u8,
759    /// Frames elapsed within the current step.
760    frame: u8,
761}
762
763impl BoulderDustState {
764    /// A finished (inactive) state — no dust showing.
765    pub const fn inactive() -> Self {
766        Self {
767            facing: Direction::Down,
768            anchor_x: 0,
769            anchor_y: 0,
770            step: BOULDER_DUST_STEPS,
771            frame: 0,
772        }
773    }
774
775    /// Start the dust for a push in `facing` direction, anchored to the
776    /// player's map tile at push time.
777    pub const fn new(facing: Direction, anchor_x: u16, anchor_y: u16) -> Self {
778        Self {
779            facing,
780            anchor_x,
781            anchor_y,
782            step: 0,
783            frame: 0,
784        }
785    }
786
787    /// Advance one frame. No-op once the animation has finished.
788    pub fn tick(&mut self) {
789        if self.step >= BOULDER_DUST_STEPS {
790            return;
791        }
792        self.frame += 1;
793        if self.frame >= BOULDER_DUST_STEP_FRAMES {
794            self.frame = 0;
795            self.step += 1;
796        }
797    }
798
799    /// True while the puff is showing (steps 0..7).
800    pub fn is_active(&self) -> bool {
801        self.step < BOULDER_DUST_STEPS
802    }
803
804    /// The push direction.
805    pub fn facing(&self) -> Direction {
806        self.facing
807    }
808
809    /// The player's map tile at push time — the dust's world anchor.
810    pub fn anchor(&self) -> (u16, u16) {
811        (self.anchor_x, self.anchor_y)
812    }
813
814    /// Current animation step index (0..=7).
815    pub fn step(&self) -> u8 {
816        self.step.min(BOULDER_DUST_STEPS - 1)
817    }
818
819    /// Base pixel offset of the dust block's top-left corner from the
820    /// player sprite's top-left (the puff appears at the boulder's base,
821    /// "2 blocks away from the player").
822    pub fn base_offset(&self) -> (i32, i32) {
823        match self.facing {
824            Direction::Down => (8, 52),
825            Direction::Up => (8, -12),
826            Direction::Left => (-24, 20),
827            Direction::Right => (40, 20),
828        }
829    }
830
831    /// Per-step pixel drift of the dust block — opposite to the boulder's
832    /// slide direction. The puff lingers as the boulder slides away from it.
833    pub fn drift_px(&self) -> (i32, i32) {
834        match self.facing {
835            Direction::Down => (0, -1),
836            Direction::Up => (0, 1),
837            Direction::Left => (1, 0),
838            Direction::Right => (-1, 0),
839        }
840    }
841
842    /// Per-step pixel delta of each of the block's four 8×8 tiles
843    /// (upper-left, upper-right, lower-left, lower-right). Vertical pushes
844    /// move the whole block; horizontal pushes move only the upper-right,
845    /// lower-left and lower-right tiles — the upper-left tile stays in
846    /// place.
847    pub fn tile_drifts(&self) -> [(i32, i32); 4] {
848        let (dx, dy) = self.drift_px();
849        match self.facing {
850            Direction::Left | Direction::Right => [(0, 0), (dx, 0), (dx, 0), (dx, 0)],
851            _ => [(dx, dy); 4],
852        }
853    }
854
855    /// True on odd steps: the palette toggles once per step, flashing the
856    /// two gray shades of the smoke sprite.
857    pub fn palette_flipped(&self) -> bool {
858        self.step % 2 == 1
859    }
860}
861
862// ── FLASH white-out ───────────────────────────────────────────────
863
864/// Frames of the all-palettes-white flash when a dark area is lit up.
865pub const FLASH_WHITE_FRAMES: u8 = 3;
866
867// ── Ship departure cutscene ───────────────────────────────────────
868
869/// Initial pause of the departure cutscene (after the departure music
870/// starts).
871pub const SHIP_DEPARTURE_INITIAL_PAUSE_FRAMES: u16 = 120;
872/// The water-fill commit (pushing the screen tile buffer's water fill to
873/// the background). Kept for frame fidelity; the erase phase below is the
874/// visible ship removal.
875pub const SHIP_DEPARTURE_WATER_FILL_FRAMES: u16 = 3;
876/// View-scroll iterations.
877pub const SHIP_DEPARTURE_SCROLL_ITERATIONS: u16 = 8;
878/// Frames per iteration: 16 smoke-drift substeps × an 8-frame delay each.
879pub const SHIP_DEPARTURE_ITERATION_FRAMES: u16 = 16 * 8;
880/// Final pause after the ship is erased.
881pub const SHIP_DEPARTURE_ERASE_FRAMES: u16 = 120;
882/// Pixels the view scrolls east per iteration (the ship slides left).
883pub const SHIP_DEPARTURE_SCROLL_PX_PER_ITERATION: i32 = 2 * 8;
884/// Smoke-puff spawn spacing: a new 2×2 puff block is emitted above the
885/// smokestack every iteration, 16px left of the previous one.
886pub const SHIP_DEPARTURE_PUFF_SPACING_PX: i32 = 16;
887/// Smoke-puff drift: every 8-frame substep all live puffs drift +2px right.
888pub const SHIP_DEPARTURE_PUFF_DRIFT_PX_PER_SUBSTEP: i32 = 2;
889/// Substeps per iteration.
890pub const SHIP_DEPARTURE_SUBSTEPS_PER_ITERATION: u16 = 16;
891/// Frames per drift substep.
892pub const SHIP_DEPARTURE_SUBSTEP_FRAMES: u16 = 8;
893/// The smokestack's map position (tile units) — the puffs' world anchor.
894pub const SHIP_DEPARTURE_SMOKESTACK_TILE_X: u16 = 16;
895pub const SHIP_DEPARTURE_SMOKESTACK_TILE_Y: f32 = 10.5;
896/// The first puff's screen X at departure start.
897pub const SHIP_DEPARTURE_PUFF_START_SCREEN_X: i32 = 88;
898
899/// Phase of the ship-departure cutscene.
900#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
901pub enum ShipDeparturePhase {
902    /// Initial pause — the ship sits at the dock while the music plays.
903    InitialPause,
904    /// Water-fill commit. The visible erase happens in [`Self::Erase`].
905    WaterFill,
906    /// The scroll loop: view-scroll iterations with a smoke puff emitted
907    /// per iteration and all live puffs drifting right.
908    Scroll,
909    /// The ship's map blocks become water, the dock→ship warp is removed,
910    /// the horn plays again, and a final pause closes the cutscene.
911    Erase,
912    /// Finished; the caller proceeds to whatever follows the cutscene.
913    Done,
914}
915
916/// Total frames of the whole cutscene: 120 + 3 + 8×128 + 120.
917pub const SHIP_DEPARTURE_TOTAL_FRAMES: u16 = SHIP_DEPARTURE_INITIAL_PAUSE_FRAMES
918    + SHIP_DEPARTURE_WATER_FILL_FRAMES
919    + SHIP_DEPARTURE_SCROLL_ITERATIONS * SHIP_DEPARTURE_ITERATION_FRAMES
920    + SHIP_DEPARTURE_ERASE_FRAMES;
921
922/// Sound cues [`ShipDepartureState::tick`] can request.
923#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
924pub enum ShipDepartureSfx {
925    /// The ship's horn — played when the scroll begins (blocking) and
926    /// again when the erase phase begins (non-blocking).
927    Horn,
928}
929
930/// Frame-driven state of the ship-departure cutscene. Constructed when the
931/// game's departure script fires; ticked once per frame by the update loop
932/// (freezing gameplay, matching the classic blocking structure). The
933/// renderer reads the scroll offset and puff positions; the game applies
934/// the ship erase + warp removal at the erase transition.
935#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
936pub struct ShipDepartureState {
937    /// Elapsed ticks (0 before the first `tick`).
938    frame: u16,
939    phase: ShipDeparturePhase,
940}
941
942impl ShipDepartureState {
943    pub fn new() -> Self {
944        Self {
945            frame: 0,
946            phase: ShipDeparturePhase::InitialPause,
947        }
948    }
949
950    fn phase_for(frame: u16) -> ShipDeparturePhase {
951        let scroll_end = SHIP_DEPARTURE_INITIAL_PAUSE_FRAMES
952            + SHIP_DEPARTURE_WATER_FILL_FRAMES
953            + SHIP_DEPARTURE_SCROLL_ITERATIONS * SHIP_DEPARTURE_ITERATION_FRAMES;
954        if frame < SHIP_DEPARTURE_INITIAL_PAUSE_FRAMES {
955            ShipDeparturePhase::InitialPause
956        } else if frame < SHIP_DEPARTURE_INITIAL_PAUSE_FRAMES + SHIP_DEPARTURE_WATER_FILL_FRAMES {
957            ShipDeparturePhase::WaterFill
958        } else if frame < scroll_end {
959            ShipDeparturePhase::Scroll
960        } else if frame < SHIP_DEPARTURE_TOTAL_FRAMES {
961            ShipDeparturePhase::Erase
962        } else {
963            ShipDeparturePhase::Done
964        }
965    }
966
967    /// Advance one frame. Returns the sound cue to play this frame, if any:
968    /// [`ShipDepartureSfx::Horn`] on the first scroll frame and on the
969    /// first erase frame.
970    pub fn tick(&mut self) -> Option<ShipDepartureSfx> {
971        if self.phase == ShipDeparturePhase::Done {
972            return None;
973        }
974        let mut sfx = None;
975        let next = self.frame + 1;
976        let next_phase = Self::phase_for(next);
977        if next_phase != self.phase {
978            match next_phase {
979                ShipDeparturePhase::Scroll => {
980                    sfx = Some(ShipDepartureSfx::Horn);
981                }
982                ShipDeparturePhase::Erase => {
983                    sfx = Some(ShipDepartureSfx::Horn);
984                }
985                _ => {}
986            }
987        }
988        self.frame = next;
989        self.phase = next_phase;
990        sfx
991    }
992
993    pub fn is_done(&self) -> bool {
994        self.phase == ShipDeparturePhase::Done
995    }
996
997    pub fn phase(&self) -> ShipDeparturePhase {
998        self.phase
999    }
1000
1001    /// Elapsed frames within the whole animation.
1002    pub fn frame(&self) -> u16 {
1003        self.frame
1004    }
1005
1006    /// True once the ship should be shown as water: the erase phase has
1007    /// begun (the game's map-block mutation lands at the same time; this
1008    /// flag covers the same frame for renderers that draw before the
1009    /// mutation takes effect).
1010    pub fn ship_erased(&self) -> bool {
1011        matches!(
1012            self.phase,
1013            ShipDeparturePhase::Erase | ShipDeparturePhase::Done
1014        )
1015    }
1016
1017    /// Current iteration of the view scroll (0..=7), or 7 once the scroll
1018    /// finished (the erase phase keeps the final scrolled position).
1019    pub fn scroll_iteration(&self) -> u16 {
1020        match self.phase {
1021            ShipDeparturePhase::Scroll => {
1022                (self.frame
1023                    - SHIP_DEPARTURE_INITIAL_PAUSE_FRAMES
1024                    - SHIP_DEPARTURE_WATER_FILL_FRAMES)
1025                    / SHIP_DEPARTURE_ITERATION_FRAMES
1026            }
1027            ShipDeparturePhase::Erase | ShipDeparturePhase::Done => {
1028                SHIP_DEPARTURE_SCROLL_ITERATIONS - 1
1029            }
1030            _ => 0,
1031        }
1032    }
1033
1034    /// Current drift substep within the scroll (0..=127 across all 8
1035    /// iterations), or 127 once the scroll finished. Each substep lasts
1036    /// 8 frames.
1037    pub fn scroll_substep(&self) -> u16 {
1038        match self.phase {
1039            ShipDeparturePhase::Scroll => {
1040                (self.frame
1041                    - SHIP_DEPARTURE_INITIAL_PAUSE_FRAMES
1042                    - SHIP_DEPARTURE_WATER_FILL_FRAMES)
1043                    / SHIP_DEPARTURE_SUBSTEP_FRAMES
1044            }
1045            ShipDeparturePhase::Erase | ShipDeparturePhase::Done => {
1046                SHIP_DEPARTURE_SCROLL_ITERATIONS * SHIP_DEPARTURE_SUBSTEPS_PER_ITERATION - 1
1047            }
1048            _ => 0,
1049        }
1050    }
1051
1052    /// Horizontal scroll of the map view in pixels (0..=128). The view
1053    /// advances [`SHIP_DEPARTURE_SCROLL_PX_PER_ITERATION`] per iteration
1054    /// and ramps one more pixel per 8-frame substep — net movement
1055    /// 16i + (substep+1).
1056    pub fn scroll_px(&self) -> i32 {
1057        match self.phase {
1058            ShipDeparturePhase::Scroll => {
1059                self.scroll_iteration() as i32 * SHIP_DEPARTURE_SCROLL_PX_PER_ITERATION
1060                    + (self.scroll_substep() as i32
1061                        % SHIP_DEPARTURE_SUBSTEPS_PER_ITERATION as i32)
1062                    + 1
1063            }
1064            ShipDeparturePhase::Erase | ShipDeparturePhase::Done => {
1065                SHIP_DEPARTURE_SCROLL_ITERATIONS as i32
1066                    * SHIP_DEPARTURE_SCROLL_PX_PER_ITERATION
1067            }
1068            _ => 0,
1069        }
1070    }
1071
1072    /// Number of smoke puffs emitted so far (1 per iteration, 8 total).
1073    /// Puff i spawns at the start of iteration i, at the smokestack's
1074    /// current screen position.
1075    pub fn puff_count(&self) -> usize {
1076        match self.phase {
1077            ShipDeparturePhase::InitialPause | ShipDeparturePhase::WaterFill => 0,
1078            ShipDeparturePhase::Scroll | ShipDeparturePhase::Erase | ShipDeparturePhase::Done => {
1079                (self.scroll_iteration() + 1) as usize
1080            }
1081        }
1082    }
1083
1084    /// Screen-x offset (px) of puff `i` from the smokestack's position at
1085    /// departure start: spawns at X = 88 − 16i and drifts +2px per substep
1086    /// from its spawn substep (the spawn iteration's own drift loop moves
1087    /// it immediately). Renderers rebase onto their own view by adding
1088    /// `(smokestack_screen_x - SHIP_DEPARTURE_PUFF_START_SCREEN_X)`.
1089    pub fn puff_x_offset(&self, i: usize) -> i32 {
1090        let i = i as i32;
1091        let spawn = SHIP_DEPARTURE_PUFF_START_SCREEN_X - SHIP_DEPARTURE_PUFF_SPACING_PX * i;
1092        let s = self.scroll_substep() as i32;
1093        let substeps_live = (s - SHIP_DEPARTURE_SUBSTEPS_PER_ITERATION as i32 * i).max(0);
1094        spawn + SHIP_DEPARTURE_PUFF_DRIFT_PX_PER_SUBSTEP * (substeps_live + 1)
1095    }
1096
1097    /// Screen y (px) of every puff at departure start — the smokestack row
1098    /// (10.5 tiles).
1099    pub fn puff_screen_y(&self) -> i32 {
1100        (SHIP_DEPARTURE_SMOKESTACK_TILE_Y * 8.0) as i32
1101    }
1102}
1103
1104impl Default for ShipDepartureState {
1105    fn default() -> Self {
1106        Self::new()
1107    }
1108}
1109
1110// ── Tests ─────────────────────────────────────────────────────────
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115
1116    /// The classic facing cycle: DOWN, LEFT, UP, RIGHT.
1117    const SPIN_ORDER: [Direction; 4] = [
1118        Direction::Down,
1119        Direction::Left,
1120        Direction::Up,
1121        Direction::Right,
1122    ];
1123
1124    // ── TeleportSpinState ─────────────────────────────────────────
1125
1126    #[test]
1127    fn spin_starts_on_current_facing_then_cycles() {
1128        // The spin starts by showing the current facing, then advances
1129        // through the injected cycle.
1130        for (start, order) in [
1131            (
1132                Direction::Down,
1133                [
1134                    Direction::Down,
1135                    Direction::Left,
1136                    Direction::Up,
1137                    Direction::Right,
1138                ],
1139            ),
1140            (
1141                Direction::Up,
1142                [
1143                    Direction::Up,
1144                    Direction::Right,
1145                    Direction::Down,
1146                    Direction::Left,
1147                ],
1148            ),
1149        ] {
1150            let mut spin = TeleportSpinState::new(start, SPIN_ORDER);
1151            for (i, want) in order.iter().enumerate() {
1152                assert_eq!(&spin.facing(), want, "start {:?} spin {}", start, i);
1153                // Advance to the next spin: spin i lasts (16 - i) frames.
1154                for _ in 0..(16 - i) {
1155                    spin.tick();
1156                }
1157            }
1158        }
1159    }
1160
1161    #[test]
1162    fn spin_honors_a_custom_facing_cycle() {
1163        // A game with a different spin cycle gets its own order back.
1164        const REVERSED: [Direction; 4] = [
1165            Direction::Down,
1166            Direction::Right,
1167            Direction::Up,
1168            Direction::Left,
1169        ];
1170        let mut spin = TeleportSpinState::new(Direction::Down, REVERSED);
1171        assert_eq!(spin.facing(), Direction::Down);
1172        for _ in 0..16 {
1173            spin.tick();
1174        }
1175        assert_eq!(spin.facing(), Direction::Right, "second spin of the custom cycle");
1176        // A start facing absent from the cycle falls back to the cycle start.
1177        let spin = TeleportSpinState::new(Direction::Up, [Direction::Down; 4]);
1178        assert_eq!(spin.facing(), Direction::Down);
1179    }
1180
1181    #[test]
1182    fn spin_sfx_schedule() {
1183        // SpinLoop when the current delay is a multiple of 4 (spins 0, 4, 8,
1184        // 12 → 4 plays); Rise once at the start of the spin-up.
1185        let mut spin = TeleportSpinState::new(Direction::Down, SPIN_ORDER);
1186        let mut loops = 0;
1187        let mut rises = 0;
1188        let mut frames = 0;
1189        while !spin.is_done() {
1190            match spin.tick() {
1191                Some(TeleportSpinSfx::SpinLoop) => loops += 1,
1192                Some(TeleportSpinSfx::Rise) => {
1193                    rises += 1;
1194                    assert_eq!(frames, SPIN_IN_PLACE_FRAMES, "rise at spin-up start");
1195                }
1196                None => {}
1197            }
1198            frames += 1;
1199            assert!(frames < 1000, "spin must terminate");
1200        }
1201        assert_eq!(loops, 4);
1202        assert_eq!(rises, 1);
1203        // 136 in-place + 17 spin-up (4×(1+3) + 1) + 10 delay.
1204        assert_eq!(frames, 136 + 17 + 10);
1205    }
1206
1207    #[test]
1208    fn spin_rises_off_screen_and_hides() {
1209        let mut spin = TeleportSpinState::new(Direction::Down, SPIN_ORDER);
1210        assert_eq!(spin.player_y_offset(), 0);
1211        assert!(spin.player_visible());
1212        for _ in 0..SPIN_IN_PLACE_FRAMES - 1 {
1213            spin.tick();
1214        }
1215        assert_eq!(spin.player_y_offset(), 0, "still grounded on the last spin frame");
1216        spin.tick(); // first spin-up step applies the -16px delta immediately
1217        assert_eq!(spin.player_y_offset(), -16);
1218        // Remaining 4 steps (each 1 spin + 3 delay frames, the last has no delay).
1219        for _ in 0..16 {
1220            spin.tick();
1221        }
1222        assert_eq!(spin.player_y_offset(), -80);
1223        assert!(!spin.player_visible(), "sprite fully above the screen");
1224    }
1225
1226    // ── EnterMapSpinState ─────────────────────────────────────────
1227
1228    #[test]
1229    fn enter_map_spin_starts_hidden_and_descends_in_five_steps() {
1230        let mut anim = EnterMapSpinState::new(Direction::Down, SPIN_ORDER, true);
1231        // The state is created at warp commit; the player is off the top of
1232        // the screen while the fade-in plays — offset -80, not visible.
1233        assert_eq!(anim.phase(), EnterMapSpinPhase::SpinDown);
1234        assert!(!anim.player_visible(), "hidden until the spin-down descends");
1235        assert_eq!(anim.player_y_offset(), -80);
1236
1237        // 5 moves of 16px on ticks 1, 5, 9, 13, 17 (a spin + 3-frame delay
1238        // each). The offset after tick 17 is the standing position.
1239        let mut last = -80;
1240        for frame in 1..=17 {
1241            anim.tick();
1242            if frame % 4 == 1 {
1243                assert!(anim.player_y_offset() > last, "move {frame} descends");
1244                last = anim.player_y_offset();
1245            }
1246        }
1247        assert_eq!(anim.player_y_offset(), 0, "standing position after the 5th move");
1248        assert!(anim.player_visible());
1249    }
1250
1251    #[test]
1252    fn enter_map_spin_timing_and_sfx() {
1253        let mut anim = EnterMapSpinState::new(Direction::Left, SPIN_ORDER, true);
1254        // Descend cue on the very first frame.
1255        assert_eq!(anim.tick(), Some(EnterMapSpinSfx::Descend));
1256        // 16 more frames of the spin-down, then the Land cue.
1257        for _ in 1..ENTER_MAP_SPIN_DOWN_FRAMES - 1 {
1258            assert_eq!(anim.tick(), None);
1259        }
1260        assert_eq!(
1261            anim.tick(),
1262            Some(EnterMapSpinSfx::Land),
1263            "land when the spin-down completes"
1264        );
1265        assert_eq!(anim.phase(), EnterMapSpinPhase::SpinInPlace);
1266
1267        // The spin-in-place is 8 spins of 0,1,…,7 frames = 36 frames, silent.
1268        let mut ticks = 0;
1269        while !anim.is_done() {
1270            anim.tick();
1271            ticks += 1;
1272            assert!(ticks <= ENTER_MAP_SPIN_IN_PLACE_FRAMES, "bounded");
1273        }
1274        assert_eq!(ticks, ENTER_MAP_SPIN_IN_PLACE_FRAMES);
1275        assert!(anim.is_done());
1276    }
1277
1278    #[test]
1279    fn enter_map_spin_skips_spin_in_place_on_warp_pad() {
1280        // Arrival on a warp pad/hole skips the final spin-in-place.
1281        let mut anim = EnterMapSpinState::new(Direction::Down, SPIN_ORDER, false);
1282        let mut ticks = 0;
1283        while !anim.is_done() && ticks < 200 {
1284            anim.tick();
1285            ticks += 1;
1286        }
1287        assert_eq!(ticks, ENTER_MAP_SPIN_DOWN_FRAMES, "no spin-in-place phase");
1288        assert!(anim.is_done());
1289    }
1290
1291    #[test]
1292    fn enter_map_spin_facing_cycles_and_restores() {
1293        // The facing advances through the cycle from the start facing (the
1294        // start facing shows while the first 16px move's delay runs).
1295        let mut anim = EnterMapSpinState::new(Direction::Down, SPIN_ORDER, true);
1296        anim.tick(); // frame 1 → first move, start facing still shown
1297        assert_eq!(anim.facing(), Direction::Down);
1298        for _ in 0..4 {
1299            anim.tick(); // frames 2..5 → second move starts
1300        }
1301        assert_eq!(anim.facing(), Direction::Left);
1302        // Deep into the spin-in-place the list has wrapped several times.
1303        for _ in 0..ENTER_MAP_SPIN_DOWN_FRAMES + 5 {
1304            anim.tick();
1305        }
1306        assert_eq!(anim.phase(), EnterMapSpinPhase::SpinInPlace);
1307        // f = 27 - 17 = 10 → spin-in-place index 4 (durations 1,2,3,4,5):
1308        // 5 + 4 = 9 steps from start → 9 mod 4 = 1 → LEFT.
1309        assert_eq!(anim.facing(), Direction::Left);
1310    }
1311
1312    // ── ElevatorShakeState ────────────────────────────────────────
1313
1314    const SHAKE: ElevatorShakeParams = ElevatorShakeParams {
1315        iterations: 100,
1316        pixel_offset: 1,
1317    };
1318
1319    #[test]
1320    fn elevator_shake_alternates_offset_each_iteration() {
1321        let mut shake = ElevatorShakeState::new(SHAKE);
1322        // First iteration scrolls negative.
1323        assert_eq!(shake.offset_y(), -1);
1324        shake.tick();
1325        assert_eq!(shake.offset_y(), -1, "one iteration lasts 2 frames");
1326        shake.tick();
1327        assert_eq!(shake.offset_y(), 1);
1328        shake.tick();
1329        assert_eq!(shake.offset_y(), 1);
1330        shake.tick();
1331        assert_eq!(shake.offset_y(), -1);
1332    }
1333
1334    #[test]
1335    fn elevator_shake_sfx_and_duration() {
1336        let mut shake = ElevatorShakeState::new(SHAKE);
1337        let mut rattles = 0;
1338        let mut dings = 0;
1339        let mut frames = 0;
1340        while !shake.is_done() {
1341            match shake.tick() {
1342                Some(ElevatorShakeSfx::Rattle) => rattles += 1,
1343                Some(ElevatorShakeSfx::Arrive) => dings += 1,
1344                None => {}
1345            }
1346            frames += 1;
1347        }
1348        assert_eq!(frames, SHAKE.total_frames());
1349        assert_eq!(rattles, 100, "rattle once per iteration");
1350        assert_eq!(dings, 1, "arrival ding at the end");
1351        assert_eq!(shake.offset_y(), 0, "scroll restored after the shake");
1352    }
1353
1354    #[test]
1355    fn elevator_shake_honors_custom_params() {
1356        let params = ElevatorShakeParams {
1357            iterations: 4,
1358            pixel_offset: 2,
1359        };
1360        let mut shake = ElevatorShakeState::new(params);
1361        assert_eq!(shake.total_frames(), 8);
1362        assert_eq!(shake.offset_y(), -2);
1363        shake.tick();
1364        shake.tick();
1365        assert_eq!(shake.offset_y(), 2);
1366        let mut frames = 2;
1367        while !shake.is_done() {
1368            shake.tick();
1369            frames += 1;
1370        }
1371        assert_eq!(frames, 8);
1372    }
1373
1374    // ── TileAnimState ─────────────────────────────────────────────
1375
1376    #[test]
1377    fn tile_anim_disabled_for_none_tilesets() {
1378        let mut anim = TileAnimState::new();
1379        anim.set_tileset(TileAnimKind::None);
1380        for _ in 0..100 {
1381            anim.tick();
1382        }
1383        assert_eq!(anim.water_shift(), 0);
1384        assert_eq!(anim.flower_frame(), None);
1385    }
1386
1387    #[test]
1388    fn tile_anim_water_rotates_every_20_frames() {
1389        let mut anim = TileAnimState::new();
1390        anim.set_tileset(TileAnimKind::Water);
1391        for _ in 0..19 {
1392            anim.tick();
1393        }
1394        assert_eq!(anim.water_shift(), 0, "no update before counter1 hits 20");
1395        anim.tick();
1396        assert_eq!(anim.water_shift(), 1, "first update shifts right one pixel");
1397        for _ in 0..20 {
1398            anim.tick();
1399        }
1400        assert_eq!(anim.water_shift(), 2);
1401    }
1402
1403    #[test]
1404    fn tile_anim_water_direction_follows_counter2_bit2() {
1405        // counter2 increments per water update; direction is right while bit 2
1406        // is clear (counter2 = 1,2,3,0), left while set (4,5,6,7). Net shift
1407        // sequence over the first 8 updates: 1,2,3,2,1,0,-1,0.
1408        let mut anim = TileAnimState::new();
1409        anim.set_tileset(TileAnimKind::Water);
1410        let expected = [1, 2, 3, 2, 1, 0, -1, 0];
1411        for want in expected {
1412            for _ in 0..20 {
1413                anim.tick();
1414            }
1415            assert_eq!(anim.water_shift(), want);
1416        }
1417    }
1418
1419    #[test]
1420    fn tile_anim_flower_frames_cycle_1_2_3_1() {
1421        // Flower update one frame after each water update; frame from
1422        // counter2&3: 0/1 → flower1, 2 → flower2, 3 → flower3.
1423        let mut anim = TileAnimState::new();
1424        anim.set_tileset(TileAnimKind::WaterFlower);
1425        assert_eq!(anim.flower_frame(), None, "base tile until the first update");
1426        let expected = [1, 2, 3, 1, 1, 2];
1427        for want in expected {
1428            for _ in 0..21 {
1429                anim.tick();
1430            }
1431            assert_eq!(anim.flower_frame(), Some(want));
1432        }
1433    }
1434
1435    #[test]
1436    fn tile_anim_map_load_resets_counter_but_keeps_water_phase() {
1437        // A tileset swap resets counter1 only.
1438        let mut anim = TileAnimState::new();
1439        anim.set_tileset(TileAnimKind::WaterFlower);
1440        for _ in 0..10 {
1441            anim.tick();
1442        }
1443        anim.set_tileset(TileAnimKind::Water);
1444        for _ in 0..19 {
1445            anim.tick();
1446        }
1447        assert_eq!(anim.water_shift(), 0);
1448        anim.tick();
1449        assert_eq!(anim.water_shift(), 1);
1450        assert_eq!(anim.flower_frame(), None, "water-only tilesets never flower");
1451    }
1452
1453    // ── FishingAnimState ──────────────────────────────────────────
1454
1455    /// Tick `anim` `n` times in place.
1456    fn tick_n(anim: &mut FishingAnimState, n: u16) {
1457        for _ in 0..n {
1458            anim.tick();
1459        }
1460    }
1461
1462    #[test]
1463    fn fishing_anim_no_bite_finishes_after_rod_out() {
1464        // No bite → straight to the result text: no shake, no bubble.
1465        for facing in [Direction::Down, Direction::Up, Direction::Left, Direction::Right] {
1466            let mut anim = FishingAnimState::new(facing, false);
1467            assert_eq!(anim.phase(), FishingAnimPhase::CastDelay);
1468            // CastDelay covers the first 10 frames: ticks 1..9 are still
1469            // casting, the 10th tick shows the rod.
1470            for _ in 1..FISHING_CAST_DELAY_FRAMES {
1471                anim.tick();
1472                assert_eq!(anim.phase(), FishingAnimPhase::CastDelay);
1473            }
1474            anim.tick();
1475            assert_eq!(anim.phase(), FishingAnimPhase::RodOut, "rod appears at frame 10");
1476            for _ in 0..FISHING_ROD_OUT_FRAMES - 1 {
1477                anim.tick();
1478                assert_eq!(anim.phase(), FishingAnimPhase::RodOut);
1479            }
1480            anim.tick();
1481            assert_eq!(anim.phase(), FishingAnimPhase::Done, "no bite → straight to text");
1482            assert!(anim.is_done());
1483        }
1484    }
1485
1486    #[test]
1487    fn fishing_anim_bite_plays_shake_then_bubble_then_done() {
1488        let mut anim = FishingAnimState::new(Direction::Down, true);
1489        tick_n(&mut anim, FISHING_CAST_DELAY_FRAMES + FISHING_ROD_OUT_FRAMES);
1490        assert_eq!(anim.phase(), FishingAnimPhase::Shake, "bite starts the shake");
1491        assert!(!anim.bubble_active());
1492
1493        // 10 iterations × 3 frames.
1494        for i in 0..FISHING_SHAKE_ITERATIONS {
1495            assert_eq!(
1496                anim.phase(),
1497                FishingAnimPhase::Shake,
1498                "shake iteration {i} still active"
1499            );
1500            // Offset toggles between +1 (even iteration) and 0 (odd).
1501            assert_eq!(anim.player_shake_offset(), if i % 2 == 0 { 1 } else { 0 });
1502            tick_n(&mut anim, FISHING_SHAKE_STEP_FRAMES);
1503        }
1504        assert_eq!(anim.phase(), FishingAnimPhase::Bubble, "shake ends → bubble");
1505        assert!(anim.bubble_active());
1506        assert_eq!(anim.player_shake_offset(), 0, "no shake during the bubble");
1507
1508        for f in 1..FISHING_BUBBLE_FRAMES {
1509            anim.tick();
1510            assert_eq!(anim.phase(), FishingAnimPhase::Bubble, "bubble frame {f}");
1511            assert!(anim.bubble_active());
1512        }
1513        anim.tick();
1514        assert_eq!(anim.phase(), FishingAnimPhase::Done, "bubble ends → result text");
1515        assert!(anim.is_done());
1516        assert!(!anim.bubble_active());
1517    }
1518
1519    #[test]
1520    fn fishing_anim_total_duration() {
1521        let mut no_bite = FishingAnimState::new(Direction::Left, false);
1522        let mut bite = FishingAnimState::new(Direction::Left, true);
1523        for _ in 0..FISHING_ANIM_FRAMES {
1524            no_bite.tick();
1525            bite.tick();
1526        }
1527        // 10 + 100 (+ 30 + 60 on a bite) — no-bite is done at 110.
1528        assert!(no_bite.is_done(), "no-bite finishes at 10 + 100 = 110 frames");
1529        assert!(bite.is_done(), "bite finishes at 10 + 100 + 30 + 60 = 200 frames");
1530        let mut late = FishingAnimState::new(Direction::Down, true);
1531        for _ in 0..FISHING_ANIM_FRAMES - 1 {
1532            late.tick();
1533        }
1534        assert_eq!(late.phase(), FishingAnimPhase::Bubble, "frame 199 is still the bubble");
1535    }
1536
1537    #[test]
1538    fn fishing_anim_pose_and_rod_visibility_follow_phase_and_facing() {
1539        // CastDelay: nothing drawn. Done: pose gone.
1540        let mut anim = FishingAnimState::new(Direction::Up, true);
1541        assert!(!anim.pose_active());
1542        assert!(!anim.rod_visible());
1543        tick_n(&mut anim, FISHING_CAST_DELAY_FRAMES);
1544        assert!(anim.pose_active(), "fishing pose while the rod is out");
1545        assert!(anim.rod_visible());
1546        assert_eq!(anim.facing(), Direction::Up);
1547
1548        // Facing up: the rod is hidden during the bubble so it does not
1549        // overlap the "!", then the pose is restored.
1550        let shake_bubble = FISHING_CAST_DELAY_FRAMES
1551            + FISHING_ROD_OUT_FRAMES
1552            + FISHING_SHAKE_ITERATIONS * FISHING_SHAKE_STEP_FRAMES;
1553        tick_n(&mut anim, shake_bubble);
1554        assert_eq!(anim.phase(), FishingAnimPhase::Bubble);
1555        assert!(!anim.rod_visible(), "up-facing rod hidden under the bubble");
1556        assert!(anim.pose_active());
1557        tick_n(&mut anim, FISHING_BUBBLE_FRAMES);
1558        assert!(anim.is_done());
1559        assert!(!anim.pose_active(), "pose restored after the anim");
1560
1561        // Other facings keep the rod visible during the bubble.
1562        let mut left = FishingAnimState::new(Direction::Left, true);
1563        tick_n(&mut left, shake_bubble);
1564        assert_eq!(left.phase(), FishingAnimPhase::Bubble);
1565        assert!(left.rod_visible());
1566    }
1567
1568    #[test]
1569    fn fishing_anim_rod_piece_offsets() {
1570        assert_eq!(FishingAnimState::rod_piece(Direction::Down), (20, 35, 0, false));
1571        assert_eq!(FishingAnimState::rod_piece(Direction::Up), (20, -12, 0, false));
1572        assert_eq!(FishingAnimState::rod_piece(Direction::Left), (0, 16, 1, false));
1573        assert_eq!(FishingAnimState::rod_piece(Direction::Right), (48, 16, 1, true));
1574    }
1575
1576    // ── BoulderDustState ──────────────────────────────────────────
1577
1578    #[test]
1579    fn boulder_dust_inactive_constant_is_inert() {
1580        let mut dust = BoulderDustState::inactive();
1581        assert!(!dust.is_active());
1582        dust.tick();
1583        assert!(!dust.is_active());
1584    }
1585
1586    #[test]
1587    fn boulder_dust_base_offsets() {
1588        for (facing, expected) in [
1589            (Direction::Down, (8, 52)),
1590            (Direction::Up, (8, -12)),
1591            (Direction::Left, (-24, 20)),
1592            (Direction::Right, (40, 20)),
1593        ] {
1594            let dust = BoulderDustState::new(facing, 5, 5);
1595            assert_eq!(dust.base_offset(), expected, "facing {:?}", facing);
1596        }
1597    }
1598
1599    #[test]
1600    fn boulder_dust_drifts_against_the_push_direction() {
1601        for (facing, expected) in [
1602            (Direction::Down, (0, -1)),
1603            (Direction::Up, (0, 1)),
1604            (Direction::Left, (1, 0)),
1605            (Direction::Right, (-1, 0)),
1606        ] {
1607            let dust = BoulderDustState::new(facing, 5, 5);
1608            assert_eq!(dust.drift_px(), expected, "facing {:?}", facing);
1609        }
1610    }
1611
1612    #[test]
1613    fn boulder_dust_horizontal_push_moves_three_of_four_tiles() {
1614        // Horizontal pushes leave the upper-left tile in place.
1615        let dust = BoulderDustState::new(Direction::Left, 5, 5);
1616        assert_eq!(dust.tile_drifts(), [(0, 0), (1, 0), (1, 0), (1, 0)]);
1617        let dust = BoulderDustState::new(Direction::Down, 5, 5);
1618        assert_eq!(dust.tile_drifts(), [(0, -1); 4], "vertical pushes move all tiles");
1619    }
1620
1621    #[test]
1622    fn boulder_dust_runs_8_steps_of_3_frames_then_ends() {
1623        let mut dust = BoulderDustState::new(Direction::Down, 5, 5);
1624        assert!(dust.is_active());
1625        assert_eq!(dust.step(), 0);
1626        for tick in 1..=24 {
1627            dust.tick();
1628            if tick == 24 {
1629                assert!(!dust.is_active(), "tick 24 completes the animation");
1630                continue;
1631            }
1632            assert!(dust.is_active(), "tick {}", tick);
1633            if tick % 3 == 0 {
1634                assert_eq!(dust.step(), tick / 3, "step advances every 3rd tick");
1635            } else {
1636                assert_eq!(dust.step(), (tick - 1) / 3, "tick {}", tick);
1637            }
1638        }
1639        assert!(!dust.is_active(), "8 steps × 3 frames = 24 frames");
1640        // Ticking a finished state is a no-op.
1641        dust.tick();
1642        assert!(!dust.is_active());
1643    }
1644
1645    #[test]
1646    fn boulder_dust_palette_flashes_every_step() {
1647        let mut dust = BoulderDustState::new(Direction::Right, 5, 5);
1648        let mut seen = Vec::new();
1649        for _ in 0..24 {
1650            seen.push(dust.palette_flipped());
1651            dust.tick();
1652        }
1653        let expected: Vec<bool> = (0..8).map(|s| s % 2 == 1).flat_map(|f| [f, f, f]).collect();
1654        assert_eq!(seen, expected, "palette toggles on each odd step");
1655    }
1656
1657    #[test]
1658    fn boulder_dust_keeps_its_world_anchor() {
1659        let mut dust = BoulderDustState::new(Direction::Up, 12, 7);
1660        assert_eq!(dust.anchor(), (12, 7));
1661        for _ in 0..24 {
1662            dust.tick();
1663        }
1664        assert_eq!(dust.anchor(), (12, 7), "anchor survives the animation");
1665    }
1666
1667    // ── ShipDepartureState ────────────────────────────────────────
1668
1669    #[test]
1670    fn ship_departure_phase_boundaries() {
1671        let mut dep = ShipDepartureState::new();
1672        assert_eq!(dep.phase(), ShipDeparturePhase::InitialPause);
1673        for _ in 0..SHIP_DEPARTURE_INITIAL_PAUSE_FRAMES {
1674            dep.tick();
1675        }
1676        assert_eq!(dep.phase(), ShipDeparturePhase::WaterFill);
1677        for _ in 0..SHIP_DEPARTURE_WATER_FILL_FRAMES {
1678            dep.tick();
1679        }
1680        assert_eq!(dep.phase(), ShipDeparturePhase::Scroll);
1681        for _ in 0..SHIP_DEPARTURE_SCROLL_ITERATIONS * SHIP_DEPARTURE_ITERATION_FRAMES {
1682            dep.tick();
1683        }
1684        assert_eq!(dep.phase(), ShipDeparturePhase::Erase);
1685        for _ in 0..SHIP_DEPARTURE_ERASE_FRAMES {
1686            dep.tick();
1687        }
1688        assert_eq!(dep.phase(), ShipDeparturePhase::Done);
1689        assert!(dep.is_done());
1690        assert_eq!(dep.frame(), SHIP_DEPARTURE_TOTAL_FRAMES);
1691        // Ticking a finished state is a no-op (returns no cue).
1692        assert_eq!(dep.tick(), None);
1693        assert_eq!(dep.frame(), SHIP_DEPARTURE_TOTAL_FRAMES);
1694    }
1695
1696    #[test]
1697    fn ship_departure_horn_timing() {
1698        // The horn plays twice: when the scroll begins and when the erase
1699        // phase begins.
1700        let mut dep = ShipDepartureState::new();
1701        let mut horns = Vec::new();
1702        for _ in 0..SHIP_DEPARTURE_TOTAL_FRAMES {
1703            if let Some(sfx) = dep.tick() {
1704                horns.push((dep.frame(), sfx));
1705            }
1706        }
1707        assert_eq!(
1708            horns,
1709            vec![
1710                (
1711                    SHIP_DEPARTURE_INITIAL_PAUSE_FRAMES + SHIP_DEPARTURE_WATER_FILL_FRAMES,
1712                    ShipDepartureSfx::Horn
1713                ),
1714                (
1715                    SHIP_DEPARTURE_INITIAL_PAUSE_FRAMES
1716                        + SHIP_DEPARTURE_WATER_FILL_FRAMES
1717                        + SHIP_DEPARTURE_SCROLL_ITERATIONS * SHIP_DEPARTURE_ITERATION_FRAMES,
1718                    ShipDepartureSfx::Horn
1719                ),
1720            ]
1721        );
1722    }
1723
1724    #[test]
1725    fn ship_departure_scroll_px_ramps() {
1726        // The view advances 16px per iteration and adds one more px per
1727        // 8-frame substep: iteration 0 sweeps 1..=16, the total reachable
1728        // offset is 128px = 16 tiles.
1729        let mut dep = ShipDepartureState::new();
1730        for _ in 0..SHIP_DEPARTURE_INITIAL_PAUSE_FRAMES + SHIP_DEPARTURE_WATER_FILL_FRAMES {
1731            dep.tick();
1732        }
1733        let mut seen = Vec::new();
1734        for _ in 0..SHIP_DEPARTURE_ITERATION_FRAMES {
1735            seen.push(dep.scroll_px());
1736            dep.tick();
1737        }
1738        let expected: Vec<i32> = (0..16).flat_map(|d| core::iter::repeat(d + 1).take(8)).collect();
1739        assert_eq!(seen, expected, "scroll ramps 1..=16 across iteration 0");
1740
1741        // Mid-animation: frame 123 + 40 substeps → iteration 2, substep 40 →
1742        // 2*16 + 40%16 + 1 = 41.
1743        for _ in 0..(40 * 8 - SHIP_DEPARTURE_ITERATION_FRAMES) {
1744            dep.tick();
1745        }
1746        assert_eq!(dep.scroll_iteration(), 2);
1747        assert_eq!(dep.scroll_substep(), 40);
1748        assert_eq!(dep.scroll_px(), 41);
1749
1750        // The erase phase keeps the fully scrolled position (16 tiles).
1751        // (Tick one frame short of the end — the final frame is Done.)
1752        for _ in 0..(SHIP_DEPARTURE_TOTAL_FRAMES - 1 - (123 + 40 * 8)) {
1753            dep.tick();
1754        }
1755        assert_eq!(dep.phase(), ShipDeparturePhase::Erase);
1756        assert_eq!(dep.scroll_px(), 128);
1757        assert_eq!(dep.scroll_iteration(), SHIP_DEPARTURE_SCROLL_ITERATIONS - 1);
1758        assert_eq!(dep.scroll_substep(), 127);
1759        assert!(dep.ship_erased());
1760    }
1761
1762    #[test]
1763    fn ship_departure_puff_positions() {
1764        // Puffs spawn 16px apart and drift +2px per substep.
1765        let mut dep = ShipDepartureState::new();
1766        for _ in 0..SHIP_DEPARTURE_INITIAL_PAUSE_FRAMES + SHIP_DEPARTURE_WATER_FILL_FRAMES {
1767            dep.tick();
1768        }
1769        // 128 scroll frames = one full iteration; at substep 16 (iteration 1)
1770        // puff 1 has just been emitted, and puff 0 has drifted 2px × (16+1) —
1771        // its spawn iteration's drift loop moved it immediately after emission.
1772        for _ in 0..16 * 8 {
1773            dep.tick();
1774        }
1775        assert_eq!(dep.puff_count(), 2);
1776        assert_eq!(dep.puff_x_offset(0), 88 + 2 * (16 + 1));
1777        assert_eq!(dep.puff_x_offset(1), 72 + 2 * (16 - 16 + 1));
1778
1779        // After one more iteration (substep 32, iteration 2): puffs 0..2 live.
1780        for _ in 0..SHIP_DEPARTURE_ITERATION_FRAMES {
1781            dep.tick();
1782        }
1783        assert_eq!(dep.puff_count(), 3);
1784        assert_eq!(dep.puff_x_offset(0), 88 + 2 * (32 + 1));
1785        assert_eq!(dep.puff_x_offset(1), 72 + 2 * (32 - 16 + 1));
1786        assert_eq!(dep.puff_x_offset(2), 56 + 2 * (32 - 32 + 1));
1787
1788        // At the very end (substep 127) all 8 puffs are live.
1789        for _ in 0..6 * SHIP_DEPARTURE_ITERATION_FRAMES {
1790            dep.tick();
1791        }
1792        assert_eq!(dep.phase(), ShipDeparturePhase::Erase);
1793        assert_eq!(dep.puff_count(), 8);
1794        assert_eq!(dep.puff_x_offset(0), 88 + 2 * 128);
1795        assert_eq!(dep.puff_x_offset(7), 88 - 16 * 7 + 2 * (127 - 16 * 7 + 1));
1796        // Puff Y is the smokestack row in screen px (10.5 tiles × 8).
1797        assert_eq!(dep.puff_screen_y(), 84);
1798    }
1799
1800    #[test]
1801    fn ship_departure_erase_flag_only_in_erase_phase() {
1802        let mut dep = ShipDepartureState::new();
1803        assert!(!dep.ship_erased());
1804        for _ in 0..SHIP_DEPARTURE_INITIAL_PAUSE_FRAMES + SHIP_DEPARTURE_WATER_FILL_FRAMES {
1805            dep.tick();
1806        }
1807        assert!(!dep.ship_erased(), "hull still visible during the scroll");
1808        for _ in 0..SHIP_DEPARTURE_SCROLL_ITERATIONS * SHIP_DEPARTURE_ITERATION_FRAMES {
1809            dep.tick();
1810        }
1811        assert!(dep.ship_erased(), "erase phase shows the ship as water");
1812    }
1813}