Skip to main content

dotzuki_renderer/
battle_transition.rs

1use crate::{FrameBuffer, TILE_SIZE};
2
3// Tile $FF is the solid-black battle transition tile (the original loads it at character-RAM tile $7F)
4const BLACK_TILE: u8 = 1;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum BattleTransitionKind {
8    DoubleCircle,
9    Spiral { outward: bool },
10    Circle,
11    SpiralTrainerStronger,
12    HorizontalStripes,
13    Shrink,
14    VerticalStripes,
15    Split,
16}
17
18// ── Circle arc data ─────────────────────────────────────────────────
19//
20// Five per-row (fill_count, skip_count) pair tables for the circle wipe,
21// terminated by -1 ($FF). The classic circle-arc renderer interprets one
22// pair per tilemap row: fill N tiles toward the screen edge, step one row,
23// then move the row start M tiles back toward the arc center.
24const CIRCLE_DATA_1: &[u8] = &[2, 3, 5, 4, 9, 0xFF];
25const CIRCLE_DATA_2: &[u8] = &[1, 1, 2, 2, 4, 2, 4, 2, 3, 0xFF];
26const CIRCLE_DATA_3: &[u8] = &[2, 1, 3, 1, 4, 1, 4, 1, 4, 1, 3, 1, 2, 1, 1, 1, 1, 0xFF];
27const CIRCLE_DATA_4: &[u8] = &[4, 1, 4, 0, 3, 1, 3, 0, 2, 1, 2, 0, 1, 0xFF];
28const CIRCLE_DATA_5: &[u8] = &[4, 0, 3, 0, 3, 0, 2, 0, 2, 0, 1, 0, 1, 0, 1, 0xFF];
29
30// Half-circle side constants (the original's `halves` const block).
31const CIRCLE_LEFT: u8 = 0;
32const CIRCLE_RIGHT: u8 = 1;
33
34/// One `half_circle` macro entry: quadrant x, circle data, target coord.
35struct HalfCircleEntry {
36    quadrant_x: u8,
37    data: &'static [u8],
38    x: i16,
39    y: i16,
40}
41
42// HalfCircle1 — the top half of the circle wipe.
43const HALF_CIRCLE_1: [HalfCircleEntry; 10] = [
44    HalfCircleEntry { quadrant_x: CIRCLE_RIGHT, data: CIRCLE_DATA_1, x: 18, y: 6 },
45    HalfCircleEntry { quadrant_x: CIRCLE_RIGHT, data: CIRCLE_DATA_2, x: 19, y: 3 },
46    HalfCircleEntry { quadrant_x: CIRCLE_RIGHT, data: CIRCLE_DATA_3, x: 18, y: 0 },
47    HalfCircleEntry { quadrant_x: CIRCLE_RIGHT, data: CIRCLE_DATA_4, x: 14, y: 0 },
48    HalfCircleEntry { quadrant_x: CIRCLE_RIGHT, data: CIRCLE_DATA_5, x: 10, y: 0 },
49    HalfCircleEntry { quadrant_x: CIRCLE_LEFT, data: CIRCLE_DATA_5, x: 9, y: 0 },
50    HalfCircleEntry { quadrant_x: CIRCLE_LEFT, data: CIRCLE_DATA_4, x: 5, y: 0 },
51    HalfCircleEntry { quadrant_x: CIRCLE_LEFT, data: CIRCLE_DATA_3, x: 1, y: 0 },
52    HalfCircleEntry { quadrant_x: CIRCLE_LEFT, data: CIRCLE_DATA_2, x: 0, y: 3 },
53    HalfCircleEntry { quadrant_x: CIRCLE_LEFT, data: CIRCLE_DATA_1, x: 1, y: 6 },
54];
55
56// HalfCircle2 — the bottom half of the circle wipe.
57const HALF_CIRCLE_2: [HalfCircleEntry; 10] = [
58    HalfCircleEntry { quadrant_x: CIRCLE_LEFT, data: CIRCLE_DATA_1, x: 1, y: 11 },
59    HalfCircleEntry { quadrant_x: CIRCLE_LEFT, data: CIRCLE_DATA_2, x: 0, y: 14 },
60    HalfCircleEntry { quadrant_x: CIRCLE_LEFT, data: CIRCLE_DATA_3, x: 1, y: 17 },
61    HalfCircleEntry { quadrant_x: CIRCLE_LEFT, data: CIRCLE_DATA_4, x: 5, y: 17 },
62    HalfCircleEntry { quadrant_x: CIRCLE_LEFT, data: CIRCLE_DATA_5, x: 9, y: 17 },
63    HalfCircleEntry { quadrant_x: CIRCLE_RIGHT, data: CIRCLE_DATA_5, x: 10, y: 17 },
64    HalfCircleEntry { quadrant_x: CIRCLE_RIGHT, data: CIRCLE_DATA_4, x: 14, y: 17 },
65    HalfCircleEntry { quadrant_x: CIRCLE_RIGHT, data: CIRCLE_DATA_3, x: 18, y: 17 },
66    HalfCircleEntry { quadrant_x: CIRCLE_RIGHT, data: CIRCLE_DATA_2, x: 19, y: 14 },
67    HalfCircleEntry { quadrant_x: CIRCLE_RIGHT, data: CIRCLE_DATA_1, x: 18, y: 11 },
68];
69
70/// State machine for battle screen-wipe transitions.
71/// Faithful to the original screen-wipe routines — writing tile $FF to
72/// specific tilemap positions at 60fps.
73///
74/// Copy-based transitions (Shrink/Split) additionally redirect each tile to
75/// a shifted source tile (`src`), reproducing the original tile-copy pass:
76/// the overworld visibly compresses toward / splits away from the center
77/// instead of being eaten by growing black borders.
78#[derive(Debug, Clone)]
79pub struct BattleTransitionState {
80    kind: BattleTransitionKind,
81    /// Flattened tile grid (row-major): 0 = show (shifted) overworld pixel,
82    /// 1 = black tile. index = row * width_tiles + col
83    tiles: Vec<u8>,
84    /// Per-tile source tile coordinate in the overworld frame. Identity for
85    /// pure-wipe transitions; Shrink/Split shift these the way the original
86    /// tile-copy pass does. (Black tiles ignore this.)
87    src: Vec<(u8, u8)>,
88    width_tiles: usize,
89    height_tiles: usize,
90    done: bool,
91    /// Generic frame counter / step index
92    frame: u8,
93    /// Secondary counter
94    counter: u8,
95    /// Generic position trackers
96    x: i16,
97    y: i16,
98    dir: u8,
99    /// Half-circle entry index (0..10) and which half (Circle only).
100    hc_step: usize,
101    hc_half: u8,
102    /// Spiral direction state
103    spiral_dir: u8,
104    spiral_x: i16,
105    spiral_y: i16,
106}
107
108/// Minimal tile-level framebuffer view used by transition wipes.
109///
110/// Implemented for the engine's RGBA [`FrameBuffer`] and for the packed
111/// indexed [`crate::IndexedFrameBuffer`]; both layouts store an 8×8 tile
112/// contiguously, so a tile copy is a small memcpy.
113pub trait TransitionFb {
114    /// (width, height) in pixels.
115    fn size(&self) -> (usize, usize);
116    /// Fill the 8×8 tile at tile coordinates (`tx`, `ty`) with black.
117    /// Out-of-bounds tiles are clamped to the visible area.
118    fn tile_black(&mut self, tx: usize, ty: usize);
119    /// Copy the 8×8 tile at tile coordinates (`stx`, `sty`) of `src` into
120    /// tile (`tx`, `ty`) of `self`. Out-of-bounds areas are clamped.
121    fn tile_copy(&mut self, tx: usize, ty: usize, src: &Self, stx: usize, sty: usize);
122}
123
124impl TransitionFb for FrameBuffer {
125    fn size(&self) -> (usize, usize) {
126        (self.width as usize, self.height as usize)
127    }
128    fn tile_black(&mut self, tx: usize, ty: usize) {
129        let (w, h) = self.size();
130        let px = tx * TILE_SIZE as usize;
131        let py = ty * TILE_SIZE as usize;
132        for dy in 0..TILE_SIZE as usize {
133            let y = py + dy;
134            if y >= h {
135                break;
136            }
137            for dx in 0..TILE_SIZE as usize {
138                let x = px + dx;
139                if x >= w {
140                    break;
141                }
142                let off = (y * w + x) * 4;
143                self.data[off] = 0;
144                self.data[off + 1] = 0;
145                self.data[off + 2] = 0;
146            }
147        }
148    }
149    fn tile_copy(&mut self, tx: usize, ty: usize, src: &Self, stx: usize, sty: usize) {
150        let (w, h) = self.size();
151        let px = tx * TILE_SIZE as usize;
152        let py = ty * TILE_SIZE as usize;
153        let spx = stx * TILE_SIZE as usize;
154        let spy = sty * TILE_SIZE as usize;
155        for dy in 0..TILE_SIZE as usize {
156            let y = py + dy;
157            let sy = spy + dy;
158            if y >= h || sy >= h {
159                break;
160            }
161            for dx in 0..TILE_SIZE as usize {
162                let x = px + dx;
163                let sx = spx + dx;
164                if x >= w || sx >= w {
165                    break;
166                }
167                let off = (y * w + x) * 4;
168                let soff = (sy * w + sx) * 4;
169                self.data[off..off + 4].copy_from_slice(&src.data[soff..soff + 4]);
170            }
171        }
172    }
173}
174
175impl<C: crate::palette::ColorIndex> TransitionFb for crate::IndexedFrameBuffer<C> {
176    fn size(&self) -> (usize, usize) {
177        (self.width(), self.height())
178    }
179    fn tile_black(&mut self, tx: usize, ty: usize) {
180        // Index 3 is black (GbColor::Black); the palette maps it to the
181        // darkest shade at present time.
182        self.fill_rect(
183            (tx * TILE_SIZE as usize) as u32,
184            (ty * TILE_SIZE as usize) as u32,
185            TILE_SIZE,
186            TILE_SIZE,
187            C::from_u8(3),
188        );
189    }
190    fn tile_copy(&mut self, tx: usize, ty: usize, src: &Self, stx: usize, sty: usize) {
191        let bits = crate::index_bits::<C>();
192        let gpr = (self.width() + 7) / 8;
193        let (w, h) = self.size();
194        let px = tx * TILE_SIZE as usize;
195        let py = ty * TILE_SIZE as usize;
196        let spx = stx * TILE_SIZE as usize;
197        let spy = sty * TILE_SIZE as usize;
198        for dy in 0..TILE_SIZE as usize {
199            let y = py + dy;
200            let sy = spy + dy;
201            if y >= h || sy >= h {
202                break;
203            }
204            for dx in 0..TILE_SIZE as usize {
205                let x = px + dx;
206                let sx = spx + dx;
207                if x >= w || sx >= w {
208                    break;
209                }
210                // Packed layout: per row, bytes run `gpr * bits` per
211                // row-group; a pixel spans one bit per plane byte.
212                let doff = (y * gpr + x / 8) * bits;
213                let soff = (sy * gpr + sx / 8) * bits;
214                let bit_shift = 7 - (x % 8);
215                let sbit_shift = 7 - (sx % 8);
216                let dst = self.packed_mut();
217                for plane in 0..bits {
218                    let sb = (src.packed()[soff + plane] >> sbit_shift) & 1;
219                    dst[doff + plane] = (dst[doff + plane] & !(1 << bit_shift)) | (sb << bit_shift);
220                }
221            }
222        }
223    }
224}
225
226impl<C: crate::palette::ColorIndex> TransitionFb for crate::RgbaIndexedFrameBuffer<C> {
227    fn size(&self) -> (usize, usize) {
228        (self.width() as usize, self.height() as usize)
229    }
230    fn tile_black(&mut self, tx: usize, ty: usize) {
231        self.indexed_mut().tile_black(tx, ty);
232    }
233    fn tile_copy(&mut self, tx: usize, ty: usize, src: &Self, stx: usize, sty: usize) {
234        self.indexed_mut().tile_copy(tx, ty, src.indexed(), stx, sty);
235    }
236}
237
238impl BattleTransitionState {
239    pub fn new(kind: BattleTransitionKind, width_tiles: usize, height_tiles: usize) -> Self {
240        let w = width_tiles;
241        let h = height_tiles;
242        let mut src = Vec::with_capacity(w * h);
243        for ty in 0..h {
244            for tx in 0..w {
245                src.push((tx as u8, ty as u8));
246            }
247        }
248        Self {
249            kind,
250            tiles: vec![0u8; w * h],
251            src,
252            width_tiles: w,
253            height_tiles: h,
254            done: false,
255            frame: 0,
256            counter: 0,
257            x: 0,
258            y: 0,
259            dir: 0,
260            hc_step: 0,
261            hc_half: 0,
262            spiral_dir: 3,
263            spiral_x: 10,
264            spiral_y: 10,
265        }
266    }
267
268    pub fn is_done(&self) -> bool {
269        self.done
270    }
271
272    pub fn tick(&mut self) {
273        if self.done {
274            return;
275        }
276        match self.kind {
277            BattleTransitionKind::DoubleCircle => self.tick_double_circle(),
278            BattleTransitionKind::Spiral { outward } => {
279                if outward {
280                    self.tick_spiral_outward()
281                } else {
282                    self.tick_spiral_inward()
283                }
284            }
285            BattleTransitionKind::Circle => self.tick_circle(),
286            BattleTransitionKind::SpiralTrainerStronger => self.tick_spiral_outward(),
287            BattleTransitionKind::HorizontalStripes => self.tick_horizontal_stripes(),
288            BattleTransitionKind::Shrink => self.tick_shrink(),
289            BattleTransitionKind::VerticalStripes => self.tick_vertical_stripes(),
290            BattleTransitionKind::Split => self.tick_split(),
291        }
292    }
293
294    /// Render the transition onto `dest_fb`.
295    /// `source_fb` is the overworld frame to wipe away.
296    /// Returns true when the screen is fully blacked (for downstream silhouette slide).
297    ///
298    /// Generic over [`TransitionFb`]: the RGBA engine buffer and the packed
299    /// 2bpp indexed buffer both implement it (tile-granular copies, so the
300    /// indexed variant moves 16 bytes per tile instead of 256).
301    pub fn render<F: TransitionFb>(&self, source_fb: &F, dest_fb: &mut F) -> bool {
302        for ty in 0..self.height_tiles {
303            for tx in 0..self.width_tiles {
304                let idx = ty * self.width_tiles + tx;
305                let black = self.tiles[idx] != 0;
306                // Copy-based transitions (Shrink/Split) redirect this tile to
307                // a shifted source tile; wipe transitions use the identity.
308                let (stx, sty) = self.src[idx];
309                if black {
310                    dest_fb.tile_black(tx, ty);
311                } else {
312                    dest_fb.tile_copy(tx, ty, source_fb, stx as usize, sty as usize);
313                }
314            }
315        }
316        self.all_black()
317    }
318
319    fn all_black(&self) -> bool {
320        for &t in &self.tiles {
321            if t == 0 {
322                return false;
323            }
324        }
325        true
326    }
327
328    fn fill_black(&mut self) {
329        self.tiles.fill(BLACK_TILE);
330    }
331
332    // ── Spirals ─────────────────────────────────────────────────────
333
334    fn tick_spiral_inward(&mut self) {
335        // Inward spiral wipe: walks an inward spiral from the top-left
336        // corner, writing $FF (black) at every tile until the screen is
337        // filled. The original bursts ~7 tiles per ~3-frame delay, giving
338        // roughly 2-3 tiles per 60Hz frame; we match that by stepping a
339        // small batch per tick.
340        // Algorithm: start at (0,0) heading down, turn counter-clockwise
341        // (down → right → up → left → down …) whenever the next cell is
342        // off-screen or already black. This produces a true inward spiral
343        // wipe instead of the previous "left column then snap-to-black" jump.
344        const TILES_PER_TICK: u8 = 3;
345        // Counter-clockwise rotation order starting with "down".
346        const DIRECTIONS: [(i16, i16); 4] = [
347            (0, 1),  // 0: down
348            (1, 0),  // 1: right
349            (0, -1), // 2: up
350            (-1, 0), // 3: left
351        ];
352
353        let w = self.width_tiles as i16;
354        let h = self.height_tiles as i16;
355
356        // First entry: start at (0,0) heading down.
357        if self.frame == 0 {
358            self.x = 0;
359            self.y = 0;
360            self.dir = 0;
361            self.frame = 1;
362        }
363
364        for _ in 0..TILES_PER_TICK {
365            // Plot current tile.
366            if self.x >= 0 && self.y >= 0 {
367                self.set_tile(self.x as usize, self.y as usize);
368            }
369
370            // Try to advance in the current direction; turn until we find a
371            // free cell. If we can't find one after a full rotation the
372            // spiral has filled the screen.
373            let mut moved = false;
374            for _ in 0..4 {
375                let (dx, dy) = DIRECTIONS[self.dir as usize];
376                let nx = self.x + dx;
377                let ny = self.y + dy;
378                let in_bounds = nx >= 0 && nx < w && ny >= 0 && ny < h;
379                if in_bounds && self.tiles[ny as usize * self.width_tiles + nx as usize] == 0 {
380                    self.x = nx;
381                    self.y = ny;
382                    moved = true;
383                    break;
384                }
385                self.dir = (self.dir + 1) % 4;
386            }
387
388            if !moved {
389                self.fill_black();
390                self.done = true;
391                return;
392            }
393        }
394    }
395
396    fn tick_spiral_outward(&mut self) {
397        // Outward spiral wipe: starts from center (10,10), fills 3 tiles per
398        // inner loop, 120 outer loops. Direction rotates when hitting a
399        // filled tile: up → left → down → right
400        const DIRECTIONS: [(i16, i16); 4] = [(0, -1), (-1, 0), (0, 1), (1, 0)];
401
402        let w = self.width_tiles as i16;
403        let h = self.height_tiles as i16;
404
405        if self.spiral_x < 0 || self.spiral_x >= w || self.spiral_y < 0 || self.spiral_y >= h {
406            self.fill_black();
407            self.done = true;
408            return;
409        }
410
411        // Write 3 tiles per frame (the original's inner loop count is 3)
412        for _ in 0..3 {
413            if self.spiral_x >= 0 && self.spiral_x < w && self.spiral_y >= 0 && self.spiral_y < h {
414                self.set_tile(self.spiral_x as usize, self.spiral_y as usize);
415            }
416
417            let (dx, dy) = DIRECTIONS[self.spiral_dir as usize];
418            let nx = self.spiral_x + dx;
419            let ny = self.spiral_y + dy;
420
421            // Check if next tile in current direction is already filled
422            if nx >= 0
423                && nx < w
424                && ny >= 0
425                && ny < h
426                && self.tiles[ny as usize * self.width_tiles + nx as usize] == 0
427            {
428                self.spiral_x = nx;
429                self.spiral_y = ny;
430            } else {
431                // Change direction
432                self.spiral_dir = (self.spiral_dir + 1) % 4;
433                let (ndx, ndy) = DIRECTIONS[self.spiral_dir as usize];
434                self.spiral_x += ndx;
435                self.spiral_y += ndy;
436            }
437        }
438
439        if self.spiral_x < 0 || self.spiral_x >= w || self.spiral_y < 0 || self.spiral_y >= h {
440            self.fill_black();
441            self.done = true;
442        }
443    }
444
445    // ── Circle / DoubleCircle ───────────────────────────────────────
446
447    /// Draw one `half_circle` arc entry (the classic circle-arc renderer).
448    /// `quadrant_y` selects the row-step direction (0 = down for HalfCircle1,
449    /// 1 = up for HalfCircle2).
450    fn draw_arc(&mut self, entry: &HalfCircleEntry, quadrant_y: u8) {
451        let fill_dir: i16 = if entry.quadrant_x == CIRCLE_RIGHT { 1 } else { -1 };
452        let row_dir: i16 = if quadrant_y == 0 { 1 } else { -1 };
453        let mut x = entry.x;
454        let mut y = entry.y;
455        let mut i = 0;
456        loop {
457            // Fill `fill` tiles toward the screen edge. The row start is
458            // preserved (the original saves and restores it) — the fill
459            // does NOT move the persistent cursor.
460            let fill = entry.data[i];
461            i += 1;
462            let row_start_x = x;
463            for _ in 0..fill {
464                if x >= 0 && y >= 0 {
465                    self.set_tile(x as usize, y as usize);
466                }
467                x += fill_dir;
468            }
469            x = row_start_x;
470            // Step one row toward the arc interior.
471            y += row_dir;
472            let skip = entry.data[i];
473            i += 1;
474            if skip == 0xFF {
475                break; // -1 terminator
476            }
477            // Move the next row's start back toward the arc center.
478            for _ in 0..skip {
479                x -= fill_dir;
480            }
481        }
482    }
483
484    fn tick_circle(&mut self) {
485        // Circle wipe — plays the top half-circle fully, then the bottom
486        // half-circle. One arc entry per step, with a ~3-frame delay
487        // between entries.
488        if self.counter > 0 {
489            self.counter -= 1;
490            return;
491        }
492        let entry = if self.hc_half == 0 {
493            &HALF_CIRCLE_1[self.hc_step]
494        } else {
495            &HALF_CIRCLE_2[self.hc_step]
496        };
497        self.draw_arc(entry, self.hc_half);
498        self.hc_step += 1;
499        if self.hc_step >= 10 {
500            if self.hc_half == 0 {
501                self.hc_half = 1;
502                self.hc_step = 0;
503                self.counter = 2; // ~3-frame delay
504            } else {
505                self.fill_black();
506                self.done = true;
507            }
508        } else {
509            self.counter = 2; // ~3-frame delay
510        }
511    }
512
513    fn tick_double_circle(&mut self) {
514        // Double circle wipe — animates BOTH half circles at the same time:
515        // one entry from each per ~3-frame delay.
516        if self.counter > 0 {
517            self.counter -= 1;
518            return;
519        }
520        self.draw_arc(&HALF_CIRCLE_1[self.hc_step], 0);
521        self.draw_arc(&HALF_CIRCLE_2[self.hc_step], 1);
522        self.hc_step += 1;
523        if self.hc_step >= 10 {
524            self.fill_black();
525            self.done = true;
526        } else {
527            self.counter = 2; // ~3-frame delay
528        }
529    }
530
531    // ── Horizontal Stripes ──────────────────────────────────────────
532
533    fn tick_horizontal_stripes(&mut self) {
534        // Horizontal-stripes wipe.
535        // Works on columns. Left side starts at (0,0), right at (19,1).
536        // Each tick: fill every SCREEN_HEIGHT/2 row in current column, move inward.
537        // ~3 frames between every column transfer (matching the original).
538        if self.counter > 0 {
539            self.counter -= 1;
540            return;
541        }
542        let col = self.frame as usize;
543        if col >= self.width_tiles {
544            self.fill_black();
545            self.done = true;
546            return;
547        }
548
549        // Left column: every 2nd row
550        for row in (0..self.height_tiles).step_by(2) {
551            self.set_tile(col, row);
552        }
553        // Right column: every 2nd row (offset by 1)
554        let rcol = self.width_tiles - 1 - col;
555        for row in (1..self.height_tiles).step_by(2) {
556            self.set_tile(rcol, row);
557        }
558
559        self.frame += 1;
560        self.counter = 3; // ~3-frame delay
561    }
562
563    // ── Vertical Stripes ────────────────────────────────────────────
564
565    fn tick_vertical_stripes(&mut self) {
566        // Vertical-stripes wipe.
567        // Works on rows. Top starts at (0,0), bottom at (1,17).
568        // Each tick: fill every SCREEN_WIDTH/2 col in current row, move inward.
569        if self.counter > 0 {
570            self.counter -= 1;
571            return;
572        }
573        let row = self.frame as usize;
574        if row >= self.height_tiles {
575            self.fill_black();
576            self.done = true;
577            return;
578        }
579
580        // Top row: every 2nd column
581        for col in (0..self.width_tiles).step_by(2) {
582            self.set_tile(col, row);
583        }
584        // Bottom row: every 2nd column
585        let brow = self.height_tiles - 1 - row;
586        for col in (0..self.width_tiles).step_by(2) {
587            self.set_tile(col, brow);
588        }
589
590        self.frame += 1;
591        self.counter = 3;
592    }
593
594    // ── Shrink ──────────────────────────────────────────────────────
595
596    /// Copy one tile's (black-flag, source-tile) pair — the equivalent of
597    /// the original tilemap byte copy, which also carries already-black tiles.
598    fn copy_tile(&mut self, sx: usize, sy: usize, dx: usize, dy: usize) {
599        let s = sy * self.width_tiles + sx;
600        let d = dy * self.width_tiles + dx;
601        self.tiles[d] = self.tiles[s];
602        self.src[d] = self.src[s];
603    }
604
605    fn tick_shrink(&mut self) {
606        // Shrink wipe: 9 steps (SCREEN_HEIGHT/2), ~6 frames between steps.
607        // Each step tile-COPIES rows/columns one tile toward the screen
608        // center, so the overworld visibly COMPRESSES; the freed outer
609        // row/column is filled with tile $FF.
610        if self.counter > 0 {
611            self.counter -= 1;
612            return;
613        }
614        let step = self.frame as usize;
615        let w = self.width_tiles;
616        let h = self.height_tiles;
617        if step >= h / 2 {
618            self.fill_black();
619            self.done = true;
620            return;
621        }
622
623        let rmid = h / 2; // 9
624        let cmid = w / 2; // 10
625
626        // Vertical pass: top half rows shift DOWN one row
627        // (rows 0..7 copied into 1..8), bottom half rows shift UP one row
628        // (rows 10..17 copied into 9..16).
629        for y in (1..rmid).rev() {
630            for x in 0..w {
631                self.copy_tile(x, y - 1, x, y);
632            }
633        }
634        for y in rmid..(h - 1) {
635            for x in 0..w {
636                self.copy_tile(x, y + 1, x, y);
637            }
638        }
639        // Freed outer rows are blacked.
640        for x in 0..w {
641            self.set_tile(x, 0);
642            self.set_tile(x, h - 1);
643        }
644
645        // Horizontal pass: left half cols shift RIGHT one col
646        // (cols 0..8 copied into 1..9), right half cols shift LEFT one col
647        // (cols 11..19 copied into 10..18).
648        for x in (1..cmid).rev() {
649            for y in 0..h {
650                self.copy_tile(x - 1, y, x, y);
651            }
652        }
653        for x in cmid..(w - 1) {
654            for y in 0..h {
655                self.copy_tile(x + 1, y, x, y);
656            }
657        }
658        // Freed outer columns are blacked.
659        for y in 0..h {
660            self.set_tile(0, y);
661            self.set_tile(w - 1, y);
662        }
663
664        self.frame += 1;
665        self.counter = 5; // ~6 frames between steps
666    }
667
668    // ── Split ───────────────────────────────────────────────────────
669
670    fn tick_split(&mut self) {
671        // Split wipe: 9 steps, ~6 frames between steps. Each step
672        // tile-COPIES rows/columns one tile AWAY from the center, so the
673        // overworld visibly SPLITS apart; the freed center row/column is
674        // filled with tile $FF.
675        if self.counter > 0 {
676            self.counter -= 1;
677            return;
678        }
679        let step = self.frame as usize;
680        let w = self.width_tiles;
681        let h = self.height_tiles;
682        if step >= h / 2 {
683            self.fill_black();
684            self.done = true;
685            return;
686        }
687
688        let rmid = h / 2; // 9
689        let cmid = w / 2; // 10
690
691        // Vertical pass: bottom half rows shift DOWN one row
692        // (rows 9..16 copied into 10..17), top half rows shift UP one row
693        // (rows 1..8 copied into 0..7).
694        for y in ((rmid + 1)..h).rev() {
695            for x in 0..w {
696                self.copy_tile(x, y - 1, x, y);
697            }
698        }
699        for y in 0..(rmid - 1) {
700            for x in 0..w {
701                self.copy_tile(x, y + 1, x, y);
702            }
703        }
704        // Freed center rows are blacked.
705        for x in 0..w {
706            self.set_tile(x, rmid - 1);
707            self.set_tile(x, rmid);
708        }
709
710        // Horizontal pass: right half cols shift RIGHT one col
711        // (cols 10..18 copied into 11..19), left half cols shift LEFT one
712        // col (cols 1..9 copied into 0..8).
713        for x in ((cmid + 1)..w).rev() {
714            for y in 0..h {
715                self.copy_tile(x - 1, y, x, y);
716            }
717        }
718        for x in 0..(cmid - 1) {
719            for y in 0..h {
720                self.copy_tile(x + 1, y, x, y);
721            }
722        }
723        // Freed center columns are blacked.
724        for y in 0..h {
725            self.set_tile(cmid - 1, y);
726            self.set_tile(cmid, y);
727        }
728
729        self.frame += 1;
730        self.counter = 5; // 6 frames between steps (3 + 3)
731    }
732
733    // ── Helpers ─────────────────────────────────────────────────────
734
735    fn set_tile(&mut self, x: usize, y: usize) {
736        if x < self.width_tiles && y < self.height_tiles {
737            self.tiles[y * self.width_tiles + x] = BLACK_TILE;
738        }
739    }
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745    use crate::Rgba;
746    use dotzuki_engine::render_config::RenderConfig;
747
748    fn tile_is_black(state: &BattleTransitionState, x: usize, y: usize) -> bool {
749        state.tiles[y * state.width_tiles + x] != 0
750    }
751
752    fn src_of(state: &BattleTransitionState, x: usize, y: usize) -> (u8, u8) {
753        state.src[y * state.width_tiles + x]
754    }
755
756    #[test]
757    fn test_battle_transition_state_new() {
758        let state = BattleTransitionState::new(BattleTransitionKind::Circle, 20, 18);
759        assert_eq!(state.tiles.len(), 360); // width_tiles * height_tiles
760        assert_eq!(state.width_tiles, 20);
761        assert_eq!(state.height_tiles, 18);
762        assert!(!state.is_done());
763        // Source map starts as the identity.
764        assert_eq!(src_of(&state, 5, 7), (5, 7));
765    }
766
767    #[test]
768    fn test_battle_transition_state_set_tile() {
769        let mut state = BattleTransitionState::new(BattleTransitionKind::Circle, 20, 18);
770        state.set_tile(0, 0);
771        assert_eq!(state.tiles[0], 1); // BLACK_TILE at index 0
772    }
773
774    // ── Circle arc rendering ──────────────────────────────────────
775
776    #[test]
777    fn test_circle_first_arc_entry() {
778        // First HalfCircle1 entry: (CIRCLE_RIGHT, CircleData1, 18, 6),
779        // quadrant_y=0 (rows step down). CircleData1 = fill 2 / skip 3 /
780        // fill 5 / skip 4 / fill 9 / end.
781        let mut state = BattleTransitionState::new(BattleTransitionKind::Circle, 20, 18);
782        state.tick();
783        // Row 6: fill 2 from (18,6) rightward → (18,6), (19,6)
784        assert!(tile_is_black(&state, 18, 6));
785        assert!(tile_is_black(&state, 19, 6));
786        assert!(!tile_is_black(&state, 17, 6));
787        // Row 7: row start 18, skip 3 → fill 5 from (15,7) → 15..=19
788        assert!(tile_is_black(&state, 15, 7));
789        assert!(tile_is_black(&state, 19, 7));
790        assert!(!tile_is_black(&state, 14, 7));
791        // Row 8: row start 15, skip 4 → fill 9 from (11,8) → 11..=19
792        assert!(tile_is_black(&state, 11, 8));
793        assert!(tile_is_black(&state, 19, 8));
794        assert!(!tile_is_black(&state, 10, 8));
795        // Nothing else yet.
796        assert!(!tile_is_black(&state, 0, 0));
797        assert!(!tile_is_black(&state, 10, 10));
798        assert!(!state.is_done());
799    }
800
801    #[test]
802    fn test_double_circle_first_tick_draws_both_halves() {
803        // DoubleCircle draws one entry from EACH half per step.
804        let mut state = BattleTransitionState::new(BattleTransitionKind::DoubleCircle, 20, 18);
805        state.tick();
806        // Top-half entry: (CIRCLE_RIGHT, CircleData1, 18, 6) — same as Circle.
807        assert!(tile_is_black(&state, 18, 6));
808        assert!(tile_is_black(&state, 15, 7));
809        assert!(tile_is_black(&state, 11, 8));
810        // Bottom-half entry: (CIRCLE_LEFT, CircleData1, 1, 11), rows step UP.
811        // Row 11: fill 2 leftward from (1,11) → (1,11), (0,11)
812        assert!(tile_is_black(&state, 1, 11));
813        assert!(tile_is_black(&state, 0, 11));
814        assert!(!tile_is_black(&state, 2, 11));
815        // Row 10: row start 1, skip 3 (toward center = +x for LEFT) → fill 5
816        // leftward from (4,10) → 0..=4
817        assert!(tile_is_black(&state, 4, 10));
818        assert!(tile_is_black(&state, 0, 10));
819        assert!(!tile_is_black(&state, 5, 10));
820        // Row 9: row start 4, skip 4 → fill 9 leftward from (8,9) → 0..=8
821        assert!(tile_is_black(&state, 8, 9));
822        assert!(tile_is_black(&state, 0, 9));
823        assert!(!tile_is_black(&state, 9, 9));
824    }
825
826    #[test]
827    fn test_circle_completes_fully_black() {
828        // 20 arc entries (10 per half) × 3 frames per entry.
829        let mut state = BattleTransitionState::new(BattleTransitionKind::Circle, 20, 18);
830        let mut ticks = 0;
831        while !state.is_done() && ticks < 120 {
832            state.tick();
833            ticks += 1;
834        }
835        assert!(state.is_done());
836        assert!(ticks <= 20 * 3, "took {ticks} ticks");
837        assert!(state.all_black());
838    }
839
840    #[test]
841    fn test_double_circle_completes_fully_black() {
842        // 10 steps (both halves simultaneously) × 3 frames per step.
843        let mut state = BattleTransitionState::new(BattleTransitionKind::DoubleCircle, 20, 18);
844        let mut ticks = 0;
845        while !state.is_done() && ticks < 60 {
846            state.tick();
847            ticks += 1;
848        }
849        assert!(state.is_done());
850        assert!(ticks <= 10 * 3, "took {ticks} ticks");
851        assert!(state.all_black());
852    }
853
854    // ── Shrink / Split tile copies ───────────────────────────────
855
856    #[test]
857    fn test_shrink_first_step_compresses_toward_center() {
858        let mut state = BattleTransitionState::new(BattleTransitionKind::Shrink, 20, 18);
859        state.tick();
860        // Freed outer rows/columns are black.
861        for x in 0..20 {
862            assert!(tile_is_black(&state, x, 0), "row 0, col {x}");
863            assert!(tile_is_black(&state, x, 17), "row 17, col {x}");
864        }
865        for y in 0..18 {
866            assert!(tile_is_black(&state, 0, y), "col 0, row {y}");
867            assert!(tile_is_black(&state, 19, y), "col 19, row {y}");
868        }
869        // Interior shifts one tile toward the center — vertically first,
870        // then horizontally on the shifted content (original order: vertical
871        // top/bottom, then horizontal left/right). So dest (5,1) shows the
872        // tile that was at (4,0): up one row, then left one column.
873        assert_eq!(src_of(&state, 5, 1), (4, 0));
874        assert_eq!(src_of(&state, 5, 8), (4, 7));
875        assert_eq!(src_of(&state, 5, 9), (4, 10));
876        assert_eq!(src_of(&state, 5, 16), (4, 17));
877        assert_eq!(src_of(&state, 1, 5), (0, 4));
878        assert_eq!(src_of(&state, 9, 5), (8, 4));
879        assert_eq!(src_of(&state, 10, 5), (11, 4));
880        assert_eq!(src_of(&state, 18, 5), (19, 4));
881        assert!(!state.is_done());
882    }
883
884    #[test]
885    fn test_split_first_step_pushes_away_from_center() {
886        let mut state = BattleTransitionState::new(BattleTransitionKind::Split, 20, 18);
887        state.tick();
888        // Freed CENTER rows/columns are black.
889        for x in 0..20 {
890            assert!(tile_is_black(&state, x, 8), "row 8, col {x}");
891            assert!(tile_is_black(&state, x, 9), "row 9, col {x}");
892        }
893        for y in 0..18 {
894            assert!(tile_is_black(&state, 9, y), "col 9, row {y}");
895            assert!(tile_is_black(&state, 10, y), "col 10, row {y}");
896        }
897        // Interior shifts one tile AWAY from the center — vertically first,
898        // then horizontally on the shifted content (same ordering as
899        // Shrink). Dest (5,0) shows the tile that was at (6,1): down one
900        // row, then right one column.
901        assert_eq!(src_of(&state, 5, 0), (6, 1));
902        assert_eq!(src_of(&state, 5, 7), (6, 8));
903        assert_eq!(src_of(&state, 5, 10), (6, 9));
904        assert_eq!(src_of(&state, 5, 17), (6, 16));
905        assert_eq!(src_of(&state, 0, 5), (1, 6));
906        assert_eq!(src_of(&state, 8, 5), (9, 6));
907        assert_eq!(src_of(&state, 11, 5), (10, 6));
908        assert_eq!(src_of(&state, 19, 5), (18, 6));
909        assert!(!state.is_done());
910    }
911
912    #[test]
913    fn test_shrink_completes_fully_black() {
914        // 9 steps × 6 frames between steps.
915        let mut state = BattleTransitionState::new(BattleTransitionKind::Shrink, 20, 18);
916        let mut ticks = 0;
917        while !state.is_done() && ticks < 120 {
918            state.tick();
919            ticks += 1;
920        }
921        assert!(state.is_done());
922        assert!(ticks <= 9 * 6 + 1, "took {ticks} ticks");
923        assert!(state.all_black());
924    }
925
926    #[test]
927    fn test_split_completes_fully_black() {
928        let mut state = BattleTransitionState::new(BattleTransitionKind::Split, 20, 18);
929        let mut ticks = 0;
930        while !state.is_done() && ticks < 120 {
931            state.tick();
932            ticks += 1;
933        }
934        assert!(state.is_done());
935        assert!(ticks <= 9 * 6 + 1, "took {ticks} ticks");
936        assert!(state.all_black());
937    }
938
939    #[test]
940    fn test_shrink_render_copies_source_pixels() {
941        // The render must blit the SHIFTED overworld content, not a mask.
942        let mut state = BattleTransitionState::new(BattleTransitionKind::Shrink, 20, 18);
943        let mut source = FrameBuffer::new(RenderConfig::new(160, 144), Rgba::BLACK);
944        // Paint every tile a distinct shade: tile (x,y) → gray value x+y*20.
945        for ty in 0..18usize {
946            for tx in 0..20usize {
947                let v = (tx + ty * 20) as u8;
948                for dy in 0..8usize {
949                    for dx in 0..8usize {
950                        let off = ((ty * 8 + dy) * 160 + (tx * 8 + dx)) * 4;
951                        source.data[off] = v;
952                        source.data[off + 1] = v;
953                        source.data[off + 2] = v;
954                        source.data[off + 3] = 255;
955                    }
956                }
957            }
958        }
959        state.tick(); // one shrink step
960        let mut dest = FrameBuffer::new(RenderConfig::new(160, 144), Rgba::BLACK);
961        state.render(&source, &mut dest);
962        // Tile (5,1) now shows source tile (4,0) → value 4.
963        let off = ((1 * 8 + 3) * 160 + (5 * 8 + 3)) * 4;
964        assert_eq!(dest.data[off], 4);
965        // Tile (0,0) is black.
966        assert_eq!(dest.data[0], 0);
967        // Tile (5,10) shows source tile (4,11) → value 4 + 11*20.
968        let off2 = ((10 * 8 + 3) * 160 + (5 * 8 + 3)) * 4;
969        assert_eq!(dest.data[off2], (4 + 11 * 20) as u8);
970    }
971}