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}