From 723c2fe0982f80438b75098fa79d1933efc13d0a Mon Sep 17 00:00:00 2001 From: salmonik Date: Sat, 11 Jul 2026 15:30:04 +0200 Subject: add enemy AI: chase, patrol, attack, and movement animation --- src/game.rs | 362 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-- src/renderer.rs | 117 ++++++++++-------- 2 files changed, 420 insertions(+), 59 deletions(-) diff --git a/src/game.rs b/src/game.rs index f66ca40..992023e 100644 --- a/src/game.rs +++ b/src/game.rs @@ -614,6 +614,16 @@ const TURN_DURATION: f64 = 0.12; // seconds for a 90° turn const ENEMY_ANIM_DURATION: f64 = 0.10; // seconds for enemy movement const ATTACK_DURATION: f64 = 0.12; // seconds for attack lunge +/// An enemy movement queued for animation. +#[derive(Clone)] +struct EnemyMove { + from_x: usize, + from_y: usize, + to_x: usize, + to_y: usize, + entity: Entity, +} + pub const MAX_HP: u32 = 5; pub const MAX_SANITY: u32 = 100; pub const MAX_WEAKNESS: u32 = 100; @@ -664,6 +674,10 @@ pub struct Game { // ---- animation: enemies ---- enemy_anim_t: f64, + enemy_moves: Vec, + + /// Enemies that already retaliated this turn (skip their independent attack). + acted_this_turn: Vec<(usize, usize)>, // ---- animation: attack ---- pub attacking: bool, @@ -724,6 +738,8 @@ impl Game { turn_delta: 0.0, turn_t: 0.0, enemy_anim_t: 0.0, + enemy_moves: Vec::new(), + acted_this_turn: Vec::new(), attacking: false, attack_t: 0.0, attack_base_x: 0.0, @@ -808,10 +824,19 @@ impl Game { } if done { - self.state = GameState::ResolvingTurn; - self.resolve_turn(); - if self.state != GameState::GameOver { - self.state = GameState::WaitingForInput; + let enemies_moved = self.compute_enemy_turns(); + if self.state == GameState::GameOver { + return true; + } + if enemies_moved { + self.state = GameState::AnimatingEnemies; + self.enemy_anim_t = 0.0; + } else { + self.state = GameState::ResolvingTurn; + self.resolve_turn(); + if self.state != GameState::GameOver { + self.state = GameState::WaitingForInput; + } } return true; } @@ -821,6 +846,7 @@ impl Game { self.enemy_anim_t += dt; if self.enemy_anim_t >= ENEMY_ANIM_DURATION { self.enemy_anim_t = 0.0; + self.apply_enemy_moves(); self.state = GameState::ResolvingTurn; self.resolve_turn(); if self.state != GameState::GameOver { @@ -866,6 +892,7 @@ impl Game { // Blocked by real enemy — waste the turn, take damage. self.set_message("Something is blocking your way."); self.hp = self.hp.saturating_sub(damage); + self.acted_this_turn.push((tx, ty)); self.advance_turn(); if self.hp == 0 { self.state = GameState::GameOver; @@ -996,6 +1023,7 @@ impl Game { } }); self.hp = self.hp.saturating_sub(damage); + self.acted_this_turn.push((fx, fy)); self.set_message(&format!( "You hit the enemy for {} dmg. It strikes back!", total_dmg @@ -1175,15 +1203,27 @@ impl Game { /// Advance the turn counter and state without animation. fn advance_turn(&mut self) { - self.state = GameState::ResolvingTurn; - self.resolve_turn(); - if self.state != GameState::GameOver { - self.state = GameState::WaitingForInput; + let enemies_moved = self.compute_enemy_turns(); + if self.state == GameState::GameOver { + return; + } + if enemies_moved { + self.state = GameState::AnimatingEnemies; + self.enemy_anim_t = 0.0; + } else { + self.state = GameState::ResolvingTurn; + self.resolve_turn(); + if self.state != GameState::GameOver { + self.state = GameState::WaitingForInput; + } } } /// Apply all per-turn effects (weakness, heartbeat, HP drain, message decay). fn resolve_turn(&mut self) { + // Clear acted-this-turn tracking (enemy AI already consumed it). + self.acted_this_turn.clear(); + // Weakness grows every 3 turns, not every turn. if self.heartbeat_count % 3 == 0 { self.weakness = (self.weakness + 1).min(MAX_WEAKNESS); @@ -1211,9 +1251,20 @@ impl Game { if !self.can_act() { return; } - self.state = GameState::AnimatingEnemies; - self.enemy_anim_t = 0.0; - // TODO Phase 7: enemy movement happens here + let enemies_moved = self.compute_enemy_turns(); + if self.state == GameState::GameOver { + return; + } + if enemies_moved { + self.state = GameState::AnimatingEnemies; + self.enemy_anim_t = 0.0; + } else { + self.state = GameState::ResolvingTurn; + self.resolve_turn(); + if self.state != GameState::GameOver { + self.state = GameState::WaitingForInput; + } + } } /// Set a HUD message that persists for 3 turns. @@ -1225,6 +1276,295 @@ impl Game { // ---- cardinal-direction helpers ---- /// Which grid direction the player faces: (dx, dy) ∈ {±1, 0}. + // ---- enemy AI --------------------------------------------------------- + + /// DDA line-of-sight check from grid cell (ex,ey) to player position (px,py). + fn has_line_of_sight(&self, ex: usize, ey: usize, px: f64, py: f64) -> bool { + let sx = ex as f64 + 0.5; + let sy = ey as f64 + 0.5; + let dx = px - sx; + let dy = py - sy; + let dist = (dx * dx + dy * dy).sqrt(); + if dist < 0.01 { + return true; + } + // Walk the grid line; step size is half a cell for accuracy. + let steps = (dist * 2.0).ceil() as usize; + for i in 1..steps { + let t = i as f64 / steps as f64; + let cx = (sx + dx * t) as usize; + let cy = (sy + dy * t) as usize; + // Reached player cell — LOS confirmed. + if cy == py as usize && cx == px as usize { + return true; + } + if self.maze.is_wall(cy, cx) { + return false; + } + } + true + } + + /// Try to move the enemy at (ex,ey) one tile toward the player. + /// Queues the move in `enemy_moves` and removes the entity from the grid. + /// `occupied` tracks cells that are already taken (by other enemy moves or static entities). + fn try_move_enemy_toward(&mut self, ex: usize, ey: usize, occupied: &mut Vec) { + let px = self.player.x.floor() as isize; + let py = self.player.y.floor() as isize; + + // Four cardinal neighbours, sorted by Manhattan distance to player. + let dirs: [(isize, isize); 4] = [(1, 0), (-1, 0), (0, 1), (0, -1)]; + let mut best: Option<(usize, usize, isize)> = None; + + for (dx, dy) in dirs { + let nx = ex as isize + dx; + let ny = ey as isize + dy; + if nx < 0 || ny < 0 { + continue; + } + let nx = nx as usize; + let ny = ny as usize; + if nx >= self.maze.width() || ny >= self.maze.height() { + continue; + } + // Must be a passage and not occupied. + if !self + .maze + .is_passage_at_float(nx as f64 + 0.5, ny as f64 + 0.5) + { + continue; + } + if occupied[ny * self.maze.width() + nx] { + continue; + } + let dist = manhattan(nx as isize, ny as isize, px, py); + let current = manhattan(ex as isize, ey as isize, px, py); + if dist < current { + match best { + None => best = Some((nx, ny, dist)), + Some((_, _, best_dist)) if dist < best_dist => best = Some((nx, ny, dist)), + _ => {} + } + } + } + + if let Some((tx, ty, _)) = best { + let entity = self.maze.take_entity(ex, ey).unwrap(); + occupied[ty * self.maze.width() + tx] = true; + self.enemy_moves.push(EnemyMove { + from_x: ex, + from_y: ey, + to_x: tx, + to_y: ty, + entity, + }); + } + } + + /// Try to move the enemy at (ex,ey) to a random adjacent passage cell (patrol). + fn try_patrol_enemy(&mut self, ex: usize, ey: usize, occupied: &mut Vec) { + let mut rng = rand::rng(); + let dirs: [(isize, isize); 4] = [(1, 0), (-1, 0), (0, 1), (0, -1)]; + // Shuffle directions. + let mut indices = [0usize, 1, 2, 3]; + for i in (1..4).rev() { + let j = rng.random_range(0..=i); + indices.swap(i, j); + } + + for &idx in &indices { + let (dx, dy) = dirs[idx]; + let nx = ex as isize + dx; + let ny = ey as isize + dy; + if nx < 0 || ny < 0 { + continue; + } + let nx = nx as usize; + let ny = ny as usize; + if nx >= self.maze.width() || ny >= self.maze.height() { + continue; + } + if !self + .maze + .is_passage_at_float(nx as f64 + 0.5, ny as f64 + 0.5) + { + continue; + } + if occupied[ny * self.maze.width() + nx] { + continue; + } + let entity = self.maze.take_entity(ex, ey).unwrap(); + occupied[ny * self.maze.width() + nx] = true; + self.enemy_moves.push(EnemyMove { + from_x: ex, + from_y: ey, + to_x: nx, + to_y: ny, + entity, + }); + return; + } + } + + /// Compute actions for every enemy/hallucination on the level. + /// Attacks are applied immediately; moves are queued in `enemy_moves`. + /// Returns true if any enemy moved (needs animation). + fn compute_enemy_turns(&mut self) -> bool { + self.enemy_moves.clear(); + + let w = self.maze.width(); + let h = self.maze.height(); + + // Occupied mask: cells that are taken (by static entities or queued moves). + let mut occupied = vec![false; w * h]; + for y in 0..h { + for x in 0..w { + if self.maze.entity_at(x, y).is_some() { + occupied[y * w + x] = true; + } + } + } + // Also mark the player's cell as occupied so enemies don't walk into it. + let pcx_usize = self.player.x.floor() as usize; + let pcy_usize = self.player.y.floor() as usize; + if pcx_usize < w && pcy_usize < h { + occupied[pcy_usize * w + pcx_usize] = true; + } + + // Gather all enemy/hallucination positions (snapshot before mutation). + let mut enemy_positions: Vec<(usize, usize)> = Vec::new(); + for y in 0..h { + for x in 0..w { + match self.maze.entity_at(x, y) { + Some(Entity::Enemy { .. }) | Some(Entity::Hallucination { .. }) => { + enemy_positions.push((x, y)); + } + _ => {} + } + } + } + + let px = self.player.x; + let py = self.player.y; + let pcx = px.floor() as isize; + let pcy = py.floor() as isize; + let is_patrol_turn = self.heartbeat_count % 3 == 0; + + // Drain acted-this-turn list for fast lookups. + let acted: Vec<(usize, usize)> = self.acted_this_turn.drain(..).collect(); + + for (ex, ey) in enemy_positions { + // Entity may have been removed by a previous enemy's action. + let entity = match self.maze.entity_at(ex, ey) { + Some(e) => e.clone(), + None => continue, + }; + + let is_hallucination = matches!(entity, Entity::Hallucination { .. }); + + // ---- hallucination behaviour gating ---- + if is_hallucination { + if self.sanity >= 40 { + // Static: hallucinations don't move or attack. + continue; + } + // Sanity 20-39%: patrol only. + // Sanity < 20%: chase (handled below via has_line_of_sight). + } + + // ---- adjacent → attack ---- + if manhattan(ex as isize, ey as isize, pcx, pcy) == 1 { + // Skip if this enemy already acted this turn (retaliated or blocked). + if acted.contains(&(ex, ey)) { + // Still let it patrol/move if applicable. + } else { + if is_hallucination { + self.sanity = self.sanity.saturating_sub(5); + self.set_message("A hallucination claws at your mind!"); + self.flash_color = 0xFF_8833CC; + self.flash_alpha = 0.35; + } else { + let damage = match &entity { + Entity::Enemy { damage, .. } => *damage, + _ => 1, + }; + self.hp = self.hp.saturating_sub(damage); + self.set_message(&format!("An enemy strikes you for {} damage!", damage)); + self.flash_color = 0xFF_CC3333; + self.flash_alpha = 0.4; + if self.hp == 0 { + self.state = GameState::GameOver; + return false; + } + } + continue; + } + } + + // ---- line-of-sight → move toward player ---- + let has_los = self.has_line_of_sight(ex, ey, px, py); + + if has_los { + // Real enemies always chase; hallucinations chase only at sanity < 20%. + if !is_hallucination { + self.try_move_enemy_toward(ex, ey, &mut occupied); + continue; + } else if self.sanity < 20 { + self.try_move_enemy_toward(ex, ey, &mut occupied); + continue; + } + } + + // ---- patrol ---- + if is_patrol_turn { + self.try_patrol_enemy(ex, ey, &mut occupied); + } + } + + !self.enemy_moves.is_empty() + } + + /// After enemy animation completes, place entities in their target cells. + fn apply_enemy_moves(&mut self) { + for m in self.enemy_moves.drain(..) { + let idx = m.to_y * self.maze.width() + m.to_x; + self.maze.entities[idx] = Some(m.entity); + } + } + + /// Interpolation offset for an enemy during movement animation. + /// Returns (dx, dy) in world units; (0, 0) when not animating. + #[allow(dead_code)] + pub fn enemy_offset(&self, cell_x: usize, cell_y: usize) -> (f64, f64) { + if self.state != GameState::AnimatingEnemies { + return (0.0, 0.0); + } + for m in &self.enemy_moves { + if m.from_x == cell_x && m.from_y == cell_y { + let t = smoothstep((self.enemy_anim_t / ENEMY_ANIM_DURATION).min(1.0)); + return ( + (m.to_x as f64 - m.from_x as f64) * t, + (m.to_y as f64 - m.from_y as f64) * t, + ); + } + } + (0.0, 0.0) + } + + /// Entities that should be rendered during enemy animation. + /// Returns (world_x, world_y, entity) for mid-air enemies. + pub fn enemy_render_entities(&self) -> Vec<(f64, f64, Entity)> { + self.enemy_moves + .iter() + .map(|m| { + let t = smoothstep((self.enemy_anim_t / ENEMY_ANIM_DURATION).min(1.0)); + let wx = m.from_x as f64 + 0.5 + (m.to_x as f64 - m.from_x as f64) * t; + let wy = m.from_y as f64 + 0.5 + (m.to_y as f64 - m.from_y as f64) * t; + (wx, wy, m.entity.clone()) + }) + .collect() + } + fn facing_delta(&self) -> (isize, isize) { let q = (self.player.angle / std::f64::consts::FRAC_PI_2).round() as i32 & 3; match q { diff --git a/src/renderer.rs b/src/renderer.rs index aa87b54..885e5de 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -74,6 +74,18 @@ pub struct Renderer { floor_dist_lut: Vec, } +// ---- entity sprites --------------------------------------------------- + +/// Screen-space info for an entity sprite to render. +struct Sprite { + sx: isize, + sy: isize, + scale: f64, + ch: char, + color: u32, + cam_z: f64, +} + impl Renderer { pub fn new(screen_w: usize, screen_h: usize) -> Self { let half_h = screen_h as f64 / 2.0; @@ -306,19 +318,9 @@ impl Renderer { ) { let mw = game.maze.width(); let mh = game.maze.height(); - - // Collect visible entities with their screen info. - struct Sprite { - sx: isize, - sy: isize, - scale: f64, - ch: char, - color: u32, - cam_z: f64, - } - let mut sprites: Vec = Vec::new(); + // Static entities on the grid. for y in 0..mh { for x in 0..mw { let entity = match game.maze.entity_at(x, y) { @@ -329,46 +331,15 @@ impl Renderer { let ex = x as f64 + 0.5; let ey = y as f64 + 0.5; - let dx = ex - game.player.x; - let dy = ey - game.player.y; - - let fx = game.player.angle.cos(); - let fy = game.player.angle.sin(); - let rx = -fy; - let ry = fx; - - let cam_z = dx * fx + dy * fy; - if cam_z <= 0.1 { - continue; - } - - let cam_x = dx * rx + dy * ry; - - let half_fov_tan = (fov / 2.0).tan(); - let screen_x_norm = cam_x / (cam_z * half_fov_tan); - if screen_x_norm < -1.0 || screen_x_norm > 1.0 { - continue; - } - - let sx = ((screen_x_norm + 1.0) * 0.5 * self.screen_w as f64) as isize; - - let entity_h = (self.screen_h as f64 / cam_z * 0.4) as isize; - let sy = (half_h - entity_h as f64 * 0.3) as isize; - - let scale = (entity_h.max(6) as f64 / LETTER_H as f64).max(0.5); - let (ch, color) = entity_char_and_color(entity); - - sprites.push(Sprite { - sx, - sy, - scale, - ch, - color, - cam_z, - }); + self.push_entity_sprite(game, ex, ey, entity, fov, half_h, &mut sprites); } } + // Animating enemies (mid-move, not on the grid). + for (wx, wy, entity) in game.enemy_render_entities() { + self.push_entity_sprite(game, wx, wy, &entity, fov, half_h, &mut sprites); + } + // Sort farthest first so nearby entities draw on top. sprites.sort_by(|a, b| b.cam_z.partial_cmp(&a.cam_z).unwrap()); @@ -387,6 +358,56 @@ impl Renderer { } } + /// Project a world-space entity position and push a Sprite if on screen. + fn push_entity_sprite( + &self, + game: &Game, + ex: f64, + ey: f64, + entity: &Entity, + fov: f64, + half_h: f64, + sprites: &mut Vec, + ) { + let dx = ex - game.player.x; + let dy = ey - game.player.y; + + let fx = game.player.angle.cos(); + let fy = game.player.angle.sin(); + let rx = -fy; + let ry = fx; + + let cam_z = dx * fx + dy * fy; + if cam_z <= 0.1 { + return; + } + + let cam_x = dx * rx + dy * ry; + + let half_fov_tan = (fov / 2.0).tan(); + let screen_x_norm = cam_x / (cam_z * half_fov_tan); + if screen_x_norm < -1.0 || screen_x_norm > 1.0 { + return; + } + + let sx = ((screen_x_norm + 1.0) * 0.5 * self.screen_w as f64) as isize; + + let entity_h = (self.screen_h as f64 / cam_z * 0.4) as isize; + let sy = (half_h - entity_h as f64 * 0.3) as isize; + + let scale = (entity_h.max(6) as f64 / LETTER_H as f64).max(0.5); + let (ch, color) = entity_char_and_color(entity); + + sprites.push(Sprite { + sx, + sy, + scale, + ch, + color, + cam_z, + }); + } + fn draw_char_scaled( &self, buffer: &mut [u32], -- cgit v1.2.3