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}