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)] pub enum Cell { Wall, Passage, 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 { /// 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]; // ---- Phase 1: place rooms ---- let mut rooms: Vec = Vec::new(); let max_attempts = 300; for _ in 0..max_attempts { if rooms.len() >= room_target { break; } 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 !overlaps { // Carve room interior. for dy in 0..rh { for dx in 0..rw { cells[(ry + dy) * w + (rx + dx)] = Cell::Passage; } } rooms.push(Room { x: rx, y: ry, w: rw, h: rh, cx: rx + rw / 2, cy: ry + rh / 2, }); } } // 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, }); } } // ---- 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; } // ---- 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, ); } } } // ---- 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] pub fn is_wall(&self, y: usize, x: usize) -> bool { self.cells .get(y * self.width + x) .map_or(true, |&c| c == Cell::Wall) } /// Floating-point query — out-of-bounds is treated as wall. #[inline] pub fn is_passage_at_float(&self, x: f64, y: f64) -> bool { let xi = x as usize; let yi = y as usize; if xi >= self.width || yi >= self.height { return false; } self.cells[yi * self.width + xi] != Cell::Wall } #[inline] pub fn is_exit(&self, x: f64, y: f64) -> bool { let xi = x as usize; let yi = y as usize; if xi >= self.width || yi >= self.height { return false; } self.cells[yi * self.width + xi] == Cell::Exit } pub fn width(&self) -> usize { self.width } 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, .. }) ) } } // ---- Maze helpers ---- fn grid_size_for_depth(depth: u64) -> usize { match depth { 1..=3 => 33, 4..=7 => 41, 8..=12 => 49, _ => 57, } } 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; 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; } } /// 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)); } } } 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 (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 const ATTACK_DURATION: f64 = 0.12; // seconds for attack lunge 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, // ---- animation: attack ---- pub attacking: bool, pub attack_t: f64, attack_base_x: f64, attack_base_y: f64, // ---- screen flash ---- pub flash_color: u32, pub flash_alpha: f64, // ---- controls overlay ---- pub show_controls: bool, } 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 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, attacking: false, attack_t: 0.0, attack_base_x: 0.0, attack_base_y: 0.0, flash_color: 0, flash_alpha: 0.0, show_controls: true, } } // ---- state transitions ------------------------------------------------ /// Returns true if the player may act right now. pub fn can_act(&self) -> bool { self.state == GameState::WaitingForInput && !self.turning } /// 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 { // Flash always fades, regardless of state. if self.flash_alpha > 0.0 { self.flash_alpha = (self.flash_alpha - dt * 4.0).max(0.0); } // 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; } } // Animate attack lunge. if self.attacking { self.attack_t += dt / ATTACK_DURATION; if self.attack_t >= 1.0 { self.player.x = self.attack_base_x; self.player.y = self.attack_base_y; self.attacking = false; } else { // Lunge forward then back. let lunge = if self.attack_t < 0.5 { smoothstep(self.attack_t * 2.0) * 0.25 } else { (1.0 - smoothstep((self.attack_t - 0.5) * 2.0)) * 0.25 }; let fx = self.player.angle.cos(); let fy = self.player.angle.sin(); self.player.x = self.attack_base_x + fx * lunge; self.player.y = self.attack_base_y + fy * lunge; 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 { self.maze.take_entity(fx, fy); self.kills += 1; self.set_message(&format!("You slay the enemy! ({} dmg)", total_dmg)); } else { 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.flash_color = 0xFF_CC3333; self.flash_alpha = 0.4; self.begin_attack_animation(); 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 { self.maze.take_entity(fx, fy); self.set_message("The hallucination dissipates."); } else { 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.flash_color = 0xFF_8833CC; self.flash_alpha = 0.35; self.begin_attack_animation(); true } _ => { self.set_message("You swing at the air."); self.flash_color = 0xFF_FFFFFF; self.flash_alpha = 0.05; self.begin_attack_animation(); true } } } fn begin_attack_animation(&mut self) { self.attack_base_x = self.player.x; self.attack_base_y = self.player.y; self.attacking = true; self.attack_t = 0.0; self.state = GameState::AnimatingPlayer; } // ---- 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) { // Weakness grows every 3 turns, not every turn. if self.heartbeat_count % 3 == 0 { 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 — stops at 1, never kills. if self.weakness >= MAX_WEAKNESS { if self.hp > 1 { self.hp -= 1; } } } /// 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 } }