From a4626cb681fdb4939772018def2019c1d1c49d46 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sat, 11 Jul 2026 16:05:15 +0200 Subject: add psychedelic horror effects: phantom walls, shifting maze, darkness, color drift, persistent hallucinations --- src/game.rs | 233 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 226 insertions(+), 7 deletions(-) (limited to 'src/game.rs') diff --git a/src/game.rs b/src/game.rs index 1c13dd4..1cc12f2 100644 --- a/src/game.rs +++ b/src/game.rs @@ -694,6 +694,9 @@ pub struct Game { // ---- death screen ---- pub death_frames: u32, + + // ---- horror effects ---- + frame_counter: u64, } fn smoothstep(t: f64) -> f64 { @@ -748,6 +751,7 @@ impl Game { flash_alpha: 0.0, show_controls: true, death_frames: 0, + frame_counter: 0, } } @@ -762,6 +766,8 @@ impl Game { /// Returns true when the current animation phase finishes and we should /// advance the state machine. pub fn update_animation(&mut self, dt: f64) -> bool { + self.frame_counter = self.frame_counter.wrapping_add(1); + // Flash always fades, regardless of state. if self.flash_alpha > 0.0 { self.flash_alpha = (self.flash_alpha - dt * 4.0).max(0.0); @@ -1224,7 +1230,8 @@ impl Game { // Clear acted-this-turn tracking (enemy AI already consumed it). self.acted_this_turn.clear(); - // Weakness grows every 3 turns, not every turn. + // At sanity < 20%, hallucinations become persistent with extra HP. + self.upgrade_hallucinations(); if self.heartbeat_count % 3 == 0 { self.weakness = (self.weakness + 1).min(MAX_WEAKNESS); } @@ -1244,6 +1251,9 @@ impl Game { self.hp -= 1; } } + + // Shifting maze: wall cells may flip at low sanity. + self.shift_maze(); } /// Skip the player's turn (C key). Enemies still act. @@ -1464,12 +1474,13 @@ impl Game { // ---- hallucination behaviour gating ---- if is_hallucination { - if self.sanity >= 40 { - // Static: hallucinations don't move or attack. + if self.depth <= 3 || self.sanity >= 40 { + // Depth 1-3: hallucinations always static. + // Sanity >= 40%: hallucinations are static. continue; } - // Sanity 20-39%: patrol only. - // Sanity < 20%: chase (handled below via has_line_of_sight). + // Depth 4+ and sanity 20-39%: patrol only. + // Depth 7+ and sanity < 20%: chase (handled below via has_line_of_sight). } // ---- adjacent → attack ---- @@ -1505,11 +1516,11 @@ impl Game { let has_los = self.has_line_of_sight(ex, ey, px, py); if has_los { - // Real enemies always chase; hallucinations chase only at sanity < 20%. + // Real enemies always chase; hallucinations chase only at depth 7+ and sanity < 20%. if !is_hallucination { self.try_move_enemy_toward(ex, ey, &mut occupied); continue; - } else if self.sanity < 20 { + } else if self.depth >= 7 && self.sanity < 20 { self.try_move_enemy_toward(ex, ey, &mut occupied); continue; } @@ -1608,9 +1619,217 @@ impl Game { self.attacking = false; self.enemy_moves.clear(); self.acted_this_turn.clear(); + self.frame_counter = 0; self.set_message(&format!("You descend to depth {}...", self.depth)); } + // ---- horror effects --------------------------------------------------- + + /// Phantom wall probability based on current sanity. + fn phantom_chance(&self) -> f64 { + match self.sanity { + 80..=100 => 0.0, + 60..=79 => 0.05, + 40..=59 => 0.10, + 20..=39 => 0.15, + _ => 0.20, + } + } + + /// Simple deterministic hash for phantom effect placement. + fn phantom_hash(&self, x: usize, y: usize) -> u64 { + let h = (x as u64) + .wrapping_mul(0x9e3779b9) + .wrapping_add((y as u64).wrapping_mul(0x517cc1b727220a95)) + .wrapping_add(self.frame_counter); + (h ^ (h >> 33)).wrapping_mul(0xc6a4a7935bd1e995) + } + + /// True if this passage cell should render as a wall (phantom wall). + pub fn is_phantom_wall(&self, x: usize, y: usize) -> bool { + let chance = self.phantom_chance(); + if chance <= 0.0 { + return false; + } + // Only applies to passages — walls are already walls. + if self.maze.is_wall(y, x) { + return false; + } + (self.phantom_hash(x, y) % 1000) < (chance * 1000.0) as u64 + } + + /// True if this wall cell should render as a passage (phantom passage). + pub fn is_phantom_passage(&self, x: usize, y: usize) -> bool { + let chance = self.phantom_chance(); + if chance <= 0.0 { + return false; + } + // Only applies to walls — passages are already passages. + if !self.maze.is_wall(y, x) { + return false; + } + // Never make the cell the player is standing in a phantom passage. + let px = self.player.x.floor() as usize; + let py = self.player.y.floor() as usize; + if x == px && y == py { + return false; + } + (self.phantom_hash(x, y) % 1000) < (chance * 1000.0) as u64 + } + + /// Depth darkness factor: 0.0 at depth 1, up to 0.8 at depth 15+. + pub fn depth_darkness_factor(&self) -> f64 { + ((self.depth as f64 - 1.0).min(14.0) / 14.0) * 0.8 + } + + /// Combined darkness factor: weakness × depth, 0..2 range. + pub fn darkness_factor(&self) -> f64 { + self.weakness_factor() + self.depth_darkness_factor() + } + + /// Sanity-based colour drift angle (radians) for hue-rotation effect. + pub fn color_drift(&self) -> f64 { + if self.sanity >= 80 { + return 0.0; + } + let t = (80.0 - self.sanity as f64) / 80.0; // 0..1 as sanity drops + let wave = (self.frame_counter as f64 * 0.02).sin() * 0.5 + 0.5; + t * wave * 0.08 // max ~0.08 rad hue rotation + } + + /// Wall-breathing X offset for texture sampling (subtle wave). + pub fn wall_breathing(&self) -> f64 { + if self.sanity >= 80 { + return 0.0; + } + let t = (80.0 - self.sanity as f64) / 80.0; + (self.frame_counter as f64 * 0.03).sin() * t * 3.0 // ±3 texels at worst + } + + /// Upgrade all hallucinations to persistent with extra HP when sanity < 20%. + fn upgrade_hallucinations(&mut self) { + if self.sanity >= 20 { + return; + } + for y in 0..self.maze.height() { + for x in 0..self.maze.width() { + if let Some(Entity::Hallucination { persistent, hp }) = + self.maze.entity_at_mut(x, y) + { + if !*persistent { + *persistent = true; + *hp = 3; // takes multiple attacks to dissipate + } + } + } + } + } + + /// Shifting maze: flip 1-3 random wall cells outside player FOV. + /// Called during turn resolution. + fn shift_maze(&mut self) { + // Depth 1–3: no shifting. + if self.depth <= 3 { + return; + } + // Chance based on sanity. + let chance = match self.sanity { + 60..=79 => 0.05, + 40..=59 => 0.05, + 20..=39 => 0.10, + _ if self.sanity < 20 => 0.15, + _ => return, + }; + let mut rng = rand::rng(); + if !rng.random_bool(chance) { + return; + } + + let w = self.maze.width(); + let h = self.maze.height(); + let px = self.player.x.floor() as isize; + let py = self.player.y.floor() as isize; + let ex = self.maze.exit_x as isize; + let ey = self.maze.exit_y as isize; + + // Candidates: wall cells at least 6 tiles from player (Chebyshev). + let mut candidates: Vec<(usize, usize)> = Vec::new(); + for y in 1..h - 1 { + for x in 1..w - 1 { + // Only wall cells, not the exit. + if !self.maze.is_wall(y, x) { + continue; + } + if x == self.maze.exit_x && y == self.maze.exit_y { + continue; + } + // At least 6 tiles away from player. + let dx = (x as isize - px).abs(); + let dy = (y as isize - py).abs(); + if dx.max(dy) < 6 { + continue; + } + candidates.push((x, y)); + } + } + + if candidates.is_empty() { + return; + } + + let flips = rng.random_range(1..=3).min(candidates.len()); + shuffle_slice(&mut candidates, &mut rng); + + for i in 0..flips { + let (x, y) = candidates[i]; + // Flip wall → passage (or passage → wall, but we only collected walls). + let idx = y * w + x; + self.maze.cells[idx] = Cell::Passage; + + // Validate: can the player still reach the exit? + if !self.path_exists((px as usize, py as usize), (ex as usize, ey as usize)) { + // Revert. + self.maze.cells[idx] = Cell::Wall; + } + } + } + + /// BFS: is there a path of passage cells from `from` to `to`? + fn path_exists(&self, from: (usize, usize), to: (usize, usize)) -> bool { + let w = self.maze.width(); + let h = self.maze.height(); + let mut visited = vec![false; w * h]; + let mut queue = std::collections::VecDeque::new(); + queue.push_back(from); + visited[from.1 * w + from.0] = true; + + let dirs: [(isize, isize); 4] = [(1, 0), (-1, 0), (0, 1), (0, -1)]; + + while let Some((cx, cy)) = queue.pop_front() { + if cx == to.0 && cy == to.1 { + return true; + } + for (dx, dy) in dirs { + let nx = cx as isize + dx; + let ny = cy as isize + dy; + if nx < 0 || ny < 0 || nx >= w as isize || ny >= h as isize { + continue; + } + let nx = nx as usize; + let ny = ny as usize; + if visited[ny * w + nx] { + continue; + } + if self.maze.is_wall(ny, nx) { + continue; + } + visited[ny * w + nx] = true; + queue.push_back((nx, ny)); + } + } + false + } + /// Whole heartbeats elapsed (displayed in HUD). pub fn heartbeats(&self) -> usize { self.heartbeat_count as usize -- cgit v1.2.3 From 31661b25d35e8f2b72b2151585b00a5af74e7ec2 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sat, 11 Jul 2026 16:05:53 +0200 Subject: fix shifting maze sanity thresholds: only triggers at <60% --- Cargo.lock | 14 +++++++------- Cargo.toml | 2 +- src/game.rs | 1 - 3 files changed, 8 insertions(+), 9 deletions(-) (limited to 'src/game.rs') diff --git a/Cargo.lock b/Cargo.lock index d3682b2..2485847 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,19 +52,19 @@ dependencies = [ ] [[package]] -name = "doom" +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dungeon" version = "0.1.0" dependencies = [ "minifb", "rand", ] -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - [[package]] name = "errno" version = "0.3.14" diff --git a/Cargo.toml b/Cargo.toml index 2be2d18..39c7286 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "doom" +name = "dungeon" version = "0.1.0" edition = "2024" diff --git a/src/game.rs b/src/game.rs index 1cc12f2..cec85a2 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1734,7 +1734,6 @@ impl Game { } // Chance based on sanity. let chance = match self.sanity { - 60..=79 => 0.05, 40..=59 => 0.05, 20..=39 => 0.10, _ if self.sanity < 20 => 0.15, -- cgit v1.2.3 From 97d4e814d1136b4749f924d6010a9666f7c0076d Mon Sep 17 00:00:00 2001 From: salmonik Date: Sat, 11 Jul 2026 16:20:37 +0200 Subject: fix player surviving at 0 HP after enemy retaliation --- src/game.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) (limited to 'src/game.rs') diff --git a/src/game.rs b/src/game.rs index cec85a2..133a43f 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1022,6 +1022,9 @@ impl Game { self.maze.take_entity(fx, fy); self.kills += 1; self.set_message(&format!("You slay the enemy! ({} dmg)", total_dmg)); + self.flash_color = 0xFF_CC3333; + self.flash_alpha = 0.4; + self.begin_attack_animation(); } else { self.maze.entity_at_mut(fx, fy).map(|e| { if let Entity::Enemy { hp: h, .. } = e { @@ -1036,11 +1039,12 @@ impl Game { )); if self.hp == 0 { self.state = GameState::GameOver; + } else { + self.flash_color = 0xFF_CC3333; + self.flash_alpha = 0.4; + self.begin_attack_animation(); } } - self.flash_color = 0xFF_CC3333; - self.flash_alpha = 0.4; - self.begin_attack_animation(); true } -- cgit v1.2.3 From 6aaabd3199425827cd3388ab8a3f8a2def4a0c9f Mon Sep 17 00:00:00 2001 From: salmonik Date: Sat, 11 Jul 2026 16:28:28 +0200 Subject: fix phantom walls flickering every frame instead of per turn --- src/game.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src/game.rs') diff --git a/src/game.rs b/src/game.rs index 133a43f..d9e3f52 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1645,7 +1645,7 @@ impl Game { let h = (x as u64) .wrapping_mul(0x9e3779b9) .wrapping_add((y as u64).wrapping_mul(0x517cc1b727220a95)) - .wrapping_add(self.frame_counter); + .wrapping_add(self.heartbeat_count); (h ^ (h >> 33)).wrapping_mul(0xc6a4a7935bd1e995) } -- cgit v1.2.3 From 0399649d5b119fdd9e9eeefc32a81436d0a22237 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sat, 11 Jul 2026 16:33:42 +0200 Subject: make hitting a hallucination take 30 san --- src/game.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src/game.rs') diff --git a/src/game.rs b/src/game.rs index d9e3f52..a739f6d 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1049,7 +1049,7 @@ impl Game { } Some(Entity::Hallucination { persistent, hp }) => { - self.sanity = self.sanity.saturating_sub(10); + self.sanity = self.sanity.saturating_sub(30); let new_hp = hp.saturating_sub(1); if !persistent || new_hp == 0 { -- cgit v1.2.3