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 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-- src/renderer.rs | 71 ++++++++++++++--- 2 files changed, 286 insertions(+), 18 deletions(-) (limited to 'src') 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 diff --git a/src/renderer.rs b/src/renderer.rs index 885e5de..86862f0 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -108,6 +108,8 @@ impl Renderer { let half_h = self.screen_h as f64 / 2.0; let fov = std::f64::consts::PI / 3.0; + let drift = game.color_drift(); + let breathing = game.wall_breathing(); // Per-column wall distances for entity occlusion. // Store Euclidean (for occlusion) and perp (for rendering). @@ -173,7 +175,18 @@ impl Renderer { break; } - if game.maze.is_wall(map_y as usize, map_x as usize) { + let ux = map_x as usize; + let uy = map_y as usize; + + // Phantom passage: a wall that appears as a passage. + // Skip it — the ray passes through. + if game.maze.is_wall(uy, ux) && game.is_phantom_passage(ux, uy) { + continue; + } + + // Phantom wall: a passage that appears as a wall. + // OR a real wall. Either way, stop the ray here. + if game.maze.is_wall(uy, ux) || game.is_phantom_wall(ux, uy) { euclidean = if !hit_side { (map_x as f64 - game.player.x + (1.0 - step_x as f64) / 2.0) / rdx } else { @@ -198,11 +211,13 @@ impl Renderer { game.player.x + euclidean * rdx }; let wall_x = wall_x - wall_x.floor(); - let tex_x = ((wall_x * TEX_SIZE as f64) as usize).min(TEX_SIZE - 1); + // Wall breathing: subtle horizontal texture wobble. + let tex_x = (((wall_x + breathing / TEX_SIZE as f64) * TEX_SIZE as f64) as isize) + .rem_euclid(TEX_SIZE as isize) as usize; let wall_bright_fx = { - let w = game.weakness_factor(); - let darken_scale = 1.0 + w * 12.0; + let d = game.darkness_factor(); + let darken_scale = 1.0 + d * 12.0; let b = 1.0 / (1.0 + perp_dist * 0.25 * darken_scale); (b * 256.0) as u32 }; @@ -212,13 +227,14 @@ impl Renderer { / 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); + buffer[py * self.screen_w + sx] = + color_drift_fx(darken_fx(color, wall_bright_fx), drift); } 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; + let d = game.darkness_factor(); + let darken_scale = 1.0 + d * 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); @@ -226,7 +242,7 @@ impl Renderer { if floor_dist > 15.0 { let gray = darken_fx(0xFF80_8080, bright_fx); - buffer[py * self.screen_w + sx] = gray; + buffer[py * self.screen_w + sx] = color_drift_fx(gray, drift); } else { let fx = game.player.x + floor_dist * rdx_floor; let fy = game.player.y + floor_dist * rdy_floor; @@ -238,7 +254,8 @@ impl Renderer { } else { game.floor_texture[fty * TEX_SIZE + ftx] }; - buffer[py * self.screen_w + sx] = darken_fx(color, bright_fx); + buffer[py * self.screen_w + sx] = + color_drift_fx(darken_fx(color, bright_fx), drift); } } } @@ -352,7 +369,8 @@ impl Renderer { s.scale, s.color, s.cam_z, - game.weakness_factor(), + game.darkness_factor(), + game.color_drift(), wall_euclidean, ); } @@ -418,6 +436,7 @@ impl Renderer { color: u32, cam_z: f64, weakness: f64, + drift: f64, wall_euclidean: &[f64], ) { let glyph = match find_glyph(ch) { @@ -428,7 +447,7 @@ impl Renderer { let darken_scale = 1.0 + weakness * 12.0; let bright = 1.0 / (1.0 + cam_z * 0.15 * darken_scale); let bright_fx = (bright * 256.0) as u32; - let final_color = darken_fx(color, bright_fx); + let final_color = color_drift_fx(darken_fx(color, bright_fx), drift); let draw_w = (LETTER_W as f64 * scale) as isize; let draw_h = (LETTER_H as f64 * scale) as isize; @@ -707,3 +726,33 @@ fn darken_fx(color: u32, bright_fx: u32) -> u32 { let b = ((color & 0xFF) * bright_fx) >> 8; 0xFF00_0000 | (r << 16) | (g << 8) | b } + +/// Subtle hue rotation for sanity-based colour drift. +/// `drift` is a small angle in radians; 0 means no change. +fn color_drift_fx(color: u32, drift: f64) -> u32 { + if drift.abs() < 0.001 { + return color; + } + let r = ((color >> 16) & 0xFF) as f64 / 255.0; + let g = ((color >> 8) & 0xFF) as f64 / 255.0; + let b = (color & 0xFF) as f64 / 255.0; + + // Cheap hue shift: rotate in RGB space around the gray axis. + let gray = (r + g + b) / 3.0; + let dr = r - gray; + let dg = g - gray; + let db = b - gray; + + let cos_d = drift.cos(); + let sin_d = drift.sin(); + + // Rotate the RG plane (simple 2D projection for subtle effect). + let nr = gray + dr * cos_d - dg * sin_d * 0.5 + db * sin_d * 0.3; + let ng = gray + dg * cos_d + dr * sin_d * 0.5 - db * sin_d * 0.3; + let nb = gray + db * cos_d - dr * sin_d * 0.3 + dg * sin_d * 0.3; + + let r = (nr.clamp(0.0, 1.0) * 255.0) as u32; + let g = (ng.clamp(0.0, 1.0) * 255.0) as u32; + let b = (nb.clamp(0.0, 1.0) * 255.0) as u32; + 0xFF00_0000 | (r << 16) | (g << 8) | b +} -- cgit v1.2.3