From c8eae871002b57f45b29a2d158e384433e76a90c Mon Sep 17 00:00:00 2001 From: salmonik Date: Fri, 10 Jul 2026 21:16:59 +0200 Subject: Phase 2 --- src/game.rs | 1155 ++++++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 1072 insertions(+), 83 deletions(-) (limited to 'src/game.rs') diff --git a/src/game.rs b/src/game.rs index 4bedecc..80ee0c6 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,5 +1,16 @@ use rand::Rng; +// --------------- game state machine --------------- + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum GameState { + WaitingForInput, + AnimatingPlayer, + AnimatingEnemies, + ResolvingTurn, + GameOver, +} + // --------------- maze grid --------------- #[derive(Clone, Copy, PartialEq, Eq)] @@ -9,77 +20,271 @@ pub enum Cell { Exit, } +/// An item the player can pick up. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum PickupType { + Food, + Pill, + Bandage, + Weapon { base_damage: u32 }, +} + +/// Something occupying a maze cell (other than walls). +#[derive(Clone)] +pub enum Entity { + #[allow(dead_code)] + Enemy { + hp: u32, + max_hp: u32, + damage: u32, + }, + Hallucination { + persistent: bool, + hp: u32, + }, + Pickup(PickupType), + HallucinatedPickup(PickupType), +} + +/// An item carried in the player's inventory. +#[derive(Clone, Copy)] +pub struct InventoryItem { + pub pickup: PickupType, + pub is_hallucination: bool, +} + pub struct Maze { cells: Vec, + entities: Vec>, width: usize, height: usize, + pub exit_x: usize, + pub exit_y: usize, +} + +/// A rectangular room placed on the grid. +struct Room { + x: usize, // top-left column + y: usize, // top-left row + w: usize, // interior width (cells) + h: usize, // interior height (cells) + cx: usize, // centre column (for corridor connections) + cy: usize, // centre row } impl Maze { - /// Recursive-backtracker perfect maze. `room_w × room_h` is the logical - /// room count; the actual grid will be `(room_w*2+1) × (room_h*2+1)`. - pub fn generate(room_w: usize, room_h: usize, rng: &mut impl Rng) -> Self { - let w = room_w * 2 + 1; - let h = room_h * 2 + 1; + /// Generate a rooms-and-corridors dungeon for the given Depth. + pub fn generate(depth: u64, rng: &mut impl Rng) -> (Self, (f64, f64)) { + let size = grid_size_for_depth(depth); + let room_target = room_count_for_size(size); + let w = size; + let h = size; let mut cells = vec![Cell::Wall; w * h]; - cells[1 * w + 1] = Cell::Passage; - let mut stack = vec![(1usize, 1usize)]; + // ---- Phase 1: place rooms ---- + let mut rooms: Vec = Vec::new(); + let max_attempts = 300; - while let Some(&(cy, cx)) = stack.last() { - let mut unvisited = Vec::new(); + for _ in 0..max_attempts { + if rooms.len() >= room_target { + break; + } - for (dy, dx) in [(-2isize, 0isize), (2, 0), (0, -2), (0, 2)] { - let ny = cy as isize + dy; - let nx = cx as isize + dx; - if ny >= 1 && ny < (h - 1) as isize && nx >= 1 && nx < (w - 1) as isize { - if cells[ny as usize * w + nx as usize] == Cell::Wall { - unvisited.push((ny as usize, nx as usize)); - } + let rw = rng.gen_range(3usize..=6); + let rh = rng.gen_range(3usize..=6); + + // Keep rooms away from the outer border by at least 2 cells + // so corridors have room to connect to them. + if rw + 2 >= w || rh + 2 >= h { + continue; + } + let rx = rng.gen_range(2..(w - rw - 2)); + let ry = rng.gen_range(2..(h - rh - 2)); + + // Check overlap with 1-cell buffer. + let mut overlaps = false; + for room in &rooms { + if rx < room.x + room.w + 1 + && rx + rw + 1 > room.x + && ry < room.y + room.h + 1 + && ry + rh + 1 > room.y + { + overlaps = true; + break; } } - if unvisited.is_empty() { - stack.pop(); - } else { - let idx = rng.gen_range(0..unvisited.len()); - let (ny, nx) = unvisited[idx]; + if !overlaps { + // Carve room interior. + for dy in 0..rh { + for dx in 0..rw { + cells[(ry + dy) * w + (rx + dx)] = Cell::Passage; + } + } - // carve the wall between current cell and chosen neighbour - let wall_y = (cy + ny) / 2; - let wall_x = (cx + nx) / 2; - cells[wall_y * w + wall_x] = Cell::Passage; - cells[ny * w + nx] = Cell::Passage; + rooms.push(Room { + x: rx, + y: ry, + w: rw, + h: rh, + cx: rx + rw / 2, + cy: ry + rh / 2, + }); + } + } - stack.push((ny, nx)); + // We need at least 2 rooms. + if rooms.len() < 2 { + // Fallback: force-place two small rooms. + rooms.clear(); + cells.fill(Cell::Wall); + for i in 0..2 { + let rx = 2 + i * (w - 8); + let ry = 2 + i * (h - 8); + let rw = 4; + let rh = 4; + for dy in 0..rh { + for dx in 0..rw { + let px = (rx + dx).min(w - 1); + let py = (ry + dy).min(h - 1); + cells[py * w + px] = Cell::Passage; + } + } + rooms.push(Room { + x: rx, + y: ry, + w: rw, + h: rh, + cx: rx + rw / 2, + cy: ry + rh / 2, + }); } } - // ---- place exit in a far-away passage ---- - let mut passages: Vec<(f64, usize, usize)> = Vec::new(); - for y in (1..h).step_by(2) { - for x in (1..w).step_by(2) { - if cells[y * w + x] == Cell::Passage && !(y == 1 && x == 1) { - let dist = ((y as f64 - 1.0).powi(2) + (x as f64 - 1.0).powi(2)).sqrt(); - passages.push((dist, y, x)); + // ---- Phase 2: connect rooms with a spanning tree (Prim) ---- + let mut connected = vec![false; rooms.len()]; + connected[0] = true; + let mut edges: Vec<(usize, usize, usize)> = Vec::new(); // (dist, from, to) + + loop { + edges.clear(); + for (i, ri) in rooms.iter().enumerate() { + if !connected[i] { + continue; } + for (j, rj) in rooms.iter().enumerate() { + if connected[j] { + continue; + } + let dist = manhattan( + ri.cx as isize, + ri.cy as isize, + rj.cx as isize, + rj.cy as isize, + ); + edges.push((dist as usize, i, j)); + } + } + + if edges.is_empty() { + break; } + + edges.sort_by_key(|e| e.0); + // Pick from the nearest few for variety. + let pick = rng.gen_range(0..(edges.len().min(3))); + let (_dist, from, to) = edges[pick]; + + carve_corridor( + &mut cells, + w, + rooms[from].cx, + rooms[from].cy, + rooms[to].cx, + rooms[to].cy, + rng, + ); + connected[to] = true; } - if !passages.is_empty() { - passages.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap()); - let far_count = (passages.len() / 4).max(1); - let idx = rng.gen_range(0..far_count); - let (_, ey, ex) = passages[idx]; - cells[ey * w + ex] = Cell::Exit; + // ---- Phase 3: add a few extra connections for loops ---- + let extra_connections = (rooms.len() / 5).max(1); + for _ in 0..extra_connections { + let a = rng.gen_range(0..rooms.len()); + let b = rng.gen_range(0..rooms.len()); + if a != b { + let dist = manhattan( + rooms[a].cx as isize, + rooms[a].cy as isize, + rooms[b].cx as isize, + rooms[b].cy as isize, + ); + // Only add short extra connections to avoid spaghetti. + if dist < (w as isize + h as isize) / 4 { + carve_corridor( + &mut cells, + w, + rooms[a].cx, + rooms[a].cy, + rooms[b].cx, + rooms[b].cy, + rng, + ); + } + } } - Self { - cells, - width: w, - height: h, + // ---- Phase 4: place exit in the room farthest from start ---- + let start_room_idx = 0; // first room placed is near top-left + let mut farthest = 0; + let mut farthest_dist = 0.0f64; + for (i, room) in rooms.iter().enumerate() { + if i == start_room_idx { + continue; + } + let dx = room.cx as f64 - rooms[start_room_idx].cx as f64; + let dy = room.cy as f64 - rooms[start_room_idx].cy as f64; + let d = (dx * dx + dy * dy).sqrt(); + if d > farthest_dist { + farthest_dist = d; + farthest = i; + } } + + let exit_room = &rooms[farthest]; + let exit_x = exit_room.cx; + let exit_y = exit_room.cy; + cells[exit_y * w + exit_x] = Cell::Exit; + + // Player starts in the first room. + let start_x = rooms[start_room_idx].cx as f64 + 0.5; + let start_y = rooms[start_room_idx].cy as f64 + 0.5; + + // ---- Phase 5: place entities ---- + let mut entities: Vec> = vec![None; w * h]; + place_entities( + &cells, + &mut entities, + w, + h, + depth, + start_x as usize, + start_y as usize, + rng, + ); + + ( + Self { + cells, + entities, + width: w, + height: h, + exit_x, + exit_y, + }, + (start_x, start_y), + ) } #[inline] @@ -116,89 +321,873 @@ impl Maze { pub fn height(&self) -> usize { self.height } + + // ---- entity accessors ---- + + /// Get the entity at grid coordinates, if any. + pub fn entity_at(&self, x: usize, y: usize) -> Option<&Entity> { + self.entities + .get(y * self.width + x) + .and_then(|e| e.as_ref()) + } + + /// Mutable reference to entity at grid coordinates. + pub fn entity_at_mut(&mut self, x: usize, y: usize) -> Option<&mut Entity> { + self.entities + .get_mut(y * self.width + x) + .and_then(|e| e.as_mut()) + } + + /// Remove and return the entity at grid coordinates. + pub fn take_entity(&mut self, x: usize, y: usize) -> Option { + self.entities + .get_mut(y * self.width + x) + .and_then(|e| e.take()) + } + + /// Check if a cell is occupied by any entity. + #[allow(dead_code)] + pub fn is_occupied(&self, x: usize, y: usize) -> bool { + self.entity_at(x, y).is_some() + } + + /// Check if the entity at a cell is a persistent hallucination. + #[allow(dead_code)] + pub fn is_persistent_hallucination(&self, x: usize, y: usize) -> bool { + matches!( + self.entity_at(x, y), + Some(Entity::Hallucination { + persistent: true, + .. + }) + ) + } } -// --------------- player --------------- +// ---- Maze helpers ---- -pub struct Player { - pub x: f64, - pub y: f64, - pub angle: f64, // radians, 0 = +x (east), π/2 = +y (south) +fn grid_size_for_depth(depth: u64) -> usize { + match depth { + 1..=3 => 33, + 4..=7 => 41, + 8..=12 => 49, + _ => 57, + } } -impl Player { - pub fn new(x: f64, y: f64, angle: f64) -> Self { - Self { x, y, angle } +fn room_count_for_size(size: usize) -> usize { + match size { + 33 => 8, + 41 => 12, + 49 => 16, + _ => 20, + } +} + +fn manhattan(x1: isize, y1: isize, x2: isize, y2: isize) -> isize { + (x1 - x2).abs() + (y1 - y2).abs() +} + +/// Carve an L-shaped corridor between two points. Randomly picks +/// horizontal-first or vertical-first, and widens to 2 cells occasionally. +fn carve_corridor( + cells: &mut Vec, + grid_w: usize, + x1: usize, + y1: usize, + x2: usize, + y2: usize, + rng: &mut impl Rng, +) { + let cx = x1 as isize; + let cy = y1 as isize; + let tx = x2 as isize; + let ty = y2 as isize; + + let wide = rng.gen_bool(0.3); // 30% chance of 2-wide corridor + + if rng.gen_bool(0.5) { + // Horizontal, then vertical. + carve_line(cells, grid_w, cx, cy, tx, cy, wide); + carve_line(cells, grid_w, tx, cy, tx, ty, wide); + } else { + // Vertical, then horizontal. + carve_line(cells, grid_w, cx, cy, cx, ty, wide); + carve_line(cells, grid_w, cx, ty, tx, ty, wide); } +} + +/// Carve a straight line of passages from (x1,y1) to (x2,y2). +/// If `wide` is true, also clears the adjacent parallel cell. +fn carve_line( + cells: &mut Vec, + grid_w: usize, + x1: isize, + y1: isize, + x2: isize, + y2: isize, + wide: bool, +) { + let dx = (x2 - x1).signum(); + let dy = (y2 - y1).signum(); + let steps = (x2 - x1).abs().max((y2 - y1).abs()); + + let perp_dx = -dy; // perpendicular for widening + let perp_dy = dx; + + let mut x = x1; + let mut y = y1; - pub fn rotate(&mut self, delta: f64) { - self.angle = (self.angle + delta).rem_euclid(2.0 * std::f64::consts::PI); + for _ in 0..=steps { + if x >= 0 && x < grid_w as isize && y >= 0 && y < grid_w as isize { + cells[y as usize * grid_w + x as usize] = Cell::Passage; + } + if wide { + let wx = x + perp_dx; + let wy = y + perp_dy; + if wx >= 0 && wx < grid_w as isize && wy >= 0 && wy < grid_w as isize { + cells[wy as usize * grid_w + wx as usize] = Cell::Passage; + } + } + x += dx; + y += dy; } +} - /// Apply a raw (dx, dy) displacement with wall-sliding collision. - /// Checks are done per-axis so the player slides along walls naturally. - pub fn try_move(&mut self, dx: f64, dy: f64, maze: &Maze) { - let m = 0.35; // collision margin — keeps the camera out of walls +/// Place enemies, hallucinations, and pickups in random passage cells. +fn place_entities( + cells: &[Cell], + entities: &mut [Option], + w: usize, + h: usize, + depth: u64, + start_x: usize, + start_y: usize, + rng: &mut impl Rng, +) { + // Collect eligible passage cells (not start, not exit). + let mut candidates: Vec<(usize, usize)> = Vec::new(); + for y in 1..h - 1 { + for x in 1..w - 1 { + if cells[y * w + x] == Cell::Passage && !(x == start_x && y == start_y) { + candidates.push((x, y)); + } + } + } - let nx = self.x + dx; - if maze.is_passage_at_float(nx + m, self.y + m) - && maze.is_passage_at_float(nx + m, self.y - m) - && maze.is_passage_at_float(nx - m, self.y + m) - && maze.is_passage_at_float(nx - m, self.y - m) - { - self.x = nx; + if candidates.is_empty() { + return; + } + + // Shuffle for random placement. + shuffle_slice(&mut candidates, rng); + let mut next = 0usize; + + // --- real enemies --- + let enemy_count = 3 + depth as usize * 2; + for _ in 0..enemy_count { + if next >= candidates.len() { + break; } + let (x, y) = candidates[next]; + next += 1; - let ny = self.y + dy; - if maze.is_passage_at_float(self.x + m, ny + m) - && maze.is_passage_at_float(self.x - m, ny + m) - && maze.is_passage_at_float(self.x + m, ny - m) - && maze.is_passage_at_float(self.x - m, ny - m) - { - self.y = ny; + let (hp_range, damage) = enemy_stats_for_depth(depth); + let max_hp = rng.gen_range(hp_range.0..=hp_range.1); + let dmg = if damage.0 == damage.1 { + damage.0 + } else { + rng.gen_range(damage.0..=damage.1) + }; + + entities[y * w + x] = Some(Entity::Enemy { + hp: max_hp, + max_hp, + damage: dmg, + }); + } + + // --- hallucinations (1-2 per level) --- + let hall_count = rng.gen_range(1..=2); + for _ in 0..hall_count { + if next >= candidates.len() { + break; + } + let (x, y) = candidates[next]; + next += 1; + entities[y * w + x] = Some(Entity::Hallucination { + persistent: false, + hp: 1, + }); + } + + // --- real pickups --- + let pickup_count = 2 + (depth as usize / 2); + for i in 0..pickup_count { + if next >= candidates.len() { + break; + } + let (x, y) = candidates[next]; + next += 1; + + let pt = if i == 0 && depth % 2 == 0 { + // Guaranteed weapon every even depth. + let base_dmg = 2 + depth as u32 / 3; + PickupType::Weapon { + base_damage: base_dmg, + } + } else { + random_pickup_type(rng, false) + }; + + entities[y * w + x] = Some(Entity::Pickup(pt)); + } + + // --- hallucinated pickups --- + let fake_count = if depth >= 3 { rng.gen_range(1..=2) } else { 0 }; + for _ in 0..fake_count { + if next >= candidates.len() { + break; + } + let (x, y) = candidates[next]; + next += 1; + let pt = random_pickup_type(rng, true); + entities[y * w + x] = Some(Entity::HallucinatedPickup(pt)); + } +} + +fn enemy_stats_for_depth(depth: u64) -> ((u32, u32), (u32, u32)) { + match depth { + 1 => ((1, 1), (1, 1)), + 2..=3 => ((2, 3), (1, 1)), + 4..=6 => ((3, 5), (1, 2)), + 7..=9 => ((5, 8), (2, 2)), + _ => ((8, 12), (2, 3)), + } +} + +fn random_pickup_type(rng: &mut impl Rng, include_weapon: bool) -> PickupType { + let roll = rng.gen_range(0..100); + if include_weapon && roll < 15 { + PickupType::Weapon { + base_damage: rng.gen_range(2..=4), } + } else if roll < 45 { + PickupType::Food + } else if roll < 75 { + PickupType::Pill + } else { + PickupType::Bandage + } +} + +/// Fisher-Yates shuffle on a slice. +fn shuffle_slice(slice: &mut [T], rng: &mut impl Rng) { + for i in (1..slice.len()).rev() { + let j = rng.gen_range(0..=i); + slice.swap(i, j); + } +} + +// --------------- player --------------- + +pub struct Player { + pub x: f64, + pub y: f64, + pub angle: f64, // radians, 0 = +x (east), π/2 = +y (south) +} + +impl Player { + pub fn new(x: f64, y: f64, angle: f64) -> Self { + Self { x, y, angle } } } // --------------- top-level game --------------- +const MOVE_DURATION: f64 = 0.18; // seconds to walk one tile +const TURN_DURATION: f64 = 0.12; // seconds for a 90° turn +const ENEMY_ANIM_DURATION: f64 = 0.10; // seconds for enemy movement + +pub const MAX_HP: u32 = 5; +pub const MAX_SANITY: u32 = 100; +pub const MAX_WEAKNESS: u32 = 100; + pub struct Game { pub maze: Maze, pub player: Player, pub wall_texture: Vec, pub floor_texture: Vec, + pub exit_texture: Vec, + + // ---- turn-based resources ---- + pub hp: u32, + pub max_hp: u32, + pub sanity: u32, // 0..100 + pub weakness: u32, // 0..100; max = HP drain + + /// Whole turns elapsed (displayed in HUD as heartbeats). + pub heartbeat_count: u64, + + // ---- state machine ---- + pub state: GameState, + + // ---- score tracking ---- + pub kills: u64, + pub depth: u64, + + // ---- weapon & inventory ---- + pub weapon_base_damage: u32, + pub inventory: [Option; 4], + pub selected_slot: usize, + + // ---- message log ---- + pub message: String, + pub message_turns: u32, + + // ---- animation: player movement ---- + pub moving: bool, + move_start: (f64, f64), + move_target: (f64, f64), + move_t: f64, // 0.0 → 1.0 + + // ---- animation: player turn ---- + pub turning: bool, + turn_start: f64, + turn_delta: f64, // signed radians to rotate + turn_t: f64, + + // ---- animation: enemies ---- + enemy_anim_t: f64, +} + +fn smoothstep(t: f64) -> f64 { + t * t * (3.0 - 2.0 * t) } impl Game { pub fn new() -> Self { let mut rng = rand::thread_rng(); - let maze = Maze::generate(16, 16, &mut rng); // 33×33 grid - let player = Player::new(1.5, 1.5, 0.0); // start centre of (1,1), facing east + let depth = 1; + let (maze, (start_x, start_y)) = Maze::generate(depth, &mut rng); + let player = Player::new(start_x, start_y, 0.0); // facing east let wall_texture = crate::texture::generate_wall_texture(&mut rng); let floor_texture = crate::texture::generate_floor_texture(&mut rng); + let exit_texture = crate::texture::generate_exit_texture(&mut rng); Self { maze, player, wall_texture, floor_texture, + exit_texture, + hp: MAX_HP, + max_hp: MAX_HP, + sanity: MAX_SANITY, + weakness: 0, + heartbeat_count: 0, + state: GameState::WaitingForInput, + kills: 0, + depth: 1, + weapon_base_damage: 1, // fists + inventory: [None; 4], + selected_slot: 0, + message: String::new(), + message_turns: 0, + moving: false, + move_start: (0.0, 0.0), + move_target: (0.0, 0.0), + move_t: 0.0, + turning: false, + turn_start: 0.0, + turn_delta: 0.0, + turn_t: 0.0, + enemy_anim_t: 0.0, } } - pub fn rotate(&mut self, delta: f64) { - self.player.rotate(delta); + // ---- state transitions ------------------------------------------------ + + /// Returns true if the player may act right now. + pub fn can_act(&self) -> bool { + self.state == GameState::WaitingForInput && !self.turning } - /// Apply forward + strafe as a single combined displacement so that - /// diagonal wall-hugging is checked atomically (no two-step clip). - pub fn apply_movement(&mut self, forward: f64, strafe: f64) { - let a = self.player.angle; - let dx = a.cos() * forward + (a + std::f64::consts::FRAC_PI_2).cos() * strafe; - let dy = a.sin() * forward + (a + std::f64::consts::FRAC_PI_2).sin() * strafe; - self.player.try_move(dx, dy, &self.maze); + /// Advance animations by `dt` seconds. Called every frame. + /// Returns true when the current animation phase finishes and we should + /// advance the state machine. + pub fn update_animation(&mut self, dt: f64) -> bool { + // Turn animation runs independently — turning never consumes a turn. + if self.turning { + self.turn_t += dt / TURN_DURATION; + if self.turn_t >= 1.0 { + self.player.angle = + (self.turn_start + self.turn_delta).rem_euclid(2.0 * std::f64::consts::PI); + self.turning = false; + } else { + let t = smoothstep(self.turn_t); + self.player.angle = self.turn_start + self.turn_delta * t; + } + } + + match self.state { + GameState::AnimatingPlayer => { + let mut done = true; + + // Animate tile-to-tile walk. + if self.moving { + self.move_t += dt / MOVE_DURATION; + if self.move_t >= 1.0 { + self.player.x = self.move_target.0; + self.player.y = self.move_target.1; + self.moving = false; + } else { + let t = smoothstep(self.move_t); + self.player.x = + self.move_start.0 + (self.move_target.0 - self.move_start.0) * t; + self.player.y = + self.move_start.1 + (self.move_target.1 - self.move_start.1) * t; + done = false; + } + } + + if done { + self.state = GameState::ResolvingTurn; + self.resolve_turn(); + self.state = GameState::WaitingForInput; + return true; + } + false + } + + GameState::AnimatingEnemies => { + self.enemy_anim_t += dt; + if self.enemy_anim_t >= ENEMY_ANIM_DURATION { + self.enemy_anim_t = 0.0; + self.state = GameState::ResolvingTurn; + self.resolve_turn(); + self.state = GameState::WaitingForInput; + return true; + } + false + } + + _ => false, + } + } + + /// Attempt to walk one tile in grid direction (dx, dy). + /// Handles entity interactions (enemies block, hallucinations vanish, pickups collect). + fn try_move_tile(&mut self, dx: isize, dy: isize) -> bool { + if !self.can_act() { + return false; + } + let cx = self.player.x.floor() as isize; + let cy = self.player.y.floor() as isize; + let tx = (cx + dx) as usize; + let ty = (cy + dy) as usize; + + // The target cell must be a passage. + if !self + .maze + .is_passage_at_float(tx as f64 + 0.5, ty as f64 + 0.5) + { + return false; + } + + // Check what's in the target cell. + match self.maze.entity_at(tx, ty).cloned() { + Some(Entity::Enemy { damage, .. }) => { + // 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.advance_turn(); + if self.hp == 0 { + self.state = GameState::GameOver; + } + return true; // action consumed + } + Some(Entity::Hallucination { + persistent: true, .. + }) => { + // Persistent hallucination blocks movement. + self.set_message("You are frightened to death just by thinking about doing this."); + self.advance_turn(); + return true; + } + Some(Entity::Hallucination { + persistent: false, .. + }) => { + // Non-persistent hallucination vanishes harmlessly. + self.maze.take_entity(tx, ty); + // Fall through to normal movement. + } + Some(Entity::Pickup(pt)) => { + // Collect the pickup, then move. + self.collect_pickup(pt, tx, ty); + // Fall through to normal movement. + } + Some(Entity::HallucinatedPickup(pt)) => { + // Collect the fake pickup. + self.collect_hallucinated_pickup(pt, tx, ty); + // Fall through to normal movement. + } + None => { + // Empty cell — normal movement. + } + } + + // Normal tile movement. + self.move_start = (self.player.x, self.player.y); + self.move_target = (tx as f64 + 0.5, ty as f64 + 0.5); + self.moving = true; + self.move_t = 0.0; + self.state = GameState::AnimatingPlayer; + true + } + + // ---- movement wrappers ---- + + pub fn move_forward(&mut self) -> bool { + let (dx, dy) = self.facing_delta(); + self.try_move_tile(dx, dy) + } + + pub fn move_backward(&mut self) -> bool { + let (dx, dy) = self.facing_delta(); + self.try_move_tile(-dx, -dy) + } + + pub fn strafe_left(&mut self) -> bool { + let (dx, dy) = self.facing_delta(); + self.try_move_tile(dy, -dx) + } + + pub fn strafe_right(&mut self) -> bool { + let (dx, dy) = self.facing_delta(); + self.try_move_tile(-dy, dx) + } + + pub fn turn_left(&mut self) { + if !self.can_act() { + return; + } + self.turn_start = self.player.angle; + self.turn_delta = -std::f64::consts::FRAC_PI_2; + self.turn_t = 0.0; + self.turning = true; + // Turning is free — no state change, no turn consumed. + } + + pub fn turn_right(&mut self) { + if !self.can_act() { + return; + } + self.turn_start = self.player.angle; + self.turn_delta = std::f64::consts::FRAC_PI_2; + self.turn_t = 0.0; + self.turning = true; + // Turning is free — no state change, no turn consumed. + } + + // ---- attack ----------------------------------------------------------- + + /// Roll bonus damage: 30% chance for +1, repeat on success. + fn roll_bonus_damage(&self, rng: &mut impl Rng) -> u32 { + let mut bonus = 0u32; + while rng.gen_bool(0.30) { + bonus += 1; + } + bonus } + /// Attack the cell directly ahead. + pub fn attack(&mut self) -> bool { + if !self.can_act() { + return false; + } + + let (fx, fy) = self.facing_cell(); + let fx = fx as usize; + let fy = fy as usize; + + let entity = self.maze.entity_at(fx, fy).cloned(); + + match entity { + Some(Entity::Enemy { hp, damage, .. }) => { + let mut rng = rand::thread_rng(); + let bonus = self.roll_bonus_damage(&mut rng); + let total_dmg = self.weapon_base_damage + bonus; + let new_hp = hp.saturating_sub(total_dmg); + + if new_hp == 0 { + // Enemy dies. + self.maze.take_entity(fx, fy); + self.kills += 1; + self.set_message(&format!("You slay the enemy! ({} dmg)", total_dmg)); + } else { + // Enemy survives and retaliates. + self.maze.entity_at_mut(fx, fy).map(|e| { + if let Entity::Enemy { hp: ref mut h, .. } = e { + *h = new_hp; + } + }); + self.hp = self.hp.saturating_sub(damage); + self.set_message(&format!( + "You hit the enemy for {} dmg. It strikes back!", + total_dmg + )); + if self.hp == 0 { + self.state = GameState::GameOver; + } + } + self.advance_turn(); + true + } + + Some(Entity::Hallucination { persistent, hp }) => { + self.sanity = self.sanity.saturating_sub(10); + let new_hp = hp.saturating_sub(1); + + if !persistent || new_hp == 0 { + // Vanishes. + self.maze.take_entity(fx, fy); + self.set_message("The hallucination dissipates."); + } else { + // Persistent — doesn't vanish yet. + self.maze.entity_at_mut(fx, fy).map(|e| { + if let Entity::Hallucination { hp: ref mut h, .. } = e { + *h = new_hp; + } + }); + self.set_message("You strike the apparition - it remains."); + } + self.advance_turn(); + true + } + + _ => { + self.set_message("You swing at the air."); + self.advance_turn(); + true + } + } + } + + // ---- inventory -------------------------------------------------------- + + /// Use the currently selected inventory item (Z key). + pub fn use_item(&mut self) { + if !self.can_act() { + return; + } + let item = match self.inventory[self.selected_slot] { + Some(item) => item, + None => { + self.set_message("Nothing in that slot."); + return; + } + }; + + if item.is_hallucination { + self.use_hallucinated_item(); + return; + } + + match item.pickup { + PickupType::Food => { + self.weakness = self.weakness.saturating_sub(20); + self.set_message("You eat the food. Weakness fades."); + } + PickupType::Pill => { + self.weakness = self.weakness.saturating_sub(15); + self.sanity = (self.sanity + 20).min(MAX_SANITY); + self.set_message("The pill clears your mind and body."); + } + PickupType::Bandage => { + self.weakness = self.weakness.saturating_sub(15); + if self.hp < self.max_hp { + self.hp += 1; + self.set_message("You bandage your wounds. HP restored."); + } else { + self.set_message("You bandage up. HP is already full."); + } + } + PickupType::Weapon { .. } => { + self.set_message("You can't use a weapon from inventory."); + return; + } + } + + self.inventory[self.selected_slot] = None; + self.advance_turn(); + } + + /// Use a hallucinated pickup — no effect, costs sanity. + fn use_hallucinated_item(&mut self) { + self.sanity = self.sanity.saturating_sub(5); + self.set_message("It was never there..."); + self.inventory[self.selected_slot] = None; + self.advance_turn(); + } + + /// Discard the currently selected inventory item (X key). + pub fn discard_item(&mut self) { + if !self.can_act() { + return; + } + if self.inventory[self.selected_slot].is_some() { + self.inventory[self.selected_slot] = None; + self.set_message("Item discarded."); + // Discarding does NOT consume a turn. + } + } + + /// Select an inventory slot (1-4 keys). + pub fn select_slot(&mut self, slot: usize) { + if slot < 4 { + self.selected_slot = slot; + } + } + + /// Collect a real pickup into inventory. + fn collect_pickup(&mut self, pt: PickupType, x: usize, y: usize) { + self.maze.take_entity(x, y); + + match pt { + PickupType::Weapon { base_damage } => { + if base_damage > self.weapon_base_damage { + self.weapon_base_damage = base_damage; + self.set_message(&format!("Equipped weapon! Base damage: {}", base_damage)); + } else { + self.set_message("This weapon is no better than what you have."); + } + } + _ => { + let item = InventoryItem { + pickup: pt, + is_hallucination: false, + }; + if let Some(slot) = self.inventory.iter_mut().find(|s| s.is_none()) { + *slot = Some(item); + self.set_message(match pt { + PickupType::Food => "Picked up food.", + PickupType::Pill => "Picked up pill.", + PickupType::Bandage => "Picked up bandage.", + _ => "Picked up item.", + }); + } else { + self.set_message("Inventory full."); + } + } + } + } + + /// Collect a hallucinated pickup. + fn collect_hallucinated_pickup(&mut self, pt: PickupType, x: usize, y: usize) { + self.maze.take_entity(x, y); + let item = InventoryItem { + pickup: pt, + is_hallucination: true, + }; + if let Some(slot) = self.inventory.iter_mut().find(|s| s.is_none()) { + *slot = Some(item); + } + self.set_message("Picked up item."); + } + + // ---- turn resolution -------------------------------------------------- + + /// Advance the turn counter and state without animation. + fn advance_turn(&mut self) { + self.state = GameState::ResolvingTurn; + self.resolve_turn(); + self.state = GameState::WaitingForInput; + } + + /// Apply all per-turn effects (weakness, heartbeat, HP drain, message decay). + fn resolve_turn(&mut self) { + self.weakness = (self.weakness + 1).min(MAX_WEAKNESS); + self.heartbeat_count += 1; + + // Message decay. + if self.message_turns > 0 { + self.message_turns -= 1; + if self.message_turns == 0 { + self.message.clear(); + } + } + + // HP drain at max weakness. + if self.weakness >= MAX_WEAKNESS { + if self.hp > 0 { + self.hp -= 1; + } + if self.hp == 0 { + self.state = GameState::GameOver; + } + } + } + + /// Skip the player's turn (C key). Enemies still act. + pub fn skip_turn(&mut self) { + if !self.can_act() { + return; + } + self.state = GameState::AnimatingEnemies; + self.enemy_anim_t = 0.0; + // TODO Phase 7: enemy movement happens here + } + + /// Set a HUD message that persists for 3 turns. + pub fn set_message(&mut self, msg: &str) { + self.message = msg.to_string(); + self.message_turns = 3; + } + + // ---- cardinal-direction helpers ---- + + /// Which grid direction the player faces: (dx, dy) ∈ {±1, 0}. + fn facing_delta(&self) -> (isize, isize) { + let q = (self.player.angle / std::f64::consts::FRAC_PI_2).round() as i32 & 3; + match q { + 0 => (1, 0), // east + 1 => (0, 1), // south + 2 => (-1, 0), // west + _ => (0, -1), // north + } + } + + /// The cell coordinate directly ahead of the player. + pub fn facing_cell(&self) -> (isize, isize) { + let (dx, dy) = self.facing_delta(); + let cx = self.player.x.floor() as isize; + let cy = self.player.y.floor() as isize; + (cx + dx, cy + dy) + } + + // ---- misc ------------------------------------------------------------- + pub fn check_win(&self) -> bool { self.maze.is_exit(self.player.x, self.player.y) } + + /// Whole heartbeats elapsed (displayed in HUD). + pub fn heartbeats(&self) -> usize { + self.heartbeat_count as usize + } + + /// Vertical camera bob (pixels) during tile-walk animation. + pub fn camera_bob(&self) -> f64 { + if self.moving { + (self.move_t * 2.0 * std::f64::consts::PI).sin() * 3.0 + } else { + 0.0 + } + } + + /// Weakness as a 0..2 factor for the renderer's darkness formula. + pub fn weakness_factor(&self) -> f64 { + (self.weakness as f64 / MAX_WEAKNESS as f64) * 2.0 + } } -- cgit v1.2.3