Skip to main content

dotzuki_engine/overworld/
npc_movement.rs

1//! Generic NPC movement system for the overworld.
2//!
3//! Implements NPC movement: per-frame sprite updates for all NPCs and the
4//! trainer-notice (emotion bubble) animation.
5//!
6//! All functions are generic over tileset and collision provider types.
7
8use std::collections::VecDeque;
9
10use crate::map::MapTrait;
11use crate::tileset::TilesetTrait;
12
13use super::collision::{CollisionProvider, SpritePosition};
14use super::player_movement::direction_delta;
15use super::types::{Direction, MapData, NpcMovementType};
16
17// ── NPC Runtime State ──────────────────────────────────────────────
18
19/// Runtime state for a single NPC on the current map.
20///
21/// Game-specific data (e.g., trainer flags, item drops) should be stored
22/// in a separate parallel array and looked up by `npc_index`.
23#[derive(Debug, Clone)]
24pub struct NpcRuntimeState {
25    pub npc_index: u8,
26    pub sprite_id: u8,
27    pub x: u16,
28    pub y: u16,
29    pub home_x: u16,
30    pub home_y: u16,
31    pub facing: Direction,
32    pub scripted_frame: Option<u8>,
33    pub movement_type: NpcMovementType,
34    pub range: u8,
35    pub walk_counter: u8,
36    pub delay_counter: u8,
37    pub text_id: u8,
38    pub defeated: bool,
39    pub visible: bool,
40    pub scripted_path: VecDeque<(u16, u16)>,
41}
42
43// ── Constants ──────────────────────────────────────────────────────
44
45/// Number of frames to walk one tile (8 frames per tile, matching the player).
46pub const NPC_WALK_FRAMES: u8 = 8;
47/// Maximum delay between random NPC movements (~1 second at 60fps).
48pub const NPC_MAX_DELAY: u8 = 63;
49
50// ── Helpers ────────────────────────────────────────────────────────
51
52/// Determine direction from `from` toward `to`.
53///
54/// Uses the axial heuristic: if dx.abs() > dy.abs(), horizontal;
55/// otherwise vertical. Returns `None` if both positions are the same.
56pub fn direction_toward(from_x: u16, from_y: u16, to_x: u16, to_y: u16) -> Option<Direction> {
57    let dx = to_x as i32 - from_x as i32;
58    let dy = to_y as i32 - from_y as i32;
59    if dx == 0 && dy == 0 {
60        return None;
61    }
62    if dx.abs() > dy.abs() {
63        Some(if dx > 0 {
64            Direction::Right
65        } else {
66            Direction::Left
67        })
68    } else {
69        Some(if dy > 0 {
70            Direction::Down
71        } else {
72            Direction::Up
73        })
74    }
75}
76
77// ── Scripted Movement ──────────────────────────────────────────────
78
79/// Start an NPC walking a fixed path of tile coordinates.
80pub fn start_scripted_move(npc: &mut NpcRuntimeState, path: &[(u8, u8)]) {
81    npc.scripted_path.clear();
82    for &(x, y) in path {
83        npc.scripted_path.push_back((x as u16, y as u16));
84    }
85}
86
87/// Returns `true` when the NPC has finished its scripted path and is idle.
88pub fn is_scripted_move_done(npc: &NpcRuntimeState) -> bool {
89    npc.scripted_path.is_empty() && npc.walk_counter == 0
90}
91
92// ── Position Utilities ─────────────────────────────────────────────
93
94/// Collect tile positions of all visible NPCs for collision checks.
95pub fn get_npc_positions(npcs: &[NpcRuntimeState]) -> Vec<SpritePosition> {
96    npcs.iter()
97        .filter(|n| n.visible)
98        .map(|n| SpritePosition { x: n.x, y: n.y })
99        .collect()
100}
101
102// ── NPC Lookup ─────────────────────────────────────────────────────
103
104/// Find a visible NPC at the given tile position.
105pub fn npc_at_position(npcs: &[NpcRuntimeState], x: u16, y: u16) -> Option<&NpcRuntimeState> {
106    npcs.iter().find(|n| n.visible && n.x == x && n.y == y)
107}
108
109/// Mutable version of [`npc_at_position`].
110pub fn npc_at_position_mut(
111    npcs: &mut [NpcRuntimeState],
112    x: u16,
113    y: u16,
114) -> Option<&mut NpcRuntimeState> {
115    npcs.iter_mut().find(|n| n.visible && n.x == x && n.y == y)
116}
117
118// ── NPC Update Loop ────────────────────────────────────────────────
119
120/// Update all NPC movement for one frame.
121///
122/// This is the main entry point called every frame from the overworld loop
123/// (equivalent to `DoMovementForAllSprites` in the original game).
124///
125/// Each NPC that is visible, not mid-step, and not on a scripted path is
126/// updated based on its movement type (Stationary, Wander, FacePlayer, or FixedPath).
127///
128/// # Parameters
129/// - `npcs` — mutable slice of NPC runtime states
130/// - `player_x`, `player_y` — player's current tile position
131/// - `player_dest` — player's destination if mid-step (for collision avoidance)
132/// - `map_width_blocks`, `map_height_blocks` — map dimensions in blocks
133/// - `rng_value` — random value for wander direction/delay
134/// - `blocks` — block data for the current map
135/// - `tileset` — current tileset
136/// - `provider` — collision provider for tile passability
137pub fn update_npc_movement<T: TilesetTrait>(
138    npcs: &mut [NpcRuntimeState],
139    player_x: u16,
140    player_y: u16,
141    player_dest: Option<(u16, u16)>,
142    map_width_blocks: u8,
143    map_height_blocks: u8,
144    rng_value: u8,
145    blocks: &[u8],
146    tileset: T,
147    provider: &impl CollisionProvider<T>,
148) {
149    let max_x = (map_width_blocks as u16) * 2;
150    let max_y = (map_height_blocks as u16) * 2;
151
152    // Build the occupied-tile set: current position of each visible NPC,
153    // plus its destination if it is mid-step (to avoid inter-NPC collisions).
154    let occupied: Vec<(u16, u16)> = npcs
155        .iter()
156        .filter(|n| n.visible)
157        .flat_map(|n| {
158            let cur = (n.x, n.y);
159            if n.walk_counter > 0 {
160                let (dx, dy) = direction_delta(n.facing);
161                let dest = (
162                    (n.x as i32 + dx as i32).max(0) as u16,
163                    (n.y as i32 + dy as i32).max(0) as u16,
164                );
165                vec![cur, dest]
166            } else {
167                vec![cur]
168            }
169        })
170        .collect();
171
172    for i in 0..npcs.len() {
173        let npc = &mut npcs[i];
174        if !npc.visible {
175            continue;
176        }
177
178        // ── Finish current step ──────────────────────────────────
179        if npc.walk_counter > 0 {
180            npc.walk_counter -= 1;
181            if npc.walk_counter == 0 {
182                let (dx, dy) = direction_delta(npc.facing);
183                npc.x = (npc.x as i32 + dx as i32).max(0) as u16;
184                npc.y = (npc.y as i32 + dy as i32).max(0) as u16;
185
186                // Advance scripted path if we reached the next waypoint
187                if !npc.scripted_path.is_empty() {
188                    let &(tx, ty) = npc.scripted_path.front().unwrap();
189                    if npc.x == tx && npc.y == ty {
190                        npc.scripted_path.pop_front();
191                    }
192                    if let Some(&(ntx, nty)) = npc.scripted_path.front() {
193                        if let Some(dir) = direction_toward(npc.x, npc.y, ntx, nty) {
194                            npc.facing = dir;
195                            npc.walk_counter = NPC_WALK_FRAMES;
196                        }
197                    }
198                }
199            }
200            continue;
201        }
202
203        // ── Scripted path: start next step ───────────────────────
204        if !npc.scripted_path.is_empty() {
205            let &(tx, ty) = npc.scripted_path.front().unwrap();
206            if npc.x == tx && npc.y == ty {
207                npc.scripted_path.pop_front();
208                if npc.scripted_path.is_empty() {
209                    continue;
210                }
211                let &(tx, ty) = npc.scripted_path.front().unwrap();
212                if let Some(dir) = direction_toward(npc.x, npc.y, tx, ty) {
213                    npc.facing = dir;
214                    npc.walk_counter = NPC_WALK_FRAMES;
215                }
216            } else if let Some(dir) = direction_toward(npc.x, npc.y, tx, ty) {
217                npc.facing = dir;
218                npc.walk_counter = NPC_WALK_FRAMES;
219            }
220            continue;
221        }
222
223        // ── Autonomous movement by type ──────────────────────────
224        match npc.movement_type {
225            NpcMovementType::Stationary => {}
226            NpcMovementType::Wander => {
227                if npc.delay_counter > 0 {
228                    npc.delay_counter -= 1;
229                    continue;
230                }
231
232                // Pick a random direction from the lower 2 bits of rng_value
233                let dir_bits = (rng_value.wrapping_add(i as u8)) & 0x03;
234                let dir = match dir_bits {
235                    0 => Direction::Down,
236                    1 => Direction::Up,
237                    2 => Direction::Left,
238                    3 => Direction::Right,
239                    _ => unreachable!(),
240                };
241
242                let (dx, dy) = direction_delta(dir);
243                let tx = (npc.x as i32 + dx as i32) as u16;
244                let ty = (npc.y as i32 + dy as i32) as u16;
245
246                // Bounds check
247                if tx >= max_x || ty >= max_y {
248                    npc.facing = dir;
249                    npc.delay_counter = rng_value & NPC_MAX_DELAY;
250                    continue;
251                }
252
253                // Range check (distance from home position)
254                if npc.range > 0 {
255                    let dist_x = (tx as i32 - npc.home_x as i32).unsigned_abs();
256                    let dist_y = (ty as i32 - npc.home_y as i32).unsigned_abs();
257                    if dist_x > npc.range as u32 || dist_y > npc.range as u32 {
258                        npc.facing = dir;
259                        npc.delay_counter = rng_value & NPC_MAX_DELAY;
260                        continue;
261                    }
262                }
263
264                // Check occupied tiles (other NPCs)
265                let blocked = occupied
266                    .iter()
267                    .any(|&(ox, oy)| !(ox == npc.x && oy == npc.y) && ox == tx && oy == ty);
268                let player_blocked = (tx == player_x && ty == player_y)
269                    || player_dest.map_or(false, |(px, py)| tx == px && ty == py);
270
271                if blocked || player_blocked {
272                    npc.facing = dir;
273                    npc.delay_counter = rng_value & NPC_MAX_DELAY;
274                    continue;
275                }
276
277                // Check tile passability
278                let target_tile = provider.get_tile_at_position(tileset, blocks, map_width_blocks, tx, ty);
279                if !provider.is_tile_passable(tileset, target_tile) {
280                    npc.facing = dir;
281                    npc.delay_counter = rng_value & NPC_MAX_DELAY;
282                    continue;
283                }
284
285                npc.facing = dir;
286                npc.walk_counter = NPC_WALK_FRAMES;
287                npc.delay_counter = rng_value & NPC_MAX_DELAY;
288            }
289            NpcMovementType::FacePlayer => {
290                let dx = player_x as i32 - npc.x as i32;
291                let dy = player_y as i32 - npc.y as i32;
292
293                if dx.abs() >= dy.abs() {
294                    npc.facing = if dx > 0 {
295                        Direction::Right
296                    } else {
297                        Direction::Left
298                    };
299                } else {
300                    npc.facing = if dy > 0 {
301                        Direction::Down
302                    } else {
303                        Direction::Up
304                    };
305                }
306            }
307            NpcMovementType::FixedPath => {}
308        }
309    }
310}
311
312// ── NPC-in-Front Check ─────────────────────────────────────────────
313
314/// Find the NPC the player is facing, accounting for counter tiles.
315///
316/// In the original game, pressing A on a counter tile extends the
317/// interaction range by one tile to allow talking to NPCs across
318/// counters (e.g. shopkeepers in Poké Marts).
319pub fn npc_in_front_of_player<'a, M: MapTrait, T: TilesetTrait, Mus>(
320    npcs: &'a [NpcRuntimeState],
321    player_x: u16,
322    player_y: u16,
323    facing: Direction,
324    map: Option<&MapData<M, T, Mus>>,
325    provider: &impl CollisionProvider<T>,
326) -> Option<&'a NpcRuntimeState> {
327    let (dx, dy) = direction_delta(facing);
328    let target_x = (player_x as i32 + dx as i32) as u16;
329    let target_y = (player_y as i32 + dy as i32) as u16;
330
331    if let Some(npc) = npc_at_position(npcs, target_x, target_y) {
332        return Some(npc);
333    }
334
335    // Counter tile extension: if the tile in front is a counter,
336    // check one more tile in the same direction for an NPC behind it.
337    if let Some(map_data) = map {
338        let tile = provider.get_tile_at_position(map_data.tileset, &map_data.blocks, map_data.width, target_x, target_y);
339        if provider.is_counter_tile(map_data.tileset, tile) {
340            let extended_x = (target_x as i32 + dx as i32) as u16;
341            let extended_y = (target_y as i32 + dy as i32) as u16;
342            return npc_at_position(npcs, extended_x, extended_y);
343        }
344    }
345
346    None
347}