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) = (
175                (self.tile_x * self.tile) as f32,
176                (self.tile_y * self.tile) as f32,
177            );
178            let speed = if self.running { RUN_SPEED } else { WALK_SPEED };
179            self.px = step_toward(self.px, tx, speed);
180            self.py = step_toward(self.py, ty, speed);
181            if (self.px - tx).abs() < 0.001 && (self.py - ty).abs() < 0.001 {
182                self.px = tx;
183                self.py = ty;
184                self.moving = false;
185                arrived = Some((self.tile_x, self.tile_y));
186            }
187        }
188
189        if self.moving {
190            self.anim = self.anim.wrapping_add(1);
191            self.idle = 0;
192        } else {
193            self.idle = self.idle.saturating_add(1);
194        }
195        arrived
196    }
197
198    /// Sprite-sheet row for the current facing (the convention `WalkSprite` and the
199    /// `character-sprite-gen` skill author: down=0, up=1, left=2, right=3).
200    pub fn facing_row(&self) -> u32 {
201        match self.facing {
202            Direction::Down => 0,
203            Direction::Up => 1,
204            Direction::Left => 2,
205            Direction::Right => 3,
206        }
207    }
208
209    /// Generic 3-frame walk index: `0` = neutral/idle, `1`/`2` = the two step frames
210    /// (alternating while walking). The consumer maps this to its sheet's columns
211    /// (a clean sheet uses it directly; a sheet whose neutral pose is elsewhere remaps).
212    pub fn walk_frame(&self) -> u32 {
213        if self.idle >= IDLE_GRACE {
214            0
215        } else {
216            1 + (self.anim / ANIM_PHASE) % 2
217        }
218    }
219
220    /// Locomotion state for picking sprite columns: `Idle` when stationary, else
221    /// `Run` if the run flag is set ([`set_running`](Self::set_running)) else `Walk`.
222    pub fn locomotion(&self) -> Locomotion {
223        if self.idle >= IDLE_GRACE {
224            Locomotion::Idle
225        } else if self.running {
226            Locomotion::Run
227        } else {
228            Locomotion::Walk
229        }
230    }
231
232    /// Which of the two step frames to show (`0` or `1`), alternating faster while
233    /// running. Pair with [`frame_col`] to resolve the actual sheet column.
234    pub fn step_phase(&self) -> u32 {
235        let phase = if self.running {
236            RUN_ANIM_PHASE
237        } else {
238            ANIM_PHASE
239        };
240        (self.anim / phase) % 2
241    }
242}
243
244/// Resolve the sprite-sheet column for a locomotion state + step phase, given the
245/// sheet's column count. Canonical overworld layout: col 0 = stand, cols 1/2 = walk,
246/// cols 3/4 = run. Sheets without run frames (`cols < 5`) reuse the walk columns while
247/// running (the faster cadence still reads as a run). `phase` is `0`/`1` from
248/// [`OverworldActor::step_phase`].
249pub fn frame_col(loc: Locomotion, phase: u32, cols: u32) -> u32 {
250    let walk = (1 + phase).min(cols.saturating_sub(1));
251    match loc {
252        Locomotion::Idle => 0,
253        Locomotion::Walk => walk,
254        Locomotion::Run => {
255            if cols >= 5 {
256                3 + phase
257            } else {
258                walk
259            }
260        }
261    }
262}
263
264/// Unit step delta for a cardinal direction.
265fn direction_delta(dir: Direction) -> (i32, i32) {
266    match dir {
267        Direction::Down => (0, 1),
268        Direction::Up => (0, -1),
269        Direction::Left => (-1, 0),
270        Direction::Right => (1, 0),
271    }
272}
273
274/// Move `cur` toward `tgt` by at most `speed` pixels.
275fn step_toward(cur: f32, tgt: f32, speed: f32) -> f32 {
276    cur + (tgt - cur).clamp(-speed, speed)
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    /// A map where a set of tiles is solid; everything else (incl. OOB by convention
284    /// of the caller) walkable.
285    struct Walls(&'static [(i32, i32)]);
286    impl OverworldCollision for Walls {
287        fn is_blocked(&self, x: i32, y: i32) -> bool {
288            self.0.contains(&(x, y))
289        }
290    }
291
292    /// A two-level map: per-level solid sets (index = level).
293    struct Floors(&'static [&'static [(i32, i32)]]);
294    impl OverworldCollision for Floors {
295        fn is_blocked(&self, x: i32, y: i32) -> bool {
296            self.is_blocked_at(0, x, y)
297        }
298        fn is_blocked_at(&self, level: u8, x: i32, y: i32) -> bool {
299            self.0
300                .get(level as usize)
301                .map(|walls| walls.contains(&(x, y)))
302                .unwrap_or(true)
303        }
304    }
305
306    /// Elevation defaults to ground level and is settable (stair transitions).
307    #[test]
308    fn elevation_defaults_and_sets() {
309        let mut a = OverworldActor::new(5, 5, 16);
310        assert_eq!(a.elevation(), 0);
311        a.set_elevation(2);
312        assert_eq!(a.elevation(), 2);
313    }
314
315    /// Movement collision is queried at the actor's elevation: a tile solid
316    /// only on level 0 blocks the ground actor but not the level-1 actor.
317    #[test]
318    fn movement_blocked_per_elevation() {
319        // (6, 5) is solid on level 0 only.
320        let map = Floors(&[&[(6, 5)], &[]]);
321
322        let mut ground = OverworldActor::new(5, 5, 16);
323        assert_eq!(ground.update(Some(Direction::Right), &map), None);
324        assert!(
325            !ground.is_moving(),
326            "solid at level 0 blocks the ground actor"
327        );
328        assert_eq!(ground.tile(), (5, 5));
329
330        let mut upper = OverworldActor::new(5, 5, 16);
331        upper.set_elevation(1);
332        assert!(matches!(upper.update(Some(Direction::Right), &map), None));
333        assert!(
334            upper.is_moving(),
335            "passable at level 1 lets the upper actor move"
336        );
337        assert_eq!(upper.tile(), (6, 5));
338    }
339
340    /// A held direction onto a free tile completes a 16px step over 8 frames and
341    /// reports the arrival tile exactly once.
342    #[test]
343    fn walks_one_tile_in_eight_frames() {
344        let map = Walls(&[]);
345        let mut a = OverworldActor::new(5, 5, 16);
346        for f in 0..8 {
347            let got = a.update(Some(Direction::Right), &map);
348            if f < 7 {
349                assert!(a.is_moving(), "still mid-step at frame {f}");
350                assert_eq!(got, None);
351            } else {
352                assert_eq!(got, Some((6, 5)), "arrives on frame 8");
353            }
354        }
355        assert_eq!(a.tile(), (6, 5));
356        assert!(!a.is_moving());
357        assert_eq!(a.facing(), Direction::Right);
358    }
359
360    /// Facing a wall turns the actor but does not move it.
361    #[test]
362    fn blocked_turns_without_moving() {
363        let map = Walls(&[(5, 4)]);
364        let mut a = OverworldActor::new(5, 5, 16);
365        assert_eq!(a.update(Some(Direction::Up), &map), None);
366        assert!(!a.is_moving());
367        assert_eq!(a.tile(), (5, 5));
368        assert_eq!(a.facing(), Direction::Up);
369    }
370
371    /// Idle shows the neutral frame; walking alternates the two step frames.
372    #[test]
373    fn walk_frame_neutral_then_alternates() {
374        let map = Walls(&[]);
375        let mut a = OverworldActor::new(0, 0, 16);
376        assert_eq!(a.walk_frame(), 0, "starts idle/neutral");
377        let mut seen = crate::hash::HashSet::default();
378        for _ in 0..16 {
379            a.update(Some(Direction::Down), &map);
380            if a.is_moving() {
381                seen.insert(a.walk_frame());
382            }
383        }
384        assert!(
385            seen.contains(&1) && seen.contains(&2),
386            "both step frames appear"
387        );
388        assert!(!seen.contains(&0), "never neutral while walking");
389    }
390
391    /// Running covers a 16px tile in 4 frames (twice walk speed) and reports Run.
392    #[test]
393    fn running_steps_twice_as_fast() {
394        let map = Walls(&[]);
395        let mut a = OverworldActor::new(5, 5, 16);
396        a.set_running(true);
397        for f in 0..4 {
398            let got = a.update(Some(Direction::Right), &map);
399            if f < 3 {
400                assert!(a.is_moving(), "still mid-step at frame {f}");
401                assert_eq!(a.locomotion(), Locomotion::Run);
402            } else {
403                assert_eq!(got, Some((6, 5)), "arrives on frame 4 when running");
404            }
405        }
406        assert_eq!(a.tile(), (6, 5));
407    }
408
409    /// Locomotion reports Idle when stationary, Walk/Run by the run flag while moving.
410    #[test]
411    fn locomotion_reflects_run_flag() {
412        let map = Walls(&[]);
413        let mut a = OverworldActor::new(0, 0, 16);
414        assert_eq!(a.locomotion(), Locomotion::Idle);
415        a.update(Some(Direction::Down), &map);
416        assert_eq!(a.locomotion(), Locomotion::Walk, "moving, not running");
417        a.set_running(true);
418        a.update(Some(Direction::Down), &map);
419        assert_eq!(a.locomotion(), Locomotion::Run, "moving + run flag");
420    }
421
422    /// Canonical column layout: stand=0, walk=1/2, run=3/4; run falls back to walk
423    /// on sheets without run frames.
424    #[test]
425    fn frame_col_canonical_and_fallback() {
426        // 5-col sheet (has run frames)
427        assert_eq!(frame_col(Locomotion::Idle, 0, 5), 0);
428        assert_eq!(frame_col(Locomotion::Walk, 0, 5), 1);
429        assert_eq!(frame_col(Locomotion::Walk, 1, 5), 2);
430        assert_eq!(frame_col(Locomotion::Run, 0, 5), 3);
431        assert_eq!(frame_col(Locomotion::Run, 1, 5), 4);
432        // 3-col sheet (no run frames) — run reuses the walk columns
433        assert_eq!(frame_col(Locomotion::Run, 0, 3), 1);
434        assert_eq!(frame_col(Locomotion::Run, 1, 3), 2);
435        assert_eq!(frame_col(Locomotion::Walk, 1, 3), 2);
436    }
437
438    #[test]
439    fn facing_row_matches_sheet_convention() {
440        let mut a = OverworldActor::new(0, 0, 16);
441        for (dir, row) in [
442            (Direction::Down, 0),
443            (Direction::Up, 1),
444            (Direction::Left, 2),
445            (Direction::Right, 3),
446        ] {
447            a.set_facing(dir);
448            assert_eq!(a.facing_row(), row);
449        }
450    }
451}