use super::types::Direction;
const WALK_SPEED: f32 = 2.0;
const ANIM_PHASE: u32 = 4;
const IDLE_GRACE: u32 = 2;
const RUN_SPEED: f32 = 4.0;
const RUN_ANIM_PHASE: u32 = 2;
pub trait OverworldCollision {
fn is_blocked(&self, x: i32, y: i32) -> bool;
fn is_blocked_at(&self, level: u8, x: i32, y: i32) -> bool {
let _ = level;
self.is_blocked(x, y)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Locomotion {
Idle,
Walk,
Run,
}
#[derive(Debug, Clone)]
pub struct OverworldActor {
tile_x: i32,
tile_y: i32,
px: f32,
py: f32,
facing: Direction,
moving: bool,
anim: u32,
idle: u32,
running: bool,
tile: i32,
elevation: u8,
}
impl OverworldActor {
pub fn new(tile_x: i32, tile_y: i32, tile: i32) -> Self {
Self {
tile_x,
tile_y,
px: (tile_x * tile) as f32,
py: (tile_y * tile) as f32,
facing: Direction::Down,
moving: false,
anim: 0,
idle: IDLE_GRACE,
running: false,
tile,
elevation: 0,
}
}
pub fn facing(&self) -> Direction {
self.facing
}
pub fn set_facing(&mut self, dir: Direction) {
self.facing = dir;
}
pub fn is_moving(&self) -> bool {
self.moving
}
pub fn set_running(&mut self, running: bool) {
self.running = running;
}
pub fn is_running(&self) -> bool {
self.running
}
pub fn tile(&self) -> (i32, i32) {
(self.tile_x, self.tile_y)
}
pub fn px(&self) -> f32 {
self.px
}
pub fn py(&self) -> f32 {
self.py
}
pub fn elevation(&self) -> u8 {
self.elevation
}
pub fn set_elevation(&mut self, level: u8) {
self.elevation = level;
}
pub fn place(&mut self, x: i32, y: i32, dir: Direction) {
self.tile_x = x;
self.tile_y = y;
self.px = (x * self.tile) as f32;
self.py = (y * self.tile) as f32;
self.facing = dir;
self.moving = false;
self.idle = IDLE_GRACE;
}
pub fn update(
&mut self,
held: Option<Direction>,
map: &impl OverworldCollision,
) -> Option<(i32, i32)> {
if !self.moving {
if let Some(dir) = held {
self.facing = dir;
let (dx, dy) = direction_delta(dir);
let (nx, ny) = (self.tile_x + dx, self.tile_y + dy);
if !map.is_blocked_at(self.elevation, nx, ny) {
self.tile_x = nx;
self.tile_y = ny;
self.moving = true;
}
}
}
let mut arrived = None;
if self.moving {
let (tx, ty) = ((self.tile_x * self.tile) as f32, (self.tile_y * self.tile) as f32);
let speed = if self.running { RUN_SPEED } else { WALK_SPEED };
self.px = step_toward(self.px, tx, speed);
self.py = step_toward(self.py, ty, speed);
if (self.px - tx).abs() < 0.001 && (self.py - ty).abs() < 0.001 {
self.px = tx;
self.py = ty;
self.moving = false;
arrived = Some((self.tile_x, self.tile_y));
}
}
if self.moving {
self.anim = self.anim.wrapping_add(1);
self.idle = 0;
} else {
self.idle = self.idle.saturating_add(1);
}
arrived
}
pub fn facing_row(&self) -> u32 {
match self.facing {
Direction::Down => 0,
Direction::Up => 1,
Direction::Left => 2,
Direction::Right => 3,
}
}
pub fn walk_frame(&self) -> u32 {
if self.idle >= IDLE_GRACE {
0
} else {
1 + (self.anim / ANIM_PHASE) % 2
}
}
pub fn locomotion(&self) -> Locomotion {
if self.idle >= IDLE_GRACE {
Locomotion::Idle
} else if self.running {
Locomotion::Run
} else {
Locomotion::Walk
}
}
pub fn step_phase(&self) -> u32 {
let phase = if self.running { RUN_ANIM_PHASE } else { ANIM_PHASE };
(self.anim / phase) % 2
}
}
pub fn frame_col(loc: Locomotion, phase: u32, cols: u32) -> u32 {
let walk = (1 + phase).min(cols.saturating_sub(1));
match loc {
Locomotion::Idle => 0,
Locomotion::Walk => walk,
Locomotion::Run => {
if cols >= 5 {
3 + phase
} else {
walk
}
}
}
}
fn direction_delta(dir: Direction) -> (i32, i32) {
match dir {
Direction::Down => (0, 1),
Direction::Up => (0, -1),
Direction::Left => (-1, 0),
Direction::Right => (1, 0),
}
}
fn step_toward(cur: f32, tgt: f32, speed: f32) -> f32 {
cur + (tgt - cur).clamp(-speed, speed)
}
#[cfg(test)]
mod tests {
use super::*;
struct Walls(&'static [(i32, i32)]);
impl OverworldCollision for Walls {
fn is_blocked(&self, x: i32, y: i32) -> bool {
self.0.contains(&(x, y))
}
}
struct Floors(&'static [&'static [(i32, i32)]]);
impl OverworldCollision for Floors {
fn is_blocked(&self, x: i32, y: i32) -> bool {
self.is_blocked_at(0, x, y)
}
fn is_blocked_at(&self, level: u8, x: i32, y: i32) -> bool {
self.0
.get(level as usize)
.map(|walls| walls.contains(&(x, y)))
.unwrap_or(true)
}
}
#[test]
fn elevation_defaults_and_sets() {
let mut a = OverworldActor::new(5, 5, 16);
assert_eq!(a.elevation(), 0);
a.set_elevation(2);
assert_eq!(a.elevation(), 2);
}
#[test]
fn movement_blocked_per_elevation() {
let map = Floors(&[&[(6, 5)], &[]]);
let mut ground = OverworldActor::new(5, 5, 16);
assert_eq!(ground.update(Some(Direction::Right), &map), None);
assert!(!ground.is_moving(), "solid at level 0 blocks the ground actor");
assert_eq!(ground.tile(), (5, 5));
let mut upper = OverworldActor::new(5, 5, 16);
upper.set_elevation(1);
assert!(matches!(upper.update(Some(Direction::Right), &map), None));
assert!(upper.is_moving(), "passable at level 1 lets the upper actor move");
assert_eq!(upper.tile(), (6, 5));
}
#[test]
fn walks_one_tile_in_eight_frames() {
let map = Walls(&[]);
let mut a = OverworldActor::new(5, 5, 16);
for f in 0..8 {
let got = a.update(Some(Direction::Right), &map);
if f < 7 {
assert!(a.is_moving(), "still mid-step at frame {f}");
assert_eq!(got, None);
} else {
assert_eq!(got, Some((6, 5)), "arrives on frame 8");
}
}
assert_eq!(a.tile(), (6, 5));
assert!(!a.is_moving());
assert_eq!(a.facing(), Direction::Right);
}
#[test]
fn blocked_turns_without_moving() {
let map = Walls(&[(5, 4)]);
let mut a = OverworldActor::new(5, 5, 16);
assert_eq!(a.update(Some(Direction::Up), &map), None);
assert!(!a.is_moving());
assert_eq!(a.tile(), (5, 5));
assert_eq!(a.facing(), Direction::Up);
}
#[test]
fn walk_frame_neutral_then_alternates() {
let map = Walls(&[]);
let mut a = OverworldActor::new(0, 0, 16);
assert_eq!(a.walk_frame(), 0, "starts idle/neutral");
let mut seen = std::collections::HashSet::new();
for _ in 0..16 {
a.update(Some(Direction::Down), &map);
if a.is_moving() {
seen.insert(a.walk_frame());
}
}
assert!(seen.contains(&1) && seen.contains(&2), "both step frames appear");
assert!(!seen.contains(&0), "never neutral while walking");
}
#[test]
fn running_steps_twice_as_fast() {
let map = Walls(&[]);
let mut a = OverworldActor::new(5, 5, 16);
a.set_running(true);
for f in 0..4 {
let got = a.update(Some(Direction::Right), &map);
if f < 3 {
assert!(a.is_moving(), "still mid-step at frame {f}");
assert_eq!(a.locomotion(), Locomotion::Run);
} else {
assert_eq!(got, Some((6, 5)), "arrives on frame 4 when running");
}
}
assert_eq!(a.tile(), (6, 5));
}
#[test]
fn locomotion_reflects_run_flag() {
let map = Walls(&[]);
let mut a = OverworldActor::new(0, 0, 16);
assert_eq!(a.locomotion(), Locomotion::Idle);
a.update(Some(Direction::Down), &map);
assert_eq!(a.locomotion(), Locomotion::Walk, "moving, not running");
a.set_running(true);
a.update(Some(Direction::Down), &map);
assert_eq!(a.locomotion(), Locomotion::Run, "moving + run flag");
}
#[test]
fn frame_col_canonical_and_fallback() {
assert_eq!(frame_col(Locomotion::Idle, 0, 5), 0);
assert_eq!(frame_col(Locomotion::Walk, 0, 5), 1);
assert_eq!(frame_col(Locomotion::Walk, 1, 5), 2);
assert_eq!(frame_col(Locomotion::Run, 0, 5), 3);
assert_eq!(frame_col(Locomotion::Run, 1, 5), 4);
assert_eq!(frame_col(Locomotion::Run, 0, 3), 1);
assert_eq!(frame_col(Locomotion::Run, 1, 3), 2);
assert_eq!(frame_col(Locomotion::Walk, 1, 3), 2);
}
#[test]
fn facing_row_matches_sheet_convention() {
let mut a = OverworldActor::new(0, 0, 16);
for (dir, row) in [
(Direction::Down, 0),
(Direction::Up, 1),
(Direction::Left, 2),
(Direction::Right, 3),
] {
a.set_facing(dir);
assert_eq!(a.facing_row(), row);
}
}
}