summaryrefslogtreecommitdiffstats
path: root/src/game.rs
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 /src/game.rs
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
Diffstat (limited to 'src/game.rs')
-rw-r--r--src/game.rs233
1 files changed, 226 insertions, 7 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