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