Skip to main content

dotzuki_engine/overworld/
actor.rs

1//! Render- and map-model-agnostic overworld walking actor.
2//!
3//! The GB-shaped overworld ([`super::player_movement`] over `MapTrait`+`TilesetTrait`
4//! with `u8` coords and 4×4 blocks) doesn't fit full-color games whose maps are
5//! source-direct image grids with `i32` coords. This is the **generic** actor every
6//! game can drive: it owns tile position, smooth pixel interpolation, facing, and the
7//! walk-cycle animation state, asking the map only [`OverworldCollision::is_blocked`].
8//!
9//! It returns *indices*, never pixels — the consumer's renderer is free (wuxia/minimon
10//! blit a full-color [`crate`]-external `WalkSprite`; pokered keeps its GB-OAM painter).
11//! NPC occupancy and warps stay caller-side: fold NPCs into `is_blocked`, and check
12//! warps against the tile [`OverworldActor::update`] reports a step completed on.
13
14use super::types::Direction;
15
16/// Pixels the actor advances per frame while walking (a `tile`-px step over `tile/SPEED`
17/// frames — 16px over 8 frames at the default, matching the classic GB cadence).
18const WALK_SPEED: f32 = 2.0;
19/// Walk-cycle: swap the two step frames every this many moving-frames.
20const ANIM_PHASE: u32 = 4;
21/// Frames stationary before the actor is considered idle (bridges the 1-frame gap
22/// between consecutive tile steps so a continuous walk doesn't flicker to neutral).
23const IDLE_GRACE: u32 = 2;
24/// Pixels per frame while *running* (a 16px step over 4 frames — twice walk speed).
25const RUN_SPEED: f32 = 4.0;
26/// Walk-cycle while running: swap the two step frames twice as fast as walking.
27const RUN_ANIM_PHASE: u32 = 2;
28
29/// The minimum a map must answer for the actor to move: is walking onto `(x, y)`
30/// blocked? Out-of-bounds should return `true` (enclosed world). Callers fold in
31/// dynamic collision (NPC tiles, locked doors) by composing it into this impl.
32pub trait OverworldCollision {
33    fn is_blocked(&self, x: i32, y: i32) -> bool;
34
35    /// Elevation-aware form of [`is_blocked`](Self::is_blocked): is walking onto
36    /// `(x, y)` blocked *at elevation `level`*? The default ignores the level
37    /// (single-level maps behave exactly as before); multi-level maps override
38    /// it to answer from the per-level collision grid.
39    fn is_blocked_at(&self, level: u8, x: i32, y: i32) -> bool {
40        let _ = level;
41        self.is_blocked(x, y)
42    }
43}
44
45/// Locomotion state the renderer maps to sprite columns: standing, walking, or
46/// running. `Run` only looks distinct when the sheet has dedicated run frames (the
47/// canonical overworld layout puts them at cols 3/4 — see [`frame_col`]); otherwise
48/// the consumer reuses the walk frames at the faster running cadence.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Locomotion {
51    Idle,
52    Walk,
53    Run,
54}
55
56/// A walking overworld actor (player or NPC): tile target + interpolated pixel
57/// position + facing + walk animation. Tile size is configurable (`tile`).
58#[derive(Debug, Clone)]
59pub struct OverworldActor {
60    /// Target tile (where the foot tile is / is heading).
61    tile_x: i32,
62    tile_y: i32,
63    /// Foot-tile top-left in world pixels (smoothly interpolated while walking).
64    px: f32,
65    py: f32,
66    facing: Direction,
67    moving: bool,
68    /// Walk-cycle phase (advances while moving) and idle-frame counter.
69    anim: u32,
70    idle: u32,
71    /// Whether the consumer is holding "run" this frame (faster speed + animation).
72    running: bool,
73    tile: i32,
74    /// Elevation level the actor walks on (0 = ground). Collision is queried
75    /// per level; stair tiles move the actor between levels (caller-side).
76    elevation: u8,
77}
78
79impl OverworldActor {
80    /// Spawn at tile `(tile_x, tile_y)` facing down, with `tile`-px tiles.
81    pub fn new(tile_x: i32, tile_y: i32, tile: i32) -> Self {
82        Self {
83            tile_x,
84            tile_y,
85            px: (tile_x * tile) as f32,
86            py: (tile_y * tile) as f32,
87            facing: Direction::Down,
88            moving: false,
89            anim: 0,
90            idle: IDLE_GRACE,
91            running: false,
92            tile,
93            elevation: 0,
94        }
95    }
96
97    pub fn facing(&self) -> Direction {
98        self.facing
99    }
100    pub fn set_facing(&mut self, dir: Direction) {
101        self.facing = dir;
102    }
103    pub fn is_moving(&self) -> bool {
104        self.moving
105    }
106    /// Set whether the actor is running this frame — the consumer calls this each
107    /// frame (e.g. from a held run button) before [`update`](Self::update). Running
108    /// uses a faster step speed and animation; the renderer reads
109    /// [`locomotion`](Self::locomotion) + [`step_phase`](Self::step_phase) to pick frames.
110    pub fn set_running(&mut self, running: bool) {
111        self.running = running;
112    }
113    pub fn is_running(&self) -> bool {
114        self.running
115    }
116    /// Target tile coordinates.
117    pub fn tile(&self) -> (i32, i32) {
118        (self.tile_x, self.tile_y)
119    }
120    /// Interpolated foot-tile top-left, in world pixels (for camera + drawing).
121    pub fn px(&self) -> f32 {
122        self.px
123    }
124    pub fn py(&self) -> f32 {
125        self.py
126    }
127    /// Elevation level the actor walks on (0 = ground).
128    pub fn elevation(&self) -> u8 {
129        self.elevation
130    }
131    /// Set the elevation level (stair transitions, spawns, save restore).
132    /// The caller clamps to the map's level count.
133    pub fn set_elevation(&mut self, level: u8) {
134        self.elevation = level;
135    }
136
137    /// Snap to tile `(x, y)`, facing `dir`, stationary (warps, spawns, debug teleport).
138    pub fn place(&mut self, x: i32, y: i32, dir: Direction) {
139        self.tile_x = x;
140        self.tile_y = y;
141        self.px = (x * self.tile) as f32;
142        self.py = (y * self.tile) as f32;
143        self.facing = dir;
144        self.moving = false;
145        self.idle = IDLE_GRACE;
146    }
147
148    /// Advance one frame. When idle and `held` is set, the actor turns to face it and
149    /// — if the next tile isn't [`OverworldCollision::is_blocked_at`] at the
150    /// actor's [`elevation`](Self::elevation) — begins a step.
151    /// The in-progress step's pixel interpolation is advanced. Returns `Some((x, y))`
152    /// on the frame a step *completes* (the tile just arrived on) so the caller can
153    /// check warps; `None` otherwise.
154    pub fn update(
155        &mut self,
156        held: Option<Direction>,
157        map: &impl OverworldCollision,
158    ) -> Option<(i32, i32)> {
159        if !self.moving {
160            if let Some(dir) = held {
161                self.facing = dir;
162                let (dx, dy) = direction_delta(dir);
163                let (nx, ny) = (self.tile_x + dx, self.tile_y + dy);
164                if !map.is_blocked_at(self.elevation, nx, ny) {
165                    self.tile_x = nx;
166                    self.tile_y = ny;
167                    self.moving = true;
168                }
169            }
170        }
171
172        let mut arrived = None;
173        if self.moving {
174            let (tx, ty) = ((self.tile_x * self.tile) as f32, (self.tile_y * self.tile) as f32);
175            let speed = if self.running { RUN_SPEED } else { WALK_SPEED };
176            self.px = step_toward(self.px, tx, speed);
177            self.py = step_toward(self.py, ty, speed);
178            if (self.px - tx).abs() < 0.001 && (self.py - ty).abs() < 0.001 {
179                self.px = tx;
180                self.py = ty;
181                self.moving = false;
182                arrived = Some((self.tile_x, self.tile_y));
183            }
184        }
185
186        if self.moving {
187            self.anim = self.anim.wrapping_add(1);
188            self.idle = 0;
189        } else {
190            self.idle = self.idle.saturating_add(1);
191        }
192        arrived
193    }
194
195    /// Sprite-sheet row for the current facing (the convention `WalkSprite` and the
196    /// `character-sprite-gen` skill author: down=0, up=1, left=2, right=3).
197    pub fn facing_row(&self) -> u32 {
198        match self.facing {
199            Direction::Down => 0,
200            Direction::Up => 1,
201            Direction::Left => 2,
202            Direction::Right => 3,
203        }
204    }
205
206    /// Generic 3-frame walk index: `0` = neutral/idle, `1`/`2` = the two step frames
207    /// (alternating while walking). The consumer maps this to its sheet's columns
208    /// (a clean sheet uses it directly; a sheet whose neutral pose is elsewhere remaps).
209    pub fn walk_frame(&self) -> u32 {
210        if self.idle >= IDLE_GRACE {
211            0
212        } else {
213            1 + (self.anim / ANIM_PHASE) % 2
214        }
215    }
216
217    /// Locomotion state for picking sprite columns: `Idle` when stationary, else
218    /// `Run` if the run flag is set ([`set_running`](Self::set_running)) else `Walk`.
219    pub fn locomotion(&self) -> Locomotion {
220        if self.idle >= IDLE_GRACE {
221            Locomotion::Idle
222        } else if self.running {
223            Locomotion::Run
224        } else {
225            Locomotion::Walk
226        }
227    }
228
229    /// Which of the two step frames to show (`0` or `1`), alternating faster while
230    /// running. Pair with [`frame_col`] to resolve the actual sheet column.
231    pub fn step_phase(&self) -> u32 {
232        let phase = if self.running { RUN_ANIM_PHASE } else { ANIM_PHASE };
233        (self.anim / phase) % 2
234    }
235}
236
237/// Resolve the sprite-sheet column for a locomotion state + step phase, given the
238/// sheet's column count. Canonical overworld layout: col 0 = stand, cols 1/2 = walk,
239/// cols 3/4 = run. Sheets without run frames (`cols < 5`) reuse the walk columns while
240/// running (the faster cadence still reads as a run). `phase` is `0`/`1` from
241/// [`OverworldActor::step_phase`].
242pub fn frame_col(loc: Locomotion, phase: u32, cols: u32) -> u32 {
243    let walk = (1 + phase).min(cols.saturating_sub(1));
244    match loc {
245        Locomotion::Idle => 0,
246        Locomotion::Walk => walk,
247        Locomotion::Run => {
248            if cols >= 5 {
249                3 + phase
250            } else {
251                walk
252            }
253        }
254    }
255}
256
257/// Unit step delta for a cardinal direction.
258fn direction_delta(dir: Direction) -> (i32, i32) {
259    match dir {
260        Direction::Down => (0, 1),
261        Direction::Up => (0, -1),
262        Direction::Left => (-1, 0),
263        Direction::Right => (1, 0),
264    }
265}
266
267/// Move `cur` toward `tgt` by at most `speed` pixels.
268fn step_toward(cur: f32, tgt: f32, speed: f32) -> f32 {
269    cur + (tgt - cur).clamp(-speed, speed)
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    /// A map where a set of tiles is solid; everything else (incl. OOB by convention
277    /// of the caller) walkable.
278    struct Walls(&'static [(i32, i32)]);
279    impl OverworldCollision for Walls {
280        fn is_blocked(&self, x: i32, y: i32) -> bool {
281            self.0.contains(&(x, y))
282        }
283    }
284
285    /// A two-level map: per-level solid sets (index = level).
286    struct Floors(&'static [&'static [(i32, i32)]]);
287    impl OverworldCollision for Floors {
288        fn is_blocked(&self, x: i32, y: i32) -> bool {
289            self.is_blocked_at(0, x, y)
290        }
291        fn is_blocked_at(&self, level: u8, x: i32, y: i32) -> bool {
292            self.0
293                .get(level as usize)
294                .map(|walls| walls.contains(&(x, y)))
295                .unwrap_or(true)
296        }
297    }
298
299    /// Elevation defaults to ground level and is settable (stair transitions).
300    #[test]
301    fn elevation_defaults_and_sets() {
302        let mut a = OverworldActor::new(5, 5, 16);
303        assert_eq!(a.elevation(), 0);
304        a.set_elevation(2);
305        assert_eq!(a.elevation(), 2);
306    }
307
308    /// Movement collision is queried at the actor's elevation: a tile solid
309    /// only on level 0 blocks the ground actor but not the level-1 actor.
310    #[test]
311    fn movement_blocked_per_elevation() {
312        // (6, 5) is solid on level 0 only.
313        let map = Floors(&[&[(6, 5)], &[]]);
314
315        let mut ground = OverworldActor::new(5, 5, 16);
316        assert_eq!(ground.update(Some(Direction::Right), &map), None);
317        assert!(!ground.is_moving(), "solid at level 0 blocks the ground actor");
318        assert_eq!(ground.tile(), (5, 5));
319
320        let mut upper = OverworldActor::new(5, 5, 16);
321        upper.set_elevation(1);
322        assert!(matches!(upper.update(Some(Direction::Right), &map), None));
323        assert!(upper.is_moving(), "passable at level 1 lets the upper actor move");
324        assert_eq!(upper.tile(), (6, 5));
325    }
326
327    /// A held direction onto a free tile completes a 16px step over 8 frames and
328    /// reports the arrival tile exactly once.
329    #[test]
330    fn walks_one_tile_in_eight_frames() {
331        let map = Walls(&[]);
332        let mut a = OverworldActor::new(5, 5, 16);
333        for f in 0..8 {
334            let got = a.update(Some(Direction::Right), &map);
335            if f < 7 {
336                assert!(a.is_moving(), "still mid-step at frame {f}");
337                assert_eq!(got, None);
338            } else {
339                assert_eq!(got, Some((6, 5)), "arrives on frame 8");
340            }
341        }
342        assert_eq!(a.tile(), (6, 5));
343        assert!(!a.is_moving());
344        assert_eq!(a.facing(), Direction::Right);
345    }
346
347    /// Facing a wall turns the actor but does not move it.
348    #[test]
349    fn blocked_turns_without_moving() {
350        let map = Walls(&[(5, 4)]);
351        let mut a = OverworldActor::new(5, 5, 16);
352        assert_eq!(a.update(Some(Direction::Up), &map), None);
353        assert!(!a.is_moving());
354        assert_eq!(a.tile(), (5, 5));
355        assert_eq!(a.facing(), Direction::Up);
356    }
357
358    /// Idle shows the neutral frame; walking alternates the two step frames.
359    #[test]
360    fn walk_frame_neutral_then_alternates() {
361        let map = Walls(&[]);
362        let mut a = OverworldActor::new(0, 0, 16);
363        assert_eq!(a.walk_frame(), 0, "starts idle/neutral");
364        let mut seen = std::collections::HashSet::new();
365        for _ in 0..16 {
366            a.update(Some(Direction::Down), &map);
367            if a.is_moving() {
368                seen.insert(a.walk_frame());
369            }
370        }
371        assert!(seen.contains(&1) && seen.contains(&2), "both step frames appear");
372        assert!(!seen.contains(&0), "never neutral while walking");
373    }
374
375    /// Running covers a 16px tile in 4 frames (twice walk speed) and reports Run.
376    #[test]
377    fn running_steps_twice_as_fast() {
378        let map = Walls(&[]);
379        let mut a = OverworldActor::new(5, 5, 16);
380        a.set_running(true);
381        for f in 0..4 {
382            let got = a.update(Some(Direction::Right), &map);
383            if f < 3 {
384                assert!(a.is_moving(), "still mid-step at frame {f}");
385                assert_eq!(a.locomotion(), Locomotion::Run);
386            } else {
387                assert_eq!(got, Some((6, 5)), "arrives on frame 4 when running");
388            }
389        }
390        assert_eq!(a.tile(), (6, 5));
391    }
392
393    /// Locomotion reports Idle when stationary, Walk/Run by the run flag while moving.
394    #[test]
395    fn locomotion_reflects_run_flag() {
396        let map = Walls(&[]);
397        let mut a = OverworldActor::new(0, 0, 16);
398        assert_eq!(a.locomotion(), Locomotion::Idle);
399        a.update(Some(Direction::Down), &map);
400        assert_eq!(a.locomotion(), Locomotion::Walk, "moving, not running");
401        a.set_running(true);
402        a.update(Some(Direction::Down), &map);
403        assert_eq!(a.locomotion(), Locomotion::Run, "moving + run flag");
404    }
405
406    /// Canonical column layout: stand=0, walk=1/2, run=3/4; run falls back to walk
407    /// on sheets without run frames.
408    #[test]
409    fn frame_col_canonical_and_fallback() {
410        // 5-col sheet (has run frames)
411        assert_eq!(frame_col(Locomotion::Idle, 0, 5), 0);
412        assert_eq!(frame_col(Locomotion::Walk, 0, 5), 1);
413        assert_eq!(frame_col(Locomotion::Walk, 1, 5), 2);
414        assert_eq!(frame_col(Locomotion::Run, 0, 5), 3);
415        assert_eq!(frame_col(Locomotion::Run, 1, 5), 4);
416        // 3-col sheet (no run frames) — run reuses the walk columns
417        assert_eq!(frame_col(Locomotion::Run, 0, 3), 1);
418        assert_eq!(frame_col(Locomotion::Run, 1, 3), 2);
419        assert_eq!(frame_col(Locomotion::Walk, 1, 3), 2);
420    }
421
422    #[test]
423    fn facing_row_matches_sheet_convention() {
424        let mut a = OverworldActor::new(0, 0, 16);
425        for (dir, row) in [
426            (Direction::Down, 0),
427            (Direction::Up, 1),
428            (Direction::Left, 2),
429            (Direction::Right, 3),
430        ] {
431            a.set_facing(dir);
432            assert_eq!(a.facing_row(), row);
433        }
434    }
435}