summaryrefslogtreecommitdiffstats
path: root/src/game.rs
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-07-11 16:34:17 +0200
committersalmonik <salmonik@tuta.io>2026-07-11 16:34:17 +0200
commit6f4bed92773b9c24a59eddcb5632ea05582e708d (patch)
tree3cf835e0d0af23aa9d13ced4e3cbe5d57d1b80c1 /src/game.rs
parentmerge floor transition (diff)
parentmake hitting a hallucination take 30 san (diff)
downloaddungeon-6f4bed92773b9c24a59eddcb5632ea05582e708d.tar.gz
dungeon-6f4bed92773b9c24a59eddcb5632ea05582e708d.zip
merge psychedelic horror effectsHEADmaster
Diffstat (limited to '')
-rw-r--r--src/game.rs244
1 files changed, 233 insertions, 11 deletions
diff --git a/src/game.rs b/src/game.rs
index 1c13dd4..a739f6d 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);
@@ -1016,6 +1022,9 @@ impl Game {
1016 self.maze.take_entity(fx, fy); 1022 self.maze.take_entity(fx, fy);
1017 self.kills += 1; 1023 self.kills += 1;
1018 self.set_message(&format!("You slay the enemy! ({} dmg)", total_dmg)); 1024 self.set_message(&format!("You slay the enemy! ({} dmg)", total_dmg));
1025 self.flash_color = 0xFF_CC3333;
1026 self.flash_alpha = 0.4;
1027 self.begin_attack_animation();
1019 } else { 1028 } else {
1020 self.maze.entity_at_mut(fx, fy).map(|e| { 1029 self.maze.entity_at_mut(fx, fy).map(|e| {
1021 if let Entity::Enemy { hp: h, .. } = e { 1030 if let Entity::Enemy { hp: h, .. } = e {
@@ -1030,16 +1039,17 @@ impl Game {
1030 )); 1039 ));
1031 if self.hp == 0 { 1040 if self.hp == 0 {
1032 self.state = GameState::GameOver; 1041 self.state = GameState::GameOver;
1042 } else {
1043 self.flash_color = 0xFF_CC3333;
1044 self.flash_alpha = 0.4;
1045 self.begin_attack_animation();
1033 } 1046 }
1034 } 1047 }
1035 self.flash_color = 0xFF_CC3333;
1036 self.flash_alpha = 0.4;
1037 self.begin_attack_animation();
1038 true 1048 true
1039 } 1049 }
1040 1050
1041 Some(Entity::Hallucination { persistent, hp }) => { 1051 Some(Entity::Hallucination { persistent, hp }) => {
1042 self.sanity = self.sanity.saturating_sub(10); 1052 self.sanity = self.sanity.saturating_sub(30);
1043 let new_hp = hp.saturating_sub(1); 1053 let new_hp = hp.saturating_sub(1);
1044 1054
1045 if !persistent || new_hp == 0 { 1055 if !persistent || new_hp == 0 {
@@ -1224,7 +1234,8 @@ impl Game {
1224 // Clear acted-this-turn tracking (enemy AI already consumed it). 1234 // Clear acted-this-turn tracking (enemy AI already consumed it).
1225 self.acted_this_turn.clear(); 1235 self.acted_this_turn.clear();
1226 1236
1227 // Weakness grows every 3 turns, not every turn. 1237 // At sanity < 20%, hallucinations become persistent with extra HP.
1238 self.upgrade_hallucinations();
1228 if self.heartbeat_count % 3 == 0 { 1239 if self.heartbeat_count % 3 == 0 {
1229 self.weakness = (self.weakness + 1).min(MAX_WEAKNESS); 1240 self.weakness = (self.weakness + 1).min(MAX_WEAKNESS);
1230 } 1241 }
@@ -1244,6 +1255,9 @@ impl Game {
1244 self.hp -= 1; 1255 self.hp -= 1;
1245 } 1256 }
1246 } 1257 }
1258
1259 // Shifting maze: wall cells may flip at low sanity.
1260 self.shift_maze();
1247 } 1261 }
1248 1262
1249 /// Skip the player's turn (C key). Enemies still act. 1263 /// Skip the player's turn (C key). Enemies still act.
@@ -1464,12 +1478,13 @@ impl Game {
1464 1478
1465 // ---- hallucination behaviour gating ---- 1479 // ---- hallucination behaviour gating ----
1466 if is_hallucination { 1480 if is_hallucination {
1467 if self.sanity >= 40 { 1481 if self.depth <= 3 || self.sanity >= 40 {
1468 // Static: hallucinations don't move or attack. 1482 // Depth 1-3: hallucinations always static.
1483 // Sanity >= 40%: hallucinations are static.
1469 continue; 1484 continue;
1470 } 1485 }
1471 // Sanity 20-39%: patrol only. 1486 // Depth 4+ and sanity 20-39%: patrol only.
1472 // Sanity < 20%: chase (handled below via has_line_of_sight). 1487 // Depth 7+ and sanity < 20%: chase (handled below via has_line_of_sight).
1473 } 1488 }
1474 1489
1475 // ---- adjacent → attack ---- 1490 // ---- adjacent → attack ----
@@ -1505,11 +1520,11 @@ impl Game {
1505 let has_los = self.has_line_of_sight(ex, ey, px, py); 1520 let has_los = self.has_line_of_sight(ex, ey, px, py);
1506 1521
1507 if has_los { 1522 if has_los {
1508 // Real enemies always chase; hallucinations chase only at sanity < 20%. 1523 // Real enemies always chase; hallucinations chase only at depth 7+ and sanity < 20%.
1509 if !is_hallucination { 1524 if !is_hallucination {
1510 self.try_move_enemy_toward(ex, ey, &mut occupied); 1525 self.try_move_enemy_toward(ex, ey, &mut occupied);
1511 continue; 1526 continue;
1512 } else if self.sanity < 20 { 1527 } else if self.depth >= 7 && self.sanity < 20 {
1513 self.try_move_enemy_toward(ex, ey, &mut occupied); 1528 self.try_move_enemy_toward(ex, ey, &mut occupied);
1514 continue; 1529 continue;
1515 } 1530 }
@@ -1608,9 +1623,216 @@ impl Game {
1608 self.attacking = false; 1623 self.attacking = false;
1609 self.enemy_moves.clear(); 1624 self.enemy_moves.clear();
1610 self.acted_this_turn.clear(); 1625 self.acted_this_turn.clear();
1626 self.frame_counter = 0;
1611 self.set_message(&format!("You descend to depth {}...", self.depth)); 1627 self.set_message(&format!("You descend to depth {}...", self.depth));
1612 } 1628 }
1613 1629
1630 // ---- horror effects ---------------------------------------------------
1631
1632 /// Phantom wall probability based on current sanity.
1633 fn phantom_chance(&self) -> f64 {
1634 match self.sanity {
1635 80..=100 => 0.0,
1636 60..=79 => 0.05,
1637 40..=59 => 0.10,
1638 20..=39 => 0.15,
1639 _ => 0.20,
1640 }
1641 }
1642
1643 /// Simple deterministic hash for phantom effect placement.
1644 fn phantom_hash(&self, x: usize, y: usize) -> u64 {
1645 let h = (x as u64)
1646 .wrapping_mul(0x9e3779b9)
1647 .wrapping_add((y as u64).wrapping_mul(0x517cc1b727220a95))
1648 .wrapping_add(self.heartbeat_count);
1649 (h ^ (h >> 33)).wrapping_mul(0xc6a4a7935bd1e995)
1650 }
1651
1652 /// True if this passage cell should render as a wall (phantom wall).
1653 pub fn is_phantom_wall(&self, x: usize, y: usize) -> bool {
1654 let chance = self.phantom_chance();
1655 if chance <= 0.0 {
1656 return false;
1657 }
1658 // Only applies to passages — walls are already walls.
1659 if self.maze.is_wall(y, x) {
1660 return false;
1661 }
1662 (self.phantom_hash(x, y) % 1000) < (chance * 1000.0) as u64
1663 }
1664
1665 /// True if this wall cell should render as a passage (phantom passage).
1666 pub fn is_phantom_passage(&self, x: usize, y: usize) -> bool {
1667 let chance = self.phantom_chance();
1668 if chance <= 0.0 {
1669 return false;
1670 }
1671 // Only applies to walls — passages are already passages.
1672 if !self.maze.is_wall(y, x) {
1673 return false;
1674 }
1675 // Never make the cell the player is standing in a phantom passage.
1676 let px = self.player.x.floor() as usize;
1677 let py = self.player.y.floor() as usize;
1678 if x == px && y == py {
1679 return false;
1680 }
1681 (self.phantom_hash(x, y) % 1000) < (chance * 1000.0) as u64
1682 }
1683
1684 /// Depth darkness factor: 0.0 at depth 1, up to 0.8 at depth 15+.
1685 pub fn depth_darkness_factor(&self) -> f64 {
1686 ((self.depth as f64 - 1.0).min(14.0) / 14.0) * 0.8
1687 }
1688
1689 /// Combined darkness factor: weakness × depth, 0..2 range.
1690 pub fn darkness_factor(&self) -> f64 {
1691 self.weakness_factor() + self.depth_darkness_factor()
1692 }
1693
1694 /// Sanity-based colour drift angle (radians) for hue-rotation effect.
1695 pub fn color_drift(&self) -> f64 {
1696 if self.sanity >= 80 {
1697 return 0.0;
1698 }
1699 let t = (80.0 - self.sanity as f64) / 80.0; // 0..1 as sanity drops
1700 let wave = (self.frame_counter as f64 * 0.02).sin() * 0.5 + 0.5;
1701 t * wave * 0.08 // max ~0.08 rad hue rotation
1702 }
1703
1704 /// Wall-breathing X offset for texture sampling (subtle wave).
1705 pub fn wall_breathing(&self) -> f64 {
1706 if self.sanity >= 80 {
1707 return 0.0;
1708 }
1709 let t = (80.0 - self.sanity as f64) / 80.0;
1710 (self.frame_counter as f64 * 0.03).sin() * t * 3.0 // ±3 texels at worst
1711 }
1712
1713 /// Upgrade all hallucinations to persistent with extra HP when sanity < 20%.
1714 fn upgrade_hallucinations(&mut self) {
1715 if self.sanity >= 20 {
1716 return;
1717 }
1718 for y in 0..self.maze.height() {
1719 for x in 0..self.maze.width() {
1720 if let Some(Entity::Hallucination { persistent, hp }) =
1721 self.maze.entity_at_mut(x, y)
1722 {
1723 if !*persistent {
1724 *persistent = true;
1725 *hp = 3; // takes multiple attacks to dissipate
1726 }
1727 }
1728 }
1729 }
1730 }
1731
1732 /// Shifting maze: flip 1-3 random wall cells outside player FOV.
1733 /// Called during turn resolution.
1734 fn shift_maze(&mut self) {
1735 // Depth 1–3: no shifting.
1736 if self.depth <= 3 {
1737 return;
1738 }
1739 // Chance based on sanity.
1740 let chance = match self.sanity {
1741 40..=59 => 0.05,
1742 20..=39 => 0.10,
1743 _ if self.sanity < 20 => 0.15,
1744 _ => return,
1745 };
1746 let mut rng = rand::rng();
1747 if !rng.random_bool(chance) {
1748 return;
1749 }
1750
1751 let w = self.maze.width();
1752 let h = self.maze.height();
1753 let px = self.player.x.floor() as isize;
1754 let py = self.player.y.floor() as isize;
1755 let ex = self.maze.exit_x as isize;
1756 let ey = self.maze.exit_y as isize;
1757
1758 // Candidates: wall cells at least 6 tiles from player (Chebyshev).
1759 let mut candidates: Vec<(usize, usize)> = Vec::new();
1760 for y in 1..h - 1 {
1761 for x in 1..w - 1 {
1762 // Only wall cells, not the exit.
1763 if !self.maze.is_wall(y, x) {
1764 continue;
1765 }
1766 if x == self.maze.exit_x && y == self.maze.exit_y {
1767 continue;
1768 }
1769 // At least 6 tiles away from player.
1770 let dx = (x as isize - px).abs();
1771 let dy = (y as isize - py).abs();
1772 if dx.max(dy) < 6 {
1773 continue;
1774 }
1775 candidates.push((x, y));
1776 }
1777 }
1778
1779 if candidates.is_empty() {
1780 return;
1781 }
1782
1783 let flips = rng.random_range(1..=3).min(candidates.len());
1784 shuffle_slice(&mut candidates, &mut rng);
1785
1786 for i in 0..flips {
1787 let (x, y) = candidates[i];
1788 // Flip wall → passage (or passage → wall, but we only collected walls).
1789 let idx = y * w + x;
1790 self.maze.cells[idx] = Cell::Passage;
1791
1792 // Validate: can the player still reach the exit?
1793 if !self.path_exists((px as usize, py as usize), (ex as usize, ey as usize)) {
1794 // Revert.
1795 self.maze.cells[idx] = Cell::Wall;
1796 }
1797 }
1798 }
1799
1800 /// BFS: is there a path of passage cells from `from` to `to`?
1801 fn path_exists(&self, from: (usize, usize), to: (usize, usize)) -> bool {
1802 let w = self.maze.width();
1803 let h = self.maze.height();
1804 let mut visited = vec![false; w * h];
1805 let mut queue = std::collections::VecDeque::new();
1806 queue.push_back(from);
1807 visited[from.1 * w + from.0] = true;
1808
1809 let dirs: [(isize, isize); 4] = [(1, 0), (-1, 0), (0, 1), (0, -1)];
1810
1811 while let Some((cx, cy)) = queue.pop_front() {
1812 if cx == to.0 && cy == to.1 {
1813 return true;
1814 }
1815 for (dx, dy) in dirs {
1816 let nx = cx as isize + dx;
1817 let ny = cy as isize + dy;
1818 if nx < 0 || ny < 0 || nx >= w as isize || ny >= h as isize {
1819 continue;
1820 }
1821 let nx = nx as usize;
1822 let ny = ny as usize;
1823 if visited[ny * w + nx] {
1824 continue;
1825 }
1826 if self.maze.is_wall(ny, nx) {
1827 continue;
1828 }
1829 visited[ny * w + nx] = true;
1830 queue.push_back((nx, ny));
1831 }
1832 }
1833 false
1834 }
1835
1614 /// Whole heartbeats elapsed (displayed in HUD). 1836 /// Whole heartbeats elapsed (displayed in HUD).
1615 pub fn heartbeats(&self) -> usize { 1837 pub fn heartbeats(&self) -> usize {
1616 self.heartbeat_count as usize 1838 self.heartbeat_count as usize