summaryrefslogtreecommitdiffstats
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
parentmerge floor transition (diff)
parentmake hitting a hallucination take 30 san (diff)
downloaddungeon-master.tar.gz
dungeon-master.zip
merge psychedelic horror effectsHEADmaster
-rw-r--r--Cargo.lock14
-rw-r--r--Cargo.toml2
-rw-r--r--src/game.rs244
-rw-r--r--src/renderer.rs71
4 files changed, 301 insertions, 30 deletions
diff --git a/Cargo.lock b/Cargo.lock
index d3682b2..2485847 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -52,7 +52,13 @@ dependencies = [
52] 52]
53 53
54[[package]] 54[[package]]
55name = "doom" 55name = "downcast-rs"
56version = "1.2.1"
57source = "registry+https://github.com/rust-lang/crates.io-index"
58checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
59
60[[package]]
61name = "dungeon"
56version = "0.1.0" 62version = "0.1.0"
57dependencies = [ 63dependencies = [
58 "minifb", 64 "minifb",
@@ -60,12 +66,6 @@ dependencies = [
60] 66]
61 67
62[[package]] 68[[package]]
63name = "downcast-rs"
64version = "1.2.1"
65source = "registry+https://github.com/rust-lang/crates.io-index"
66checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
67
68[[package]]
69name = "errno" 69name = "errno"
70version = "0.3.14" 70version = "0.3.14"
71source = "registry+https://github.com/rust-lang/crates.io-index" 71source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 2be2d18..39c7286 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,5 @@
1[package] 1[package]
2name = "doom" 2name = "dungeon"
3version = "0.1.0" 3version = "0.1.0"
4edition = "2024" 4edition = "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
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
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}