1use super::types::Direction;
15
16const WALK_SPEED: f32 = 2.0;
19const ANIM_PHASE: u32 = 4;
21const IDLE_GRACE: u32 = 2;
24const RUN_SPEED: f32 = 4.0;
26const RUN_ANIM_PHASE: u32 = 2;
28
29pub trait OverworldCollision {
33 fn is_blocked(&self, x: i32, y: i32) -> bool;
34
35 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Locomotion {
51 Idle,
52 Walk,
53 Run,
54}
55
56#[derive(Debug, Clone)]
59pub struct OverworldActor {
60 tile_x: i32,
62 tile_y: i32,
63 px: f32,
65 py: f32,
66 facing: Direction,
67 moving: bool,
68 anim: u32,
70 idle: u32,
71 running: bool,
73 tile: i32,
74 elevation: u8,
77}
78
79impl OverworldActor {
80 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 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 pub fn tile(&self) -> (i32, i32) {
118 (self.tile_x, self.tile_y)
119 }
120 pub fn px(&self) -> f32 {
122 self.px
123 }
124 pub fn py(&self) -> f32 {
125 self.py
126 }
127 pub fn elevation(&self) -> u8 {
129 self.elevation
130 }
131 pub fn set_elevation(&mut self, level: u8) {
134 self.elevation = level;
135 }
136
137 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 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 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 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 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 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
237pub 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
257fn 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
267fn 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 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 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 #[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 #[test]
311 fn movement_blocked_per_elevation() {
312 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 #[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 #[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 #[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 #[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 #[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 #[test]
409 fn frame_col_canonical_and_fallback() {
410 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 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}