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 = if state.player.transport == TransportMode::Biking
165        && state.player.bike_speedup_active
166    {
167        2
168    } else {
169        1
170    };
171
172    state.walk_counter = state.walk_counter.saturating_sub(decrement);
173
174    if state.walk_counter == 0 {
175        let (dx, dy) = direction_delta(state.player.facing);
176
177        if state.player.movement_state == MovementState::Jumping {
178            let new_x = (state.player.x as i32 + dx as i32 * 2) as u16;
179            let new_y = (state.player.y as i32 + dy as i32 * 2) as u16;
180            state.player.x = new_x;
181            state.player.y = new_y;
182        } else {
183            let new_x = (state.player.x as i32 + dx as i32) as u16;
184            let new_y = (state.player.y as i32 + dy as i32) as u16;
185            state.player.x = new_x;
186            state.player.y = new_y;
187        }
188
189        state.player.movement_state = MovementState::Idle;
190
191        if state.encounter_cooldown > 0 {
192            state.encounter_cooldown -= 1;
193        }
194
195        if state.repel_steps > 0 {
196            state.repel_steps -= 1;
197        }
198
199        return true;
200    }
201
202    false
203}
204
205/// Get the x/y delta for a direction.
206pub fn direction_delta(dir: Direction) -> (i8, i8) {
207    match dir {
208        Direction::Down => (0, 1),
209        Direction::Up => (0, -1),
210        Direction::Left => (-1, 0),
211        Direction::Right => (1, 0),
212    }
213}
214
215/// Get the opposite direction.
216pub fn opposite_direction(dir: Direction) -> Direction {
217    match dir {
218        Direction::Down => Direction::Up,
219        Direction::Up => Direction::Down,
220        Direction::Left => Direction::Right,
221        Direction::Right => Direction::Left,
222    }
223}
224
225/// Calculate the number of frames for a step based on transport mode.
226pub fn frames_per_step(transport: TransportMode) -> u8 {
227    match transport {
228        TransportMode::Walking => WALK_COUNTER_INIT,
229        TransportMode::Biking => WALK_COUNTER_INIT / 2,
230        TransportMode::Surfing => WALK_COUNTER_INIT,
231    }
232}
233
234/// Convert Direction to the facing index (0=Down, 1=Up, 2=Left, 3=Right).
235pub fn direction_to_facing_index(dir: Direction) -> u8 {
236    match dir {
237        Direction::Down => 0,
238        Direction::Up => 1,
239        Direction::Left => 2,
240        Direction::Right => 3,
241    }
242}
243
244/// Get the tile ID at a specific position in the map.
245pub fn get_tile_at_position<M: MapTrait, T: TilesetTrait, Mus>(
246    map: &MapData<M, T, Mus>,
247    x: u16,
248    y: u16,
249    provider: &impl CollisionProvider<T>,
250) -> u8 {
251    provider.get_tile_at_position(map.tileset, &map.blocks, map.width, x, y)
252}
253
254pub fn get_target_tile_for_direction<M: MapTrait, T: TilesetTrait, Mus>(
255    map: &MapData<M, T, Mus>,
256    x: u16,
257    y: u16,
258    dir: Direction,
259    provider: &impl CollisionProvider<T>,
260) -> u8 {
261    let (dx, dy) = direction_delta(dir);
262    let target_x = ((x as i32) + dx as i32).max(0) as u16;
263    let target_y = ((y as i32) + dy as i32).max(0) as u16;
264    provider.get_tile_at_position(map.tileset, &map.blocks, map.width, target_x, target_y)
265}
266
267/// Extra-warp check (classic behavior).
268pub fn extra_warp_check<M: MapTrait, T: TilesetTrait, Mus>(
269    map: &MapData<M, T, Mus>,
270    player_x: u16,
271    player_y: u16,
272    facing: Direction,
273    provider: &impl CollisionProvider<T>,
274) -> bool {
275    // Check for game-specific special cases (e.g. SS_ANNE_BOW tile 0x15).
276    let tile_in_front = get_target_tile_for_direction(map, player_x, player_y, facing, provider);
277    if let Some(result) = provider.check_extra_warp_special(map.tileset, tile_in_front) {
278        return result;
279    }
280
281    if provider.uses_warp_tile_in_front_check(map.tileset) {
282        let facing_idx = direction_to_facing_index(facing);
283        provider.is_warp_carpet_tile_in_front(map.tileset, facing_idx, tile_in_front)
284    } else {
285        is_facing_map_edge(player_x, player_y, facing, map.width, map.height)
286    }
287}
288
289/// Two-phase warp check after a step completes onto a warp position.
290pub fn check_warps_no_collision<M: MapTrait, T: TilesetTrait, Mus>(
291    state: &mut GenericOverworldState<M>,
292    map: &MapData<M, T, Mus>,
293    standing_tile: u8,
294    direction_held: bool,
295    provider: &impl CollisionProvider<T>,
296) -> Option<usize> {
297    let warp_idx = check_warp_at_position(state.player.x, state.player.y, map)?;
298
299    state.standing_on_warp = true;
300
301    if provider.is_door_tile(map.tileset, standing_tile) {
302        return Some(warp_idx);
303    }
304
305    if provider.is_warp_tile(map.tileset, standing_tile) {
306        state.standing_on_warp = false;
307        return Some(warp_idx);
308    }
309
310    if extra_warp_check(map, state.player.x, state.player.y, state.player.facing, provider) {
311        if direction_held {
312            return Some(warp_idx);
313        }
314    }
315
316    None
317}
318
319/// CheckWarpsCollision path: when collision occurs while standing_on_warp is set.
320pub fn check_collision_warp<M: MapTrait, T: TilesetTrait, Mus>(
321    state: &mut GenericOverworldState<M>,
322    map: &MapData<M, T, Mus>,
323    move_result: MoveResult,
324    provider: &impl CollisionProvider<T>,
325) -> MoveResult {
326    match move_result {
327        MoveResult::Blocked(_) | MoveResult::ReachedMapEdge => {
328            if state.standing_on_warp {
329                if extra_warp_check(
330                    map,
331                    state.player.x,
332                    state.player.y,
333                    state.player.facing,
334                    provider,
335                ) {
336                    if let Some(warp_idx) =
337                        check_warp_at_position(state.player.x, state.player.y, map)
338                    {
339                        return MoveResult::Warped {
340                            warp_index: warp_idx,
341                        };
342                    }
343                }
344            }
345            move_result
346        }
347        _ => move_result,
348    }
349}
350
351/// Process one frame of overworld movement.
352///
353/// This is the high-level frame-by-frame update, combining input
354/// processing and step advancement.
355pub fn process_frame<M: MapTrait, T: TilesetTrait, Mus>(
356    state: &mut GenericOverworldState<M>,
357    input: &InputState,
358    map: &MapData<M, T, Mus>,
359    standing_tile: u8,
360    target_tile: u8,
361    npc_positions: &[SpritePosition],
362    provider: &impl CollisionProvider<T>,
363) -> MoveResult {
364    // If currently moving, advance the step
365    if state.player.movement_state != MovementState::Idle {
366        let step_done = advance_step(state);
367        if step_done {
368            let new_standing_tile = get_tile_at_position(map, state.player.x, state.player.y, provider);
369            let direction_held = input.direction_pressed().is_some();
370
371            if let Some(warp_idx) = check_warps_no_collision(
372                state,
373                map,
374                new_standing_tile,
375                direction_held,
376                provider,
377            ) {
378                return MoveResult::Warped {
379                    warp_index: warp_idx,
380                };
381            }
382
383            if let Some(direction) = input.direction_pressed() {
384                let held_input = input.to_pad_bits();
385
386                let new_target_tile = get_target_tile_for_direction(
387                    map,
388                    state.player.x,
389                    state.player.y,
390                    direction,
391                    provider,
392                );
393
394                let move_result = try_move(
395                    state,
396                    direction,
397                    map.tileset,
398                    map.width,
399                    map.height,
400                    new_standing_tile,
401                    new_target_tile,
402                    npc_positions,
403                    held_input,
404                    provider,
405                );
406
407                return check_collision_warp(state, map, move_result, provider);
408            }
409        }
410        return MoveResult::StillMoving;
411    }
412
413    // Not moving — check for new input
414    let direction = match input.direction_pressed() {
415        Some(dir) => dir,
416        None => return MoveResult::NoInput,
417    };
418
419    let held_input = input.to_pad_bits();
420
421    let move_result = try_move(
422        state,
423        direction,
424        map.tileset,
425        map.width,
426        map.height,
427        standing_tile,
428        target_tile,
429        npc_positions,
430        held_input,
431        provider,
432    );
433
434    check_collision_warp(state, map, move_result, provider)
435}