Skip to main content

dotzuki_renderer/battle_anim/
player.rs

1use crate::sprite::SpriteOamEntry;
2
3use super::*;
4
5// ─── Animation player ────────────────────────────────────────────────
6
7/// State of the subanimation playback within a single AnimCommand::SubAnim.
8#[derive(Debug, Clone)]
9struct SubAnimState {
10    /// The SubAnimation being played.
11    subanim_id: u8,
12    /// Resolved transform type (after Enemy resolution).
13    transform: SubAnimTransform,
14    /// Current frame index within the subanimation.
15    frame_index: usize,
16    /// Total number of frames.
17    num_frames: usize,
18    /// Per-frame delay in ticks (from wSubAnimFrameDelay).
19    delay: u8,
20    /// Whether we're waiting for the caller to apply the delay.
21    waiting_for_delay: bool,
22    /// Tileset index (0 or 1) for this subanimation.
23    tileset: u8,
24    /// Move id whose sound this subanimation plays (0 = NO_MOVE).
25    sound_id: u8,
26    /// Whether the sound still needs to be reported (first frame only —
27    /// PlaySubanimation plays the sound once when the subanimation starts).
28    sound_pending: bool,
29}
30
31/// Plays a move animation, stepping through its command sequence.
32///
33/// The player is a state machine:
34///   1. `start(move_id)` loads the move's command list.
35///   2. `tick()` advances one step and returns what happened.
36///   3. After each tick, `oam_entries()` has the current sprite data.
37///
38/// The caller is responsible for:
39///   - Rendering OAM entries to the screen each frame.
40///   - Applying `AnimEffect` actions (palette changes, screen shake, etc.).
41///   - Counting down `WaitDelay` frames before calling tick again.
42#[derive(Debug, Clone)]
43pub struct AnimationPlayer {
44    /// The move animation ID (1-based, matching wAnimationID).
45    move_id: usize,
46    /// Whether the player's mon is the attacker (affects Enemy transform).
47    player_is_attacker: bool,
48    /// Index into the current move animation's command list.
49    command_index: usize,
50    /// Total number of commands in this move animation.
51    num_commands: usize,
52    /// Current subanimation playback state (Some while playing a SubAnim command).
53    subanim_state: Option<SubAnimState>,
54    /// Accumulated OAM entries for the current frame.
55    oam_buffer: Vec<SpriteOamEntry>,
56    /// Whether the animation is finished.
57    finished: bool,
58}
59
60impl AnimationPlayer {
61    /// Create a new idle animation player.
62    pub fn new() -> Self {
63        Self {
64            move_id: 0,
65            player_is_attacker: true,
66            command_index: 0,
67            num_commands: 0,
68            subanim_state: None,
69            oam_buffer: Vec::with_capacity(40),
70            finished: true,
71        }
72    }
73
74    /// Start playing the animation for the given move.
75    /// `move_id` is 0-based index into MOVE_ANIM_DATA.
76    /// `player_is_attacker` determines Enemy transform resolution.
77    pub fn start(&mut self, move_id: usize, player_is_attacker: bool) {
78        // On the enemy's turn, AMNESIA plays CONF_ANIM and REST plays
79        // SLP_ANIM instead.
80        // Animation ids are 1-based (MOVE_ANIM_DATA index + 1).
81        let move_id = if !player_is_attacker {
82            match move_id + 1 {
83                id if id == AMNESIA as usize => CONF_ANIM as usize - 1,
84                id if id == REST as usize => SLP_ANIM as usize - 1,
85                _ => move_id,
86            }
87        } else {
88            move_id
89        };
90
91        self.move_id = move_id;
92        self.player_is_attacker = player_is_attacker;
93        self.command_index = 0;
94        self.subanim_state = None;
95        self.oam_buffer.clear();
96        self.finished = false;
97
98        if move_id < NUM_MOVE_ANIMS {
99            self.num_commands = MOVE_ANIM_DATA[move_id].len();
100        } else {
101            self.num_commands = 0;
102            self.finished = true;
103        }
104    }
105
106    /// Whether the animation has finished playing.
107    pub fn is_finished(&self) -> bool {
108        self.finished
109    }
110
111    /// Get the current OAM buffer for rendering.
112    pub fn oam_entries(&self) -> &[SpriteOamEntry] {
113        &self.oam_buffer
114    }
115
116    /// Current move-animation tileset index (0/1/2) while a subanimation is active.
117    pub fn current_tileset(&self) -> Option<u8> {
118        self.subanim_state.as_ref().map(|s| s.tileset)
119    }
120
121    /// Decode a raw command tuple from MOVE_ANIM_DATA into an AnimCommand.
122    pub fn decode_command(raw: &(u8, u8, u8, u8)) -> AnimCommand {
123        let (kind, sound_val, id_val, packed) = *raw;
124        if kind == 0 {
125            // SubAnim command
126            AnimCommand::SubAnim {
127                sound_id: sound_val,
128                subanim_id: id_val,
129                tileset: packed >> 6,
130                delay: packed & 0x3F,
131            }
132        } else {
133            // Effect command
134            AnimCommand::Effect {
135                sound_id: sound_val,
136                effect: SpecialEffect::from_u8(id_val).unwrap_or(SpecialEffect::WavyScreen),
137            }
138        }
139    }
140
141    /// Resolve the effective transform for a subanimation, accounting for
142    /// Enemy type and whose turn it is.
143    pub fn resolve_transform(&self, raw_transform: SubAnimTransform) -> SubAnimTransform {
144        match raw_transform {
145            SubAnimTransform::Enemy => {
146                if self.player_is_attacker {
147                    // Player's turn + Enemy type → HFlip
148                    SubAnimTransform::HFlip
149                } else {
150                    // Enemy's turn + Enemy type → Normal
151                    SubAnimTransform::Normal
152                }
153            }
154            other => {
155                // For non-Enemy types:
156                // If it's the player's turn, use Normal (override).
157                // If it's the enemy's turn, use the specified type.
158                // (from GetSubanimationTransform1)
159                if self.player_is_attacker {
160                    SubAnimTransform::Normal
161                } else {
162                    other
163                }
164            }
165        }
166    }
167}
168
169// ─── Frame block rendering ──────────────────────────────────────────
170
171impl AnimationPlayer {
172    /// Render a frame block into OAM entries, applying the given transform.
173    ///
174    /// `frame_block_id`: index into FRAME_BLOCK_DATA.
175    /// `base_coord_id`: index into BASE_COORDS.
176    /// `transform`: the resolved SubAnimTransform to apply.
177    /// `dest`: output buffer to append OAM entries to.
178    pub fn render_frame_block(
179        frame_block_id: usize,
180        base_coord_id: usize,
181        transform: SubAnimTransform,
182        dest: &mut Vec<SpriteOamEntry>,
183    ) {
184        if frame_block_id >= NUM_FRAMEBLOCKS || base_coord_id >= NUM_BASECOORDS {
185            return;
186        }
187
188        let fb_data = FRAME_BLOCK_DATA[frame_block_id];
189        let (base_y, base_x) = BASE_COORDS[base_coord_id];
190
191        // Frame block offsets are pixel bytes (Y = y_tile*8 + y_px,
192        // X = x_tile*8 + x_px), added to the base coordinate.
193        for &(x_off, y_off, raw_tile, flags) in fb_data {
194            let (screen_y, screen_x, tile_id, oam_flags) = match transform {
195                SubAnimTransform::Normal | SubAnimTransform::Reverse => {
196                    // No transformation — direct mapping.
197                    // y = base_y + y_offset
198                    // x = base_x + x_offset
199                    let y = base_y as i32 + y_off as i32;
200                    let x = base_x as i32 + x_off as i32;
201                    let tile = raw_tile.wrapping_add(ANIM_BASE_TILE_ID);
202                    (y, x, tile, flags)
203                }
204
205                SubAnimTransform::HvFlip => {
206                    // Flip both H and V: mirror around (136, 168).
207                    // y = 136 - (base_y + y_offset)
208                    // x = 168 - (base_x + x_offset)
209                    let y = 136i32 - (base_y as i32 + y_off as i32);
210                    let x = 168i32 - (base_x as i32 + x_off as i32);
211                    let tile = raw_tile.wrapping_add(ANIM_BASE_TILE_ID);
212                    // Toggle flip flags: 0x00→0x60, 0x20→0x40, 0x40→0x20, 0x60→0x00
213                    let new_flags = match flags & 0x60 {
214                        0x00 => (flags & !0x60) | 0x60,
215                        0x20 => (flags & !0x60) | 0x40,
216                        0x40 => (flags & !0x60) | 0x20,
217                        0x60 => flags & !0x60,
218                        _ => flags, // unreachable, 0x60 mask covers all
219                    };
220                    (y, x, tile, new_flags)
221                }
222
223                SubAnimTransform::HFlip => {
224                    // Flip horizontally + translate 40px down.
225                    // y = base_y + y_offset + 40
226                    // x = 168 - (base_x + x_offset)
227                    let y = base_y as i32 + y_off as i32 + 40;
228                    let x = 168i32 - (base_x as i32 + x_off as i32);
229                    let tile = raw_tile.wrapping_add(ANIM_BASE_TILE_ID);
230                    // Toggle X flip bit only
231                    let new_flags = flags ^ OAM_XFLIP;
232                    (y, x, tile, new_flags)
233                }
234
235                SubAnimTransform::CoordFlip => {
236                    // Flip base coordinates, keep offsets normal.
237                    // y = (136 - base_y) + y_offset
238                    // x = (168 - base_x) + x_offset
239                    let y = (136i32 - base_y as i32) + y_off as i32;
240                    let x = (168i32 - base_x as i32) + x_off as i32;
241                    let tile = raw_tile.wrapping_add(ANIM_BASE_TILE_ID);
242                    (y, x, tile, flags)
243                }
244
245                SubAnimTransform::Enemy => {
246                    // Should have been resolved before calling. Fall back to Normal.
247                    let y = base_y as i32 + y_off as i32;
248                    let x = base_x as i32 + x_off as i32;
249                    let tile = raw_tile.wrapping_add(ANIM_BASE_TILE_ID);
250                    (y, x, tile, flags)
251                }
252            };
253
254            dest.push(SpriteOamEntry::new(screen_y, screen_x, tile_id, oam_flags));
255        }
256    }
257}
258
259// ─── Animation tick (state machine) ─────────────────────────────────
260
261impl AnimationPlayer {
262    /// Advance the animation by one step.
263    ///
264    /// Returns what happened this tick. The caller should:
265    ///   - `Playing` → render `oam_entries()` to screen.
266    ///   - `WaitDelay` → wait `frames` frames, then call tick again.
267    ///   - `Effect` → apply the special effect, then call tick again.
268    ///   - `Done` → animation is finished.
269    /// `Playing`/`WaitDelay` also carry a `hook` when the current animation
270    /// id has an entry in ANIMATION_ID_HOOKS (the original's
271    /// DoSpecialEffectByAnimationId, run after every DrawFrameBlock).
272    pub fn tick(&mut self) -> AnimTickResult {
273        if self.finished {
274            return AnimTickResult::Done;
275        }
276
277        // If we're in the middle of playing a subanimation, advance it.
278        if let Some(ref mut state) = self.subanim_state {
279            // Check if we're waiting for a delay from the previous frame.
280            if state.waiting_for_delay {
281                state.waiting_for_delay = false;
282                // Proceed to render next frame (delay already waited by caller).
283            }
284
285            // Render next frame of the subanimation.
286            let subanim = get_subanimation(state.subanim_id as usize);
287
288            if state.frame_index < state.num_frames {
289                let frame_idx = if state.transform == SubAnimTransform::Reverse {
290                    // Reverse: play frames from last to first.
291                    state.num_frames - 1 - state.frame_index
292                } else {
293                    state.frame_index
294                };
295
296                let frame = &subanim.frames[frame_idx];
297                let mode = frame.mode;
298
299                // Determine whether to clear OAM before drawing.
300                if mode.cleans_oam() {
301                    self.oam_buffer.clear();
302                }
303
304                // Render the frame block tiles.
305                Self::render_frame_block(
306                    frame.frame_block_id as usize,
307                    frame.base_coord_id as usize,
308                    state.transform,
309                    &mut self.oam_buffer,
310                );
311
312                state.frame_index += 1;
313
314                // DoSpecialEffectByAnimationId: after every drawn frame block,
315                // run the hook for this animation id (if any). wSubAnimCounter
316                // counts down from num_frames to 1 as frame blocks are drawn.
317                let frame_hook = get_frame_hook(self.move_id as u8 + 1);
318                let counter = (state.num_frames - state.frame_index) as u8 + 1;
319                let hook = frame_hook.and_then(|h| h.effect_for_counter(counter));
320
321                // DoGrowlSpecialEffects: copy OAM entries 0-3 to slots 4-7
322                // (a second music note flying towards the defending mon).
323                if frame_hook == Some(FrameHook::Growl) {
324                    let copies: Vec<SpriteOamEntry> =
325                        self.oam_buffer.iter().take(4).cloned().collect();
326                    for (i, entry) in copies.into_iter().enumerate() {
327                        if self.oam_buffer.len() > 4 + i {
328                            self.oam_buffer[4 + i] = entry;
329                        } else {
330                            self.oam_buffer.push(entry);
331                        }
332                    }
333                }
334
335                // The command's sound is reported once, with its first result
336                // (PlaySubanimation plays the sound before the frame loop).
337                let sound = if state.sound_pending {
338                    state.sound_pending = false;
339                    (state.sound_id != 0).then_some(state.sound_id)
340                } else {
341                    None
342                };
343
344                // Apply delay based on mode.
345                // In the original ASM, DelayFrames waits for wSubAnimFrameDelay VBlank frames.
346                // We return WaitDelay to tell the caller to wait, then proceed on next tick.
347                if mode.has_delay() && state.delay > 0 {
348                    state.waiting_for_delay = true;
349                    return AnimTickResult::WaitDelay {
350                        frames: state.delay,
351                        sound,
352                        hook,
353                    };
354                }
355
356                // Mode02: no delay, keep OAM, advance — immediately process next frame.
357                return AnimTickResult::Playing { sound, hook };
358            }
359
360            // Subanimation finished — clear state and advance to next command.
361            self.subanim_state = None;
362            self.oam_buffer.clear();
363        }
364
365        // Process next command in the move animation.
366        if self.command_index >= self.num_commands {
367            self.finished = true;
368            return AnimTickResult::Done;
369        }
370
371        let raw = &MOVE_ANIM_DATA[self.move_id][self.command_index];
372        self.command_index += 1;
373        let cmd = Self::decode_command(raw);
374
375        match cmd {
376            AnimCommand::SubAnim {
377                sound_id,
378                subanim_id,
379                tileset,
380                delay,
381            } => {
382                let subanim = get_subanimation(subanim_id as usize);
383                let raw_transform = subanim.transform;
384                let resolved = self.resolve_transform(raw_transform);
385                let num_frames = subanim.frames.len();
386
387                self.subanim_state = Some(SubAnimState {
388                    subanim_id,
389                    transform: resolved,
390                    frame_index: 0,
391                    num_frames,
392                    delay,
393                    waiting_for_delay: false,
394                    tileset,
395                    sound_id,
396                    sound_pending: true,
397                });
398
399                // Immediately start rendering the first frame.
400                self.tick()
401            }
402
403            AnimCommand::Effect { sound_id, effect } => AnimTickResult::Effect {
404                sound: (sound_id != 0).then_some(sound_id),
405                effect,
406            },
407        }
408    }
409}
410
411// ─── Per-frame hook evaluation ────────────────────────────────────────
412
413impl FrameHook {
414    /// Effect fired after the frame block drawn with the given
415    /// sub-animation frame counter value (counts down from the
416    /// subanimation's frame count to 1). Returns None for hooks the player
417    /// handles internally (Growl) or cannot reproduce (ball / trade flow —
418    /// see the FrameHook variant docs).
419    pub fn effect_for_counter(self, counter: u8) -> Option<AnimEffect> {
420        // Flash effect: inverted palette for 2 frames, then white for 2.
421        const FLASH: AnimEffect = AnimEffect::FlashScreen { frames: 4 };
422        match self {
423            FrameHook::FlashScreen => Some(FLASH),
424            FrameHook::FlashScreenEveryFour => (counter % 4 == 0).then_some(FLASH),
425            FrameHook::FlashScreenEveryEight => (counter % 8 == 0).then_some(FLASH),
426            FrameHook::BlizzardFlash => matches!(counter, 13 | 9 | 5 | 1).then_some(FLASH),
427            FrameHook::Explode => {
428                if counter == 1 {
429                    // At the end of the subanimation, hide the attacking
430                    // mon's pic.
431                    Some(AnimEffect::HidePlayerMon)
432                } else {
433                    (counter % 4 == 0).then_some(FLASH)
434                }
435            }
436            FrameHook::RockSlide => match counter {
437                // Shake the screen horizontally then vertically, both with
438                // intensity 1.
439                8..=11 => Some(AnimEffect::ShakeScreenHV {
440                    pixels: 1,
441                    frames: 9,
442                }),
443                1 => Some(FLASH),
444                _ => None,
445            },
446            FrameHook::Growl
447            | FrameHook::TailWhipUnused
448            | FrameHook::BallToss
449            | FrameHook::BallShake
450            | FrameHook::BallPoof
451            | FrameHook::TradeHideMonster
452            | FrameHook::TradeShakeBall
453            | FrameHook::TradeJumpBall => None,
454        }
455    }
456}
457
458// ─── Special effect mapping ─────────────────────────────────────────
459
460impl AnimationPlayer {
461    /// Map a SpecialEffect to a high-level AnimEffect the caller should apply.
462    ///
463    /// This translates the original ASM effect handlers into abstract operations.
464    /// The caller's rendering layer is responsible for executing these.
465    pub fn apply_effect(effect: SpecialEffect) -> AnimEffect {
466        match effect {
467            SpecialEffect::WavyScreen => AnimEffect::WavyScreen,
468            SpecialEffect::SubstituteMon => AnimEffect::SubstituteMon,
469            SpecialEffect::ShakeBackAndForth => AnimEffect::ShakeBackAndForth,
470            SpecialEffect::SlideEnemyMonOff => AnimEffect::SlideEnemyMonOff,
471            SpecialEffect::ShowEnemyMonPic => AnimEffect::ShowEnemyMon,
472            SpecialEffect::ShowMonPic => AnimEffect::ShowPlayerMon,
473            SpecialEffect::BlinkEnemyMon => AnimEffect::BlinkEnemyMon { times: 6 },
474            SpecialEffect::HideEnemyMonPic => AnimEffect::HideEnemyMon,
475            SpecialEffect::FlashEnemyMonPic => AnimEffect::FlashEnemyMonPic,
476            SpecialEffect::DelayAnimation10 => AnimEffect::Delay10,
477            SpecialEffect::SpiralBallsInward => AnimEffect::SpiralBallsInward,
478            SpecialEffect::ShakeEnemyHud2 => AnimEffect::ShakeEnemyHud { variant: 2 },
479            SpecialEffect::ShakeEnemyHud => AnimEffect::ShakeEnemyHud { variant: 1 },
480            SpecialEffect::SlideMonHalfOff => AnimEffect::SlidePlayerMonHalfOff,
481            SpecialEffect::PetalsFalling => AnimEffect::PetalsFalling,
482            SpecialEffect::LeavesFalling => AnimEffect::LeavesFalling,
483            SpecialEffect::TransformMon => AnimEffect::TransformMon,
484            SpecialEffect::SlideMonDownAndHide => AnimEffect::SlidePlayerMonDownAndHide,
485            SpecialEffect::MinimizeMon => AnimEffect::MinimizeMon,
486            SpecialEffect::BounceUpAndDown => AnimEffect::BounceUpAndDown,
487            SpecialEffect::ShootManyBallsUpward => AnimEffect::ShootBallsUpward { many: true },
488            SpecialEffect::ShootBallsUpward => AnimEffect::ShootBallsUpward { many: false },
489            SpecialEffect::SquishMonPic => AnimEffect::SquishMonPic,
490            SpecialEffect::HideMonPic => AnimEffect::HidePlayerMon,
491            SpecialEffect::LightScreenPalette => AnimEffect::LightScreenPalette,
492            SpecialEffect::ResetMonPosition => AnimEffect::ResetPlayerMonPosition,
493            SpecialEffect::MoveMonHorizontally => AnimEffect::MovePlayerMonH,
494            SpecialEffect::BlinkMon => AnimEffect::BlinkPlayerMon { times: 6 },
495            // AnimationSlideMonOff: e = 8 slides the whole 7-tile-wide mon
496            // pic off the screen (AnimationSlideMonHalfOff uses e = 4).
497            SpecialEffect::SlideMonOff => AnimEffect::SlidePlayerMonOff,
498            SpecialEffect::FlashMonPic => AnimEffect::FlashPlayerMonPic,
499            SpecialEffect::SlideMonDown => AnimEffect::SlidePlayerMonDown,
500            SpecialEffect::SlideMonUp => AnimEffect::SlidePlayerMonUp,
501            // AnimationFlashScreenLong: 3 cycles through a 12-palette table
502            // ("flashes the screen for an extended period (48 frames)").
503            SpecialEffect::FlashScreenLong => AnimEffect::FlashScreen { frames: 48 },
504            SpecialEffect::DarkenMonPalette => AnimEffect::DarkenMonPalette,
505            SpecialEffect::WaterDropletsEverywhere => AnimEffect::WaterDroplets,
506            // AnimationShakeScreen: PredefShakeScreenHorizontally with b = 8 —
507            // amplitude decays 8→1 px at ~9 frames per step (≈72 frames).
508            SpecialEffect::ShakeScreen => AnimEffect::ShakeScreenH {
509                pixels: 8,
510                frames: 72,
511            },
512            SpecialEffect::ResetScreenPalette => AnimEffect::ResetScreenPalette,
513            SpecialEffect::DarkScreenPalette => AnimEffect::DarkScreenPalette,
514            // AnimationFlashScreen: inverted palette 2 frames + white 2 frames.
515            SpecialEffect::DarkScreenFlash => AnimEffect::FlashScreen { frames: 4 },
516        }
517    }
518}
519
520impl Default for AnimationPlayer {
521    fn default() -> Self {
522        Self::new()
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    fn run_animation(move_id: usize) -> Vec<AnimTickResult> {
531        run_animation_as(move_id, true)
532    }
533
534    fn run_animation_as(move_id: usize, player_is_attacker: bool) -> Vec<AnimTickResult> {
535        let mut player = AnimationPlayer::new();
536        player.start(move_id, player_is_attacker);
537        let mut results = Vec::new();
538        let mut iterations = 0;
539        const MAX_ITERATIONS: usize = 10000;
540
541        loop {
542            if iterations >= MAX_ITERATIONS {
543                break;
544            }
545            iterations += 1;
546
547            let result = player.tick();
548            results.push(result.clone());
549
550            match result {
551                AnimTickResult::Done => break,
552                AnimTickResult::WaitDelay { frames: n, .. } => {
553                    for _ in 0..n {
554                        let delay_result = player.tick();
555                        results.push(delay_result.clone());
556                        iterations += 1;
557                    }
558                }
559                _ => {}
560            }
561        }
562        results
563    }
564
565    #[test]
566    fn pound_animation_has_frames() {
567        let results = run_animation(0x00);
568        let has_frames = results.iter().any(|r| matches!(r, AnimTickResult::Playing { .. } | AnimTickResult::WaitDelay { .. }));
569        assert!(has_frames, "Pound should have animation frames");
570    }
571
572    #[test]
573    fn earthquake_has_screen_shake() {
574        let results = run_animation(0x58);
575        let shake_count = results.iter().filter(|r| matches!(r, AnimTickResult::Effect { effect: SpecialEffect::ShakeScreen, .. })).count();
576        assert_eq!(shake_count, 2, "Earthquake should have 2 screen shakes");
577    }
578
579    #[test]
580    fn thunder_punch_has_palette_effects() {
581        let results = run_animation(0x08);
582        let dark_palette = results.iter().any(|r| matches!(r, AnimTickResult::Effect { effect: SpecialEffect::DarkScreenPalette, .. }));
583        let reset_palette = results.iter().any(|r| matches!(r, AnimTickResult::Effect { effect: SpecialEffect::ResetScreenPalette, .. }));
584        assert!(dark_palette, "ThunderPunch should have dark screen palette");
585        assert!(reset_palette, "ThunderPunch should have reset screen palette");
586    }
587
588    #[test]
589    fn selfdestruct_has_explosion() {
590        let results = run_animation(0x77);
591        let has_frames = results.iter().any(|r| matches!(r, AnimTickResult::Playing { .. } | AnimTickResult::WaitDelay { .. }));
592        assert!(has_frames, "Selfdestruct should have explosion frames");
593    }
594
595    #[test]
596    fn splash_has_bounce() {
597        let results = run_animation(0x95);
598        let bounce = results.iter().any(|r| matches!(r, AnimTickResult::Effect { effect: SpecialEffect::BounceUpAndDown, .. }));
599        assert!(bounce, "Splash should have bounce effect");
600    }
601
602    #[test]
603    fn teleport_has_squish_and_balls() {
604        let results = run_animation(0x63);
605        let squish = results.iter().any(|r| matches!(r, AnimTickResult::Effect { effect: SpecialEffect::SquishMonPic, .. }));
606        let balls = results.iter().any(|r| matches!(r, AnimTickResult::Effect { effect: SpecialEffect::ShootBallsUpward, .. }));
607        assert!(squish, "Teleport should have squish effect");
608        assert!(balls, "Teleport should have shoot balls upward");
609    }
610
611    #[test]
612    fn acid_armor_slides_down() {
613        let results = run_animation(0x96);
614        let slide = results.iter().any(|r| matches!(r, AnimTickResult::Effect { effect: SpecialEffect::SlideMonDownAndHide, .. }));
615        assert!(slide, "Acid Armor should have slide down and hide");
616    }
617
618    #[test]
619    fn minimize_has_minimize_effect() {
620        let results = run_animation(0x6A);
621        let minimize = results.iter().any(|r| matches!(r, AnimTickResult::Effect { effect: SpecialEffect::MinimizeMon, .. }));
622        assert!(minimize, "Minimize should have minimize effect");
623    }
624
625    #[test]
626    fn transform_has_transform_effect() {
627        let results = run_animation(0x8F);
628        let transform = results.iter().any(|r| matches!(r, AnimTickResult::Effect { effect: SpecialEffect::TransformMon, .. }));
629        assert!(transform, "Transform should have transform effect");
630    }
631
632    #[test]
633    fn substitute_has_substitute_effect() {
634        let results = run_animation(0xA3);
635        let substitute = results.iter().any(|r| matches!(r, AnimTickResult::Effect { effect: SpecialEffect::SubstituteMon, .. }));
636        assert!(substitute, "Substitute should have substitute effect");
637    }
638
639    #[test]
640    fn double_team_has_shake() {
641        let results = run_animation(0x67);
642        let shake = results.iter().any(|r| matches!(r, AnimTickResult::Effect { effect: SpecialEffect::ShakeBackAndForth, .. }));
643        assert!(shake, "Double Team should have shake back and forth");
644    }
645
646    #[test]
647    fn recover_has_blink() {
648        let results = run_animation(0x68);
649        let blink = results.iter().any(|r| matches!(r, AnimTickResult::Effect { effect: SpecialEffect::BlinkMon, .. }));
650        assert!(blink, "Recover should have blink effect");
651    }
652
653    #[test]
654    fn whirlwind_slides_enemy_off() {
655        let results = run_animation(0x11);
656        let slide = results.iter().any(|r| matches!(r, AnimTickResult::Effect { effect: SpecialEffect::SlideEnemyMonOff, .. }));
657        assert!(slide, "Whirlwind should slide enemy off");
658    }
659
660    #[test]
661    fn dig_slides_mon_up() {
662        let results = run_animation(0x5A);
663        let slide = results.iter().any(|r| matches!(r, AnimTickResult::Effect { effect: SpecialEffect::SlideMonUp, .. }));
664        assert!(slide, "Dig should slide mon up");
665    }
666
667    #[test]
668    fn confusion_has_flash() {
669        let results = run_animation(0x5C);
670        let flash = results.iter().any(|r| matches!(r, AnimTickResult::Effect { effect: SpecialEffect::FlashScreenLong, .. }));
671        assert!(flash, "Confusion should have screen flash");
672    }
673
674    #[test]
675    fn psychic_has_flash_and_wavy() {
676        let results = run_animation(0x5D);
677        let flash = results.iter().any(|r| matches!(r, AnimTickResult::Effect { effect: SpecialEffect::FlashScreenLong, .. }));
678        let wavy = results.iter().any(|r| matches!(r, AnimTickResult::Effect { effect: SpecialEffect::WavyScreen, .. }));
679        assert!(flash, "Psychic should have screen flash");
680        assert!(wavy, "Psychic should have wavy screen");
681    }
682
683    #[test]
684    fn hyper_beam_has_complex_sequence() {
685        let results = run_animation(0x3E);
686        let dark_palette = results.iter().any(|r| matches!(r, AnimTickResult::Effect { effect: SpecialEffect::DarkScreenPalette, .. }));
687        let spiral = results.iter().any(|r| matches!(r, AnimTickResult::Effect { effect: SpecialEffect::SpiralBallsInward, .. }));
688        let reset = results.iter().any(|r| matches!(r, AnimTickResult::Effect { effect: SpecialEffect::ResetScreenPalette, .. }));
689        assert!(dark_palette, "Hyper Beam should have dark palette");
690        assert!(spiral, "Hyper Beam should have spiral balls");
691        assert!(reset, "Hyper Beam should have reset palette");
692    }
693
694    #[test]
695    fn slide_mon_off_is_full_slide() {
696        // AnimationSlideMonOff: e = 8 slides the whole 7-tile-wide pic off
697        // screen, unlike AnimationSlideMonHalfOff (e = 4).
698        assert_eq!(
699            AnimationPlayer::apply_effect(SpecialEffect::SlideMonOff),
700            AnimEffect::SlidePlayerMonOff
701        );
702        assert_eq!(
703            AnimationPlayer::apply_effect(SpecialEffect::SlideMonHalfOff),
704            AnimEffect::SlidePlayerMonHalfOff
705        );
706    }
707
708    #[test]
709    fn flash_screen_long_and_shake_screen_params() {
710        // AnimationFlashScreenLong: 3 cycles through a 12-palette table
711        // ("an extended period (48 frames)").
712        assert_eq!(
713            AnimationPlayer::apply_effect(SpecialEffect::FlashScreenLong),
714            AnimEffect::FlashScreen { frames: 48 }
715        );
716        // AnimationShakeScreen: PredefShakeScreenHorizontally with b = 8.
717        assert_eq!(
718            AnimationPlayer::apply_effect(SpecialEffect::ShakeScreen),
719            AnimEffect::ShakeScreenH {
720                pixels: 8,
721                frames: 72
722            }
723        );
724    }
725
726    #[test]
727    fn share_move_animations_enemy_turn() {
728        // ShareMoveAnimations: on the enemy's turn AMNESIA plays CONF_ANIM
729        // and REST plays SLP_ANIM instead.
730        assert_eq!(
731            run_animation_as(AMNESIA as usize - 1, false),
732            run_animation_as(CONF_ANIM as usize - 1, false)
733        );
734        assert_eq!(
735            run_animation_as(REST as usize - 1, false),
736            run_animation_as(SLP_ANIM as usize - 1, false)
737        );
738    }
739
740    #[test]
741    fn share_move_animations_player_turn_unchanged() {
742        // On the player's turn the animation is NOT replaced: AMNESIA uses
743        // SUBANIM_0_STATUS_CONFUSED (base coords 0x71+), while the enemy-turn
744        // CONF_ANIM replacement uses SUBANIM_0_STATUS_CONFUSED_ENEMY (0x01+).
745        let mut player = AnimationPlayer::new();
746        player.start(AMNESIA as usize - 1, true);
747        player.tick();
748        // SpriteOamEntry has no PartialEq; compare positions.
749        let player_turn_oam: Vec<(i32, i32)> =
750            player.oam_entries().iter().map(|e| (e.y, e.x)).collect();
751
752        let mut enemy = AnimationPlayer::new();
753        enemy.start(AMNESIA as usize - 1, false);
754        enemy.tick();
755        let enemy_turn_oam: Vec<(i32, i32)> =
756            enemy.oam_entries().iter().map(|e| (e.y, e.x)).collect();
757        assert_ne!(player_turn_oam, enemy_turn_oam);
758    }
759
760    /// Extract the per-frame hook effect from a tick result, if any.
761    fn hook_of(r: &AnimTickResult) -> Option<&AnimEffect> {
762        match r {
763            AnimTickResult::Playing { hook, .. } | AnimTickResult::WaitDelay { hook, .. } => {
764                hook.as_ref()
765            }
766            _ => None,
767        }
768    }
769
770    #[test]
771    fn hyper_beam_hook_flashes_every_four_frame_blocks() {
772        // FlashScreenEveryFourFrameBlocks: flash when the subanimation
773        // counter is divisible by 4. Subanim_0Beam has 14 frames → counters
774        // 12, 8, 4; Subanim_1StarBigMoving has 3 frames → no flashes.
775        let results = run_animation(0x3E);
776        let flashes = results
777            .iter()
778            .filter(|r| matches!(hook_of(r), Some(AnimEffect::FlashScreen { .. })))
779            .count();
780        assert_eq!(flashes, 3, "Hyper Beam should flash 3 times via the hook");
781    }
782
783    #[test]
784    fn selfdestruct_hook_flashes_and_hides_mon() {
785        // DoExplodeSpecialEffects: flash every 4 frame blocks (Subanim_1Selfdestruct
786        // has 21 frames → counters 20, 16, 12, 8, 4) and hide the attacking
787        // mon at counter 1.
788        let results = run_animation(0x77);
789        let flashes = results
790            .iter()
791            .filter(|r| matches!(hook_of(r), Some(AnimEffect::FlashScreen { .. })))
792            .count();
793        assert_eq!(flashes, 5, "Selfdestruct should flash 5 times via the hook");
794        let hides = results
795            .iter()
796            .filter(|r| matches!(hook_of(r), Some(AnimEffect::HidePlayerMon)))
797            .count();
798        assert_eq!(hides, 1, "Selfdestruct should hide the attacking mon once");
799    }
800
801    #[test]
802    fn rock_slide_hook_shakes_and_flashes() {
803        // DoRockSlideSpecialEffects: shake H+V at counters 8..=11
804        // (Subanim_0RocksLift has 15 frames → 4 shakes) and flash at
805        // counter 1 of every subanimation (RocksLift, RocksToss and
806        // StarBigMoving → 3 flashes).
807        let results = run_animation(0x9C);
808        let shakes = results
809            .iter()
810            .filter(|r| matches!(hook_of(r), Some(AnimEffect::ShakeScreenHV { .. })))
811            .count();
812        assert_eq!(shakes, 4, "Rock Slide should shake 4 times via the hook");
813        let flashes = results
814            .iter()
815            .filter(|r| matches!(hook_of(r), Some(AnimEffect::FlashScreen { .. })))
816            .count();
817        assert_eq!(flashes, 3, "Rock Slide should flash 3 times via the hook");
818    }
819
820    #[test]
821    fn growl_hook_duplicates_oam_entries() {
822        // DoGrowlSpecialEffects copies OAM entries 0-3 to slots 4-7 after
823        // every frame block (FrameBlock17 = 4 tiles → 8 entries per frame).
824        let mut player = AnimationPlayer::new();
825        player.start(0x2C, true); // GROWL
826        player.tick();
827        assert_eq!(player.oam_entries().len(), 8);
828    }
829
830    #[test]
831    fn sound_id_reported_on_first_frame_only() {
832        // Pound: battle_anim POUND, SUBANIM_0_STAR_TWICE, 0, 8 — the sound
833        // (POUND = 1) plays once when the subanimation starts.
834        let mut player = AnimationPlayer::new();
835        player.start(0x00, true);
836        match player.tick() {
837            AnimTickResult::Playing { sound, .. } | AnimTickResult::WaitDelay { sound, .. } => {
838                assert_eq!(sound, Some(1));
839            }
840            other => panic!("Expected Playing/WaitDelay, got {:?}", other),
841        }
842        match player.tick() {
843            AnimTickResult::Playing { sound, .. } | AnimTickResult::WaitDelay { sound, .. } => {
844                assert_eq!(sound, None);
845            }
846            other => panic!("Expected Playing/WaitDelay, got {:?}", other),
847        }
848    }
849
850    #[test]
851    fn effect_command_reports_sound() {
852        // Tackle: battle_anim LEECH_SEED, SE_MOVE_MON_HORIZONTALLY —
853        // the effect command carries LEECH_SEED's sound (73 = 0x49).
854        let mut player = AnimationPlayer::new();
855        player.start(0x20, true);
856        match player.tick() {
857            AnimTickResult::Effect { sound, .. } => {
858                assert_eq!(sound, Some(73));
859            }
860            other => panic!("Expected Effect, got {:?}", other),
861        }
862    }
863
864    #[test]
865    fn all_moves_produce_animations() {
866        for move_id in 0..203 {
867            let mut player = AnimationPlayer::new();
868            player.start(move_id, true);
869            let mut has_frames = false;
870            let mut iterations = 0;
871
872            for _ in 0..1000 {
873                if iterations >= 1000 {
874                    break;
875                }
876                iterations += 1;
877
878                match player.tick() {
879                    AnimTickResult::Done => break,
880                    AnimTickResult::Playing { .. } => has_frames = true,
881                    AnimTickResult::Effect { .. } => has_frames = true,
882                    AnimTickResult::WaitDelay { frames: n, .. } => {
883                        has_frames = true;
884                        for _ in 0..n {
885                            player.tick();
886                            iterations += 1;
887                        }
888                    }
889                }
890            }
891            assert!(has_frames, "Move 0x{:02X} should produce animation frames", move_id);
892        }
893    }
894}