Skip to main content

dotzuki_renderer/battle_anim/
effects.rs

1//! Framebuffer special effects (SE) — shared by the pokered frontends.
2//!
3//! Faithful ports of the classic special-effect routines (the SE id →
4//! routine dispatch table). Each effect is a per-frame state machine:
5//! the caller feeds `AnimEffect`s in via [`BattleEffects::apply`] (which returns
6//! how many frames the original routine would have blocked the animation
7//! command stream), calls [`BattleEffects::tick`] once per frame, and uses the
8//! query/draw methods while rendering the battle scene.
9//!
10//! All effects operate on the same `FrameBuffer`/`TileSet`/`Palette`
11//! abstractions the frontends already use; game assets (the substitute doll
12//! sprite, the move-animation tilesets) are passed in by the caller.
13
14use crate::palette::{GbColor, Palette};
15use crate::sprite::{SpriteLayer, SpriteOamEntry, OAM_X_FLIP};
16use crate::tile::{TileSet, TILE_PIXELS};
17use crate::indexed_framebuffer::RgbaIndexedFrameBuffer;
18use crate::{Rgba, TILE_SIZE};
19
20use super::types::ANIM_BASE_TILE_ID;
21use super::AnimEffect;
22
23// ─── Shared constants ────────────────────────────────────────────────
24
25/// Which mon a special effect acts on (whose turn it is).
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum MonSide {
28    Player,
29    Enemy,
30}
31
32impl MonSide {
33    /// The opposing side (the original flips whose-turn).
34    pub fn other(self) -> Self {
35        match self {
36            Self::Player => Self::Enemy,
37            Self::Enemy => Self::Player,
38        }
39    }
40
41    fn index(self) -> usize {
42        match self {
43            Self::Player => 0,
44            Self::Enemy => 1,
45        }
46    }
47}
48
49/// Pixel rectangle of a mon pic on screen (both pics are 7×7 tiles = 56×56).
50#[derive(Debug, Clone, Copy)]
51pub struct MonRect {
52    pub x: i32,
53    pub y: i32,
54}
55
56/// Per-scanline SCX values for the wavy-screen effect, looping; the start
57/// advances one entry per frame for 255 frames.
58pub const WAVY_LINE_OFFSETS: [i8; 32] = [
59    0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1, 1, //
60    0, 0, 0, 0, 0, -1, -1, -1, -2, -2, -2, -2, -2, -1, -1, -1,
61];
62
63/// Duration of the wavy-screen effect in frames.
64pub const WAVY_SCREEN_FRAMES: u16 = 255;
65
66/// (Y, X) OAM pairs the three spiralling balls of the spiral-balls-inward
67/// effect step through, one pair per 5 frames. Terminated by $FF in the
68/// original; the 21 pairs are stored here.
69pub const SPIRAL_BALL_COORDS: [(u8, u8); 21] = [
70    (0x38, 0x28), (0x40, 0x18), (0x50, 0x10), (0x60, 0x18), (0x68, 0x28),
71    (0x60, 0x38), (0x50, 0x40), (0x40, 0x38), (0x40, 0x28), (0x46, 0x1E),
72    (0x50, 0x18), (0x5B, 0x1E), (0x60, 0x28), (0x5B, 0x32), (0x50, 0x38),
73    (0x46, 0x32), (0x48, 0x28), (0x50, 0x20), (0x58, 0x28), (0x50, 0x30),
74    (0x50, 0x28),
75];
76
77/// X coordinates of the upward-shooting balls on the player's turn.
78pub const UPWARD_BALLS_X_PLAYER: [u8; 6] = [0x10, 0x40, 0x28, 0x18, 0x38, 0x30];
79/// X coordinates of the upward-shooting balls on the enemy's turn.
80pub const UPWARD_BALLS_X_ENEMY: [u8; 6] = [0x60, 0x90, 0x78, 0x68, 0x88, 0x80];
81
82/// Initial OAM X values of the falling objects (petals/leaves).
83pub const FALLING_INITIAL_X: [u8; 20] = [
84    0x38, 0x40, 0x50, 0x60, 0x70, 0x88, 0x90, 0x56, 0x67, 0x4A, //
85    0x77, 0x84, 0x98, 0x32, 0x22, 0x5C, 0x6C, 0x7D, 0x8E, 0x99,
86];
87
88/// Initial movement data of the falling objects.
89pub const FALLING_INITIAL_MOVEMENT: [u8; 20] = [
90    0x00, 0x84, 0x06, 0x81, 0x02, 0x88, 0x01, 0x83, 0x05, 0x89, //
91    0x09, 0x80, 0x07, 0x87, 0x03, 0x82, 0x04, 0x85, 0x08, 0x86,
92];
93
94/// Per-step X deltas of the falling objects. The original table has only 9
95/// entries; two of the initial movement bytes ($09/$89 → index 10) read past
96/// it into the following code bytes in the original (an original-game bug —
97/// those objects dart sideways with a garbage delta). We approximate the
98/// garbage with 0xFA, the first byte after the table.
99pub const FALLING_DELTA_XS: [u8; 9] = [0, 1, 3, 5, 7, 9, 11, 13, 15];
100const FALLING_DELTA_XS_OVERFLOW: u8 = 0xFA;
101
102/// Ball tile used by the spiral/shoot-balls effects, move anim tileset 0.
103/// Stored as the absolute VRAM tile id (tileset base $31).
104pub const BALL_TILE: u8 = 0x7A;
105/// Water droplet tile (tile id $71), tileset 0.
106pub const DROPLET_TILE: u8 = 0x71;
107/// Petal tile (tile id $71), tileset 1.
108pub const PETAL_TILE: u8 = 0x71;
109/// Leaf tile (tile id $37), tileset 1.
110pub const LEAF_TILE: u8 = 0x37;
111
112/// Long-flash BG palette sequence (12 steps), cycled 3 times: first cycle
113/// 2 frames/step, then 1 frame/step → 12*2 + 12 + 12 = 48 frames.
114pub const FLASH_SCREEN_LONG_PALETTE: [[u8; 4]; 12] = [
115    [3, 3, 2, 1],
116    [3, 3, 3, 2],
117    [3, 3, 3, 3],
118    [3, 3, 3, 2],
119    [3, 3, 2, 1],
120    [3, 2, 1, 0],
121    [2, 1, 0, 0],
122    [1, 0, 0, 0],
123    [0, 0, 0, 0],
124    [1, 0, 0, 0],
125    [2, 1, 0, 0],
126    [3, 2, 1, 0],
127];
128
129/// Minimized-mon sprite: 8×5 px blob that replaces the mon pic for the
130/// minimize effect. Each byte is written twice (both bitplanes) → color
131/// index 3 (black).
132pub const MINIMIZED_BLOB: [[u8; 8]; 5] = [
133    [0, 0, 0, 1, 1, 0, 0, 0],
134    [0, 0, 1, 1, 1, 1, 0, 0],
135    [0, 1, 1, 1, 1, 1, 1, 0],
136    [0, 0, 1, 1, 1, 1, 0, 0],
137    [0, 0, 1, 0, 0, 1, 0, 0],
138];
139
140// Effect durations in frames (every ~3-frame delay in the original routines
141// is counted, since the original blocks the animation command stream while
142// the effect runs).
143const FLASH_SHORT_FRAMES: u8 = 4; // short flash: 2 + 2
144const FLASH_LONG_FRAMES: u8 = 48; // long flash
145const BLINK_FRAMES: u8 = 60; // blink: 6 × (5 hidden + 5 shown)
146const SQUISH_FRAMES: u8 = 26; // squish: 8 passes × 3 + 2
147const SHAKE_BACK_AND_FORTH_FRAMES: u8 = 96; // back-and-forth shake: $10 × 2 × 3
148const SPIRAL_BALLS_FRAMES: u8 = 105; // 21 coordinate pairs × 5
149const FALLING_FRAMES: u16 = 156; // falling objects: 52 ticks × 3
150const DROPLET_FRAMES: u8 = 64; // droplets: 32 × 2
151const HUD_SHAKE_FRAMES: u8 = 32; // ShakeEnemyHUD_ShakeBG: 8 × (2 + 2)
152const MINIMIZE_FRAMES: u8 = 6; // minimize: 3 + re-show's 3
153const SUBSTITUTE_FRAMES: u8 = 3; // substitute: re-show's 3
154/// Bounce: 5 × slide-down runs (7 rows × 3) + the re-show's 3.
155const BOUNCE_FRAMES: u8 = 5 * 7 * 3 + 3;
156/// Slide-down-and-hide: 2 steps (7×5, 7×3) × 8 frames.
157const SLIDE_DOWN_HIDE_FRAMES: u8 = 16;
158/// Transform: the original synchronously swaps the pic (the preceding poof
159/// subanimation covers the swap) and runs a palette command — it blocks the
160/// command stream for the SE call itself. We model that block: the pic
161/// stays hidden for a beat (under the poof) and reappears as the opposing
162/// species (the frontend draws the target sprite once the core TRANSFORMED
163/// flag is set).
164const TRANSFORM_FRAMES: u8 = 12;
165/// Frames the pic stays hidden at the start of the transform block.
166const TRANSFORM_HIDDEN_FRAMES: u8 = 6;
167
168/// Height of the top screen region shaken by the enemy-HUD shake
169/// (the window is parked at WY = 7*8, so rows 0..7 scroll with SCX).
170const HUD_SHAKE_HEIGHT: u32 = 7 * TILE_SIZE;
171/// Height of the region moved by the classic screen shakes
172/// (everything above the bottom text box).
173const SCREEN_SHAKE_HEIGHT: u32 = 12 * TILE_SIZE;
174
175// ─── Shade remap helpers ─────────────────────────────────────────────
176
177/// Apply a DMG palette map (shade → shade) to the whole framebuffer,
178/// e.g. rBGP = $6f for the dark-screen palette. On the indexed
179/// framebuffer this is a display-palette remap — the GB-hardware way.
180fn remap_shades(fb: &mut RgbaIndexedFrameBuffer, map: &[u8; 4]) {
181    fb.remap_shades(map);
182}
183
184/// Shift a horizontal strip of the framebuffer sideways, filling the exposed
185/// edge with white. Used for the SCX-based shakes. Operates on the packed
186/// 2bpp indices (cloned cheaply — 5.7 KiB), so the result is identical to
187/// the old per-pixel RGBA shift.
188fn shift_rows_h(fb: &mut RgbaIndexedFrameBuffer, y_end: u32, dx: i32) {
189    if dx == 0 {
190        return;
191    }
192    let w = fb.width() as i32;
193    let src = fb.indexed().clone();
194    for y in 0..y_end.min(fb.height()) {
195        for x in 0..w {
196            let sx = x - dx;
197            let color = if sx >= 0 && sx < w {
198                src.get_pixel(sx as u32, y).unwrap_or(GbColor::White)
199            } else {
200                GbColor::White
201            };
202            fb.set_pixel_index(x as u32, y, color);
203        }
204    }
205}
206
207/// Shift the top strip of the framebuffer vertically, filling with white.
208fn shift_rows_v(fb: &mut RgbaIndexedFrameBuffer, y_end: u32, dy: i32) {
209    if dy == 0 {
210        return;
211    }
212    let src = fb.indexed().clone();
213    for y in 0..y_end.min(fb.height()) as i32 {
214        let sy = y - dy;
215        for x in 0..fb.width() as i32 {
216            let color = if sy >= 0 && (sy as u32) < y_end.min(fb.height()) {
217                src.get_pixel(x as u32, sy as u32).unwrap_or(GbColor::White)
218            } else {
219                GbColor::White
220            };
221            fb.set_pixel_index(x as u32, y as u32, color);
222        }
223    }
224}
225
226// ─── Individual effect state machines ────────────────────────────────
227
228/// Short flash (invert 2 frames + white 2 frames) and long flash
229/// (12-step palette cycle ×3, 48 frames).
230#[derive(Debug, Clone, Copy)]
231enum Flash {
232    Short { frame: u8 },
233    Long { frame: u8 },
234}
235
236/// Screen shake. Two flavors, matching the original routines:
237/// - `decay`: per amplitude step, 4 frames displaced + 5 frames at rest,
238///   amplitude decaying pixels → 1 (total pixels*9 frames).
239/// - `alternate`: the small ±1 px shakes used by the applying-attack
240///   feedback.
241#[derive(Debug, Clone, Copy)]
242struct Shake {
243    horizontal: bool,
244    vertical: bool,
245    pixels: i32,
246    decay: bool,
247    frame: u16,
248    total: u16,
249}
250
251impl Shake {
252    fn offset(&self) -> (i32, i32) {
253        let amp = if self.decay {
254            // 9 frames per amplitude step: displaced for 4, at rest for 5.
255            let step = self.frame / 9;
256            let within = self.frame % 9;
257            let amp = (self.pixels - step as i32).max(1);
258            if within < 4 {
259                amp
260            } else {
261                0
262            }
263        } else {
264            // ±pixels every frame.
265            if self.frame % 2 == 0 {
266                self.pixels
267            } else {
268                -self.pixels
269            }
270        };
271        (
272            if self.horizontal { amp } else { 0 },
273            if self.vertical { amp } else { 0 },
274        )
275    }
276}
277
278/// Persistent BG palette remaps (dark screen rBGP=$6f, light screen
279/// rBGP=$90, darken mon rBGP=$f9). These stay in effect until the palette
280/// is reset.
281#[derive(Debug, Clone, Copy)]
282enum Tint {
283    Dark,
284    Light,
285    DarkenMon,
286}
287
288impl Tint {
289    fn map(&self) -> [u8; 4] {
290        match self {
291            // $6f = %01_10_11_11
292            Tint::Dark => [3, 3, 2, 1],
293            // $90 = %10_01_00_00
294            Tint::Light => [0, 0, 1, 2],
295            // $f9 = %11_11_10_01
296            Tint::DarkenMon => [1, 2, 3, 3],
297        }
298    }
299}
300
301/// Blink: 6 cycles of (hide 5 frames, show 5 frames).
302#[derive(Debug, Clone, Copy)]
303struct Blink {
304    side: MonSide,
305    frame: u8,
306}
307
308/// Squish: 4 iterations × (left pass + right pass); each pass narrows the
309/// pic by one tile (alternating anchor) and waits ~3 frames, then the pic
310/// is hidden. 8 passes × 3 frames + 2 = 26 frames.
311#[derive(Debug, Clone, Copy)]
312struct Squish {
313    side: MonSide,
314    frame: u8,
315}
316
317impl Squish {
318    /// (width in tiles 0..=7, anchor_right) for the current frame.
319    fn params(&self) -> (u32, bool) {
320        let pass = (self.frame / 3).min(7);
321        let width = 7u32.saturating_sub(pass as u32 + 1);
322        (width, pass % 2 == 1)
323    }
324}
325
326/// Transform: the mon's pic is hidden while the SE blocks the command
327/// stream, then reappears (already drawn as the opposing species by the
328/// frontend, which keys the sprite off the core TRANSFORMED flag).
329#[derive(Debug, Clone, Copy)]
330struct Transform {
331    side: MonSide,
332    frame: u8,
333}
334
335/// Back-and-forth shake (Double Team): the mon's pic jumps ±1 tile
336/// horizontally every ~3 frames, $10 iterations, then stays hidden.
337#[derive(Debug, Clone, Copy)]
338struct ShakeBackAndForth {
339    side: MonSide,
340    frame: u8,
341}
342
343/// Enemy-HUD shake: SCX shake of the top 7 tile rows, ±2 px every 2
344/// frames, 8 iterations.
345#[derive(Debug, Clone, Copy)]
346struct HudShake {
347    frame: u8,
348}
349
350/// Bounce (Splash): five runs of a slide-down (the pic slides down one row
351/// per ~3 frames, 7 rows = 21 frames) followed by a re-show — the mon sinks
352/// out of its box and instantly pops back to the top, 5 times.
353#[derive(Debug, Clone, Copy)]
354struct Bounce {
355    side: MonSide,
356    frame: u8,
357}
358
359/// Slide-down-and-hide (Acid Armor): redraw the pic with the 7×5 then 7×3
360/// tile-id lists (top 5 / top 3 rows, drawn 2 / 4 rows lower), 8 frames
361/// each, then hide the pic and blank its tile data.
362#[derive(Debug, Clone, Copy)]
363struct SlideDownHide {
364    side: MonSide,
365    frame: u8,
366}
367
368/// Object (sprite) effects drawn with the move-animation tilesets.
369#[derive(Debug, Clone)]
370enum Objects {
371    None,
372    /// Spiral-balls-inward: 3 balls step through SPIRAL_BALL_COORDS,
373    /// 5 frames per step; ends with a short flash.
374    SpiralBalls { side: MonSide, frame: u8 },
375    /// Shoot-balls-upward: pillars of balls rising 4 px/frame, removed at
376    /// the pillar top.
377    ShootBalls { pillars: Vec<Pillar>, active: usize, frame: u8 },
378    /// Falling objects (petals / leaves).
379    Falling { tile: u8, frame: u16, tick: u8, objects: Vec<FallingObject> },
380    /// Droplets: a full-screen droplet grid that scrolls; 32 iterations ×
381    /// 2 frames.
382    Droplets { iter: u8, half: u8, base_x: i32 },
383}
384
385#[derive(Debug, Clone)]
386struct Pillar {
387    base_y: i32, // OAM Y of the pillar top
388    base_x: i32, // OAM X
389    /// OAM Y of each ball (removed when it wraps to base_y + 8).
390    balls: Vec<i32>,
391}
392
393#[derive(Debug, Clone, Copy)]
394struct FallingObject {
395    x: u8, // OAM X
396    y: u8, // OAM Y
397    movement: u8,
398}
399
400// ─── BattleEffects ───────────────────────────────────────────────────
401
402/// Shared framebuffer special-effect state, driven by `AnimEffect`s from the
403/// animation player. Both pokered frontends own one of these instead of
404/// their own duplicated effect code.
405#[derive(Debug, Clone)]
406pub struct BattleEffects {
407    flash: Option<Flash>,
408    shake: Option<Shake>,
409    tint: Option<Tint>,
410    wave_frame: u16,
411    hud_shake: Option<HudShake>,
412    blink: Option<Blink>,
413    squish: Option<Squish>,
414    shake_bnf: Option<ShakeBackAndForth>,
415    bounce: Option<Bounce>,
416    slide_down_hide: Option<SlideDownHide>,
417    /// Transform: pic hidden for a beat while the opposing species' sprite
418    /// takes its place (see TRANSFORM_FRAMES).
419    transform: Option<Transform>,
420    /// Pic hidden by the squish / shake effects until a Show effect (the
421    /// original hides it at the end of both routines).
422    forced_hidden: [bool; 2],
423    /// Minimize: pic replaced by the mini blob. Persists until the mon
424    /// leaves (the original keeps per-side minimized flags).
425    minimized: [bool; 2],
426    /// Substitute: pic replaced by the mini doll sprite. Frontends normally
427    /// drive this from the core HAS_SUBSTITUTE_UP flag via
428    /// [`BattleEffects::set_substitute`]; the SE itself latches it too.
429    substitute: [bool; 2],
430    objects: Objects,
431}
432
433impl Default for BattleEffects {
434    fn default() -> Self {
435        Self::new()
436    }
437}
438
439impl BattleEffects {
440    pub fn new() -> Self {
441        Self {
442            flash: None,
443            shake: None,
444            tint: None,
445            wave_frame: 0,
446            hud_shake: None,
447            blink: None,
448            squish: None,
449            shake_bnf: None,
450            bounce: None,
451            slide_down_hide: None,
452            transform: None,
453            forced_hidden: [false; 2],
454            minimized: [false; 2],
455            substitute: [false; 2],
456            objects: Objects::None,
457        }
458    }
459
460    /// Clear per-mon latches when a mon leaves the field (switch/faint).
461    pub fn clear_side(&mut self, side: MonSide) {
462        let i = side.index();
463        self.forced_hidden[i] = false;
464        self.minimized[i] = false;
465        self.substitute[i] = false;
466        if self.transform.is_some_and(|t| t.side == side) {
467            self.transform = None;
468        }
469    }
470
471    /// Sync the substitute doll latch from the core HAS_SUBSTITUTE_UP flag.
472    pub fn set_substitute(&mut self, side: MonSide, up: bool) {
473        self.substitute[side.index()] = up;
474    }
475
476    /// Show the mon pic again (player or enemy side). Clears the
477    /// forced-hidden latch left by the squish / shake effects.
478    pub fn show_mon(&mut self, side: MonSide) {
479        self.forced_hidden[side.index()] = false;
480    }
481
482    /// Apply an `AnimEffect`. `attacker` is whose turn it is; the
483    /// "PlayerMon"/"EnemyMonPic" effect names follow the original convention
484    /// of whose-turn vs flipped-turn mon. Returns the number of frames the
485    /// original routine blocks the animation command stream — the caller
486    /// should hold the animation player for that long.
487    pub fn apply(&mut self, effect: &AnimEffect, attacker: MonSide) -> u8 {
488        match *effect {
489            AnimEffect::None => 0,
490            AnimEffect::FlashScreen { frames } => {
491                if frames as usize >= FLASH_LONG_FRAMES as usize {
492                    self.flash = Some(Flash::Long { frame: 0 });
493                    FLASH_LONG_FRAMES
494                } else {
495                    self.flash = Some(Flash::Short { frame: 0 });
496                    FLASH_SHORT_FRAMES
497                }
498            }
499            AnimEffect::ShakeScreenH { pixels, frames } => {
500                self.start_shake(true, false, pixels, frames);
501                self.shake_duration()
502            }
503            AnimEffect::ShakeScreenV { pixels, frames } => {
504                self.start_shake(false, true, pixels, frames);
505                self.shake_duration()
506            }
507            AnimEffect::ShakeScreenHV { pixels, frames } => {
508                self.start_shake(true, true, pixels, frames);
509                self.shake_duration()
510            }
511            AnimEffect::DarkScreenPalette => {
512                self.tint = Some(Tint::Dark);
513                0
514            }
515            AnimEffect::LightScreenPalette => {
516                self.tint = Some(Tint::Light);
517                0
518            }
519            AnimEffect::DarkenMonPalette => {
520                self.tint = Some(Tint::DarkenMon);
521                0
522            }
523            AnimEffect::ResetScreenPalette => {
524                self.tint = None;
525                0
526            }
527            AnimEffect::WavyScreen => {
528                self.wave_frame = self.wave_frame.max(1);
529                WAVY_SCREEN_FRAMES as u8
530            }
531            AnimEffect::SubstituteMon => {
532                // Substitute: swap the pic for the mini doll sprite, then
533                // re-show the pic.
534                self.substitute[attacker.index()] = true;
535                self.forced_hidden[attacker.index()] = false;
536                SUBSTITUTE_FRAMES
537            }
538            AnimEffect::MinimizeMon => {
539                // Minimize: swap the pic for the 8×5 mini blob, wait ~3
540                // frames, then re-show the pic.
541                self.minimized[attacker.index()] = true;
542                self.forced_hidden[attacker.index()] = false;
543                MINIMIZE_FRAMES
544            }
545            AnimEffect::TransformMon => {
546                // Transform: the pic is instantly reloaded as the opposing
547                // mon's sprite — the morph reads as instant because the
548                // preceding poof covers it. The SE call itself is
549                // synchronous, so block the command stream while the swap
550                // settles: hide the pic for a beat (under the poof), then
551                // re-show it (the frontend draws the opposing species'
552                // sprite as soon as the core TRANSFORMED flag is set).
553                self.transform = Some(Transform {
554                    side: attacker,
555                    frame: 0,
556                });
557                self.forced_hidden[attacker.index()] = true;
558                TRANSFORM_FRAMES
559            }
560            AnimEffect::SquishMonPic => {
561                self.squish = Some(Squish {
562                    side: attacker,
563                    frame: 0,
564                });
565                SQUISH_FRAMES
566            }
567            AnimEffect::ShakeBackAndForth => {
568                self.shake_bnf = Some(ShakeBackAndForth {
569                    side: attacker,
570                    frame: 0,
571                });
572                SHAKE_BACK_AND_FORTH_FRAMES
573            }
574            AnimEffect::BounceUpAndDown => {
575                self.bounce = Some(Bounce {
576                    side: attacker,
577                    frame: 0,
578                });
579                BOUNCE_FRAMES
580            }
581            AnimEffect::SlidePlayerMonDownAndHide => {
582                self.slide_down_hide = Some(SlideDownHide {
583                    side: attacker,
584                    frame: 0,
585                });
586                SLIDE_DOWN_HIDE_FRAMES
587            }
588            // The blink effect blinks whose-turn mon; the enemy variant
589            // blinks the flipped-turn mon.
590            AnimEffect::BlinkPlayerMon { .. } | AnimEffect::FlashPlayerMonPic => {
591                self.blink = Some(Blink {
592                    side: attacker,
593                    frame: 0,
594                });
595                BLINK_FRAMES
596            }
597            AnimEffect::BlinkEnemyMon { .. } | AnimEffect::FlashEnemyMonPic => {
598                self.blink = Some(Blink {
599                    side: attacker.other(),
600                    frame: 0,
601                });
602                BLINK_FRAMES
603            }
604            AnimEffect::ShowPlayerMon => {
605                self.show_mon(attacker);
606                0
607            }
608            AnimEffect::ShowEnemyMon => {
609                self.show_mon(attacker.other());
610                0
611            }
612            AnimEffect::HidePlayerMon => {
613                self.forced_hidden[attacker.index()] = true;
614                0
615            }
616            AnimEffect::HideEnemyMon => {
617                self.forced_hidden[attacker.other().index()] = true;
618                0
619            }
620            AnimEffect::ShakeEnemyHud { .. } => {
621                // The enemy-HUD-shake effect ids (one unused) both map to
622                // the same HUD shake.
623                self.hud_shake = Some(HudShake { frame: 0 });
624                HUD_SHAKE_FRAMES
625            }
626            AnimEffect::SpiralBallsInward => {
627                self.objects = Objects::SpiralBalls {
628                    side: attacker,
629                    frame: 0,
630                };
631                SPIRAL_BALLS_FRAMES
632            }
633            AnimEffect::ShootBallsUpward { many } => {
634                self.objects = Self::new_shoot_balls(attacker, many);
635                self.shoot_balls_frames() as u8
636            }
637            AnimEffect::PetalsFalling => {
638                self.objects = Self::new_falling(PETAL_TILE, 20);
639                FALLING_FRAMES as u8
640            }
641            AnimEffect::LeavesFalling => {
642                self.objects = Self::new_falling(LEAF_TILE, 3);
643                FALLING_FRAMES as u8
644            }
645            AnimEffect::WaterDroplets => {
646                self.objects = Objects::Droplets {
647                    iter: 0,
648                    half: 0,
649                    base_x: -16,
650                };
651                DROPLET_FRAMES
652            }
653            // Slides, lunges, delays and visibility flows are handled by the
654            // frontends (they own the mon slide/lunge state machines).
655            _ => 0,
656        }
657    }
658
659    fn start_shake(&mut self, h: bool, v: bool, pixels: i8, frames: u8) {
660        let pixels = pixels.unsigned_abs() as i32;
661        // A pixel-count > 1 decays the amplitude step by step; the ±1 px
662        // feedback shakes simply alternate.
663        let decay = pixels > 1;
664        let total = if decay {
665            (pixels as u16) * 9
666        } else {
667            frames.max(1) as u16
668        };
669        self.shake = Some(Shake {
670            horizontal: h,
671            vertical: v,
672            pixels,
673            decay,
674            frame: 0,
675            total,
676        });
677    }
678
679    fn shake_duration(&self) -> u8 {
680        self.shake.map_or(0, |s| s.total.min(255) as u8)
681    }
682
683    fn new_shoot_balls(side: MonSide, many: bool) -> Objects {
684        // Shoot-balls-upward: balls start at baseY + 8*i (each OAM entry
685        // adds 8 *before* the write).
686        let make_pillar = |base_y: i32, base_x: i32, count: usize| Pillar {
687            base_y,
688            base_x,
689            balls: (1..=count).map(|i| base_y + 8 * i as i32).collect(),
690        };
691        let pillars = if many {
692            // Many-balls variant: 6 sequential pillars of 4 balls.
693            let (xs, y) = match side {
694                MonSide::Player => (&UPWARD_BALLS_X_PLAYER[..], 0x50),
695                MonSide::Enemy => (&UPWARD_BALLS_X_ENEMY[..], 0x28),
696            };
697            xs.iter()
698                .map(|&x| make_pillar(y as i32, x as i32, 4))
699                .collect()
700        } else {
701            // Single-pillar variant: 5 balls.
702            let (y, x) = match side {
703                MonSide::Player => (6 * 8, 5 * 8),
704                MonSide::Enemy => (0, 16 * 8),
705            };
706            vec![make_pillar(y, x, 5)]
707        };
708        Objects::ShootBalls {
709            pillars,
710            active: 0,
711            frame: 0,
712        }
713    }
714
715    fn shoot_balls_frames(&self) -> u16 {
716        match &self.objects {
717            // Balls rise 4 px/frame and pop at base_y + 8; the lowest ball
718            // (base_y + 8*n) takes 2*(n-1) frames, +1 for the removal check.
719            Objects::ShootBalls { pillars, .. } => pillars
720                .iter()
721                .map(|p| 2 * (p.balls.len() as u16).saturating_sub(1))
722                .sum(),
723            _ => 0,
724        }
725    }
726
727    fn new_falling(tile: u8, count: usize) -> Objects {
728        let objects = (0..count)
729            .map(|i| FallingObject {
730                // Object init: Y = 8*(i+1); the first object's Y is then
731                // set to 0.
732                y: if i == 0 { 0 } else { 8 * (i as u8 + 1) },
733                x: FALLING_INITIAL_X[i],
734                movement: FALLING_INITIAL_MOVEMENT[i],
735            })
736            .collect();
737        Objects::Falling {
738            tile,
739            frame: 0,
740            tick: 0,
741            objects,
742        }
743    }
744
745    /// Advance all effect state machines by one frame.
746    pub fn tick(&mut self) {
747        if let Some(flash) = &mut self.flash {
748            let done = match flash {
749                Flash::Short { frame } => {
750                    *frame += 1;
751                    *frame >= FLASH_SHORT_FRAMES
752                }
753                Flash::Long { frame } => {
754                    *frame += 1;
755                    *frame >= FLASH_LONG_FRAMES
756                }
757            };
758            if done {
759                self.flash = None;
760            }
761        }
762
763        if let Some(shake) = &mut self.shake {
764            shake.frame += 1;
765            if shake.frame >= shake.total {
766                self.shake = None;
767            }
768        }
769
770        if self.wave_frame > 0 {
771            self.wave_frame += 1;
772            if self.wave_frame > WAVY_SCREEN_FRAMES {
773                self.wave_frame = 0;
774            }
775        }
776
777        if let Some(hud) = &mut self.hud_shake {
778            hud.frame += 1;
779            if hud.frame >= HUD_SHAKE_FRAMES {
780                self.hud_shake = None;
781            }
782        }
783
784        if let Some(blink) = &mut self.blink {
785            blink.frame += 1;
786            if blink.frame >= BLINK_FRAMES {
787                self.blink = None;
788            }
789        }
790
791        if let Some(squish) = &mut self.squish {
792            squish.frame += 1;
793            if squish.frame >= SQUISH_FRAMES {
794                // The squish ends by hiding the pic.
795                self.forced_hidden[squish.side.index()] = true;
796                self.squish = None;
797            }
798        }
799
800        if let Some(bnf) = &mut self.shake_bnf {
801            bnf.frame += 1;
802            if bnf.frame >= SHAKE_BACK_AND_FORTH_FRAMES {
803                // "The mon's sprite disappears after this animation."
804                self.forced_hidden[bnf.side.index()] = true;
805                self.shake_bnf = None;
806            }
807        }
808
809        if let Some(bounce) = &mut self.bounce {
810            bounce.frame += 1;
811            if bounce.frame >= BOUNCE_FRAMES {
812                // Ends with a re-show — the mon stays visible.
813                self.bounce = None;
814            }
815        }
816
817        if let Some(sdh) = &mut self.slide_down_hide {
818            sdh.frame += 1;
819            if sdh.frame >= SLIDE_DOWN_HIDE_FRAMES {
820                // The slide-down-and-hide ends by hiding the pic and
821                // blanking its tile data.
822                self.forced_hidden[sdh.side.index()] = true;
823                self.slide_down_hide = None;
824            }
825        }
826
827        if let Some(tf) = &mut self.transform {
828            tf.frame += 1;
829            if tf.frame == TRANSFORM_HIDDEN_FRAMES {
830                // The swap has happened: re-show the pic (now drawn as the
831                // opposing species, keyed off the core TRANSFORMED flag).
832                self.forced_hidden[tf.side.index()] = false;
833            }
834            if tf.frame >= TRANSFORM_FRAMES {
835                self.transform = None;
836            }
837        }
838
839        match &mut self.objects {
840            Objects::None => {}
841            Objects::SpiralBalls { frame, .. } => {
842                *frame += 1;
843                if *frame >= SPIRAL_BALLS_FRAMES {
844                    self.objects = Objects::None;
845                    // The spiral-balls effect ends with a short flash.
846                    self.flash = Some(Flash::Short { frame: 0 });
847                }
848            }
849            Objects::ShootBalls {
850                pillars,
851                active,
852                frame,
853            } => {
854                *frame += 1;
855                let pillar = &mut pillars[*active];
856                let top = pillar.base_y + 8;
857                for ball in &mut pillar.balls {
858                    if *ball != top {
859                        // Balls rise 4 px/frame; removed once they reach the
860                        // pillar top (checked before the move, as in the original).
861                        *ball = ball.wrapping_sub(4);
862                    }
863                }
864                // Drop removed balls (keep the vec compact for rendering).
865                pillar.balls.retain(|b| *b != top);
866                if pillar.balls.is_empty() {
867                    if *active + 1 < pillars.len() {
868                        *active += 1;
869                        *frame = 0;
870                    } else {
871                        self.objects = Objects::None;
872                    }
873                }
874            }
875            Objects::Falling {
876                frame,
877                tick,
878                objects,
879                ..
880            } => {
881                *frame += 1;
882                // Falling-object updates run once per ~3 frames.
883                if *frame % 3 == 0 {
884                    *tick += 1;
885                    for obj in objects.iter_mut() {
886                        // Update the movement byte.
887                        let next = obj.movement.wrapping_add(1);
888                        obj.movement = if next & 0x7f == 9 {
889                            (next & 0x80) ^ 0x80
890                        } else {
891                            next
892                        };
893                        // Falling-object update: Y += 2, off-screen at
894                        // >= 112; X ± DeltaXs[movement & $7f], X flip when
895                        // moving left.
896                        obj.y = obj.y.wrapping_add(2);
897                        if obj.y >= 112 {
898                            obj.y = 160; // SCREEN_HEIGHT_PX + OAM_Y_OFS
899                        }
900                        let idx = (obj.movement & 0x7f) as usize;
901                        let delta = FALLING_DELTA_XS
902                            .get(idx)
903                            .copied()
904                            .unwrap_or(FALLING_DELTA_XS_OVERFLOW);
905                        if obj.movement & 0x80 != 0 {
906                            obj.x = obj.x.wrapping_sub(delta);
907                        } else {
908                            obj.x = obj.x.wrapping_add(delta);
909                        }
910                    }
911                }
912                // The loop ends when the first object's Y reaches 104.
913                if *frame >= FALLING_FRAMES {
914                    self.objects = Objects::None;
915                }
916            }
917            Objects::Droplets { iter, half, base_x } => {
918                // The droplets pass draws the whole grid (advancing
919                // base_x), then cleans OAM and waits a frame — one frame per
920                // half; the base X persists between calls, scrolling the
921                // grid.
922                *base_x = Self::droplet_grid_end_x(*base_x, if *half == 0 { 16 } else { 24 });
923                *half += 1;
924                if *half >= 2 {
925                    *half = 0;
926                    *iter += 1;
927                    if *iter >= 32 {
928                        self.objects = Objects::None;
929                    }
930                }
931            }
932        }
933    }
934
935    /// Simulate one droplets pass over the grid and return the resulting
936    /// base X.
937    fn droplet_grid_end_x(mut x: i32, base_y: i32) -> i32 {
938        let mut y = base_y;
939        loop {
940            x += 27;
941            if x >= 144 {
942                x -= 168;
943                y += 16;
944                if y >= 112 {
945                    return x;
946                }
947            }
948        }
949    }
950
951    // ─── Queries used while drawing the scene ────────────────────────
952
953    /// Whether the mon pic is currently hidden by an effect (blink-off phase,
954    /// squish/shake-back-and-forth aftermath, Hide effects).
955    pub fn mon_hidden(&self, side: MonSide) -> bool {
956        if self.forced_hidden[side.index()] {
957            return true;
958        }
959        if let Some(blink) = self.blink {
960            if blink.side == side && blink.frame % 10 < 5 {
961                return true;
962            }
963        }
964        false
965    }
966
967    /// Horizontal pic offset from the back-and-forth shake (±1 tile around
968    /// the normal position: (0,5)/(2,5) vs (1,5), and (11,0)/(13,0) vs
969    /// (12,0)).
970    pub fn mon_dx(&self, side: MonSide) -> i32 {
971        if let Some(bnf) = self.shake_bnf {
972            if bnf.side == side {
973                return if (bnf.frame / 3) % 2 == 0 { -8 } else { 8 };
974            }
975        }
976        0
977    }
978
979    /// Vertical pic offset from the bounce (Splash): five 21-frame
980    /// slide-down runs (one row per ~3 frames), the mon popping back to the
981    /// top between runs; the final 3 frames are the re-show (back at the
982    /// normal position).
983    pub fn mon_dy(&self, side: MonSide) -> i32 {
984        if let Some(bounce) = self.bounce {
985            if bounce.side == side {
986                let frame = bounce.frame as i32;
987                if frame >= 5 * 21 {
988                    return 0;
989                }
990                let within = frame % 21;
991                return (within / 3 + 1) * 8;
992            }
993        }
994        0
995    }
996
997    /// Active slide-down-and-hide (Acid Armor) parameters for a side:
998    /// (visible tile rows, vertical offset in px) — the pic is cropped to
999    /// its top rows and drawn lower, matching the 7×5/7×3 tile-id lists.
1000    pub fn slide_down_hide_params(&self, side: MonSide) -> Option<(u32, i32)> {
1001        match self.slide_down_hide {
1002            Some(s) if s.side == side => {
1003                if s.frame < 8 {
1004                    Some((5, 16))
1005                } else {
1006                    Some((3, 32))
1007                }
1008            }
1009            _ => None,
1010        }
1011    }
1012
1013    /// Active squish parameters for a side: (width in tiles, anchor_right).
1014    pub fn squish_params(&self, side: MonSide) -> Option<(u32, bool)> {
1015        match self.squish {
1016            Some(s) if s.side == side => Some(s.params()),
1017            _ => None,
1018        }
1019    }
1020
1021    /// Whether the mon pic is replaced by the minimize blob.
1022    pub fn is_minimized(&self, side: MonSide) -> bool {
1023        self.minimized[side.index()]
1024    }
1025
1026    /// Whether the mon pic is replaced by the substitute doll.
1027    pub fn is_substitute(&self, side: MonSide) -> bool {
1028        self.substitute[side.index()]
1029    }
1030
1031    /// Whether an object effect (balls/petals/leaves/droplets) is active.
1032    pub fn objects_active(&self) -> bool {
1033        !matches!(self.objects, Objects::None)
1034    }
1035
1036    /// Current SCX offset for the enemy-HUD shake (0 when inactive).
1037    pub fn enemy_hud_shake_offset(&self) -> i32 {
1038        match self.hud_shake {
1039            Some(hud) => {
1040                if (hud.frame / 2) % 2 == 0 {
1041                    2
1042                } else {
1043                    -2
1044                }
1045            }
1046            None => 0,
1047        }
1048    }
1049
1050    // ─── Framebuffer passes ──────────────────────────────────────────
1051
1052    /// SCX shake of the enemy HUD strip. Call after the background/HUD is
1053    /// drawn but BEFORE the mon sprites, so only the HUD (and background
1054    /// rows 0..7) shakes — the original protects the player back pic by
1055    /// copying it to OAM first.
1056    pub fn apply_enemy_hud_shake(&self, fb: &mut RgbaIndexedFrameBuffer) {
1057        let dx = self.enemy_hud_shake_offset();
1058        if dx != 0 {
1059            shift_rows_h(fb, HUD_SHAKE_HEIGHT, dx);
1060        }
1061    }
1062
1063    /// Full-screen post effects: screen shake, wavy screen, palette tint,
1064    /// screen flash. Call once the scene is fully drawn.
1065    pub fn apply_screen_effects(&self, fb: &mut RgbaIndexedFrameBuffer) {
1066        if let Some(shake) = self.shake {
1067            let (dx, dy) = shake.offset();
1068            shift_rows_h(fb, SCREEN_SHAKE_HEIGHT, dx);
1069            shift_rows_v(fb, SCREEN_SHAKE_HEIGHT, dy);
1070        }
1071
1072        if self.wave_frame > 0 {
1073            // Wavy screen: per-scanline SCX from WAVY_LINE_OFFSETS;
1074            // the table start advances one entry per frame.
1075            let w = fb.width() as usize;
1076            let src = fb.indexed().clone();
1077            let start = (self.wave_frame - 1) as usize;
1078            for y in 0..fb.height() as usize {
1079                let shift = WAVY_LINE_OFFSETS[(start + y) % 32] as i32;
1080                if shift == 0 {
1081                    continue;
1082                }
1083                for x in 0..w as i32 {
1084                    let sx = (x + shift).clamp(0, w as i32 - 1) as usize;
1085                    let color = src
1086                        .get_pixel(sx as u32, y as u32)
1087                        .unwrap_or(GbColor::White);
1088                    fb.set_pixel_index(x as u32, y as u32, color);
1089                }
1090            }
1091        }
1092
1093        if let Some(tint) = self.tint {
1094            remap_shades(fb, &tint.map());
1095        }
1096
1097        match self.flash {
1098            Some(Flash::Short { frame }) => {
1099                if frame < 2 {
1100                    // rBGP = %00011011: inverted colors.
1101                    remap_shades(fb, &[3, 2, 1, 0]);
1102                } else {
1103                    // rBGP = 0: white out.
1104                    fb.clear(Rgba::WHITE);
1105                }
1106            }
1107            Some(Flash::Long { frame }) => {
1108                // 3 cycles through the 12-step table: the first cycle holds
1109                // each step 2 frames, cycles 2-3 hold 1 frame (48 total).
1110                let step = if frame < 24 {
1111                    (frame / 2) as usize
1112                } else {
1113                    (frame - 24) as usize % 12
1114                };
1115                remap_shades(fb, &FLASH_SCREEN_LONG_PALETTE[step]);
1116            }
1117            None => {}
1118        }
1119    }
1120
1121    /// Draw the active object effect (balls/petals/leaves/droplets) with the
1122    /// move-animation tilesets. `ts0`/`ts1` are the tilesets loaded from
1123    /// move_anim_0.png / move_anim_1.png (tiles indexed from 0; the absolute
1124    /// VRAM tile ids used by the effects are converted with
1125    /// [`ANIM_BASE_TILE_ID`]).
1126    pub fn render_objects(
1127        &self,
1128        fb: &mut RgbaIndexedFrameBuffer,
1129        ts0: &TileSet,
1130        ts1: &TileSet,
1131        pal: &Palette,
1132    ) {
1133        let mut layer = SpriteLayer::new();
1134        match &self.objects {
1135            Objects::None => return,
1136            Objects::SpiralBalls { side, frame } => {
1137                let step = (*frame / 5) as usize;
1138                // Enemy turn: spiral-balls base offset (-40, 80).
1139                let (base_y, base_x) = match side {
1140                    MonSide::Player => (0i32, 0i32),
1141                    MonSide::Enemy => (-40, 80),
1142                };
1143                for k in 0..3 {
1144                    let Some(&(y, x)) = SPIRAL_BALL_COORDS.get(step + k) else {
1145                        break;
1146                    };
1147                    layer.add(Self::oam(
1148                        base_y + y as i32,
1149                        base_x + x as i32,
1150                        BALL_TILE,
1151                        0,
1152                    ));
1153                }
1154                layer.render(fb, ts0, pal, pal, None);
1155            }
1156            Objects::ShootBalls {
1157                pillars, active, ..
1158            } => {
1159                let pillar = &pillars[*active];
1160                for &y in &pillar.balls {
1161                    layer.add(Self::oam(y, pillar.base_x, BALL_TILE, 0));
1162                }
1163                layer.render(fb, ts0, pal, pal, None);
1164            }
1165            Objects::Falling { tile, objects, .. } => {
1166                for obj in objects {
1167                    let attr = if obj.movement & 0x80 != 0 {
1168                        OAM_X_FLIP
1169                    } else {
1170                        0
1171                    };
1172                    layer.add(Self::oam(obj.y as i32, obj.x as i32, *tile, attr));
1173                }
1174                layer.render(fb, ts1, pal, pal, None);
1175            }
1176            Objects::Droplets { half, base_x, .. } => {
1177                let base_y = if *half == 0 { 16 } else { 24 };
1178                let mut x = *base_x;
1179                let mut y = base_y;
1180                loop {
1181                    x += 27;
1182                    if x >= 144 {
1183                        x -= 168;
1184                        y += 16;
1185                        if y >= 112 {
1186                            break;
1187                        }
1188                    }
1189                    layer.add(Self::oam(y, x, DROPLET_TILE, 0));
1190                }
1191                layer.render(fb, ts0, pal, pal, None);
1192            }
1193        }
1194    }
1195
1196    /// OAM → screen coordinates (hardware OAM Y = screen Y + 16, X = screen
1197    /// X + 8) and absolute → tileset-relative tile id.
1198    fn oam(oam_y: i32, oam_x: i32, tile: u8, attr: u8) -> SpriteOamEntry {
1199        SpriteOamEntry::new(
1200            oam_y - 16,
1201            oam_x - 8,
1202            tile.wrapping_sub(ANIM_BASE_TILE_ID),
1203            attr,
1204        )
1205    }
1206
1207    // ─── Mon pic replacements / transforms ───────────────────────────
1208
1209    /// Draw the substitute doll over a mon pic rect.
1210    ///
1211    /// `doll` is the mini-doll tileset (gfx/sprites/monster.png, 24
1212    /// tiles). Placement within the 7×7 pic (mon pic tiles are column-major,
1213    /// tile = col*7 + row):
1214    ///   - enemy turn (facing down): tiles [0,1;2,3] at (col 2, row 4)
1215    ///   - player turn (facing up):  tiles [4,5;6,7] at (col 3, row 4)
1216    pub fn draw_substitute(fb: &mut RgbaIndexedFrameBuffer, rect: MonRect, doll: &TileSet, pal: &Palette, side: MonSide) {
1217        let (base_tile, dx, dy) = match side {
1218            MonSide::Enemy => (0usize, 2 * TILE_SIZE, 4 * TILE_SIZE),
1219            MonSide::Player => (4, 3 * TILE_SIZE, 4 * TILE_SIZE),
1220        };
1221        let origin_x = rect.x + dx as i32;
1222        let origin_y = rect.y + dy as i32;
1223        for t in 0..4usize {
1224            let tile = doll.get(base_tile + t);
1225            let tx = origin_x + (t % 2) as i32 * TILE_PIXELS as i32;
1226            let ty = origin_y + (t / 2) as i32 * TILE_PIXELS as i32;
1227            Self::draw_tile_opaque(fb, tile, tx, ty, pal);
1228        }
1229    }
1230
1231    /// Draw the minimize blob over a mon pic rect. Placement: pic base +
1232    /// (7*3+4) tiles + TILE_SIZE/4 → (col 3, row 4) + 2 px, i.e.
1233    /// pic-relative (24, 34); color index 3.
1234    pub fn draw_minimized(fb: &mut RgbaIndexedFrameBuffer, rect: MonRect, pal: &Palette) {
1235        let color = pal.color(GbColor::from_u8(3));
1236        let ox = rect.x + 3 * TILE_SIZE as i32;
1237        let oy = rect.y + 4 * TILE_SIZE as i32 + 2;
1238        for (row, bits) in MINIMIZED_BLOB.iter().enumerate() {
1239            for (col, &on) in bits.iter().enumerate() {
1240                if on != 0 {
1241                    let x = ox + col as i32;
1242                    let y = oy + row as i32;
1243                    if x >= 0 && y >= 0 {
1244                        fb.set_pixel(x as u32, y as u32, color);
1245                    }
1246                }
1247            }
1248        }
1249    }
1250
1251    /// Draw a mon pic tileset squished horizontally to `width_tiles` tiles
1252    /// (the squish narrows the 7-tile pic one tile per pass, alternating the
1253    /// anchored side). Nearest-neighbor scale; color 0 is transparent like
1254    /// the normal mon blit.
1255    pub fn draw_squished(
1256        fb: &mut RgbaIndexedFrameBuffer,
1257        ts: &TileSet,
1258        x: i32,
1259        y: i32,
1260        tiles_per_row: u32,
1261        pal: &Palette,
1262        width_tiles: u32,
1263        anchor_right: bool,
1264    ) {
1265        if width_tiles == 0 {
1266            return;
1267        }
1268        let full_w = tiles_per_row * TILE_SIZE;
1269        let out_w = width_tiles * TILE_SIZE;
1270        let ox = if anchor_right {
1271            x + (full_w - out_w) as i32
1272        } else {
1273            x
1274        };
1275        let total_rows = ts.len() as u32 / tiles_per_row;
1276        for dy in 0..total_rows * TILE_SIZE {
1277            let ty = dy / TILE_SIZE;
1278            for dx in 0..out_w {
1279                // Map the output column back to the full-width source.
1280                let sx = dx * full_w / out_w;
1281                let tx = sx / TILE_SIZE;
1282                let tile = ts.get((ty * tiles_per_row + tx) as usize);
1283                let c = tile.get((dy % TILE_SIZE) as usize, (sx % TILE_SIZE) as usize);
1284                if c == 0 {
1285                    continue;
1286                }
1287                let px = ox + dx as i32;
1288                let py = y + dy as i32;
1289                if px >= 0 && py >= 0 {
1290                    fb.set_pixel(px as u32, py as u32, pal.color(GbColor::from_u8(c)));
1291                }
1292            }
1293        }
1294    }
1295
1296    /// Draw only the top `rows` tile rows of a mon pic tileset (the
1297    /// slide-down-and-hide redraws the pic with the 7×5 / 7×3 tile-id lists
1298    /// — a crop, not a scale). Color 0 is transparent like the normal mon
1299    /// blit.
1300    pub fn draw_mon_rows(
1301        fb: &mut RgbaIndexedFrameBuffer,
1302        ts: &TileSet,
1303        x: i32,
1304        y: i32,
1305        tiles_per_row: u32,
1306        pal: &Palette,
1307        rows: u32,
1308    ) {
1309        let max_tiles = (rows * tiles_per_row) as usize;
1310        for idx in 0..max_tiles.min(ts.len()) {
1311            let tile = ts.get(idx);
1312            let tx = idx as u32 % tiles_per_row;
1313            let ty = idx as u32 / tiles_per_row;
1314            for row in 0..TILE_PIXELS {
1315                for col in 0..TILE_PIXELS {
1316                    let c = tile.get(row, col);
1317                    if c == 0 {
1318                        continue;
1319                    }
1320                    let px = x + (tx * TILE_SIZE) as i32 + col as i32;
1321                    let py = y + (ty * TILE_SIZE) as i32 + row as i32;
1322                    if px >= 0 && py >= 0 {
1323                        fb.set_pixel(px as u32, py as u32, pal.color(GbColor::from_u8(c)));
1324                    }
1325                }
1326            }
1327        }
1328    }
1329
1330    /// Draw one 8×8 tile with all four shades opaque (the substitute doll
1331    /// replaces the mon pic area, whose background was blanked).
1332    fn draw_tile_opaque(
1333        fb: &mut RgbaIndexedFrameBuffer,
1334        tile: &crate::tile::Tile,
1335        x: i32,
1336        y: i32,
1337        pal: &Palette,
1338    ) {
1339        for row in 0..TILE_PIXELS {
1340            for col in 0..TILE_PIXELS {
1341                let c = tile.get(row, col);
1342                let px = x + col as i32;
1343                let py = y + row as i32;
1344                if px >= 0 && py >= 0 {
1345                    fb.set_pixel(px as u32, py as u32, pal.color(GbColor::from_u8(c)));
1346                }
1347            }
1348        }
1349    }
1350}
1351
1352// ─── Tests ───────────────────────────────────────────────────────────
1353
1354#[cfg(test)]
1355mod tests {
1356    use super::*;
1357    use crate::palette::GRAYSCALE_PALETTE;
1358    use crate::tile::Tile;
1359    use crate::RenderConfig;
1360
1361    fn fb() -> RgbaIndexedFrameBuffer {
1362        RgbaIndexedFrameBuffer::new(RenderConfig::new(160, 144), Rgba::WHITE)
1363    }
1364
1365    fn apply(fx: &mut BattleEffects, effect: AnimEffect) -> u8 {
1366        fx.apply(&effect, MonSide::Player)
1367    }
1368
1369    // ── SquishMonPic ─────────────────────────────────────────────────
1370
1371    #[test]
1372    fn squish_narrows_one_tile_per_pass_alternating_anchor() {
1373        let mut fx = BattleEffects::new();
1374        let wait = apply(&mut fx, AnimEffect::SquishMonPic);
1375        assert_eq!(wait, 26);
1376        // Pass 1 (frames 0-2): 6 tiles, anchored left. Pass 2: 5, right. …
1377        let expected_widths = [6, 5, 4, 3, 2, 1, 0, 0];
1378        for (pass, &w) in expected_widths.iter().enumerate() {
1379            let (width, anchor_right) = fx.squish_params(MonSide::Player).unwrap();
1380            assert_eq!(width, w, "pass {}", pass);
1381            assert_eq!(anchor_right, pass % 2 == 1, "pass {}", pass);
1382            for _ in 0..3 {
1383                fx.tick();
1384            }
1385        }
1386        // 8 passes × 3 frames = 24; two more frames (hide + one more frame).
1387        fx.tick();
1388        fx.tick();
1389        assert!(fx.squish_params(MonSide::Player).is_none());
1390        // Ends hidden.
1391        assert!(fx.mon_hidden(MonSide::Player));
1392        // Re-show clears it.
1393        fx.apply(&AnimEffect::ShowPlayerMon, MonSide::Player);
1394        assert!(!fx.mon_hidden(MonSide::Player));
1395    }
1396
1397    #[test]
1398    fn squish_targets_attacker_side_only() {
1399        let mut fx = BattleEffects::new();
1400        fx.apply(&AnimEffect::SquishMonPic, MonSide::Enemy);
1401        assert!(fx.squish_params(MonSide::Enemy).is_some());
1402        assert!(fx.squish_params(MonSide::Player).is_none());
1403    }
1404
1405    // ── TransformMon ─────────────────────────────────────────────────
1406
1407    #[test]
1408    fn transform_blocks_stream_and_reveals_after_beat() {
1409        let mut fx = BattleEffects::new();
1410        // The SE blocks the command stream (was a 0-wait no-op).
1411        let wait = apply(&mut fx, AnimEffect::TransformMon);
1412        assert_eq!(wait, 12);
1413        // The pic hides immediately (covered by the preceding poof)…
1414        assert!(fx.mon_hidden(MonSide::Player));
1415        assert!(!fx.mon_hidden(MonSide::Enemy));
1416        // …stays hidden for the swap beat…
1417        for _ in 0..5 {
1418            fx.tick();
1419        }
1420        assert!(fx.mon_hidden(MonSide::Player));
1421        // …then reappears (drawn as the opposing species by the frontend)
1422        // before the block ends.
1423        fx.tick();
1424        assert!(!fx.mon_hidden(MonSide::Player));
1425        for _ in 0..6 {
1426            fx.tick();
1427        }
1428        // Latch stays clear once the transform state has run out.
1429        assert!(!fx.mon_hidden(MonSide::Player));
1430    }
1431
1432    #[test]
1433    fn transform_targets_attacker_side() {
1434        let mut fx = BattleEffects::new();
1435        fx.apply(&AnimEffect::TransformMon, MonSide::Enemy);
1436        assert!(fx.mon_hidden(MonSide::Enemy));
1437        assert!(!fx.mon_hidden(MonSide::Player));
1438        // A switch/faint on that side cancels the pending reveal cleanly.
1439        fx.clear_side(MonSide::Enemy);
1440        fx.tick();
1441        assert!(!fx.mon_hidden(MonSide::Enemy));
1442    }
1443
1444    // ── ShakeBackAndForth ────────────────────────────────────────────
1445
1446    #[test]
1447    fn shake_back_and_forth_offsets_and_duration() {
1448        let mut fx = BattleEffects::new();
1449        let wait = apply(&mut fx, AnimEffect::ShakeBackAndForth);
1450        assert_eq!(wait, 96);
1451        for i in 0..32u8 {
1452            let want = if i % 2 == 0 { -8 } else { 8 };
1453            for _ in 0..3 {
1454                assert_eq!(fx.mon_dx(MonSide::Player), want);
1455                fx.tick();
1456            }
1457        }
1458        assert_eq!(fx.mon_dx(MonSide::Player), 0);
1459        // The mon's sprite disappears after this animation.
1460        assert!(fx.mon_hidden(MonSide::Player));
1461    }
1462
1463    // ── Blink ────────────────────────────────────────────────────────
1464
1465    #[test]
1466    fn blink_hides_first_five_of_ten() {
1467        let mut fx = BattleEffects::new();
1468        let wait = apply(&mut fx, AnimEffect::BlinkPlayerMon { times: 6 });
1469        assert_eq!(wait, 60);
1470        for cycle in 0..6 {
1471            for _ in 0..5 {
1472                assert!(fx.mon_hidden(MonSide::Player), "cycle {}", cycle);
1473                fx.tick();
1474            }
1475            for _ in 0..5 {
1476                assert!(!fx.mon_hidden(MonSide::Player), "cycle {}", cycle);
1477                fx.tick();
1478            }
1479        }
1480    }
1481
1482    #[test]
1483    fn blink_enemy_mon_blinks_defender() {
1484        // The enemy blink targets the flipped-turn mon: on the player's
1485        // turn it blinks the enemy, and vice versa.
1486        let mut fx = BattleEffects::new();
1487        fx.apply(&AnimEffect::BlinkEnemyMon { times: 6 }, MonSide::Enemy);
1488        assert!(fx.mon_hidden(MonSide::Player));
1489        assert!(!fx.mon_hidden(MonSide::Enemy));
1490    }
1491
1492    // ── SpiralBallsInward ────────────────────────────────────────────
1493
1494    #[test]
1495    fn spiral_balls_follow_coordinate_sequence() {
1496        let mut fx = BattleEffects::new();
1497        let wait = apply(&mut fx, AnimEffect::SpiralBallsInward);
1498        assert_eq!(wait, 105);
1499        let ts = TileSet::blank(80);
1500        // Frame 0: 3 balls at the first 3 coordinate pairs (player base 0,0).
1501        let mut buf = fb();
1502        fx.render_objects(&mut buf, &ts, &ts, &GRAYSCALE_PALETTE);
1503        if let Objects::SpiralBalls { frame, .. } = &fx.objects {
1504            assert_eq!(*frame, 0);
1505        } else {
1506            panic!("expected spiral balls");
1507        }
1508        // 21 steps × 5 frames, then a short flash fires at the end.
1509        for _ in 0..105 {
1510            fx.tick();
1511        }
1512        assert!(!fx.objects_active());
1513        assert!(matches!(fx.flash, Some(Flash::Short { .. })));
1514    }
1515
1516    #[test]
1517    fn spiral_balls_enemy_base_offset() {
1518        let mut fx = BattleEffects::new();
1519        fx.apply(&AnimEffect::SpiralBallsInward, MonSide::Enemy);
1520        // Enemy base (-40, 80): first ball at OAM (0x38-40, 0x28+80).
1521        if let Objects::SpiralBalls { side, .. } = &fx.objects {
1522            assert_eq!(*side, MonSide::Enemy);
1523        } else {
1524            panic!("expected spiral balls");
1525        }
1526    }
1527
1528    // ── ShootBallsUpward ─────────────────────────────────────────────
1529
1530    #[test]
1531    fn shoot_balls_rise_and_pop_at_top() {
1532        let mut fx = BattleEffects::new();
1533        let wait = apply(&mut fx, AnimEffect::ShootBallsUpward { many: false });
1534        // 5 balls: the last pops after 2*(5-1) = 8 frames.
1535        assert_eq!(wait, 8);
1536        if let Objects::ShootBalls { pillars, .. } = &fx.objects {
1537            // Player turn: base Y = 48, balls at 56, 64, 72, 80, 88.
1538            assert_eq!(pillars[0].base_y, 48);
1539            assert_eq!(pillars[0].base_x, 40);
1540            assert_eq!(pillars[0].balls, vec![56, 64, 72, 80, 88]);
1541        } else {
1542            panic!("expected shoot balls");
1543        }
1544        // Frame 1: first ball already at top (56 = 48+8) → removed.
1545        fx.tick();
1546        if let Objects::ShootBalls { pillars, .. } = &fx.objects {
1547            assert_eq!(pillars[0].balls, vec![60, 68, 76, 84]);
1548        }
1549        for _ in 0..7 {
1550            fx.tick();
1551        }
1552        assert!(!fx.objects_active());
1553    }
1554
1555    #[test]
1556    fn shoot_many_balls_runs_six_pillars() {
1557        let mut fx = BattleEffects::new();
1558        fx.apply(&AnimEffect::ShootBallsUpward { many: true }, MonSide::Player);
1559        if let Objects::ShootBalls { pillars, .. } = &fx.objects {
1560            assert_eq!(pillars.len(), 6);
1561            assert_eq!(pillars[0].base_x, 0x10);
1562            assert_eq!(pillars[0].base_y, 0x50);
1563            assert_eq!(pillars[0].balls.len(), 4);
1564        } else {
1565            panic!("expected shoot balls");
1566        }
1567        // Run to completion; must terminate.
1568        for _ in 0..100 {
1569            fx.tick();
1570        }
1571        assert!(!fx.objects_active());
1572    }
1573
1574    // ── Falling objects (petals/leaves) ──────────────────────────────
1575
1576    #[test]
1577    fn petals_fall_until_first_reaches_104() {
1578        let mut fx = BattleEffects::new();
1579        let wait = apply(&mut fx, AnimEffect::PetalsFalling);
1580        assert_eq!(wait, 156);
1581        if let Objects::Falling { tile, objects, .. } = &fx.objects {
1582            assert_eq!(*tile, PETAL_TILE);
1583            assert_eq!(objects.len(), 20);
1584            // First object starts at Y 0, others at 8*(i+1).
1585            assert_eq!(objects[0].y, 0);
1586            assert_eq!(objects[1].y, 16);
1587            assert_eq!(objects[0].x, FALLING_INITIAL_X[0]);
1588        } else {
1589            panic!("expected falling petals");
1590        }
1591        // After one tick (3 frames): first object Y = 2.
1592        fx.tick();
1593        fx.tick();
1594        fx.tick();
1595        if let Objects::Falling { objects, tick, .. } = &fx.objects {
1596            assert_eq!(*tick, 1);
1597            assert_eq!(objects[0].y, 2);
1598        }
1599        for _ in 0..153 {
1600            fx.tick();
1601        }
1602        assert!(!fx.objects_active());
1603    }
1604
1605    #[test]
1606    fn leaves_use_leaf_tile_and_three_objects() {
1607        let mut fx = BattleEffects::new();
1608        fx.apply(&AnimEffect::LeavesFalling, MonSide::Player);
1609        if let Objects::Falling { tile, objects, .. } = &fx.objects {
1610            assert_eq!(*tile, LEAF_TILE);
1611            assert_eq!(objects.len(), 3);
1612        } else {
1613            panic!("expected falling leaves");
1614        }
1615    }
1616
1617    #[test]
1618    fn falling_movement_byte_wraps_and_flips_direction() {
1619        // Object 0 starts with movement 0x00: deltas increase 0,1,3,5,7,9,11,13,15
1620        // then wrap to 0 with the direction bit flipped (moving left).
1621        let mut fx = BattleEffects::new();
1622        fx.apply(&AnimEffect::LeavesFalling, MonSide::Player);
1623        let x0 = FALLING_INITIAL_X[0];
1624        // Tick 1: movement 1 → delta 1. Tick 2: movement 2 → delta 3. …
1625        let mut expected_x = x0;
1626        for (i, delta) in [1u8, 3, 5, 7, 9, 11, 13, 15].iter().enumerate() {
1627            for _ in 0..3 {
1628                fx.tick();
1629            }
1630            expected_x = expected_x.wrapping_add(*delta);
1631            if let Objects::Falling { objects, .. } = &fx.objects {
1632                assert_eq!(objects[0].x, expected_x, "after tick {}", i + 1);
1633            }
1634        }
1635        // Tick 9: movement would be 9 → wraps to 0 with direction flipped
1636        // (0x80): delta 0, X unchanged but now moving left with X flip.
1637        for _ in 0..3 {
1638            fx.tick();
1639        }
1640        if let Objects::Falling { objects, .. } = &fx.objects {
1641            assert_eq!(objects[0].x, expected_x);
1642            assert_eq!(objects[0].movement, 0x80);
1643        }
1644    }
1645
1646    // ── Water droplets ───────────────────────────────────────────────
1647
1648    #[test]
1649    fn droplets_run_64_frames_and_scroll() {
1650        let mut fx = BattleEffects::new();
1651        let wait = apply(&mut fx, AnimEffect::WaterDroplets);
1652        assert_eq!(wait, 64);
1653        let x_start = match &fx.objects {
1654            Objects::Droplets { base_x, .. } => *base_x,
1655            _ => panic!("expected droplets"),
1656        };
1657        assert_eq!(x_start, -16);
1658        fx.tick();
1659        // After the first half-frame the grid advanced base_x.
1660        let x_after = match &fx.objects {
1661            Objects::Droplets { base_x, .. } => *base_x,
1662            _ => panic!("expected droplets"),
1663        };
1664        assert_ne!(x_after, x_start);
1665        for _ in 0..63 {
1666            fx.tick();
1667        }
1668        assert!(!fx.objects_active());
1669    }
1670
1671    // ── HUD shake ────────────────────────────────────────────────────
1672
1673    #[test]
1674    fn hud_shake_alternates_plus_minus_two() {
1675        let mut fx = BattleEffects::new();
1676        let wait = apply(&mut fx, AnimEffect::ShakeEnemyHud { variant: 1 });
1677        assert_eq!(wait, 32);
1678        let pattern = [2, 2, -2, -2];
1679        for i in 0..32 {
1680            assert_eq!(fx.enemy_hud_shake_offset(), pattern[i % 4], "frame {}", i);
1681            fx.tick();
1682        }
1683        assert_eq!(fx.enemy_hud_shake_offset(), 0);
1684    }
1685
1686    // ── Wavy screen ──────────────────────────────────────────────────
1687
1688    #[test]
1689    fn wavy_screen_lasts_255_frames() {
1690        let mut fx = BattleEffects::new();
1691        let wait = apply(&mut fx, AnimEffect::WavyScreen);
1692        assert_eq!(wait, 255);
1693        for _ in 0..255 {
1694            fx.tick();
1695        }
1696        assert_eq!(fx.wave_frame, 0);
1697    }
1698
1699    // ── Screen shake ─────────────────────────────────────────────────
1700
1701    #[test]
1702    fn shake_screen_decays_amplitude() {
1703        // Screen shake (pixels = 8): displaced 4 frames then at rest 5
1704        // frames per amplitude step, 8 → 1 (72 fr).
1705        let mut fx = BattleEffects::new();
1706        let wait = apply(&mut fx, AnimEffect::ShakeScreenH {
1707            pixels: 8,
1708            frames: 72,
1709        });
1710        assert_eq!(wait, 72);
1711        let offsets: Vec<i32> = (0..9).map(|_| {
1712            let o = fx.shake.unwrap().offset().0;
1713            fx.tick();
1714            o
1715        }).collect();
1716        assert_eq!(offsets, vec![8, 8, 8, 8, 0, 0, 0, 0, 0]);
1717        // Second step: amplitude 7.
1718        assert_eq!(fx.shake.unwrap().offset().0, 7);
1719        for _ in 0..63 {
1720            fx.tick();
1721        }
1722        assert!(fx.shake.is_none());
1723    }
1724
1725    #[test]
1726    fn small_shake_alternates() {
1727        let mut fx = BattleEffects::new();
1728        apply(&mut fx, AnimEffect::ShakeScreenV {
1729            pixels: 1,
1730            frames: 4,
1731        });
1732        let offsets: Vec<i32> = (0..4).map(|_| {
1733            let o = fx.shake.unwrap().offset().1;
1734            fx.tick();
1735            o
1736        }).collect();
1737        assert_eq!(offsets, vec![1, -1, 1, -1]);
1738        assert!(fx.shake.is_none());
1739    }
1740
1741    // ── Flash ────────────────────────────────────────────────────────
1742
1743    #[test]
1744    fn flash_short_and_long_durations() {
1745        let mut fx = BattleEffects::new();
1746        assert_eq!(apply(&mut fx, AnimEffect::FlashScreen { frames: 4 }), 4);
1747        for _ in 0..4 {
1748            fx.tick();
1749        }
1750        assert!(fx.flash.is_none());
1751        assert_eq!(apply(&mut fx, AnimEffect::FlashScreen { frames: 48 }), 48);
1752        for _ in 0..48 {
1753            fx.tick();
1754        }
1755        assert!(fx.flash.is_none());
1756    }
1757
1758    #[test]
1759    fn flash_long_palette_step_timing() {
1760        // 3 cycles through the 12-step table: first cycle 2 frames/step,
1761        // cycles 2-3 1 frame/step. Mirrors apply_screen_effects.
1762        let step_of = |frame: u8| {
1763            if frame < 24 {
1764                (frame / 2) as usize
1765            } else {
1766                (frame - 24) as usize % 12
1767            }
1768        };
1769        assert_eq!(step_of(0), 0);
1770        assert_eq!(step_of(1), 0);
1771        assert_eq!(step_of(2), 1);
1772        assert_eq!(step_of(23), 11);
1773        assert_eq!(step_of(24), 0);
1774        assert_eq!(step_of(35), 11);
1775        assert_eq!(step_of(36), 0);
1776        assert_eq!(step_of(47), 11);
1777    }
1778
1779    // ── BounceUpAndDown ──────────────────────────────────────────────
1780
1781    #[test]
1782    fn bounce_slides_down_five_times_then_shows() {
1783        let mut fx = BattleEffects::new();
1784        // Bounce: 5 × slide-down runs + a re-show.
1785        let wait = apply(&mut fx, AnimEffect::BounceUpAndDown);
1786        assert_eq!(wait, 5 * 21 + 3);
1787        for cycle in 0..5 {
1788            // Each cycle: one row (8 px) per ~3 frames, 8..56 px.
1789            for step in 0..7 {
1790                for _ in 0..3 {
1791                    assert_eq!(
1792                        fx.mon_dy(MonSide::Player),
1793                        (step + 1) * 8,
1794                        "cycle {} step {}",
1795                        cycle,
1796                        step
1797                    );
1798                    fx.tick();
1799                }
1800            }
1801        }
1802        // Re-show: back at the top, not hidden.
1803        assert_eq!(fx.mon_dy(MonSide::Player), 0);
1804        assert!(!fx.mon_hidden(MonSide::Player));
1805    }
1806
1807    // ── SlideMonDownAndHide ──────────────────────────────────────────
1808
1809    #[test]
1810    fn slide_down_hide_shrinks_rows_then_hides() {
1811        let mut fx = BattleEffects::new();
1812        let wait = apply(&mut fx, AnimEffect::SlidePlayerMonDownAndHide);
1813        assert_eq!(wait, 16);
1814        // Step 1 (8 frames): top 5 rows, drawn 2 rows lower.
1815        for _ in 0..8 {
1816            assert_eq!(fx.slide_down_hide_params(MonSide::Player), Some((5, 16)));
1817            fx.tick();
1818        }
1819        // Step 2 (8 frames): top 3 rows, drawn 4 rows lower.
1820        for _ in 0..8 {
1821            assert_eq!(fx.slide_down_hide_params(MonSide::Player), Some((3, 32)));
1822            fx.tick();
1823        }
1824        assert_eq!(fx.slide_down_hide_params(MonSide::Player), None);
1825        // Ends hidden, with the pic tile data blanked.
1826        assert!(fx.mon_hidden(MonSide::Player));
1827        fx.apply(&AnimEffect::ShowPlayerMon, MonSide::Player);
1828        assert!(!fx.mon_hidden(MonSide::Player));
1829    }
1830
1831    #[test]
1832    fn draw_mon_rows_crops_bottom_rows() {
1833        // 7×7 tile pic, tile i solid color (i % 4).
1834        let mut ts = TileSet::blank(49);
1835        for i in 0..49 {
1836            let mut t = Tile::blank();
1837            for row in 0..8 {
1838                for col in 0..8 {
1839                    t.pixels[row][col] = (i % 4) as u8;
1840                }
1841            }
1842            ts.set(i, t);
1843        }
1844        let mut buf = fb();
1845        buf.clear(Rgba::WHITE);
1846        BattleEffects::draw_mon_rows(&mut buf, &ts, 8, 40, 7, &GRAYSCALE_PALETTE, 5);
1847        // Row 4 (tiles 28..35, color 0) drawn; row 5 (tile 35, color 3) not.
1848        let below = buf.get_pixel(8, 40 + 5 * 8).unwrap();
1849        assert_eq!((below.r, below.g, below.b), (255, 255, 255));
1850        // Tile 1 (color 1) in the first row is drawn.
1851        let c1 = buf.get_pixel(8 + 8, 40).unwrap();
1852        assert_eq!((c1.r, c1.g, c1.b), (170, 170, 170));
1853    }
1854
1855    // ── Substitute / minimize latches ────────────────────────────────
1856
1857    #[test]
1858    fn substitute_and_minimize_latch_until_cleared() {
1859        let mut fx = BattleEffects::new();
1860        apply(&mut fx, AnimEffect::SubstituteMon);
1861        assert!(fx.is_substitute(MonSide::Player));
1862        assert!(!fx.is_substitute(MonSide::Enemy));
1863        fx.clear_side(MonSide::Player);
1864        assert!(!fx.is_substitute(MonSide::Player));
1865
1866        apply(&mut fx, AnimEffect::MinimizeMon);
1867        assert!(fx.is_minimized(MonSide::Player));
1868        fx.clear_side(MonSide::Player);
1869        assert!(!fx.is_minimized(MonSide::Player));
1870    }
1871
1872    // ── Drawing ──────────────────────────────────────────────────────
1873
1874    #[test]
1875    fn draw_substitute_places_doll_tiles() {
1876        // Doll tileset: tile i filled with color (i % 4).
1877        let mut doll = TileSet::blank(24);
1878        for i in 0..24 {
1879            let mut t = Tile::blank();
1880            for row in 0..8 {
1881                for col in 0..8 {
1882                    t.pixels[row][col] = (i % 4) as u8;
1883                }
1884            }
1885            doll.set(i, t);
1886        }
1887        let mut buf = fb();
1888        let rect = MonRect { x: 96, y: 0 };
1889        BattleEffects::draw_substitute(&mut buf, rect, &doll, &GRAYSCALE_PALETTE, MonSide::Enemy);
1890        // Enemy doll: tiles [0,1;2,3] at (col 2, row 4) → px (96+16, 32).
1891        assert!(buf.get_pixel(96 + 16, 32).is_some());
1892        // Tile 0 (color 0 → white) at top-left; tile 1 (color 1) at top-right.
1893        let white = buf.get_pixel(96 + 16, 32).unwrap();
1894        assert_eq!((white.r, white.g, white.b), (255, 255, 255));
1895        let c1 = buf.get_pixel(96 + 16 + 8, 32).unwrap();
1896        assert_eq!((c1.r, c1.g, c1.b), (170, 170, 170));
1897        // Pixel outside the doll is untouched (white background anyway, so
1898        // check the boundary: one column left of the doll is from tile 0
1899        // too — check below the doll instead).
1900        let below = buf.get_pixel(96 + 16, 32 + 16).unwrap();
1901        assert_eq!((below.r, below.g, below.b), (255, 255, 255));
1902    }
1903
1904    #[test]
1905    fn draw_minimized_blob_at_pic_offset() {
1906        let mut buf = fb();
1907        buf.clear(Rgba::WHITE);
1908        BattleEffects::draw_minimized(&mut buf, MonRect { x: 8, y: 40 }, &GRAYSCALE_PALETTE);
1909        // Blob at pic-relative (24, 34) → (32, 74); row 0 = ..XX....
1910        assert_eq!(buf.get_pixel(32 + 3, 74).unwrap().r, 0);
1911        assert_eq!(buf.get_pixel(32 + 4, 74).unwrap().r, 0);
1912        assert_eq!(buf.get_pixel(32 + 2, 74).unwrap().r, 255);
1913        // Row 4 = ..X..X..
1914        assert_eq!(buf.get_pixel(32 + 2, 74 + 4).unwrap().r, 0);
1915        assert_eq!(buf.get_pixel(32 + 5, 74 + 4).unwrap().r, 0);
1916    }
1917
1918    #[test]
1919    fn draw_squished_scales_horizontally() {
1920        // 7×1 tile strip, each tile a solid color = its index.
1921        let mut ts = TileSet::blank(7);
1922        for i in 0..7 {
1923            let mut t = Tile::blank();
1924            for row in 0..8 {
1925                for col in 0..8 {
1926                    t.pixels[row][col] = (i % 4) as u8;
1927                }
1928            }
1929            ts.set(i, t);
1930        }
1931        let mut buf = fb();
1932        BattleEffects::draw_squished(&mut buf, &ts, 0, 0, 7, &GRAYSCALE_PALETTE, 4, false);
1933        // 4/7 width → 32 px from the left; source column = dx * 56 / 32.
1934        // dx 0..8 → src 0..14 (tile 0/1), dx 8..16 → src 14..28 ...
1935        assert!(buf.get_pixel(0, 4).is_some());
1936        // Anchored left: nothing drawn at x >= 32.
1937        let right = buf.get_pixel(33, 4).unwrap();
1938        assert_eq!((right.r, right.g, right.b), (255, 255, 255));
1939    }
1940}