summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-07-11 01:35:02 +0200
committersalmonik <raisersoft@proton.me>2026-07-11 01:35:02 +0200
commitaae1d1a56a935b2aeba7eb953b71106b190da974 (patch)
treed87d11ca843878795f0ccbac12cb0d8d69e6ee68
parentPhase 2 (diff)
downloaddungeon-aae1d1a56a935b2aeba7eb953b71106b190da974.tar.gz
dungeon-aae1d1a56a935b2aeba7eb953b71106b190da974.zip
animations, introduction, no terminal
-rw-r--r--src/game.rs84
-rw-r--r--src/main.rs56
-rw-r--r--src/renderer.rs162
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 {
608const MOVE_DURATION: f64 = 0.18; // seconds to walk one tile 608const MOVE_DURATION: f64 = 0.18; // seconds to walk one tile
609const TURN_DURATION: f64 = 0.12; // seconds for a 90° turn 609const TURN_DURATION: f64 = 0.12; // seconds for a 90° turn
610const ENEMY_ANIM_DURATION: f64 = 0.10; // seconds for enemy movement 610const ENEMY_ANIM_DURATION: f64 = 0.10; // seconds for enemy movement
611const ATTACK_DURATION: f64 = 0.12; // seconds for attack lunge
611 612
612pub const MAX_HP: u32 = 5; 613pub const MAX_HP: u32 = 5;
613pub const MAX_SANITY: u32 = 100; 614pub 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
664fn smoothstep(t: f64) -> f64 { 678fn 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
1mod game; 3mod game;
2mod renderer; 4mod renderer;
3mod texture; 5mod texture;
@@ -11,17 +13,6 @@ const SCREEN_W: usize = 640;
11const SCREEN_H: usize = 480; 13const SCREEN_H: usize = 480;
12 14
13fn main() { 15fn 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 ----