diff options
| author | salmonik <raisersoft@proton.me> | 2026-07-11 01:35:02 +0200 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-07-11 01:35:02 +0200 |
| commit | aae1d1a56a935b2aeba7eb953b71106b190da974 (patch) | |
| tree | d87d11ca843878795f0ccbac12cb0d8d69e6ee68 | |
| parent | Phase 2 (diff) | |
| download | dungeon-aae1d1a56a935b2aeba7eb953b71106b190da974.tar.gz dungeon-aae1d1a56a935b2aeba7eb953b71106b190da974.zip | |
animations, introduction, no terminal
| -rw-r--r-- | src/game.rs | 84 | ||||
| -rw-r--r-- | src/main.rs | 56 | ||||
| -rw-r--r-- | src/renderer.rs | 162 |
3 files changed, 245 insertions, 57 deletions
diff --git a/src/game.rs b/src/game.rs index 80ee0c6..ca30207 100644 --- a/src/game.rs +++ b/src/game.rs | |||
| @@ -608,6 +608,7 @@ impl Player { | |||
| 608 | const MOVE_DURATION: f64 = 0.18; // seconds to walk one tile | 608 | const MOVE_DURATION: f64 = 0.18; // seconds to walk one tile |
| 609 | const TURN_DURATION: f64 = 0.12; // seconds for a 90° turn | 609 | const TURN_DURATION: f64 = 0.12; // seconds for a 90° turn |
| 610 | const ENEMY_ANIM_DURATION: f64 = 0.10; // seconds for enemy movement | 610 | const ENEMY_ANIM_DURATION: f64 = 0.10; // seconds for enemy movement |
| 611 | const ATTACK_DURATION: f64 = 0.12; // seconds for attack lunge | ||
| 611 | 612 | ||
| 612 | pub const MAX_HP: u32 = 5; | 613 | pub const MAX_HP: u32 = 5; |
| 613 | pub const MAX_SANITY: u32 = 100; | 614 | pub const MAX_SANITY: u32 = 100; |
| @@ -659,6 +660,19 @@ pub struct Game { | |||
| 659 | 660 | ||
| 660 | // ---- animation: enemies ---- | 661 | // ---- animation: enemies ---- |
| 661 | enemy_anim_t: f64, | 662 | enemy_anim_t: f64, |
| 663 | |||
| 664 | // ---- animation: attack ---- | ||
| 665 | pub attacking: bool, | ||
| 666 | pub attack_t: f64, | ||
| 667 | attack_base_x: f64, | ||
| 668 | attack_base_y: f64, | ||
| 669 | |||
| 670 | // ---- screen flash ---- | ||
| 671 | pub flash_color: u32, | ||
| 672 | pub flash_alpha: f64, | ||
| 673 | |||
| 674 | // ---- controls overlay ---- | ||
| 675 | pub show_controls: bool, | ||
| 662 | } | 676 | } |
| 663 | 677 | ||
| 664 | fn smoothstep(t: f64) -> f64 { | 678 | fn smoothstep(t: f64) -> f64 { |
| @@ -703,6 +717,13 @@ impl Game { | |||
| 703 | turn_delta: 0.0, | 717 | turn_delta: 0.0, |
| 704 | turn_t: 0.0, | 718 | turn_t: 0.0, |
| 705 | enemy_anim_t: 0.0, | 719 | enemy_anim_t: 0.0, |
| 720 | attacking: false, | ||
| 721 | attack_t: 0.0, | ||
| 722 | attack_base_x: 0.0, | ||
| 723 | attack_base_y: 0.0, | ||
| 724 | flash_color: 0, | ||
| 725 | flash_alpha: 0.0, | ||
| 726 | show_controls: true, | ||
| 706 | } | 727 | } |
| 707 | } | 728 | } |
| 708 | 729 | ||
| @@ -717,6 +738,11 @@ impl Game { | |||
| 717 | /// Returns true when the current animation phase finishes and we should | 738 | /// Returns true when the current animation phase finishes and we should |
| 718 | /// advance the state machine. | 739 | /// advance the state machine. |
| 719 | pub fn update_animation(&mut self, dt: f64) -> bool { | 740 | pub fn update_animation(&mut self, dt: f64) -> bool { |
| 741 | // Flash always fades, regardless of state. | ||
| 742 | if self.flash_alpha > 0.0 { | ||
| 743 | self.flash_alpha = (self.flash_alpha - dt * 4.0).max(0.0); | ||
| 744 | } | ||
| 745 | |||
| 720 | // Turn animation runs independently — turning never consumes a turn. | 746 | // Turn animation runs independently — turning never consumes a turn. |
| 721 | if self.turning { | 747 | if self.turning { |
| 722 | self.turn_t += dt / TURN_DURATION; | 748 | self.turn_t += dt / TURN_DURATION; |
| @@ -751,6 +777,28 @@ impl Game { | |||
| 751 | } | 777 | } |
| 752 | } | 778 | } |
| 753 | 779 | ||
| 780 | // Animate attack lunge. | ||
| 781 | if self.attacking { | ||
| 782 | self.attack_t += dt / ATTACK_DURATION; | ||
| 783 | if self.attack_t >= 1.0 { | ||
| 784 | self.player.x = self.attack_base_x; | ||
| 785 | self.player.y = self.attack_base_y; | ||
| 786 | self.attacking = false; | ||
| 787 | } else { | ||
| 788 | // Lunge forward then back. | ||
| 789 | let lunge = if self.attack_t < 0.5 { | ||
| 790 | smoothstep(self.attack_t * 2.0) * 0.25 | ||
| 791 | } else { | ||
| 792 | (1.0 - smoothstep((self.attack_t - 0.5) * 2.0)) * 0.25 | ||
| 793 | }; | ||
| 794 | let fx = self.player.angle.cos(); | ||
| 795 | let fy = self.player.angle.sin(); | ||
| 796 | self.player.x = self.attack_base_x + fx * lunge; | ||
| 797 | self.player.y = self.attack_base_y + fy * lunge; | ||
| 798 | done = false; | ||
| 799 | } | ||
| 800 | } | ||
| 801 | |||
| 754 | if done { | 802 | if done { |
| 755 | self.state = GameState::ResolvingTurn; | 803 | self.state = GameState::ResolvingTurn; |
| 756 | self.resolve_turn(); | 804 | self.resolve_turn(); |
| @@ -921,12 +969,10 @@ impl Game { | |||
| 921 | let new_hp = hp.saturating_sub(total_dmg); | 969 | let new_hp = hp.saturating_sub(total_dmg); |
| 922 | 970 | ||
| 923 | if new_hp == 0 { | 971 | if new_hp == 0 { |
| 924 | // Enemy dies. | ||
| 925 | self.maze.take_entity(fx, fy); | 972 | self.maze.take_entity(fx, fy); |
| 926 | self.kills += 1; | 973 | self.kills += 1; |
| 927 | self.set_message(&format!("You slay the enemy! ({} dmg)", total_dmg)); | 974 | self.set_message(&format!("You slay the enemy! ({} dmg)", total_dmg)); |
| 928 | } else { | 975 | } else { |
| 929 | // Enemy survives and retaliates. | ||
| 930 | self.maze.entity_at_mut(fx, fy).map(|e| { | 976 | self.maze.entity_at_mut(fx, fy).map(|e| { |
| 931 | if let Entity::Enemy { hp: ref mut h, .. } = e { | 977 | if let Entity::Enemy { hp: ref mut h, .. } = e { |
| 932 | *h = new_hp; | 978 | *h = new_hp; |
| @@ -941,7 +987,9 @@ impl Game { | |||
| 941 | self.state = GameState::GameOver; | 987 | self.state = GameState::GameOver; |
| 942 | } | 988 | } |
| 943 | } | 989 | } |
| 944 | self.advance_turn(); | 990 | self.flash_color = 0xFF_CC3333; |
| 991 | self.flash_alpha = 0.4; | ||
| 992 | self.begin_attack_animation(); | ||
| 945 | true | 993 | true |
| 946 | } | 994 | } |
| 947 | 995 | ||
| @@ -950,11 +998,9 @@ impl Game { | |||
| 950 | let new_hp = hp.saturating_sub(1); | 998 | let new_hp = hp.saturating_sub(1); |
| 951 | 999 | ||
| 952 | if !persistent || new_hp == 0 { | 1000 | if !persistent || new_hp == 0 { |
| 953 | // Vanishes. | ||
| 954 | self.maze.take_entity(fx, fy); | 1001 | self.maze.take_entity(fx, fy); |
| 955 | self.set_message("The hallucination dissipates."); | 1002 | self.set_message("The hallucination dissipates."); |
| 956 | } else { | 1003 | } else { |
| 957 | // Persistent — doesn't vanish yet. | ||
| 958 | self.maze.entity_at_mut(fx, fy).map(|e| { | 1004 | self.maze.entity_at_mut(fx, fy).map(|e| { |
| 959 | if let Entity::Hallucination { hp: ref mut h, .. } = e { | 1005 | if let Entity::Hallucination { hp: ref mut h, .. } = e { |
| 960 | *h = new_hp; | 1006 | *h = new_hp; |
| @@ -962,18 +1008,30 @@ impl Game { | |||
| 962 | }); | 1008 | }); |
| 963 | self.set_message("You strike the apparition - it remains."); | 1009 | self.set_message("You strike the apparition - it remains."); |
| 964 | } | 1010 | } |
| 965 | self.advance_turn(); | 1011 | self.flash_color = 0xFF_8833CC; |
| 1012 | self.flash_alpha = 0.35; | ||
| 1013 | self.begin_attack_animation(); | ||
| 966 | true | 1014 | true |
| 967 | } | 1015 | } |
| 968 | 1016 | ||
| 969 | _ => { | 1017 | _ => { |
| 970 | self.set_message("You swing at the air."); | 1018 | self.set_message("You swing at the air."); |
| 971 | self.advance_turn(); | 1019 | self.flash_color = 0xFF_FFFFFF; |
| 1020 | self.flash_alpha = 0.05; | ||
| 1021 | self.begin_attack_animation(); | ||
| 972 | true | 1022 | true |
| 973 | } | 1023 | } |
| 974 | } | 1024 | } |
| 975 | } | 1025 | } |
| 976 | 1026 | ||
| 1027 | fn begin_attack_animation(&mut self) { | ||
| 1028 | self.attack_base_x = self.player.x; | ||
| 1029 | self.attack_base_y = self.player.y; | ||
| 1030 | self.attacking = true; | ||
| 1031 | self.attack_t = 0.0; | ||
| 1032 | self.state = GameState::AnimatingPlayer; | ||
| 1033 | } | ||
| 1034 | |||
| 977 | // ---- inventory -------------------------------------------------------- | 1035 | // ---- inventory -------------------------------------------------------- |
| 978 | 1036 | ||
| 979 | /// Use the currently selected inventory item (Z key). | 1037 | /// Use the currently selected inventory item (Z key). |
| @@ -1107,7 +1165,10 @@ impl Game { | |||
| 1107 | 1165 | ||
| 1108 | /// Apply all per-turn effects (weakness, heartbeat, HP drain, message decay). | 1166 | /// Apply all per-turn effects (weakness, heartbeat, HP drain, message decay). |
| 1109 | fn resolve_turn(&mut self) { | 1167 | fn resolve_turn(&mut self) { |
| 1110 | self.weakness = (self.weakness + 1).min(MAX_WEAKNESS); | 1168 | // Weakness grows every 3 turns, not every turn. |
| 1169 | if self.heartbeat_count % 3 == 0 { | ||
| 1170 | self.weakness = (self.weakness + 1).min(MAX_WEAKNESS); | ||
| 1171 | } | ||
| 1111 | self.heartbeat_count += 1; | 1172 | self.heartbeat_count += 1; |
| 1112 | 1173 | ||
| 1113 | // Message decay. | 1174 | // Message decay. |
| @@ -1118,14 +1179,11 @@ impl Game { | |||
| 1118 | } | 1179 | } |
| 1119 | } | 1180 | } |
| 1120 | 1181 | ||
| 1121 | // HP drain at max weakness. | 1182 | // HP drain at max weakness — stops at 1, never kills. |
| 1122 | if self.weakness >= MAX_WEAKNESS { | 1183 | if self.weakness >= MAX_WEAKNESS { |
| 1123 | if self.hp > 0 { | 1184 | if self.hp > 1 { |
| 1124 | self.hp -= 1; | 1185 | self.hp -= 1; |
| 1125 | } | 1186 | } |
| 1126 | if self.hp == 0 { | ||
| 1127 | self.state = GameState::GameOver; | ||
| 1128 | } | ||
| 1129 | } | 1187 | } |
| 1130 | } | 1188 | } |
| 1131 | 1189 | ||
diff --git a/src/main.rs b/src/main.rs index 391bb90..a59edcd 100644 --- a/src/main.rs +++ b/src/main.rs | |||
| @@ -1,3 +1,5 @@ | |||
| 1 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] | ||
| 2 | |||
| 1 | mod game; | 3 | mod game; |
| 2 | mod renderer; | 4 | mod renderer; |
| 3 | mod texture; | 5 | mod texture; |
| @@ -11,17 +13,6 @@ const SCREEN_W: usize = 640; | |||
| 11 | const SCREEN_H: usize = 480; | 13 | const SCREEN_H: usize = 480; |
| 12 | 14 | ||
| 13 | fn main() { | 15 | fn main() { |
| 14 | println!("=== DUNGEON CRAWLER ==="); | ||
| 15 | println!(" W / S — forward / backward"); | ||
| 16 | println!(" A / D — strafe left / right"); | ||
| 17 | println!(" Q / E — turn 90° left / right"); | ||
| 18 | println!(" Space — attack"); | ||
| 19 | println!(" Z — use selected inventory item"); | ||
| 20 | println!(" X — discard selected inventory item"); | ||
| 21 | println!(" 1-4 — select inventory slot"); | ||
| 22 | println!(" C — skip turn"); | ||
| 23 | println!(" Esc — quit\n"); | ||
| 24 | |||
| 25 | let mut game = Game::new(); | 16 | let mut game = Game::new(); |
| 26 | let renderer = Renderer::new(SCREEN_W, SCREEN_H); | 17 | let renderer = Renderer::new(SCREEN_W, SCREEN_H); |
| 27 | 18 | ||
| @@ -33,6 +24,9 @@ fn main() { | |||
| 33 | ) | 24 | ) |
| 34 | .expect("Failed to create window"); | 25 | .expect("Failed to create window"); |
| 35 | 26 | ||
| 27 | // Try to centre on screen (best-effort; not all backends support this). | ||
| 28 | window.set_position(640, 240); | ||
| 29 | |||
| 36 | let mut framebuffer = vec![0u32; SCREEN_W * SCREEN_H]; | 30 | let mut framebuffer = vec![0u32; SCREEN_W * SCREEN_H]; |
| 37 | let mut last = Instant::now(); | 31 | let mut last = Instant::now(); |
| 38 | let frame_budget = std::time::Duration::from_micros(16600); // ~60 fps | 32 | let frame_budget = std::time::Duration::from_micros(16600); // ~60 fps |
| @@ -52,6 +46,7 @@ fn main() { | |||
| 52 | let mut prev_2 = false; | 46 | let mut prev_2 = false; |
| 53 | let mut prev_3 = false; | 47 | let mut prev_3 = false; |
| 54 | let mut prev_4 = false; | 48 | let mut prev_4 = false; |
| 49 | let mut prev_any = false; | ||
| 55 | 50 | ||
| 56 | while window.is_open() && !window.is_key_down(Key::Escape) { | 51 | while window.is_open() && !window.is_key_down(Key::Escape) { |
| 57 | let frame_start = Instant::now(); | 52 | let frame_start = Instant::now(); |
| @@ -73,9 +68,35 @@ fn main() { | |||
| 73 | let cur_2 = window.is_key_down(Key::Key2); | 68 | let cur_2 = window.is_key_down(Key::Key2); |
| 74 | let cur_3 = window.is_key_down(Key::Key3); | 69 | let cur_3 = window.is_key_down(Key::Key3); |
| 75 | let cur_4 = window.is_key_down(Key::Key4); | 70 | let cur_4 = window.is_key_down(Key::Key4); |
| 71 | let cur_any = cur_w | ||
| 72 | || cur_s | ||
| 73 | || cur_a | ||
| 74 | || cur_d | ||
| 75 | || cur_q | ||
| 76 | || cur_e | ||
| 77 | || cur_space | ||
| 78 | || cur_z | ||
| 79 | || cur_x | ||
| 80 | || cur_c | ||
| 81 | || cur_1 | ||
| 82 | || cur_2 | ||
| 83 | || cur_3 | ||
| 84 | || cur_4 | ||
| 85 | || window.is_key_down(Key::Enter) | ||
| 86 | || window.is_key_down(Key::Escape); | ||
| 87 | |||
| 88 | // ---- input ---- | ||
| 89 | let was_showing_controls = game.show_controls; | ||
| 90 | |||
| 91 | if game.show_controls { | ||
| 92 | if cur_any && !prev_any { | ||
| 93 | game.show_controls = false; | ||
| 94 | } | ||
| 95 | } | ||
| 76 | 96 | ||
| 77 | // ---- input (edge-triggered, only in WaitingForInput) ---- | 97 | // Only process game input if controls were NOT showing at the start |
| 78 | if game.can_act() { | 98 | // of this frame (prevents the dismissing key from triggering an action). |
| 99 | if !was_showing_controls && game.can_act() { | ||
| 79 | if cur_w && !prev_w { | 100 | if cur_w && !prev_w { |
| 80 | game.move_forward(); | 101 | game.move_forward(); |
| 81 | } else if cur_s && !prev_s { | 102 | } else if cur_s && !prev_s { |
| @@ -107,7 +128,7 @@ fn main() { | |||
| 107 | } | 128 | } |
| 108 | } | 129 | } |
| 109 | 130 | ||
| 110 | // Update previous states. | 131 | // Update previous states for next frame. |
| 111 | prev_w = cur_w; | 132 | prev_w = cur_w; |
| 112 | prev_s = cur_s; | 133 | prev_s = cur_s; |
| 113 | prev_a = cur_a; | 134 | prev_a = cur_a; |
| @@ -122,23 +143,18 @@ fn main() { | |||
| 122 | prev_2 = cur_2; | 143 | prev_2 = cur_2; |
| 123 | prev_3 = cur_3; | 144 | prev_3 = cur_3; |
| 124 | prev_4 = cur_4; | 145 | prev_4 = cur_4; |
| 146 | prev_any = cur_any; | ||
| 125 | 147 | ||
| 126 | // ---- advance animations / state machine ---- | 148 | // ---- advance animations / state machine ---- |
| 127 | game.update_animation(dt); | 149 | game.update_animation(dt); |
| 128 | 150 | ||
| 129 | // ---- death check ---- | 151 | // ---- death check ---- |
| 130 | if game.state == GameState::GameOver { | 152 | if game.state == GameState::GameOver { |
| 131 | let score = (game.kills * 100) + (game.depth.pow(2) * 50) + 500; // fists = base damage 1 | ||
| 132 | println!( | ||
| 133 | "You had become yet another legend of this dungeon.\nScore: {}", | ||
| 134 | score | ||
| 135 | ); | ||
| 136 | std::process::exit(0); | 153 | std::process::exit(0); |
| 137 | } | 154 | } |
| 138 | 155 | ||
| 139 | // ---- win check (placeholder — becomes staircase in Phase 1) ---- | 156 | // ---- win check (placeholder — becomes staircase in Phase 1) ---- |
| 140 | if game.check_win() { | 157 | if game.check_win() { |
| 141 | println!("You found the exit in {} heartbeats!", game.heartbeats()); | ||
| 142 | std::process::exit(0); | 158 | std::process::exit(0); |
| 143 | } | 159 | } |
| 144 | 160 | ||
diff --git a/src/renderer.rs b/src/renderer.rs index eec89b2..6eff9fa 100644 --- a/src/renderer.rs +++ b/src/renderer.rs | |||
| @@ -38,14 +38,14 @@ const LETTERS: &[(&str, [u8; 5])] = &[ | |||
| 38 | ("2", [0b01110, 0b10001, 0b00010, 0b00100, 0b11111]), | 38 | ("2", [0b01110, 0b10001, 0b00010, 0b00100, 0b11111]), |
| 39 | ("3", [0b01110, 0b10001, 0b00110, 0b10001, 0b01110]), | 39 | ("3", [0b01110, 0b10001, 0b00110, 0b10001, 0b01110]), |
| 40 | ("4", [0b00010, 0b00110, 0b01010, 0b11111, 0b00010]), | 40 | ("4", [0b00010, 0b00110, 0b01010, 0b11111, 0b00010]), |
| 41 | ("5", [0b11111, 0b10000, 0b11110, 0b10001, 0b01110]), | 41 | ("5", [0b11111, 0b10000, 0b11110, 0b00001, 0b11110]), |
| 42 | ("6", [0b01110, 0b10000, 0b11110, 0b10001, 0b01110]), | 42 | ("6", [0b01110, 0b10000, 0b11110, 0b10001, 0b01110]), |
| 43 | ("7", [0b11111, 0b00001, 0b00010, 0b00100, 0b00100]), | 43 | ("7", [0b11111, 0b00001, 0b00010, 0b00100, 0b00100]), |
| 44 | ("8", [0b01110, 0b10001, 0b01110, 0b10001, 0b01110]), | 44 | ("8", [0b01110, 0b10001, 0b01110, 0b10001, 0b01110]), |
| 45 | ("9", [0b01110, 0b10001, 0b01111, 0b00001, 0b01110]), | 45 | ("9", [0b01110, 0b10001, 0b01111, 0b00001, 0b01110]), |
| 46 | // Punctuation & symbols | 46 | // Punctuation & symbols |
| 47 | ("!", [0b00100, 0b00100, 0b00100, 0b00000, 0b00100]), | 47 | ("!", [0b00100, 0b00100, 0b00100, 0b00000, 0b00100]), |
| 48 | ("@", [0b01110, 0b10001, 0b10111, 0b10000, 0b01110]), | 48 | ("@", [0b01110, 0b10001, 0b10110, 0b10000, 0b01110]), |
| 49 | ("+", [0b00000, 0b00100, 0b01110, 0b00100, 0b00000]), | 49 | ("+", [0b00000, 0b00100, 0b01110, 0b00100, 0b00000]), |
| 50 | (":", [0b00000, 0b00100, 0b00000, 0b00100, 0b00000]), | 50 | (":", [0b00000, 0b00100, 0b00000, 0b00100, 0b00000]), |
| 51 | ("%", [0b10001, 0b00010, 0b00100, 0b01000, 0b10001]), | 51 | ("%", [0b10001, 0b00010, 0b00100, 0b01000, 0b10001]), |
| @@ -98,6 +98,8 @@ impl Renderer { | |||
| 98 | let fov = std::f64::consts::PI / 3.0; | 98 | let fov = std::f64::consts::PI / 3.0; |
| 99 | 99 | ||
| 100 | // Per-column wall distances for entity occlusion. | 100 | // Per-column wall distances for entity occlusion. |
| 101 | // Store Euclidean (for occlusion) and perp (for rendering). | ||
| 102 | let mut wall_euclidean = vec![f64::MAX; self.screen_w]; | ||
| 101 | let mut wall_dists = vec![f64::MAX; self.screen_w]; | 103 | let mut wall_dists = vec![f64::MAX; self.screen_w]; |
| 102 | 104 | ||
| 103 | for sx in 0..self.screen_w { | 105 | for sx in 0..self.screen_w { |
| @@ -170,6 +172,7 @@ impl Renderer { | |||
| 170 | } | 172 | } |
| 171 | 173 | ||
| 172 | let perp_dist = (euclidean * (ray_angle - game.player.angle).cos().abs()).max(0.001); | 174 | let perp_dist = (euclidean * (ray_angle - game.player.angle).cos().abs()).max(0.001); |
| 175 | wall_euclidean[sx] = euclidean; | ||
| 173 | wall_dists[sx] = perp_dist; | 176 | wall_dists[sx] = perp_dist; |
| 174 | 177 | ||
| 175 | let wall_h = (self.screen_h as f64 / perp_dist) as isize; | 178 | let wall_h = (self.screen_h as f64 / perp_dist) as isize; |
| @@ -230,7 +233,7 @@ impl Renderer { | |||
| 230 | } | 233 | } |
| 231 | 234 | ||
| 232 | // ---- entity sprites (drawn after walls so occlusion is easy) ---- | 235 | // ---- entity sprites (drawn after walls so occlusion is easy) ---- |
| 233 | self.draw_entities(game, buffer, &wall_dists, fov, half_h); | 236 | self.draw_entities(game, buffer, &wall_euclidean, fov, half_h); |
| 234 | 237 | ||
| 235 | // ---- camera bob ---- | 238 | // ---- camera bob ---- |
| 236 | let bob = game.camera_bob().round() as isize; | 239 | let bob = game.camera_bob().round() as isize; |
| @@ -257,6 +260,33 @@ impl Renderer { | |||
| 257 | 260 | ||
| 258 | // ---- HUD ---- | 261 | // ---- HUD ---- |
| 259 | self.draw_hud(buffer, game); | 262 | self.draw_hud(buffer, game); |
| 263 | |||
| 264 | // ---- controls overlay (shown on start) ---- | ||
| 265 | if game.show_controls { | ||
| 266 | self.draw_controls_overlay(buffer); | ||
| 267 | } | ||
| 268 | |||
| 269 | // ---- screen flash ---- | ||
| 270 | if game.flash_alpha > 0.001 { | ||
| 271 | let r = ((game.flash_color >> 16) & 0xFF) as f64; | ||
| 272 | let g = ((game.flash_color >> 8) & 0xFF) as f64; | ||
| 273 | let b = (game.flash_color & 0xFF) as f64; | ||
| 274 | let a = game.flash_alpha; | ||
| 275 | for py in 0..self.screen_h { | ||
| 276 | for px in 0..self.screen_w { | ||
| 277 | let idx = py * self.screen_w + px; | ||
| 278 | let existing = buffer[idx]; | ||
| 279 | let er = ((existing >> 16) & 0xFF) as f64; | ||
| 280 | let eg = ((existing >> 8) & 0xFF) as f64; | ||
| 281 | let eb = (existing & 0xFF) as f64; | ||
| 282 | let fr = (er * (1.0 - a) + r * a) as u8; | ||
| 283 | let fg = (eg * (1.0 - a) + g * a) as u8; | ||
| 284 | let fb = (eb * (1.0 - a) + b * a) as u8; | ||
| 285 | buffer[idx] = | ||
| 286 | 0xFF00_0000 | ((fr as u32) << 16) | ((fg as u32) << 8) | (fb as u32); | ||
| 287 | } | ||
| 288 | } | ||
| 289 | } | ||
| 260 | } | 290 | } |
| 261 | 291 | ||
| 262 | // ---- entity sprites --------------------------------------------------- | 292 | // ---- entity sprites --------------------------------------------------- |
| @@ -265,13 +295,25 @@ impl Renderer { | |||
| 265 | &self, | 295 | &self, |
| 266 | game: &Game, | 296 | game: &Game, |
| 267 | buffer: &mut [u32], | 297 | buffer: &mut [u32], |
| 268 | wall_dists: &[f64], | 298 | wall_euclidean: &[f64], |
| 269 | fov: f64, | 299 | fov: f64, |
| 270 | half_h: f64, | 300 | half_h: f64, |
| 271 | ) { | 301 | ) { |
| 272 | let mw = game.maze.width(); | 302 | let mw = game.maze.width(); |
| 273 | let mh = game.maze.height(); | 303 | let mh = game.maze.height(); |
| 274 | 304 | ||
| 305 | // Collect visible entities with their screen info. | ||
| 306 | struct Sprite { | ||
| 307 | sx: isize, | ||
| 308 | sy: isize, | ||
| 309 | scale: f64, | ||
| 310 | ch: char, | ||
| 311 | color: u32, | ||
| 312 | cam_z: f64, | ||
| 313 | } | ||
| 314 | |||
| 315 | let mut sprites: Vec<Sprite> = Vec::new(); | ||
| 316 | |||
| 275 | for y in 0..mh { | 317 | for y in 0..mh { |
| 276 | for x in 0..mw { | 318 | for x in 0..mw { |
| 277 | let entity = match game.maze.entity_at(x, y) { | 319 | let entity = match game.maze.entity_at(x, y) { |
| @@ -290,14 +332,13 @@ impl Renderer { | |||
| 290 | let rx = -fy; | 332 | let rx = -fy; |
| 291 | let ry = fx; | 333 | let ry = fx; |
| 292 | 334 | ||
| 293 | let cam_z = dx * fx + dy * fy; // depth | 335 | let cam_z = dx * fx + dy * fy; |
| 294 | if cam_z <= 0.1 { | 336 | if cam_z <= 0.1 { |
| 295 | continue; // behind camera | 337 | continue; |
| 296 | } | 338 | } |
| 297 | 339 | ||
| 298 | let cam_x = dx * rx + dy * ry; | 340 | let cam_x = dx * rx + dy * ry; |
| 299 | 341 | ||
| 300 | // Check if entity is within FOV. | ||
| 301 | let half_fov_tan = (fov / 2.0).tan(); | 342 | let half_fov_tan = (fov / 2.0).tan(); |
| 302 | let screen_x_norm = cam_x / (cam_z * half_fov_tan); | 343 | let screen_x_norm = cam_x / (cam_z * half_fov_tan); |
| 303 | if screen_x_norm < -1.0 || screen_x_norm > 1.0 { | 344 | if screen_x_norm < -1.0 || screen_x_norm > 1.0 { |
| @@ -306,35 +347,46 @@ impl Renderer { | |||
| 306 | 347 | ||
| 307 | let sx = ((screen_x_norm + 1.0) * 0.5 * self.screen_w as f64) as isize; | 348 | let sx = ((screen_x_norm + 1.0) * 0.5 * self.screen_w as f64) as isize; |
| 308 | 349 | ||
| 309 | // Entity height on screen. | ||
| 310 | let entity_h = (self.screen_h as f64 / cam_z * 0.4) as isize; | 350 | let entity_h = (self.screen_h as f64 / cam_z * 0.4) as isize; |
| 311 | let sy = (half_h - entity_h as f64 * 0.3) as isize; | 351 | let sy = (half_h - entity_h as f64 * 0.3) as isize; |
| 312 | 352 | ||
| 313 | let (ch, color) = entity_char_and_color(entity); | 353 | // Occlusion: entity is visible only if its centre column is not behind a wall. |
| 314 | 354 | // Compare Euclidean distance to entity vs Euclidean distance to wall. | |
| 315 | // Check occlusion: is any wall column this entity overlaps with closer? | 355 | let col = sx.clamp(0, self.screen_w as isize - 1) as usize; |
| 316 | let left = (sx - entity_h / 2).max(0) as usize; | 356 | if cam_z >= wall_euclidean[col] { |
| 317 | let right = (sx + entity_h / 2).min(self.screen_w as isize - 1) as usize; | ||
| 318 | let visible = (left..=right).any(|col| cam_z < wall_dists[col]); | ||
| 319 | |||
| 320 | if !visible { | ||
| 321 | continue; | 357 | continue; |
| 322 | } | 358 | } |
| 323 | 359 | ||
| 324 | let scale = (entity_h.max(6) as f64 / LETTER_H as f64).max(0.5); | 360 | let scale = (entity_h.max(6) as f64 / LETTER_H as f64).max(0.5); |
| 361 | let (ch, color) = entity_char_and_color(entity); | ||
| 325 | 362 | ||
| 326 | self.draw_char_scaled( | 363 | sprites.push(Sprite { |
| 327 | buffer, | ||
| 328 | ch, | ||
| 329 | sx, | 364 | sx, |
| 330 | sy, | 365 | sy, |
| 331 | scale, | 366 | scale, |
| 367 | ch, | ||
| 332 | color, | 368 | color, |
| 333 | cam_z, | 369 | cam_z, |
| 334 | game.weakness_factor(), | 370 | }); |
| 335 | ); | ||
| 336 | } | 371 | } |
| 337 | } | 372 | } |
| 373 | |||
| 374 | // Sort farthest first so nearby entities draw on top. | ||
| 375 | sprites.sort_by(|a, b| b.cam_z.partial_cmp(&a.cam_z).unwrap()); | ||
| 376 | |||
| 377 | for s in &sprites { | ||
| 378 | self.draw_char_scaled( | ||
| 379 | buffer, | ||
| 380 | s.ch, | ||
| 381 | s.sx, | ||
| 382 | s.sy, | ||
| 383 | s.scale, | ||
| 384 | s.color, | ||
| 385 | s.cam_z, | ||
| 386 | game.weakness_factor(), | ||
| 387 | wall_euclidean, | ||
| 388 | ); | ||
| 389 | } | ||
| 338 | } | 390 | } |
| 339 | 391 | ||
| 340 | fn draw_char_scaled( | 392 | fn draw_char_scaled( |
| @@ -345,8 +397,9 @@ impl Renderer { | |||
| 345 | cy: isize, | 397 | cy: isize, |
| 346 | scale: f64, | 398 | scale: f64, |
| 347 | color: u32, | 399 | color: u32, |
| 348 | distance: f64, | 400 | cam_z: f64, |
| 349 | weakness: f64, | 401 | weakness: f64, |
| 402 | wall_euclidean: &[f64], | ||
| 350 | ) { | 403 | ) { |
| 351 | let glyph = match find_glyph(ch) { | 404 | let glyph = match find_glyph(ch) { |
| 352 | Some(g) => g, | 405 | Some(g) => g, |
| @@ -354,7 +407,7 @@ impl Renderer { | |||
| 354 | }; | 407 | }; |
| 355 | 408 | ||
| 356 | let darken_scale = 1.0 + weakness * 12.0; | 409 | let darken_scale = 1.0 + weakness * 12.0; |
| 357 | let bright = 1.0 / (1.0 + distance * 0.15 * darken_scale); | 410 | let bright = 1.0 / (1.0 + cam_z * 0.15 * darken_scale); |
| 358 | let bright_fx = (bright * 256.0) as u32; | 411 | let bright_fx = (bright * 256.0) as u32; |
| 359 | let final_color = darken_fx(color, bright_fx); | 412 | let final_color = darken_fx(color, bright_fx); |
| 360 | 413 | ||
| @@ -377,6 +430,10 @@ impl Renderer { | |||
| 377 | 430 | ||
| 378 | for py in py_start.max(0)..py_end.min(self.screen_h as isize) { | 431 | for py in py_start.max(0)..py_end.min(self.screen_h as isize) { |
| 379 | for px in px_start.max(0)..px_end.min(self.screen_w as isize) { | 432 | for px in px_start.max(0)..px_end.min(self.screen_w as isize) { |
| 433 | // Per-column occlusion: skip if wall is closer. | ||
| 434 | if cam_z >= wall_euclidean[px as usize] { | ||
| 435 | continue; | ||
| 436 | } | ||
| 380 | let idx = py as usize * self.screen_w + px as usize; | 437 | let idx = py as usize * self.screen_w + px as usize; |
| 381 | buffer[idx] = final_color; | 438 | buffer[idx] = final_color; |
| 382 | } | 439 | } |
| @@ -468,9 +525,66 @@ impl Renderer { | |||
| 468 | } | 525 | } |
| 469 | } | 526 | } |
| 470 | } | 527 | } |
| 471 | x += LETTER_W + 1; // 1px gap | 528 | x += LETTER_W + 1; |
| 472 | } | 529 | } |
| 473 | } | 530 | } |
| 531 | |||
| 532 | fn draw_controls_overlay(&self, buffer: &mut [u32]) { | ||
| 533 | // Semi-transparent darken. | ||
| 534 | for py in 0..self.screen_h { | ||
| 535 | for px in 0..self.screen_w { | ||
| 536 | let idx = py * self.screen_w + px; | ||
| 537 | let c = buffer[idx]; | ||
| 538 | let r = ((c >> 16) & 0xFF) as u32; | ||
| 539 | let g = ((c >> 8) & 0xFF) as u32; | ||
| 540 | let b = (c & 0xFF) as u32; | ||
| 541 | buffer[idx] = 0xFF00_0000 | ((r / 3) << 16) | ((g / 3) << 8) | (b / 3); | ||
| 542 | } | ||
| 543 | } | ||
| 544 | |||
| 545 | let title_color = 0xFF_CC8844; | ||
| 546 | let key_color = 0xFF_88CC88; | ||
| 547 | let desc_color = 0xFF_CCCCCC; | ||
| 548 | let hint_color = 0xFF_888888; | ||
| 549 | |||
| 550 | let cx = self.screen_w / 2; | ||
| 551 | let mut y = 40usize; | ||
| 552 | |||
| 553 | // Title. | ||
| 554 | let title = "DUNGEON CRAWLER"; | ||
| 555 | let title_w = title.len() * (LETTER_W + 1) - 1; | ||
| 556 | self.draw_text(buffer, cx - title_w / 2, y, title, title_color); | ||
| 557 | y += LETTER_H + 10; | ||
| 558 | |||
| 559 | // Controls. | ||
| 560 | let lines: &[(&str, &str)] = &[ | ||
| 561 | ("WASD", "MOVE / STRAFE"), | ||
| 562 | ("Q / E", "TURN 90 LEFT / RIGHT"), | ||
| 563 | ("SPACE", "ATTACK"), | ||
| 564 | ("Z", "USE SELECTED ITEM"), | ||
| 565 | ("X", "DISCARD SELECTED ITEM"), | ||
| 566 | ("1 - 4", "SELECT INVENTORY SLOT"), | ||
| 567 | ("C", "SKIP TURN"), | ||
| 568 | ("ESC", "QUIT"), | ||
| 569 | ]; | ||
| 570 | |||
| 571 | for (key, desc) in lines { | ||
| 572 | let key_px = key.len() * (LETTER_W + 1); | ||
| 573 | let gap_px = 2 * (LETTER_W + 1); | ||
| 574 | let desc_px = desc.len() * (LETTER_W + 1); | ||
| 575 | let total_w = key_px + gap_px + desc_px; | ||
| 576 | let start_x = cx - total_w / 2; | ||
| 577 | |||
| 578 | self.draw_text(buffer, start_x, y, key, key_color); | ||
| 579 | self.draw_text(buffer, start_x + key_px + gap_px, y, desc, desc_color); | ||
| 580 | y += LETTER_H + 4; | ||
| 581 | } | ||
| 582 | |||
| 583 | y += 12; | ||
| 584 | let hint = "PRESS ANY KEY TO BEGIN"; | ||
| 585 | let hint_w = hint.len() * (LETTER_W + 1) - 1; | ||
| 586 | self.draw_text(buffer, cx - hint_w / 2, y, hint, hint_color); | ||
| 587 | } | ||
| 474 | } | 588 | } |
| 475 | 589 | ||
| 476 | // ---- helpers ---- | 590 | // ---- helpers ---- |
