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