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 = std::collections::HashSet::new();
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}