Skip to main content

dotzuki_engine/overworld/
collision.rs

1//! Generic collision detection for the overworld.
2//!
3//! This module provides the core collision detection algorithms that are
4//! shared across all JRPG engine consumers. Game-specific tile data (passable
5//! tiles, ledge tiles, tile pair collisions, counter tiles, etc.) is provided
6//! via the [`CollisionProvider`] trait.
7//!
8//! Implements the classic overworld collision and movement behavior.
9
10use crate::tileset::TilesetTrait;
11
12use super::types::{Direction, MapData, TransportMode};
13
14// ── Sprite Facing Constants ──────────────────────────────────────
15/// Direction constants matching SPRITE_FACING_*.
16pub const SPRITE_FACING_DOWN: u8 = 0x00;
17pub const SPRITE_FACING_UP: u8 = 0x04;
18pub const SPRITE_FACING_LEFT: u8 = 0x08;
19pub const SPRITE_FACING_RIGHT: u8 = 0x0C;
20
21// ── D-pad Input Constants ────────────────────────────────────────
22pub const PAD_DOWN: u8 = 0x80;
23pub const PAD_UP: u8 = 0x40;
24pub const PAD_LEFT: u8 = 0x20;
25pub const PAD_RIGHT: u8 = 0x10;
26
27// ── Collision Provider Trait ─────────────────────────────────────
28/// Provides game-specific collision data to the engine.
29///
30/// Implementations supply tile passability, ledge jump rules, tile-pair
31/// collision data, and warp-support metadata for a given tileset type.
32pub trait CollisionProvider<T: TilesetTrait> {
33    /// Returns `true` if `tile_id` is passable (walkable) in the given tileset.
34    fn is_tile_passable(&self, tileset: T, tile_id: u8) -> bool;
35
36    /// Returns `true` if movement between `standing_tile` and `target_tile`
37    /// is blocked by a tile-pair collision (elevation difference).
38    fn check_tile_pair_collision(
39        &self,
40        tileset: T,
41        standing_tile: u8,
42        target_tile: u8,
43        on_water: bool,
44    ) -> bool;
45
46    /// Returns `true` if the player should jump a ledge.
47    ///
48    /// `sprite_facing` is one of [`SPRITE_FACING_DOWN`], etc.
49    /// `held_input` is a bitmask of pressed d-pad buttons.
50    fn check_ledge_jump(
51        &self,
52        tileset: T,
53        sprite_facing: u8,
54        standing_tile: u8,
55        target_tile: u8,
56        held_input: u8,
57    ) -> bool;
58
59    /// Returns `true` if `tile_id` is a counter tile (can interact across but not walk onto).
60    fn is_counter_tile(&self, tileset: T, tile_id: u8) -> bool;
61
62    /// Resolve the tile ID at a given map position from block data.
63    fn get_tile_at_position(
64        &self,
65        tileset: T,
66        blocks: &[u8],
67        map_width: u8,
68        x: u16,
69        y: u16,
70    ) -> u8;
71
72    /// Returns `true` if `tile_id` is a door tile (for auto-step-out logic).
73    fn is_door_tile(&self, tileset: T, tile_id: u8) -> bool;
74
75    /// Returns `true` if `tile_id` is a warp tile (immediate warp trigger).
76    fn is_warp_tile(&self, tileset: T, tile_id: u8) -> bool;
77
78    /// Returns `true` if the tile-in-front with the given `facing_idx` (0=Down, 1=Up, 2=Left, 3=Right)
79    /// is a warp-carpet tile for ExtraWarpCheck function 2.
80    fn is_warp_carpet_tile_in_front(&self, tileset: T, facing_idx: u8, tile_id: u8) -> bool;
81
82    /// Returns `true` if `tile_id` is a water tile in the given tileset —
83    /// a tile a surfer can move onto while staying on the water
84    /// (CollisionCheckOnWater). Default: no water tiles.
85    fn is_water_tile(&self, _tileset: T, _tile_id: u8) -> bool {
86        false
87    }
88
89    /// Resolve the tile the player would step onto when crossing a map
90    /// boundary in `direction` — the connected map's edge tile. The original
91    /// reads this tile from the connection strip drawn in the tilemap
92    /// (LoadTileBlockMap / GetTileAndCoordsInFrontOfPlayer) and applies the
93    /// normal passability rules to it before the player may cross
94    /// (CollisionCheckOnLand / CollisionCheckOnWater → CheckTilePassable).
95    ///
96    /// Returns `None` when the map has no connection in `direction` (or the
97    /// connected map has no tile at the arrival position) — the engine then
98    /// falls back to the plain map-edge behavior.
99    fn get_connection_edge_tile(
100        &self,
101        _tileset: T,
102        _map_width_blocks: u8,
103        _map_height_blocks: u8,
104        _x: u16,
105        _y: u16,
106        _direction: Direction,
107    ) -> Option<u8> {
108        None
109    }
110
111    /// Returns `true` if the tileset should use warp-tile-in-front checking
112    /// (ExtraWarpCheck function 2) instead of facing-map-edge (function 1).
113    fn uses_warp_tile_in_front_check(&self, tileset: T) -> bool;
114
115    /// Handle map-specific extra-warp special cases (e.g. SS_ANNE_BOW tile 0x15).
116    /// Returns `Some(true)` if warp should fire, `Some(false)` if not,
117    /// or `None` to fall through to normal logic.
118    fn check_extra_warp_special(&self, tileset: T, tile_in_front: u8) -> Option<bool>;
119}
120
121// ── Collision Result ─────────────────────────────────────────────
122
123/// Result of a collision check when the player tries to move.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum CollisionResult {
126    /// Movement is allowed — the target tile is clear.
127    Passable,
128    /// The tile itself is impassable (wall, obstacle, etc.).
129    TileBlocked,
130    /// A tile pair collision prevents crossing (elevation difference).
131    TilePairBlocked,
132    /// An NPC or other sprite is blocking the way.
133    SpriteBlocked,
134    /// The player is at the edge of the map (triggers map connection).
135    MapEdge,
136    /// The player should jump a ledge (special movement).
137    LedgeJump,
138    /// Cannot surf here (water tile but no surf).
139    WaterBlocked,
140    /// Counter tile — can talk across but not walk onto.
141    CounterTile,
142    /// Movement is allowed and ends a surf: the surfer steps onto a passable
143    /// land tile and returns to walking (CollisionCheckOnWater .stopSurfing).
144    StopSurfing,
145}
146
147// ── Direction Helpers ────────────────────────────────────────────
148
149/// Convert a Direction to the sprite facing constant.
150pub fn direction_to_sprite_facing(dir: Direction) -> u8 {
151    match dir {
152        Direction::Down => SPRITE_FACING_DOWN,
153        Direction::Up => SPRITE_FACING_UP,
154        Direction::Left => SPRITE_FACING_LEFT,
155        Direction::Right => SPRITE_FACING_RIGHT,
156    }
157}
158
159/// Convert a Direction to the d-pad input bitmask.
160pub fn direction_to_pad_input(dir: Direction) -> u8 {
161    match dir {
162        Direction::Down => PAD_DOWN,
163        Direction::Up => PAD_UP,
164        Direction::Left => PAD_LEFT,
165        Direction::Right => PAD_RIGHT,
166    }
167}
168
169// ── Coordinate & Block Helpers ───────────────────────────────────
170
171/// Get the target tile coordinates when moving in a direction.
172/// Returns `None` if movement would go out of map bounds.
173///
174/// Coordinates are in tile space (2× block space).
175/// Map dimensions (`map_width_blocks`, `map_height_blocks`) are in blocks.
176pub fn get_target_coords(
177    x: u16,
178    y: u16,
179    direction: Direction,
180    map_width_blocks: u8,
181    map_height_blocks: u8,
182) -> Option<(u16, u16)> {
183    let max_x = (map_width_blocks as u16) * 2;
184    let max_y = (map_height_blocks as u16) * 2;
185
186    match direction {
187        Direction::Up => {
188            if y == 0 {
189                None
190            } else {
191                Some((x, y - 1))
192            }
193        }
194        Direction::Down => {
195            if y + 1 >= max_y {
196                None
197            } else {
198                Some((x, y + 1))
199            }
200        }
201        Direction::Left => {
202            if x == 0 {
203                None
204            } else {
205                Some((x - 1, y))
206            }
207        }
208        Direction::Right => {
209            if x + 1 >= max_x {
210                None
211            } else {
212                Some((x + 1, y))
213            }
214        }
215    }
216}
217
218/// Get the block ID at a position in the map's block data.
219///
220/// Each block is a 2×2 grid of tiles. Block index = (y/2) * width + (x/2).
221pub fn get_block_at(x: u16, y: u16, map_width_blocks: u8, blocks: &[u8]) -> Option<u8> {
222    let bx = (x / 2) as usize;
223    let by = (y / 2) as usize;
224    let w = map_width_blocks as usize;
225    let idx = by * w + bx;
226    blocks.get(idx).copied()
227}
228
229// ── Sprite Collision ─────────────────────────────────────────────
230
231/// Represents the position of a sprite for collision checks.
232#[derive(Debug, Clone, Copy)]
233pub struct SpritePosition {
234    /// Tile X coordinate.
235    pub x: u16,
236    /// Tile Y coordinate.
237    pub y: u16,
238}
239
240/// Check if an NPC sprite occupies the target tile.
241pub fn check_sprite_collision(
242    target_x: u16,
243    target_y: u16,
244    npc_positions: &[SpritePosition],
245) -> bool {
246    npc_positions
247        .iter()
248        .any(|npc| npc.x == target_x && npc.y == target_y)
249}
250
251// ── Main Collision Check ─────────────────────────────────────────
252
253/// Full collision check for player movement.
254///
255/// Checks in order (matching the original game):
256/// 1. Map edge (triggers map connection; the tile in front — the connected
257///    map's edge tile — must pass the same rules as an in-map move)
258/// 2. Ledge jump (special movement)
259/// 3. Sprite collision (NPC blocking)
260/// 4. Tile pair collision (elevation)
261/// 5. Counter tile (can interact across but not walk through)
262/// 6. Tile passability (wall/obstacle)
263pub fn check_movement_collision<T: TilesetTrait>(
264    player_x: u16,
265    player_y: u16,
266    direction: Direction,
267    tileset: T,
268    map_width_blocks: u8,
269    map_height_blocks: u8,
270    standing_tile: u8,
271    target_tile: u8,
272    transport: TransportMode,
273    npc_positions: &[SpritePosition],
274    held_input: u8,
275    provider: &impl CollisionProvider<T>,
276) -> CollisionResult {
277    // 1. Check map edge (triggers map connection)
278    let target_coords = get_target_coords(
279        player_x,
280        player_y,
281        direction,
282        map_width_blocks,
283        map_height_blocks,
284    );
285
286    if target_coords.is_none() {
287        // The player faces a map boundary. The original still checks the tile
288        // in front — read from the connection strip, i.e. the connected map's
289        // edge tile — and applies the normal rules before allowing the cross
290        // (CollisionCheckOnLand/OnWater → CheckTilePassable). An impassable
291        // seam bumps like any wall; only a passable (or, when surfing, water)
292        // seam lets the player walk into the next map.
293        if let Some(edge_tile) = provider.get_connection_edge_tile(
294            tileset,
295            map_width_blocks,
296            map_height_blocks,
297            player_x,
298            player_y,
299            direction,
300        ) {
301            // Surfing (CollisionCheckOnWater): water keeps surfing; stepping
302            // onto a passable land tile ends the surf (the game layer
303            // dismounts after the map swap); anything else is a collision.
304            if transport == TransportMode::Surfing {
305                if provider.check_tile_pair_collision(tileset, standing_tile, edge_tile, true) {
306                    return CollisionResult::TilePairBlocked;
307                }
308                if provider.is_water_tile(tileset, edge_tile) {
309                    return CollisionResult::MapEdge;
310                }
311                if provider.is_tile_passable(tileset, edge_tile) {
312                    return CollisionResult::MapEdge;
313                }
314                return CollisionResult::TileBlocked;
315            }
316
317            // Walking / biking: same checks as an in-map move onto `edge_tile`.
318            if provider.check_tile_pair_collision(tileset, standing_tile, edge_tile, false) {
319                return CollisionResult::TilePairBlocked;
320            }
321            if provider.is_counter_tile(tileset, edge_tile) {
322                return CollisionResult::CounterTile;
323            }
324            if !provider.is_tile_passable(tileset, edge_tile) {
325                return CollisionResult::TileBlocked;
326            }
327        }
328        return CollisionResult::MapEdge;
329    }
330
331    let (tx, ty) = target_coords.unwrap();
332
333    // 2. Check ledge jump (only on land, only overworld tileset)
334    if transport == TransportMode::Walking || transport == TransportMode::Biking {
335        let sprite_facing = direction_to_sprite_facing(direction);
336        if provider.check_ledge_jump(tileset, sprite_facing, standing_tile, target_tile, held_input) {
337            return CollisionResult::LedgeJump;
338        }
339    }
340
341    // 3. Check sprite collision
342    if check_sprite_collision(tx, ty, npc_positions) {
343        return CollisionResult::SpriteBlocked;
344    }
345
346    // 4. Surfing movement:
347    // water tiles are traversable and keep the player surfing; stepping onto
348    // a passable land tile ends the surf; everything else is a collision.
349    if transport == TransportMode::Surfing {
350        if provider.check_tile_pair_collision(tileset, standing_tile, target_tile, true) {
351            return CollisionResult::TilePairBlocked;
352        }
353        if provider.is_water_tile(tileset, target_tile) {
354            return CollisionResult::Passable;
355        }
356        if provider.is_tile_passable(tileset, target_tile) {
357            return CollisionResult::StopSurfing;
358        }
359        return CollisionResult::TileBlocked;
360    }
361
362    // 5. Check tile pair collision
363    let on_water = transport == TransportMode::Surfing;
364    if provider.check_tile_pair_collision(tileset, standing_tile, target_tile, on_water) {
365        return CollisionResult::TilePairBlocked;
366    }
367
368    // 6. Check counter tile
369    if provider.is_counter_tile(tileset, target_tile) {
370        return CollisionResult::CounterTile;
371    }
372
373    // 7. Check tile passability
374    if !provider.is_tile_passable(tileset, target_tile) {
375        return CollisionResult::TileBlocked;
376    }
377
378    CollisionResult::Passable
379}
380
381// ── Edge & Warp Detection ────────────────────────────────────────
382
383/// Check if the player is facing the edge of the map.
384pub fn is_facing_map_edge(
385    player_x: u16,
386    player_y: u16,
387    direction: Direction,
388    map_width_blocks: u8,
389    map_height_blocks: u8,
390) -> bool {
391    get_target_coords(
392        player_x,
393        player_y,
394        direction,
395        map_width_blocks,
396        map_height_blocks,
397    )
398    .is_none()
399}
400
401/// Check if the player is standing on a warp tile.
402/// Returns the warp index if a match is found.
403pub fn check_warp_at_position<M: crate::map::MapTrait, T: TilesetTrait, Mus>(
404    x: u16,
405    y: u16,
406    map: &MapData<M, T, Mus>,
407) -> Option<usize> {
408    map.warps
409        .iter()
410        .position(|w| w.x as u16 == x && w.y as u16 == y)
411}