summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-07-11 16:05:15 +0200
committersalmonik <salmonik@tuta.io>2026-07-11 16:05:15 +0200
commita4626cb681fdb4939772018def2019c1d1c49d46 (patch)
tree455178e212f225e43aae702b6de23b6be900273c
parentadd floor transition: Space on exit descends to next depth (diff)
downloaddungeon-a4626cb681fdb4939772018def2019c1d1c49d46.tar.gz
dungeon-a4626cb681fdb4939772018def2019c1d1c49d46.zip
add psychedelic horror effects: phantom walls, shifting maze, darkness, color drift, persistent hallucinations
-rw-r--r--src/game.rs233
-rw-r--r--src/renderer.rs71
2 files changed, 286 insertions, 18 deletions
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 {
694 694
695 // ---- death screen ---- 695 // ---- death screen ----
696 pub death_frames: u32, 696 pub death_frames: u32,
697
698 // ---- horror effects ----
699 frame_counter: u64,
697} 700}
698 701
699fn smoothstep(t: f64) -> f64 { 702fn smoothstep(t: f64) -> f64 {
@@ -748,6 +751,7 @@ impl Game {
748 flash_alpha: 0.0, 751 flash_alpha: 0.0,
749 show_controls: true, 752 show_controls: true,
750 death_frames: 0, 753 death_frames: 0,
754 frame_counter: 0,
751 } 755 }
752 } 756 }
753 757
@@ -762,6 +766,8 @@ impl Game {
762 /// Returns true when the current animation phase finishes and we should 766 /// Returns true when the current animation phase finishes and we should
763 /// advance the state machine. 767 /// advance the state machine.
764 pub fn update_animation(&mut self, dt: f64) -> bool { 768 pub fn update_animation(&mut self, dt: f64) -> bool {
769 self.frame_counter = self.frame_counter.wrapping_add(1);
770
765 // Flash always fades, regardless of state. 771 // Flash always fades, regardless of state.
766 if self.flash_alpha > 0.0 { 772 if self.flash_alpha > 0.0 {
767 self.flash_alpha = (self.flash_alpha - dt * 4.0).max(0.0); 773 self.flash_alpha = (self.flash_alpha - dt * 4.0).max(0.0);
@@ -1224,7 +1230,8 @@ impl Game {
1224 // Clear acted-this-turn tracking (enemy AI already consumed it). 1230 // Clear acted-this-turn tracking (enemy AI already consumed it).
1225 self.acted_this_turn.clear(); 1231 self.acted_this_turn.clear();
1226 1232
1227 // Weakness grows every 3 turns, not every turn. 1233 // At sanity < 20%, hallucinations become persistent with extra HP.
1234 self.upgrade_hallucinations();
1228 if self.heartbeat_count % 3 == 0 { 1235 if self.heartbeat_count % 3 == 0 {
1229 self.weakness = (self.weakness + 1).min(MAX_WEAKNESS); 1236 self.weakness = (self.weakness + 1).min(MAX_WEAKNESS);
1230 } 1237 }
@@ -1244,6 +1251,9 @@ impl Game {
1244 self.hp -= 1; 1251 self.hp -= 1;
1245 } 1252 }
1246 } 1253 }
1254
1255 // Shifting maze: wall cells may flip at low sanity.
1256 self.shift_maze();
1247 } 1257 }
1248 1258
1249 /// Skip the player's turn (C key). Enemies still act. 1259 /// Skip the player's turn (C key). Enemies still act.
@@ -1464,12 +1474,13 @@ impl Game {
1464 1474
1465 // ---- hallucination behaviour gating ---- 1475 // ---- hallucination behaviour gating ----
1466 if is_hallucination { 1476 if is_hallucination {
1467 if self.sanity >= 40 { 1477 if self.depth <= 3 || self.sanity >= 40 {
1468 // Static: hallucinations don't move or attack. 1478 // Depth 1-3: hallucinations always static.
1479 // Sanity >= 40%: hallucinations are static.
1469 continue; 1480 continue;
1470 } 1481 }
1471 // Sanity 20-39%: patrol only. 1482 // Depth 4+ and sanity 20-39%: patrol only.
1472 // Sanity < 20%: chase (handled below via has_line_of_sight). 1483 // Depth 7+ and sanity < 20%: chase (handled below via has_line_of_sight).
1473 } 1484 }
1474 1485
1475 // ---- adjacent → attack ---- 1486 // ---- adjacent → attack ----
@@ -1505,11 +1516,11 @@ impl Game {
1505 let has_los = self.has_line_of_sight(ex, ey, px, py); 1516 let has_los = self.has_line_of_sight(ex, ey, px, py);
1506 1517
1507 if has_los { 1518 if has_los {
1508 // Real enemies always chase; hallucinations chase only at sanity < 20%. 1519 // Real enemies always chase; hallucinations chase only at depth 7+ and sanity < 20%.
1509 if !is_hallucination { 1520 if !is_hallucination {
1510 self.try_move_enemy_toward(ex, ey, &mut occupied); 1521 self.try_move_enemy_toward(ex, ey, &mut occupied);
1511 continue; 1522 continue;
1512 } else if self.sanity < 20 { 1523 } else if self.depth >= 7 && self.sanity < 20 {
1513 self.try_move_enemy_toward(ex, ey, &mut occupied); 1524 self.try_move_enemy_toward(ex, ey, &mut occupied);
1514 continue; 1525 continue;
1515 } 1526 }
@@ -1608,9 +1619,217 @@ impl Game {
1608 self.attacking = false; 1619 self.attacking = false;
1609 self.enemy_moves.clear(); 1620 self.enemy_moves.clear();
1610 self.acted_this_turn.clear(); 1621 self.acted_this_turn.clear();
1622 self.frame_counter = 0;
1611 self.set_message(&format!("You descend to depth {}...", self.depth)); 1623 self.set_message(&format!("You descend to depth {}...", self.depth));
1612 } 1624 }
1613 1625
1626 // ---- horror effects ---------------------------------------------------
1627
1628 /// Phantom wall probability based on current sanity.
1629 fn phantom_chance(&self) -> f64 {
1630 match self.sanity {
1631 80..=100 => 0.0,
1632 60..=79 => 0.05,
1633 40..=59 => 0.10,
1634 20..=39 => 0.15,
1635 _ => 0.20,
1636 }
1637 }
1638
1639 /// Simple deterministic hash for phantom effect placement.
1640 fn phantom_hash(&self, x: usize, y: usize) -> u64 {
1641 let h = (x as u64)
1642 .wrapping_mul(0x9e3779b9)
1643 .wrapping_add((y as u64).wrapping_mul(0x517cc1b727220a95))
1644 .wrapping_add(self.frame_counter);
1645 (h ^ (h >> 33)).wrapping_mul(0xc6a4a7935bd1e995)
1646 }
1647
1648 /// True if this passage cell should render as a wall (phantom wall).
1649 pub fn is_phantom_wall(&self, x: usize, y: usize) -> bool {
1650 let chance = self.phantom_chance();
1651 if chance <= 0.0 {
1652 return false;
1653 }
1654 // Only applies to passages — walls are already walls.
1655 if self.maze.is_wall(y, x) {
1656 return false;
1657 }
1658 (self.phantom_hash(x, y) % 1000) < (chance * 1000.0) as u64
1659 }
1660
1661 /// True if this wall cell should render as a passage (phantom passage).
1662 pub fn is_phantom_passage(&self, x: usize, y: usize) -> bool {
1663 let chance = self.phantom_chance();
1664 if chance <= 0.0 {
1665 return false;
1666 }
1667 // Only applies to walls — passages are already passages.
1668 if !self.maze.is_wall(y, x) {
1669 return false;
1670 }
1671 // Never make the cell the player is standing in a phantom passage.
1672 let px = self.player.x.floor() as usize;
1673 let py = self.player.y.floor() as usize;
1674 if x == px && y == py {
1675 return false;
1676 }
1677 (self.phantom_hash(x, y) % 1000) < (chance * 1000.0) as u64
1678 }
1679
1680 /// Depth darkness factor: 0.0 at depth 1, up to 0.8 at depth 15+.
1681 pub fn depth_darkness_factor(&self) -> f64 {
1682 ((self.depth as f64 - 1.0).min(14.0) / 14.0) * 0.8
1683 }
1684
1685 /// Combined darkness factor: weakness × depth, 0..2 range.
1686 pub fn darkness_factor(&self) -> f64 {
1687 self.weakness_factor() + self.depth_darkness_factor()
1688 }
1689
1690 /// Sanity-based colour drift angle (radians) for hue-rotation effect.
1691 pub fn color_drift(&self) -> f64 {
1692 if self.sanity >= 80 {
1693 return 0.0;
1694 }
1695 let t = (80.0 - self.sanity as f64) / 80.0; // 0..1 as sanity drops
1696 let wave = (self.frame_counter as f64 * 0.02).sin() * 0.5 + 0.5;
1697 t * wave * 0.08 // max ~0.08 rad hue rotation
1698 }
1699
1700 /// Wall-breathing X offset for texture sampling (subtle wave).
1701 pub fn wall_breathing(&self) -> f64 {
1702 if self.sanity >= 80 {
1703 return 0.0;
1704 }
1705 let t = (80.0 - self.sanity as f64) / 80.0;
1706 (self.frame_counter as f64 * 0.03).sin() * t * 3.0 // ±3 texels at worst
1707 }
1708
1709 /// Upgrade all hallucinations to persistent with extra HP when sanity < 20%.
1710 fn upgrade_hallucinations(&mut self) {
1711 if self.sanity >= 20 {
1712 return;
1713 }
1714 for y in 0..self.maze.height() {
1715 for x in 0..self.maze.width() {
1716 if let Some(Entity::Hallucination { persistent, hp }) =
1717 self.maze.entity_at_mut(x, y)
1718 {
1719 if !*persistent {
1720 *persistent = true;
1721 *hp = 3; // takes multiple attacks to dissipate
1722 }
1723 }
1724 }
1725 }
1726 }
1727
1728 /// Shifting maze: flip 1-3 random wall cells outside player FOV.
1729 /// Called during turn resolution.
1730 fn shift_maze(&mut self) {
1731 // Depth 1–3: no shifting.
1732 if self.depth <= 3 {
1733 return;
1734 }
1735 // Chance based on sanity.
1736 let chance = match self.sanity {
1737 60..=79 => 0.05,
1738 40..=59 => 0.05,
1739 20..=39 => 0.10,
1740 _ if self.sanity < 20 => 0.15,
1741 _ => return,
1742 };
1743 let mut rng = rand::rng();
1744 if !rng.random_bool(chance) {
1745 return;
1746 }
1747
1748 let w = self.maze.width();
1749 let h = self.maze.height();
1750 let px = self.player.x.floor() as isize;
1751 let py = self.player.y.floor() as isize;
1752 let ex = self.maze.exit_x as isize;
1753 let ey = self.maze.exit_y as isize;
1754
1755 // Candidates: wall cells at least 6 tiles from player (Chebyshev).
1756 let mut candidates: Vec<(usize, usize)> = Vec::new();
1757 for y in 1..h - 1 {
1758 for x in 1..w - 1 {
1759 // Only wall cells, not the exit.
1760 if !self.maze.is_wall(y, x) {
1761 continue;
1762 }
1763 if x == self.maze.exit_x && y == self.maze.exit_y {
1764 continue;
1765 }
1766 // At least 6 tiles away from player.
1767 let dx = (x as isize - px).abs();
1768 let dy = (y as isize - py).abs();
1769 if dx.max(dy) < 6 {
1770 continue;
1771 }
1772 candidates.push((x, y));
1773 }
1774 }
1775
1776 if candidates.is_empty() {
1777 return;
1778 }
1779
1780 let flips = rng.random_range(1..=3).min(candidates.len());
1781 shuffle_slice(&mut candidates, &mut rng);
1782
1783 for i in 0..flips {
1784 let (x, y) = candidates[i];
1785 // Flip wall → passage (or passage → wall, but we only collected walls).
1786 let idx = y * w + x;
1787 self.maze.cells[idx] = Cell::Passage;
1788
1789 // Validate: can the player still reach the exit?
1790 if !self.path_exists((px as usize, py as usize), (ex as usize, ey as usize)) {
1791 // Revert.
1792 self.maze.cells[idx] = Cell::Wall;
1793 }
1794 }
1795 }
1796
1797 /// BFS: is there a path of passage cells from `from` to `to`?
1798 fn path_exists(&self, from: (usize, usize), to: (usize, usize)) -> bool {
1799 let w = self.maze.width();
1800 let h = self.maze.height();
1801 let mut visited = vec![false; w * h];
1802 let mut queue = std::collections::VecDeque::new();
1803 queue.push_back(from);
1804 visited[from.1 * w + from.0] = true;
1805
1806 let dirs: [(isize, isize); 4] = [(1, 0), (-1, 0), (0, 1), (0, -1)];
1807
1808 while let Some((cx, cy)) = queue.pop_front() {
1809 if cx == to.0 && cy == to.1 {
1810 return true;
1811 }
1812 for (dx, dy) in dirs {
1813 let nx = cx as isize + dx;
1814 let ny = cy as isize + dy;
1815 if nx < 0 || ny < 0 || nx >= w as isize || ny >= h as isize {
1816 continue;
1817 }
1818 let nx = nx as usize;
1819 let ny = ny as usize;
1820 if visited[ny * w + nx] {
1821 continue;
1822 }
1823 if self.maze.is_wall(ny, nx) {
1824 continue;
1825 }
1826 visited[ny * w + nx] = true;
1827 queue.push_back((nx, ny));
1828 }
1829 }
1830 false
1831 }
1832
1614 /// Whole heartbeats elapsed (displayed in HUD). 1833 /// Whole heartbeats elapsed (displayed in HUD).
1615 pub fn heartbeats(&self) -> usize { 1834 pub fn heartbeats(&self) -> usize {
1616 self.heartbeat_count as usize 1835 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 {
108 108
109 let half_h = self.screen_h as f64 / 2.0; 109 let half_h = self.screen_h as f64 / 2.0;
110 let fov = std::f64::consts::PI / 3.0; 110 let fov = std::f64::consts::PI / 3.0;
111 let drift = game.color_drift();
112 let breathing = game.wall_breathing();
111 113
112 // Per-column wall distances for entity occlusion. 114 // Per-column wall distances for entity occlusion.
113 // Store Euclidean (for occlusion) and perp (for rendering). 115 // Store Euclidean (for occlusion) and perp (for rendering).
@@ -173,7 +175,18 @@ impl Renderer {
173 break; 175 break;
174 } 176 }
175 177
176 if game.maze.is_wall(map_y as usize, map_x as usize) { 178 let ux = map_x as usize;
179 let uy = map_y as usize;
180
181 // Phantom passage: a wall that appears as a passage.
182 // Skip it — the ray passes through.
183 if game.maze.is_wall(uy, ux) && game.is_phantom_passage(ux, uy) {
184 continue;
185 }
186
187 // Phantom wall: a passage that appears as a wall.
188 // OR a real wall. Either way, stop the ray here.
189 if game.maze.is_wall(uy, ux) || game.is_phantom_wall(ux, uy) {
177 euclidean = if !hit_side { 190 euclidean = if !hit_side {
178 (map_x as f64 - game.player.x + (1.0 - step_x as f64) / 2.0) / rdx 191 (map_x as f64 - game.player.x + (1.0 - step_x as f64) / 2.0) / rdx
179 } else { 192 } else {
@@ -198,11 +211,13 @@ impl Renderer {
198 game.player.x + euclidean * rdx 211 game.player.x + euclidean * rdx
199 }; 212 };
200 let wall_x = wall_x - wall_x.floor(); 213 let wall_x = wall_x - wall_x.floor();
201 let tex_x = ((wall_x * TEX_SIZE as f64) as usize).min(TEX_SIZE - 1); 214 // Wall breathing: subtle horizontal texture wobble.
215 let tex_x = (((wall_x + breathing / TEX_SIZE as f64) * TEX_SIZE as f64) as isize)
216 .rem_euclid(TEX_SIZE as isize) as usize;
202 217
203 let wall_bright_fx = { 218 let wall_bright_fx = {
204 let w = game.weakness_factor(); 219 let d = game.darkness_factor();
205 let darken_scale = 1.0 + w * 12.0; 220 let darken_scale = 1.0 + d * 12.0;
206 let b = 1.0 / (1.0 + perp_dist * 0.25 * darken_scale); 221 let b = 1.0 / (1.0 + perp_dist * 0.25 * darken_scale);
207 (b * 256.0) as u32 222 (b * 256.0) as u32
208 }; 223 };
@@ -212,13 +227,14 @@ impl Renderer {
212 / wall_h as f64) as usize; 227 / wall_h as f64) as usize;
213 let tex_y = tex_y.min(TEX_SIZE - 1); 228 let tex_y = tex_y.min(TEX_SIZE - 1);
214 let color = game.wall_texture[tex_y * TEX_SIZE + tex_x]; 229 let color = game.wall_texture[tex_y * TEX_SIZE + tex_x];
215 buffer[py * self.screen_w + sx] = darken_fx(color, wall_bright_fx); 230 buffer[py * self.screen_w + sx] =
231 color_drift_fx(darken_fx(color, wall_bright_fx), drift);
216 } 232 }
217 233
218 let floor_start = draw_end + 1; 234 let floor_start = draw_end + 1;
219 if floor_start < self.screen_h { 235 if floor_start < self.screen_h {
220 let w = game.weakness_factor(); 236 let d = game.darkness_factor();
221 let darken_scale = 1.0 + w * 12.0; 237 let darken_scale = 1.0 + d * 12.0;
222 for py in floor_start..self.screen_h { 238 for py in floor_start..self.screen_h {
223 let floor_dist = self.floor_dist_lut[py]; 239 let floor_dist = self.floor_dist_lut[py];
224 let bright = 1.0 / (1.0 + floor_dist * 0.35 * darken_scale); 240 let bright = 1.0 / (1.0 + floor_dist * 0.35 * darken_scale);
@@ -226,7 +242,7 @@ impl Renderer {
226 242
227 if floor_dist > 15.0 { 243 if floor_dist > 15.0 {
228 let gray = darken_fx(0xFF80_8080, bright_fx); 244 let gray = darken_fx(0xFF80_8080, bright_fx);
229 buffer[py * self.screen_w + sx] = gray; 245 buffer[py * self.screen_w + sx] = color_drift_fx(gray, drift);
230 } else { 246 } else {
231 let fx = game.player.x + floor_dist * rdx_floor; 247 let fx = game.player.x + floor_dist * rdx_floor;
232 let fy = game.player.y + floor_dist * rdy_floor; 248 let fy = game.player.y + floor_dist * rdy_floor;
@@ -238,7 +254,8 @@ impl Renderer {
238 } else { 254 } else {
239 game.floor_texture[fty * TEX_SIZE + ftx] 255 game.floor_texture[fty * TEX_SIZE + ftx]
240 }; 256 };
241 buffer[py * self.screen_w + sx] = darken_fx(color, bright_fx); 257 buffer[py * self.screen_w + sx] =
258 color_drift_fx(darken_fx(color, bright_fx), drift);
242 } 259 }
243 } 260 }
244 } 261 }
@@ -352,7 +369,8 @@ impl Renderer {
352 s.scale, 369 s.scale,
353 s.color, 370 s.color,
354 s.cam_z, 371 s.cam_z,
355 game.weakness_factor(), 372 game.darkness_factor(),
373 game.color_drift(),
356 wall_euclidean, 374 wall_euclidean,
357 ); 375 );
358 } 376 }
@@ -418,6 +436,7 @@ impl Renderer {
418 color: u32, 436 color: u32,
419 cam_z: f64, 437 cam_z: f64,
420 weakness: f64, 438 weakness: f64,
439 drift: f64,
421 wall_euclidean: &[f64], 440 wall_euclidean: &[f64],
422 ) { 441 ) {
423 let glyph = match find_glyph(ch) { 442 let glyph = match find_glyph(ch) {
@@ -428,7 +447,7 @@ impl Renderer {
428 let darken_scale = 1.0 + weakness * 12.0; 447 let darken_scale = 1.0 + weakness * 12.0;
429 let bright = 1.0 / (1.0 + cam_z * 0.15 * darken_scale); 448 let bright = 1.0 / (1.0 + cam_z * 0.15 * darken_scale);
430 let bright_fx = (bright * 256.0) as u32; 449 let bright_fx = (bright * 256.0) as u32;
431 let final_color = darken_fx(color, bright_fx); 450 let final_color = color_drift_fx(darken_fx(color, bright_fx), drift);
432 451
433 let draw_w = (LETTER_W as f64 * scale) as isize; 452 let draw_w = (LETTER_W as f64 * scale) as isize;
434 let draw_h = (LETTER_H as f64 * scale) as isize; 453 let draw_h = (LETTER_H as f64 * scale) as isize;
@@ -707,3 +726,33 @@ fn darken_fx(color: u32, bright_fx: u32) -> u32 {
707 let b = ((color & 0xFF) * bright_fx) >> 8; 726 let b = ((color & 0xFF) * bright_fx) >> 8;
708 0xFF00_0000 | (r << 16) | (g << 8) | b 727 0xFF00_0000 | (r << 16) | (g << 8) | b
709} 728}
729
730/// Subtle hue rotation for sanity-based colour drift.
731/// `drift` is a small angle in radians; 0 means no change.
732fn color_drift_fx(color: u32, drift: f64) -> u32 {
733 if drift.abs() < 0.001 {
734 return color;
735 }
736 let r = ((color >> 16) & 0xFF) as f64 / 255.0;
737 let g = ((color >> 8) & 0xFF) as f64 / 255.0;
738 let b = (color & 0xFF) as f64 / 255.0;
739
740 // Cheap hue shift: rotate in RGB space around the gray axis.
741 let gray = (r + g + b) / 3.0;
742 let dr = r - gray;
743 let dg = g - gray;
744 let db = b - gray;
745
746 let cos_d = drift.cos();
747 let sin_d = drift.sin();
748
749 // Rotate the RG plane (simple 2D projection for subtle effect).
750 let nr = gray + dr * cos_d - dg * sin_d * 0.5 + db * sin_d * 0.3;
751 let ng = gray + dg * cos_d + dr * sin_d * 0.5 - db * sin_d * 0.3;
752 let nb = gray + db * cos_d - dr * sin_d * 0.3 + dg * sin_d * 0.3;
753
754 let r = (nr.clamp(0.0, 1.0) * 255.0) as u32;
755 let g = (ng.clamp(0.0, 1.0) * 255.0) as u32;
756 let b = (nb.clamp(0.0, 1.0) * 255.0) as u32;
757 0xFF00_0000 | (r << 16) | (g << 8) | b
758}