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 +++++++++++++++++++++++++++++++++++++++++++++++++++---- src/main.rs | 145 +++++-- src/renderer.rs | 404 +++++++++++++++++-- src/texture.rs | 49 ++- 4 files changed, 1596 insertions(+), 157 deletions(-) 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 + } } diff --git a/src/main.rs b/src/main.rs index d51fdfd..391bb90 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ mod game; mod renderer; mod texture; -use game::Game; +use game::{Game, GameState}; use minifb::{Key, Window, WindowOptions}; use renderer::Renderer; use std::time::Instant; @@ -11,19 +11,22 @@ const SCREEN_W: usize = 640; const SCREEN_H: usize = 480; fn main() { - println!("=== DOOM-LIKE MAZE ==="); - println!("Find the exit!"); - println!(" W / Up — forward"); - println!(" S / Down — backward"); - println!(" A / D — strafe left / right"); - println!(" ← / → — turn"); - println!(" Esc — quit\n"); + println!("=== DUNGEON CRAWLER ==="); + println!(" W / S — forward / backward"); + println!(" A / D — strafe left / right"); + println!(" Q / E — turn 90° left / right"); + println!(" Space — attack"); + println!(" Z — use selected inventory item"); + println!(" X — discard selected inventory item"); + println!(" 1-4 — select inventory slot"); + println!(" C — skip turn"); + println!(" Esc — quit\n"); let mut game = Game::new(); let renderer = Renderer::new(SCREEN_W, SCREEN_H); let mut window = Window::new( - "Maze — Find the Exit!", + "Dungeon Crawler", SCREEN_W, SCREEN_H, WindowOptions::default(), @@ -34,45 +37,109 @@ fn main() { let mut last = Instant::now(); let frame_budget = std::time::Duration::from_micros(16600); // ~60 fps + // Track previous key state for edge-triggered actions. + let mut prev_w = false; + let mut prev_s = false; + let mut prev_a = false; + let mut prev_d = false; + let mut prev_q = false; + let mut prev_e = false; + let mut prev_space = false; + let mut prev_z = false; + let mut prev_x = false; + let mut prev_c = false; + let mut prev_1 = false; + let mut prev_2 = false; + let mut prev_3 = false; + let mut prev_4 = false; + while window.is_open() && !window.is_key_down(Key::Escape) { let frame_start = Instant::now(); let dt = (frame_start - last).as_secs_f64().min(0.1); last = frame_start; - // ---- input ---- - let move_speed = 2.8; - let turn_speed = 2.8; - let strafe_speed = 2.2; + // ---- read current key states ---- + let cur_w = window.is_key_down(Key::W); + let cur_s = window.is_key_down(Key::S); + let cur_a = window.is_key_down(Key::A); + let cur_d = window.is_key_down(Key::D); + let cur_q = window.is_key_down(Key::Q); + let cur_e = window.is_key_down(Key::E); + let cur_space = window.is_key_down(Key::Space); + let cur_z = window.is_key_down(Key::Z); + let cur_x = window.is_key_down(Key::X); + let cur_c = window.is_key_down(Key::C); + let cur_1 = window.is_key_down(Key::Key1); + let cur_2 = window.is_key_down(Key::Key2); + let cur_3 = window.is_key_down(Key::Key3); + let cur_4 = window.is_key_down(Key::Key4); - if window.is_key_down(Key::Left) { - game.rotate(-turn_speed * dt); + // ---- input (edge-triggered, only in WaitingForInput) ---- + if game.can_act() { + if cur_w && !prev_w { + game.move_forward(); + } else if cur_s && !prev_s { + game.move_backward(); + } else if cur_a && !prev_a { + game.strafe_left(); + } else if cur_d && !prev_d { + game.strafe_right(); + } else if cur_q && !prev_q { + game.turn_left(); + } else if cur_e && !prev_e { + game.turn_right(); + } else if cur_space && !prev_space { + game.attack(); + } else if cur_z && !prev_z { + game.use_item(); + } else if cur_x && !prev_x { + game.discard_item(); + } else if cur_c && !prev_c { + game.skip_turn(); + } else if cur_1 && !prev_1 { + game.select_slot(0); + } else if cur_2 && !prev_2 { + game.select_slot(1); + } else if cur_3 && !prev_3 { + game.select_slot(2); + } else if cur_4 && !prev_4 { + game.select_slot(3); + } } - if window.is_key_down(Key::Right) { - game.rotate(turn_speed * dt); + + // Update previous states. + prev_w = cur_w; + prev_s = cur_s; + prev_a = cur_a; + prev_d = cur_d; + prev_q = cur_q; + prev_e = cur_e; + prev_space = cur_space; + prev_z = cur_z; + prev_x = cur_x; + prev_c = cur_c; + prev_1 = cur_1; + prev_2 = cur_2; + prev_3 = cur_3; + prev_4 = cur_4; + + // ---- advance animations / state machine ---- + game.update_animation(dt); + + // ---- death check ---- + if game.state == GameState::GameOver { + let score = (game.kills * 100) + (game.depth.pow(2) * 50) + 500; // fists = base damage 1 + println!( + "You had become yet another legend of this dungeon.\nScore: {}", + score + ); + std::process::exit(0); } - // Combine forward + strafe into one displacement (prevents two-step - // wall clipping when pressing W+D / W+A near a wall). - let fwd = if window.is_key_down(Key::W) || window.is_key_down(Key::Up) { - move_speed * dt - } else if window.is_key_down(Key::S) || window.is_key_down(Key::Down) { - -move_speed * dt - } else { - 0.0 - }; - let strf = if window.is_key_down(Key::D) { - strafe_speed * dt - } else if window.is_key_down(Key::A) { - -strafe_speed * dt - } else { - 0.0 - }; - game.apply_movement(fwd, strf); - - // ---- win condition ---- + + // ---- win check (placeholder — becomes staircase in Phase 1) ---- if game.check_win() { - println!("🎉 You found the exit! Generating a new maze…"); - game = Game::new(); - last = Instant::now(); // avoid huge dt spike + println!("You found the exit in {} heartbeats!", game.heartbeats()); + std::process::exit(0); } // ---- render ---- diff --git a/src/renderer.rs b/src/renderer.rs index bce3391..eec89b2 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -1,44 +1,104 @@ -use crate::game::Game; +use crate::game::{Entity, Game, PickupType}; use crate::texture::TEX_SIZE; +// ---- simple 5×5 ASCII glyphs ---- +// ---- simple 5×5 ASCII glyphs for letters & symbols (incomplete — add as needed) ---- + +const LETTERS: &[(&str, [u8; 5])] = &[ + // Uppercase A-Z + ("A", [0b01110, 0b10001, 0b11111, 0b10001, 0b10001]), + ("B", [0b11110, 0b10001, 0b11110, 0b10001, 0b11110]), + ("C", [0b01110, 0b10001, 0b10000, 0b10001, 0b01110]), + ("D", [0b11110, 0b10001, 0b10001, 0b10001, 0b11110]), + ("E", [0b11111, 0b10000, 0b11110, 0b10000, 0b11111]), + ("F", [0b11111, 0b10000, 0b11110, 0b10000, 0b10000]), + ("G", [0b01110, 0b10000, 0b10111, 0b10001, 0b01110]), + ("H", [0b10001, 0b10001, 0b11111, 0b10001, 0b10001]), + ("I", [0b01110, 0b00100, 0b00100, 0b00100, 0b01110]), + ("J", [0b00011, 0b00001, 0b00001, 0b10001, 0b01110]), + ("K", [0b10001, 0b10010, 0b11100, 0b10010, 0b10001]), + ("L", [0b10000, 0b10000, 0b10000, 0b10000, 0b11111]), + ("M", [0b10001, 0b11011, 0b10101, 0b10001, 0b10001]), + ("N", [0b10001, 0b11001, 0b10101, 0b10011, 0b10001]), + ("O", [0b01110, 0b10001, 0b10001, 0b10001, 0b01110]), + ("P", [0b11110, 0b10001, 0b11110, 0b10000, 0b10000]), + ("Q", [0b01110, 0b10001, 0b10001, 0b10010, 0b01101]), + ("R", [0b11110, 0b10001, 0b11110, 0b10010, 0b10001]), + ("S", [0b01110, 0b10000, 0b01110, 0b00001, 0b11110]), + ("T", [0b11111, 0b00100, 0b00100, 0b00100, 0b00100]), + ("U", [0b10001, 0b10001, 0b10001, 0b10001, 0b01110]), + ("V", [0b10001, 0b10001, 0b10001, 0b01010, 0b00100]), + ("W", [0b10001, 0b10001, 0b10101, 0b10101, 0b01010]), + ("X", [0b10001, 0b01010, 0b00100, 0b01010, 0b10001]), + ("Y", [0b10001, 0b01010, 0b00100, 0b00100, 0b00100]), + ("Z", [0b11111, 0b00010, 0b00100, 0b01000, 0b11111]), + // Digits + ("0", [0b01110, 0b10011, 0b10101, 0b11001, 0b01110]), + ("1", [0b00100, 0b01100, 0b00100, 0b00100, 0b01110]), + ("2", [0b01110, 0b10001, 0b00010, 0b00100, 0b11111]), + ("3", [0b01110, 0b10001, 0b00110, 0b10001, 0b01110]), + ("4", [0b00010, 0b00110, 0b01010, 0b11111, 0b00010]), + ("5", [0b11111, 0b10000, 0b11110, 0b10001, 0b01110]), + ("6", [0b01110, 0b10000, 0b11110, 0b10001, 0b01110]), + ("7", [0b11111, 0b00001, 0b00010, 0b00100, 0b00100]), + ("8", [0b01110, 0b10001, 0b01110, 0b10001, 0b01110]), + ("9", [0b01110, 0b10001, 0b01111, 0b00001, 0b01110]), + // Punctuation & symbols + ("!", [0b00100, 0b00100, 0b00100, 0b00000, 0b00100]), + ("@", [0b01110, 0b10001, 0b10111, 0b10000, 0b01110]), + ("+", [0b00000, 0b00100, 0b01110, 0b00100, 0b00000]), + (":", [0b00000, 0b00100, 0b00000, 0b00100, 0b00000]), + ("%", [0b10001, 0b00010, 0b00100, 0b01000, 0b10001]), + ("/", [0b00001, 0b00010, 0b00100, 0b01000, 0b10000]), + (".", [0b00000, 0b00000, 0b00000, 0b00000, 0b00100]), + (",", [0b00000, 0b00000, 0b00000, 0b00100, 0b01000]), + ("(", [0b00010, 0b00100, 0b00100, 0b00100, 0b00010]), + (")", [0b01000, 0b00100, 0b00100, 0b00100, 0b01000]), + ("-", [0b00000, 0b00000, 0b01110, 0b00000, 0b00000]), + ("'", [0b00100, 0b00000, 0b00000, 0b00000, 0b00000]), + // Unicode symbols + ("♥", [0b01010, 0b11111, 0b11111, 0b01110, 0b00100]), + ("♦", [0b00100, 0b01110, 0b11111, 0b01110, 0b00100]), + ("⌂", [0b01110, 0b10001, 0b10101, 0b10001, 0b11111]), + ("[", [0b01110, 0b01000, 0b01000, 0b01000, 0b01110]), + ("]", [0b01110, 0b00010, 0b00010, 0b00010, 0b01110]), + (" ", [0b00000; 5]), +]; + +const LETTER_W: usize = 5; +const LETTER_H: usize = 5; + pub struct Renderer { screen_w: usize, screen_h: usize, - /// Precomputed: floor_dist_lut[row] = perpendicular distance to floor at that row. floor_dist_lut: Vec, - /// Precomputed: floor_bright_lut[row] = brightness × 256 for that row (fixed-point). - floor_bright_lut: Vec, } impl Renderer { pub fn new(screen_w: usize, screen_h: usize) -> Self { let half_h = screen_h as f64 / 2.0; let mut floor_dist_lut = vec![0.0f64; screen_h]; - let mut floor_bright_lut = vec![0u32; screen_h]; - // Only rows below the horizon have meaningful floor values. for py in (screen_h / 2 + 1)..screen_h { let dy = py as f64 - half_h; - let dist = 0.5 * screen_h as f64 / dy; - floor_dist_lut[py] = dist; - let bright = (1.0 / (1.0 + dist * 0.15)).max(0.05); - floor_bright_lut[py] = (bright * 256.0) as u32; + floor_dist_lut[py] = 0.5 * screen_h as f64 / dy; } Self { screen_w, screen_h, floor_dist_lut, - floor_bright_lut, } } - /// Produce one frame into `buffer` (packed `0xAARRGGBB`). pub fn render(&self, game: &Game, buffer: &mut [u32]) { - buffer.fill(0xFF00_0000); // pure-black ceiling + buffer.fill(0xFF00_0000); let half_h = self.screen_h as f64 / 2.0; - let fov = std::f64::consts::PI / 3.0; // 60° + let fov = std::f64::consts::PI / 3.0; + + // Per-column wall distances for entity occlusion. + let mut wall_dists = vec![f64::MAX; self.screen_w]; for sx in 0..self.screen_w { let cam_x = 2.0 * sx as f64 / self.screen_w as f64 - 1.0; @@ -46,15 +106,10 @@ impl Renderer { let rdx = ray_angle.cos(); let rdy = ray_angle.sin(); - // Floor uses perpendicular distance from the LUT, but needs - // Euclidean distance for correct world-position projection. - // Scale by 1/cos(angle_diff) so that rdx_floor / rdy_floor - // map floor_dist → Euclidean travel along the ray. let cos_diff = (ray_angle - game.player.angle).cos().abs().max(0.001); let rdx_floor = rdx / cos_diff; let rdy_floor = rdy / cos_diff; - // ---- DDA setup ------------------------------------------------- let mut map_x = game.player.x as isize; let mut map_y = game.player.y as isize; @@ -84,7 +139,6 @@ impl Renderer { let mut hit_side: bool; let euclidean: f64; - // ---- DDA loop -------------------------------------------------- loop { if side_x < side_y { side_x += ddx; @@ -115,16 +169,14 @@ impl Renderer { } } - // Fisheye correction let perp_dist = (euclidean * (ray_angle - game.player.angle).cos().abs()).max(0.001); + wall_dists[sx] = perp_dist; - // ---- wall slice ------------------------------------------------ let wall_h = (self.screen_h as f64 / perp_dist) as isize; let draw_start = ((-wall_h / 2) + self.screen_h as isize / 2).max(0) as usize; let draw_end = ((wall_h / 2) + self.screen_h as isize / 2) .min(self.screen_h as isize - 1) as usize; - // Texture x-coordinate (uses Euclidean, not perp) let wall_x = if !hit_side { game.player.y + euclidean * rdy } else { @@ -133,9 +185,10 @@ impl Renderer { let wall_x = wall_x - wall_x.floor(); let tex_x = ((wall_x * TEX_SIZE as f64) as usize).min(TEX_SIZE - 1); - // Hoist: brightness is identical for every pixel in this column let wall_bright_fx = { - let b = (1.0 / (1.0 + perp_dist * 0.12)).max(0.08); + let w = game.weakness_factor(); + let darken_scale = 1.0 + w * 12.0; + let b = 1.0 / (1.0 + perp_dist * 0.25 * darken_scale); (b * 256.0) as u32 }; @@ -143,39 +196,322 @@ impl Renderer { let tex_y = ((py as f64 - half_h + wall_h as f64 / 2.0) * TEX_SIZE as f64 / wall_h as f64) as usize; let tex_y = tex_y.min(TEX_SIZE - 1); - let color = game.wall_texture[tex_y * TEX_SIZE + tex_x]; buffer[py * self.screen_w + sx] = darken_fx(color, wall_bright_fx); } - // ---- floor below the wall -------------------------------------- let floor_start = draw_end + 1; if floor_start < self.screen_h { + let w = game.weakness_factor(); + let darken_scale = 1.0 + w * 12.0; for py in floor_start..self.screen_h { let floor_dist = self.floor_dist_lut[py]; + let bright = 1.0 / (1.0 + floor_dist * 0.35 * darken_scale); + let bright_fx = (bright * 256.0) as u32; if floor_dist > 15.0 { - // Far floor → just a dark pixel, skip texture & world calc. - let gray = darken_fx(0xFF80_8080, self.floor_bright_lut[py]); + let gray = darken_fx(0xFF80_8080, bright_fx); buffer[py * self.screen_w + sx] = gray; } else { let fx = game.player.x + floor_dist * rdx_floor; let fy = game.player.y + floor_dist * rdy_floor; - let ftx = ((fx.fract().abs() * TEX_SIZE as f64) as usize).min(TEX_SIZE - 1); let fty = ((fy.fract().abs() * TEX_SIZE as f64) as usize).min(TEX_SIZE - 1); + let color = + if fx as usize == game.maze.exit_x && fy as usize == game.maze.exit_y { + game.exit_texture[fty * TEX_SIZE + ftx] + } else { + game.floor_texture[fty * TEX_SIZE + ftx] + }; + buffer[py * self.screen_w + sx] = darken_fx(color, bright_fx); + } + } + } + } + + // ---- entity sprites (drawn after walls so occlusion is easy) ---- + self.draw_entities(game, buffer, &wall_dists, fov, half_h); + + // ---- camera bob ---- + let bob = game.camera_bob().round() as isize; + if bob != 0 { + if bob > 0 { + let shift = bob as usize; + for py in (shift..self.screen_h).rev() { + let src = (py - shift) * self.screen_w; + let dst = py * self.screen_w; + buffer.copy_within(src..src + self.screen_w, dst); + } + buffer[0..shift * self.screen_w].fill(0xFF00_0000); + } else { + let shift = (-bob) as usize; + for py in 0..self.screen_h - shift { + let src = (py + shift) * self.screen_w; + let dst = py * self.screen_w; + buffer.copy_within(src..src + self.screen_w, dst); + } + let start = (self.screen_h - shift) * self.screen_w; + buffer[start..].fill(0xFF00_0000); + } + } + + // ---- HUD ---- + self.draw_hud(buffer, game); + } + + // ---- entity sprites --------------------------------------------------- + + fn draw_entities( + &self, + game: &Game, + buffer: &mut [u32], + wall_dists: &[f64], + fov: f64, + half_h: f64, + ) { + let mw = game.maze.width(); + let mh = game.maze.height(); + + for y in 0..mh { + for x in 0..mw { + let entity = match game.maze.entity_at(x, y) { + Some(e) => e, + None => continue, + }; + + 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; // depth + if cam_z <= 0.1 { + continue; // behind camera + } + + let cam_x = dx * rx + dy * ry; + + // Check if entity is within FOV. + 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; + + // Entity height on screen. + 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 (ch, color) = entity_char_and_color(entity); + + // Check occlusion: is any wall column this entity overlaps with closer? + let left = (sx - entity_h / 2).max(0) as usize; + let right = (sx + entity_h / 2).min(self.screen_w as isize - 1) as usize; + let visible = (left..=right).any(|col| cam_z < wall_dists[col]); + + if !visible { + continue; + } + + let scale = (entity_h.max(6) as f64 / LETTER_H as f64).max(0.5); + + self.draw_char_scaled( + buffer, + ch, + sx, + sy, + scale, + color, + cam_z, + game.weakness_factor(), + ); + } + } + } + + fn draw_char_scaled( + &self, + buffer: &mut [u32], + ch: char, + cx: isize, + cy: isize, + scale: f64, + color: u32, + distance: f64, + weakness: f64, + ) { + let glyph = match find_glyph(ch) { + Some(g) => g, + None => return, + }; + + let darken_scale = 1.0 + weakness * 12.0; + let bright = 1.0 / (1.0 + distance * 0.15 * darken_scale); + let bright_fx = (bright * 256.0) as u32; + let final_color = darken_fx(color, bright_fx); + + let draw_w = (LETTER_W as f64 * scale) as isize; + let draw_h = (LETTER_H as f64 * scale) as isize; + + let start_x = cx - draw_w / 2; + let start_y = cy - draw_h / 2; - let color = game.floor_texture[fty * TEX_SIZE + ftx]; - buffer[py * self.screen_w + sx] = - darken_fx(color, self.floor_bright_lut[py]); + for gy in 0..LETTER_H { + let bits = glyph[gy]; + for gx in 0..LETTER_W { + if (bits >> (LETTER_W - 1 - gx)) & 1 == 0 { + continue; + } + let px_start = start_x + (gx as f64 * scale) as isize; + let py_start = start_y + (gy as f64 * scale) as isize; + let px_end = start_x + ((gx + 1) as f64 * scale) as isize; + let py_end = start_y + ((gy + 1) as f64 * scale) as isize; + + for py in py_start.max(0)..py_end.min(self.screen_h as isize) { + for px in px_start.max(0)..px_end.min(self.screen_w as isize) { + let idx = py as usize * self.screen_w + px as usize; + buffer[idx] = final_color; + } + } + } + } + } + + // ---- HUD ------------------------------------------------------------- + + fn draw_hud(&self, buffer: &mut [u32], game: &Game) { + let bar_h = 40usize; + let bar_y = self.screen_h - bar_h; + + // Background. + for py in bar_y..self.screen_h { + for px in 0..self.screen_w { + buffer[py * self.screen_w + px] = 0xFF_222222; + } + } + + let x = 8usize; + let y = bar_y + 6; + + // Format the HUD string. + let hp_str = format!("♥{}/{}", game.hp, game.max_hp); + let san_str = format!("♦{}%", game.sanity); + let weak_str = format!("⌂{}%", game.weakness); + let beats_str = format!("♥♥{}", game.heartbeats()); + let wpn_str = format!("WPN:{}", game.weapon_base_damage); + let depth_str = format!("DEPTH:{}", game.depth); + + let inv_str: String = game + .inventory + .iter() + .enumerate() + .map(|(i, item)| match item { + Some(item) => { + let ch = pickup_char(item.pickup); + if i == game.selected_slot { + format!("[{}]", ch) + } else { + format!(" {} ", ch) + } + } + None => { + if i == game.selected_slot { + "[ ]".to_string() + } else { + " ".to_string() + } + } + }) + .collect(); + + let full = format!( + "{} {} {} {} {} {} {}", + hp_str, san_str, weak_str, beats_str, wpn_str, inv_str, depth_str + ); + + self.draw_text(buffer, x, y, &full, 0xFF_CCCCCC); + + // ---- message log above the bar ---- + if !game.message.is_empty() { + let msg_y = bar_y.saturating_sub(LETTER_H + 4); + self.draw_text(buffer, 8, msg_y, &game.message, 0xFF_DDAA44); + } + } + + fn draw_text(&self, buffer: &mut [u32], mut x: usize, y: usize, text: &str, color: u32) { + for ch in text.to_uppercase().chars() { + let glyph = match find_glyph(ch) { + Some(g) => g, + None => { + x += LETTER_W + 1; + continue; + } + }; + + for gy in 0..LETTER_H { + let bits = glyph[gy]; + for gx in 0..LETTER_W { + if (bits >> (LETTER_W - 1 - gx)) & 1 != 0 { + let px = x + gx; + let py = y + gy; + if px < self.screen_w && py < self.screen_h { + buffer[py * self.screen_w + px] = color; + } } } } + x += LETTER_W + 1; // 1px gap + } + } +} + +// ---- helpers ---- + +fn find_glyph(ch: char) -> Option<[u8; 5]> { + let s = ch.to_string(); + for (name, glyph) in LETTERS { + if *name == s { + return Some(*glyph); } } + None +} + +fn entity_char_and_color(entity: &Entity) -> (char, u32) { + match entity { + Entity::Enemy { .. } => ('@', 0xFF_CC3333), + Entity::Hallucination { .. } => ('@', 0xFF_CC3333), + Entity::Pickup(pt) => (pickup_char(*pt), pickup_color(*pt)), + Entity::HallucinatedPickup(pt) => (pickup_char(*pt), pickup_color(*pt)), + } +} + +fn pickup_char(pt: PickupType) -> char { + match pt { + PickupType::Food => 'F', + PickupType::Pill => '0', + PickupType::Bandage => '+', + PickupType::Weapon { .. } => '!', + } +} + +fn pickup_color(pt: PickupType) -> u32 { + match pt { + PickupType::Food => 0xFF_33CC33, + PickupType::Pill => 0xFF_33CCCC, + PickupType::Bandage => 0xFF_CCCCCC, + PickupType::Weapon { .. } => 0xFF_CCCC33, + } } -/// Integer darken: `bright_fx` is brightness × 256 in fixed-point. #[inline(always)] fn darken_fx(color: u32, bright_fx: u32) -> u32 { let r = (((color >> 16) & 0xFF) * bright_fx) >> 8; diff --git a/src/texture.rs b/src/texture.rs index bdbb15c..e5720c0 100644 --- a/src/texture.rs +++ b/src/texture.rs @@ -60,6 +60,49 @@ pub fn generate_wall_texture(rng: &mut impl Rng) -> Vec { tex } +/// Gray stone-tile floor with a dark hole in the centre — the exit staircase. +pub fn generate_exit_texture(rng: &mut impl Rng) -> Vec { + let mut tex = generate_floor_texture(rng); + + let cx = TEX_SIZE as f64 / 2.0; + let cy = TEX_SIZE as f64 / 2.0; + let hole_radius = TEX_SIZE as f64 * 0.35; + let fade_zone = TEX_SIZE as f64 * 0.08; + + for y in 0..TEX_SIZE { + for x in 0..TEX_SIZE { + let dx = x as f64 - cx; + let dy = y as f64 - cy; + let dist = (dx * dx + dy * dy).sqrt(); + + if dist < hole_radius { + // Deep black void. + let edge = (dist / hole_radius).clamp(0.0, 1.0); + // Slight blueish tint near the edge for depth. + let r = (8.0 * (1.0 - edge)) as u8; + let g = (6.0 * (1.0 - edge)) as u8; + let b = (12.0 * edge + 4.0 * (1.0 - edge)) as u8; + tex[y * TEX_SIZE + x] = + 0xFF00_0000 | ((r as u32) << 16) | ((g as u32) << 8) | (b as u32); + } else if dist < hole_radius + fade_zone { + // Fade zone: blend from floor to darkness. + let t = ((dist - hole_radius) / fade_zone).clamp(0.0, 1.0); + let floor_color = tex[y * TEX_SIZE + x]; + let fr = ((floor_color >> 16) & 0xFF) as f64; + let fg = ((floor_color >> 8) & 0xFF) as f64; + let fb = (floor_color & 0xFF) as f64; + + let r = (fr * t + 8.0 * (1.0 - t)) as u8; + let g = (fg * t + 6.0 * (1.0 - t)) as u8; + let b = (fb * t + 12.0 * (1.0 - t)) as u8; + tex[y * TEX_SIZE + x] = + 0xFF00_0000 | ((r as u32) << 16) | ((g as u32) << 8) | (b as u32); + } + } + } + tex +} + /// Gray stone-tile floor — 64×64, checkerboard with grout and subtle colour tint. pub fn generate_floor_texture(rng: &mut impl Rng) -> Vec { let seed = rng.gen::(); @@ -68,7 +111,11 @@ pub fn generate_floor_texture(rng: &mut impl Rng) -> Vec { for y in 0..TEX_SIZE { for x in 0..TEX_SIZE { let h = hash(x as u32, y as u32, seed); - let h2 = hash(x.wrapping_add(2000) as u32, y.wrapping_add(2000) as u32, seed); + let h2 = hash( + x.wrapping_add(2000) as u32, + y.wrapping_add(2000) as u32, + seed, + ); let tile_x = x / 16; let tile_y = y / 16; -- cgit v1.2.3