Skip to main content

dotzuki_engine/overworld/
player_movement.rs

1//! Generic player movement system for the overworld.
2//!
3//! Implements player movement: the movement loop with input handling,
4//! walking/sprite-advance logic, and player state transitions.
5
6use crate::map::MapTrait;
7use crate::tileset::TilesetTrait;
8
9use super::collision::{
10    check_movement_collision, check_warp_at_position, is_facing_map_edge, CollisionProvider,
11    CollisionResult, SpritePosition,
12};
13use super::types::{
14    Direction, MapData, MovementState, OverworldState as GenericOverworldState, TransportMode,
15};
16
17/// Walk counter initial value (8 frames per tile).
18pub const WALK_COUNTER_INIT: u8 = 8;
19
20/// Input state from the player's controller.
21#[derive(Debug, Clone, Copy, Default)]
22pub struct InputState {
23    pub up: bool,
24    pub down: bool,
25    pub left: bool,
26    pub right: bool,
27    pub a_button: bool,
28    pub b_button: bool,
29    pub start: bool,
30    pub select: bool,
31}
32
33impl InputState {
34    /// Get the direction being pressed, if any.
35    /// Priority matches the original game: Down > Up > Left > Right.
36    pub fn direction_pressed(&self) -> Option<Direction> {
37        if self.down {
38            Some(Direction::Down)
39        } else if self.up {
40            Some(Direction::Up)
41        } else if self.left {
42            Some(Direction::Left)
43        } else if self.right {
44            Some(Direction::Right)
45        } else {
46            None
47        }
48    }
49
50    /// Convert to the raw d-pad bitmask used in the original game.
51    pub fn to_pad_bits(&self) -> u8 {
52        let mut bits = 0u8;
53        if self.down {
54            bits |= super::collision::PAD_DOWN;
55        }
56        if self.up {
57            bits |= super::collision::PAD_UP;
58        }
59        if self.left {
60            bits |= super::collision::PAD_LEFT;
61        }
62        if self.right {
63            bits |= super::collision::PAD_RIGHT;
64        }
65        bits
66    }
67}
68
69/// Result of processing a movement attempt.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum MoveResult {
72    /// Player started walking to the target tile.
73    Walking,
74    /// Player started a ledge jump.
75    LedgeJump,
76    /// Player only turned to face a new direction (no movement).
77    TurnedOnly,
78    /// Movement was blocked (wall, NPC, etc.).
79    Blocked(CollisionResult),
80    /// Player reached the edge of the map (connection should be checked).
81    ReachedMapEdge,
82    /// Player stepped onto a warp tile.
83    Warped { warp_index: usize },
84    /// Player is still mid-step from previous movement.
85    StillMoving,
86    /// No input was pressed.
87    NoInput,
88}
89
90/// Try to move the player in a direction.
91///
92/// Returns a `MoveResult` indicating what happened.
93pub fn try_move<M: MapTrait, T: TilesetTrait>(
94    state: &mut GenericOverworldState<M>,
95    direction: Direction,
96    tileset: T,
97    map_width_blocks: u8,
98    map_height_blocks: u8,
99    standing_tile: u8,
100    target_tile: u8,
101    npc_positions: &[SpritePosition],
102    held_input: u8,
103    provider: &impl CollisionProvider<T>,
104) -> MoveResult {
105    if state.player.movement_state != MovementState::Idle {
106        return MoveResult::StillMoving;
107    }
108
109    let was_facing = state.player.facing;
110    state.player.facing = direction;
111
112    let result = check_movement_collision(
113        state.player.x,
114        state.player.y,
115        direction,
116        tileset,
117        map_width_blocks,
118        map_height_blocks,
119        standing_tile,
120        target_tile,
121        state.player.transport,
122        npc_positions,
123        held_input,
124        provider,
125    );
126
127    match result {
128        CollisionResult::Passable | CollisionResult::StopSurfing => {
129            // StopSurfing: the surfer stepped onto a passable land tile and
130            // returns to walking (CollisionCheckOnWater .stopSurfing).
131            if result == CollisionResult::StopSurfing {
132                state.player.transport = TransportMode::Walking;
133            }
134            state.standing_on_warp = false;
135            state.player.movement_state = MovementState::Walking;
136            state.walk_counter = WALK_COUNTER_INIT;
137            MoveResult::Walking
138        }
139        CollisionResult::LedgeJump => {
140            state.standing_on_warp = false;
141            state.player.movement_state = MovementState::Jumping;
142            state.walk_counter = WALK_COUNTER_INIT * 2;
143            MoveResult::LedgeJump
144        }
145        CollisionResult::MapEdge => MoveResult::ReachedMapEdge,
146        _ => {
147            if was_facing != direction {
148                MoveResult::TurnedOnly
149            } else {
150                MoveResult::Blocked(result)
151            }
152        }
153    }
154}
155
156/// Advance the player's position by one pixel-step during movement.
157///
158/// Returns true when the step is complete (walk counter reached 0).
159pub fn advance_step<M: MapTrait>(state: &mut GenericOverworldState<M>) -> bool {
160    if state.walk_counter == 0 {
161        return true;
162    }
163
164    let decrement =
165        if state.player.transport == TransportMode::Biking && state.player.bike_speedup_active {
166            2
167        } else {
168            1
169        };
170
171    state.walk_counter = state.walk_counter.saturating_sub(decrement);
172
173    if state.walk_counter == 0 {
174        let (dx, dy) = direction_delta(state.player.facing);
175
176        if state.player.movement_state == MovementState::Jumping {
177            let new_x = (state.player.x as i32 + dx as i32 * 2) as u16;
178            let new_y = (state.player.y as i32 + dy as i32 * 2) as u16;
179            state.player.x = new_x;
180            state.player.y = new_y;
181        } else {
182            let new_x = (state.player.x as i32 + dx as i32) as u16;
183            let new_y = (state.player.y as i32 + dy as i32) as u16;
184            state.player.x = new_x;
185            state.player.y = new_y;
186        }
187
188        state.player.movement_state = MovementState::Idle;
189
190        if state.encounter_cooldown > 0 {
191            state.encounter_cooldown -= 1;
192        }
193
194        // NOTE: REPEL is intentionally NOT decremented here. In the classic
195        // model the counter ticks inside the wild-encounter check itself
196        // (pokered: wild_encounters.asm:19-25), which only runs when the
197        // step may actually roll an encounter (not while warping, ledge
198        // jumping, cooldown-active, or during scripted movement). Games
199        // that want a plain per-step tick may call `tick_repel_step`.
200
201        return true;
202    }
203
204    false
205}
206
207/// Decrement the REPEL counter by one (saturating at 0). Games call this from
208/// their own encounter-roll gating, mirroring the classic TryDoWildEncounter
209/// placement; see the note on [`advance_step`].
210pub fn tick_repel_step<M: MapTrait>(state: &mut GenericOverworldState<M>) {
211    if state.repel_steps > 0 {
212        state.repel_steps -= 1;
213    }
214}
215
216/// Get the x/y delta for a direction.
217pub fn direction_delta(dir: Direction) -> (i8, i8) {
218    match dir {
219        Direction::Down => (0, 1),
220        Direction::Up => (0, -1),
221        Direction::Left => (-1, 0),
222        Direction::Right => (1, 0),
223    }
224}
225
226/// Get the opposite direction.
227pub fn opposite_direction(dir: Direction) -> Direction {
228    match dir {
229        Direction::Down => Direction::Up,
230        Direction::Up => Direction::Down,
231        Direction::Left => Direction::Right,
232        Direction::Right => Direction::Left,
233    }
234}
235
236/// Calculate the number of frames for a step based on transport mode.
237pub fn frames_per_step(transport: TransportMode) -> u8 {
238    match transport {
239        TransportMode::Walking => WALK_COUNTER_INIT,
240        TransportMode::Biking => WALK_COUNTER_INIT / 2,
241        TransportMode::Surfing => WALK_COUNTER_INIT,
242    }
243}
244
245/// Convert Direction to the facing index (0=Down, 1=Up, 2=Left, 3=Right).
246pub fn direction_to_facing_index(dir: Direction) -> u8 {
247    match dir {
248        Direction::Down => 0,
249        Direction::Up => 1,
250        Direction::Left => 2,
251        Direction::Right => 3,
252    }
253}
254
255/// Get the tile ID at a specific position in the map.
256pub fn get_tile_at_position<M: MapTrait, T: TilesetTrait, Mus>(
257    map: &MapData<M, T, Mus>,
258    x: u16,
259    y: u16,
260    provider: &impl CollisionProvider<T>,
261) -> u8 {
262    provider.get_tile_at_position(map.tileset, &map.blocks, map.width, x, y)
263}
264
265pub fn get_target_tile_for_direction<M: MapTrait, T: TilesetTrait, Mus>(
266    map: &MapData<M, T, Mus>,
267    x: u16,
268    y: u16,
269    dir: Direction,
270    provider: &impl CollisionProvider<T>,
271) -> u8 {
272    let (dx, dy) = direction_delta(dir);
273    let target_x = ((x as i32) + dx as i32).max(0) as u16;
274    let target_y = ((y as i32) + dy as i32).max(0) as u16;
275    provider.get_tile_at_position(map.tileset, &map.blocks, map.width, target_x, target_y)
276}
277
278/// Extra-warp check (classic behavior).
279pub fn extra_warp_check<M: MapTrait, T: TilesetTrait, Mus>(
280    map: &MapData<M, T, Mus>,
281    player_x: u16,
282    player_y: u16,
283    facing: Direction,
284    provider: &impl CollisionProvider<T>,
285) -> bool {
286    // Check for game-specific special cases (e.g. SS_ANNE_BOW tile 0x15).
287    let tile_in_front = get_target_tile_for_direction(map, player_x, player_y, facing, provider);
288    if let Some(result) = provider.check_extra_warp_special(map.tileset, tile_in_front) {
289        return result;
290    }
291
292    if provider.uses_warp_tile_in_front_check(map.tileset) {
293        let facing_idx = direction_to_facing_index(facing);
294        provider.is_warp_carpet_tile_in_front(map.tileset, facing_idx, tile_in_front)
295    } else {
296        is_facing_map_edge(player_x, player_y, facing, map.width, map.height)
297    }
298}
299
300/// Two-phase warp check after a step completes onto a warp position.
301pub fn check_warps_no_collision<M: MapTrait, T: TilesetTrait, Mus>(
302    state: &mut GenericOverworldState<M>,
303    map: &MapData<M, T, Mus>,
304    standing_tile: u8,
305    direction_held: bool,
306    provider: &impl CollisionProvider<T>,
307) -> Option<usize> {
308    let warp_idx = check_warp_at_position(state.player.x, state.player.y, map)?;
309
310    state.standing_on_warp = true;
311
312    if provider.is_door_tile(map.tileset, standing_tile) {
313        return Some(warp_idx);
314    }
315
316    if provider.is_warp_tile(map.tileset, standing_tile) {
317        state.standing_on_warp = false;
318        return Some(warp_idx);
319    }
320
321    if extra_warp_check(
322        map,
323        state.player.x,
324        state.player.y,
325        state.player.facing,
326        provider,
327    ) {
328        if direction_held {
329            return Some(warp_idx);
330        }
331    }
332
333    None
334}
335
336/// CheckWarpsCollision path: when collision occurs while standing_on_warp is set.
337pub fn check_collision_warp<M: MapTrait, T: TilesetTrait, Mus>(
338    state: &mut GenericOverworldState<M>,
339    map: &MapData<M, T, Mus>,
340    move_result: MoveResult,
341    provider: &impl CollisionProvider<T>,
342) -> MoveResult {
343    match move_result {
344        MoveResult::Blocked(_) | MoveResult::ReachedMapEdge => {
345            if state.standing_on_warp {
346                if extra_warp_check(
347                    map,
348                    state.player.x,
349                    state.player.y,
350                    state.player.facing,
351                    provider,
352                ) {
353                    if let Some(warp_idx) =
354                        check_warp_at_position(state.player.x, state.player.y, map)
355                    {
356                        return MoveResult::Warped {
357                            warp_index: warp_idx,
358                        };
359                    }
360                }
361            }
362            move_result
363        }
364        _ => move_result,
365    }
366}
367
368/// Process one frame of overworld movement.
369///
370/// This is the high-level frame-by-frame update, combining input
371/// processing and step advancement.
372pub fn process_frame<M: MapTrait, T: TilesetTrait, Mus>(
373    state: &mut GenericOverworldState<M>,
374    input: &InputState,
375    map: &MapData<M, T, Mus>,
376    standing_tile: u8,
377    target_tile: u8,
378    npc_positions: &[SpritePosition],
379    provider: &impl CollisionProvider<T>,
380) -> MoveResult {
381    // If currently moving, advance the step
382    if state.player.movement_state != MovementState::Idle {
383        let step_done = advance_step(state);
384        if step_done {
385            let new_standing_tile =
386                get_tile_at_position(map, state.player.x, state.player.y, provider);
387            let direction_held = input.direction_pressed().is_some();
388
389            if let Some(warp_idx) =
390                check_warps_no_collision(state, map, new_standing_tile, direction_held, provider)
391            {
392                return MoveResult::Warped {
393                    warp_index: warp_idx,
394                };
395            }
396
397            if let Some(direction) = input.direction_pressed() {
398                let held_input = input.to_pad_bits();
399
400                let new_target_tile = get_target_tile_for_direction(
401                    map,
402                    state.player.x,
403                    state.player.y,
404                    direction,
405                    provider,
406                );
407
408                let move_result = try_move(
409                    state,
410                    direction,
411                    map.tileset,
412                    map.width,
413                    map.height,
414                    new_standing_tile,
415                    new_target_tile,
416                    npc_positions,
417                    held_input,
418                    provider,
419                );
420
421                return check_collision_warp(state, map, move_result, provider);
422            }
423        }
424        return MoveResult::StillMoving;
425    }
426
427    // Not moving — check for new input
428    let direction = match input.direction_pressed() {
429        Some(dir) => dir,
430        None => return MoveResult::NoInput,
431    };
432
433    let held_input = input.to_pad_bits();
434
435    let move_result = try_move(
436        state,
437        direction,
438        map.tileset,
439        map.width,
440        map.height,
441        standing_tile,
442        target_tile,
443        npc_positions,
444        held_input,
445        provider,
446    );
447
448    check_collision_warp(state, map, move_result, provider)
449}