diff options
| -rw-r--r-- | Cargo.lock | 14 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | src/game.rs | 244 | ||||
| -rw-r--r-- | src/renderer.rs | 71 |
4 files changed, 301 insertions, 30 deletions
| @@ -52,7 +52,13 @@ dependencies = [ | |||
| 52 | ] | 52 | ] |
| 53 | 53 | ||
| 54 | [[package]] | 54 | [[package]] |
| 55 | name = "doom" | 55 | name = "downcast-rs" |
| 56 | version = "1.2.1" | ||
| 57 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 58 | checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" | ||
| 59 | |||
| 60 | [[package]] | ||
| 61 | name = "dungeon" | ||
| 56 | version = "0.1.0" | 62 | version = "0.1.0" |
| 57 | dependencies = [ | 63 | dependencies = [ |
| 58 | "minifb", | 64 | "minifb", |
| @@ -60,12 +66,6 @@ dependencies = [ | |||
| 60 | ] | 66 | ] |
| 61 | 67 | ||
| 62 | [[package]] | 68 | [[package]] |
| 63 | name = "downcast-rs" | ||
| 64 | version = "1.2.1" | ||
| 65 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 66 | checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" | ||
| 67 | |||
| 68 | [[package]] | ||
| 69 | name = "errno" | 69 | name = "errno" |
| 70 | version = "0.3.14" | 70 | version = "0.3.14" |
| 71 | source = "registry+https://github.com/rust-lang/crates.io-index" | 71 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1,5 +1,5 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "doom" | 2 | name = "dungeon" |
| 3 | version = "0.1.0" | 3 | version = "0.1.0" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | 5 | ||
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 | ||
| 699 | fn smoothstep(t: f64) -> f64 { | 702 | fn 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 |
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. | ||
| 732 | fn 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 | } | ||
