summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-07-10 21:16:59 +0200
committersalmonik <raisersoft@proton.me>2026-07-10 21:16:59 +0200
commitc8eae871002b57f45b29a2d158e384433e76a90c (patch)
treef521d4535649ee4d515271af58b842a8e192e20f
parentfix floor warping (diff)
downloaddungeon-c8eae871002b57f45b29a2d158e384433e76a90c.tar.gz
dungeon-c8eae871002b57f45b29a2d158e384433e76a90c.zip
Phase 2
-rw-r--r--src/game.rs1155
-rw-r--r--src/main.rs145
-rw-r--r--src/renderer.rs404
-rw-r--r--src/texture.rs49
4 files changed, 1596 insertions, 157 deletions
diff --git a/src/game.rs b/src/game.rs
index 4bedecc..80ee0c6 100644
--- a/src/game.rs
+++ b/src/game.rs
@@ -1,5 +1,16 @@
1use rand::Rng; 1use rand::Rng;
2 2
3// --------------- game state machine ---------------
4
5#[derive(Clone, Copy, PartialEq, Eq)]
6pub enum GameState {
7 WaitingForInput,
8 AnimatingPlayer,
9 AnimatingEnemies,
10 ResolvingTurn,
11 GameOver,
12}
13
3// --------------- maze grid --------------- 14// --------------- maze grid ---------------
4 15
5#[derive(Clone, Copy, PartialEq, Eq)] 16#[derive(Clone, Copy, PartialEq, Eq)]
@@ -9,77 +20,271 @@ pub enum Cell {
9 Exit, 20 Exit,
10} 21}
11 22
23/// An item the player can pick up.
24#[derive(Clone, Copy, PartialEq, Eq)]
25pub enum PickupType {
26 Food,
27 Pill,
28 Bandage,
29 Weapon { base_damage: u32 },
30}
31
32/// Something occupying a maze cell (other than walls).
33#[derive(Clone)]
34pub enum Entity {
35 #[allow(dead_code)]
36 Enemy {
37 hp: u32,
38 max_hp: u32,
39 damage: u32,
40 },
41 Hallucination {
42 persistent: bool,
43 hp: u32,
44 },
45 Pickup(PickupType),
46 HallucinatedPickup(PickupType),
47}
48
49/// An item carried in the player's inventory.
50#[derive(Clone, Copy)]
51pub struct InventoryItem {
52 pub pickup: PickupType,
53 pub is_hallucination: bool,
54}
55
12pub struct Maze { 56pub struct Maze {
13 cells: Vec<Cell>, 57 cells: Vec<Cell>,
58 entities: Vec<Option<Entity>>,
14 width: usize, 59 width: usize,
15 height: usize, 60 height: usize,
61 pub exit_x: usize,
62 pub exit_y: usize,
63}
64
65/// A rectangular room placed on the grid.
66struct Room {
67 x: usize, // top-left column
68 y: usize, // top-left row
69 w: usize, // interior width (cells)
70 h: usize, // interior height (cells)
71 cx: usize, // centre column (for corridor connections)
72 cy: usize, // centre row
16} 73}
17 74
18impl Maze { 75impl Maze {
19 /// Recursive-backtracker perfect maze. `room_w × room_h` is the logical 76 /// Generate a rooms-and-corridors dungeon for the given Depth.
20 /// room count; the actual grid will be `(room_w*2+1) × (room_h*2+1)`. 77 pub fn generate(depth: u64, rng: &mut impl Rng) -> (Self, (f64, f64)) {
21 pub fn generate(room_w: usize, room_h: usize, rng: &mut impl Rng) -> Self { 78 let size = grid_size_for_depth(depth);
22 let w = room_w * 2 + 1; 79 let room_target = room_count_for_size(size);
23 let h = room_h * 2 + 1;
24 80
81 let w = size;
82 let h = size;
25 let mut cells = vec![Cell::Wall; w * h]; 83 let mut cells = vec![Cell::Wall; w * h];
26 cells[1 * w + 1] = Cell::Passage;
27 84
28 let mut stack = vec![(1usize, 1usize)]; 85 // ---- Phase 1: place rooms ----
86 let mut rooms: Vec<Room> = Vec::new();
87 let max_attempts = 300;
29 88
30 while let Some(&(cy, cx)) = stack.last() { 89 for _ in 0..max_attempts {
31 let mut unvisited = Vec::new(); 90 if rooms.len() >= room_target {
91 break;
92 }
32 93
33 for (dy, dx) in [(-2isize, 0isize), (2, 0), (0, -2), (0, 2)] { 94 let rw = rng.gen_range(3usize..=6);
34 let ny = cy as isize + dy; 95 let rh = rng.gen_range(3usize..=6);
35 let nx = cx as isize + dx; 96
36 if ny >= 1 && ny < (h - 1) as isize && nx >= 1 && nx < (w - 1) as isize { 97 // Keep rooms away from the outer border by at least 2 cells
37 if cells[ny as usize * w + nx as usize] == Cell::Wall { 98 // so corridors have room to connect to them.
38 unvisited.push((ny as usize, nx as usize)); 99 if rw + 2 >= w || rh + 2 >= h {
39 } 100 continue;
101 }
102 let rx = rng.gen_range(2..(w - rw - 2));
103 let ry = rng.gen_range(2..(h - rh - 2));
104
105 // Check overlap with 1-cell buffer.
106 let mut overlaps = false;
107 for room in &rooms {
108 if rx < room.x + room.w + 1
109 && rx + rw + 1 > room.x
110 && ry < room.y + room.h + 1
111 && ry + rh + 1 > room.y
112 {
113 overlaps = true;
114 break;
40 } 115 }
41 } 116 }
42 117
43 if unvisited.is_empty() { 118 if !overlaps {
44 stack.pop(); 119 // Carve room interior.
45 } else { 120 for dy in 0..rh {
46 let idx = rng.gen_range(0..unvisited.len()); 121 for dx in 0..rw {
47 let (ny, nx) = unvisited[idx]; 122 cells[(ry + dy) * w + (rx + dx)] = Cell::Passage;
123 }
124 }
48 125
49 // carve the wall between current cell and chosen neighbour 126 rooms.push(Room {
50 let wall_y = (cy + ny) / 2; 127 x: rx,
51 let wall_x = (cx + nx) / 2; 128 y: ry,
52 cells[wall_y * w + wall_x] = Cell::Passage; 129 w: rw,
53 cells[ny * w + nx] = Cell::Passage; 130 h: rh,
131 cx: rx + rw / 2,
132 cy: ry + rh / 2,
133 });
134 }
135 }
54 136
55 stack.push((ny, nx)); 137 // We need at least 2 rooms.
138 if rooms.len() < 2 {
139 // Fallback: force-place two small rooms.
140 rooms.clear();
141 cells.fill(Cell::Wall);
142 for i in 0..2 {
143 let rx = 2 + i * (w - 8);
144 let ry = 2 + i * (h - 8);
145 let rw = 4;
146 let rh = 4;
147 for dy in 0..rh {
148 for dx in 0..rw {
149 let px = (rx + dx).min(w - 1);
150 let py = (ry + dy).min(h - 1);
151 cells[py * w + px] = Cell::Passage;
152 }
153 }
154 rooms.push(Room {
155 x: rx,
156 y: ry,
157 w: rw,
158 h: rh,
159 cx: rx + rw / 2,
160 cy: ry + rh / 2,
161 });
56 } 162 }
57 } 163 }
58 164
59 // ---- place exit in a far-away passage ---- 165 // ---- Phase 2: connect rooms with a spanning tree (Prim) ----
60 let mut passages: Vec<(f64, usize, usize)> = Vec::new(); 166 let mut connected = vec![false; rooms.len()];
61 for y in (1..h).step_by(2) { 167 connected[0] = true;
62 for x in (1..w).step_by(2) { 168 let mut edges: Vec<(usize, usize, usize)> = Vec::new(); // (dist, from, to)
63 if cells[y * w + x] == Cell::Passage && !(y == 1 && x == 1) { 169
64 let dist = ((y as f64 - 1.0).powi(2) + (x as f64 - 1.0).powi(2)).sqrt(); 170 loop {
65 passages.push((dist, y, x)); 171 edges.clear();
172 for (i, ri) in rooms.iter().enumerate() {
173 if !connected[i] {
174 continue;
66 } 175 }
176 for (j, rj) in rooms.iter().enumerate() {
177 if connected[j] {
178 continue;
179 }
180 let dist = manhattan(
181 ri.cx as isize,
182 ri.cy as isize,
183 rj.cx as isize,
184 rj.cy as isize,
185 );
186 edges.push((dist as usize, i, j));
187 }
188 }
189
190 if edges.is_empty() {
191 break;
67 } 192 }
193
194 edges.sort_by_key(|e| e.0);
195 // Pick from the nearest few for variety.
196 let pick = rng.gen_range(0..(edges.len().min(3)));
197 let (_dist, from, to) = edges[pick];
198
199 carve_corridor(
200 &mut cells,
201 w,
202 rooms[from].cx,
203 rooms[from].cy,
204 rooms[to].cx,
205 rooms[to].cy,
206 rng,
207 );
208 connected[to] = true;
68 } 209 }
69 210
70 if !passages.is_empty() { 211 // ---- Phase 3: add a few extra connections for loops ----
71 passages.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap()); 212 let extra_connections = (rooms.len() / 5).max(1);
72 let far_count = (passages.len() / 4).max(1); 213 for _ in 0..extra_connections {
73 let idx = rng.gen_range(0..far_count); 214 let a = rng.gen_range(0..rooms.len());
74 let (_, ey, ex) = passages[idx]; 215 let b = rng.gen_range(0..rooms.len());
75 cells[ey * w + ex] = Cell::Exit; 216 if a != b {
217 let dist = manhattan(
218 rooms[a].cx as isize,
219 rooms[a].cy as isize,
220 rooms[b].cx as isize,
221 rooms[b].cy as isize,
222 );
223 // Only add short extra connections to avoid spaghetti.
224 if dist < (w as isize + h as isize) / 4 {
225 carve_corridor(
226 &mut cells,
227 w,
228 rooms[a].cx,
229 rooms[a].cy,
230 rooms[b].cx,
231 rooms[b].cy,
232 rng,
233 );
234 }
235 }
76 } 236 }
77 237
78 Self { 238 // ---- Phase 4: place exit in the room farthest from start ----
79 cells, 239 let start_room_idx = 0; // first room placed is near top-left
80 width: w, 240 let mut farthest = 0;
81 height: h, 241 let mut farthest_dist = 0.0f64;
242 for (i, room) in rooms.iter().enumerate() {
243 if i == start_room_idx {
244 continue;
245 }
246 let dx = room.cx as f64 - rooms[start_room_idx].cx as f64;
247 let dy = room.cy as f64 - rooms[start_room_idx].cy as f64;
248 let d = (dx * dx + dy * dy).sqrt();
249 if d > farthest_dist {
250 farthest_dist = d;
251 farthest = i;
252 }
82 } 253 }
254
255 let exit_room = &rooms[farthest];
256 let exit_x = exit_room.cx;
257 let exit_y = exit_room.cy;
258 cells[exit_y * w + exit_x] = Cell::Exit;
259
260 // Player starts in the first room.
261 let start_x = rooms[start_room_idx].cx as f64 + 0.5;
262 let start_y = rooms[start_room_idx].cy as f64 + 0.5;
263
264 // ---- Phase 5: place entities ----
265 let mut entities: Vec<Option<Entity>> = vec![None; w * h];
266 place_entities(
267 &cells,
268 &mut entities,
269 w,
270 h,
271 depth,
272 start_x as usize,
273 start_y as usize,
274 rng,
275 );
276
277 (
278 Self {
279 cells,
280 entities,
281 width: w,
282 height: h,
283 exit_x,
284 exit_y,
285 },
286 (start_x, start_y),
287 )
83 } 288 }
84 289
85 #[inline] 290 #[inline]
@@ -116,89 +321,873 @@ impl Maze {
116 pub fn height(&self) -> usize { 321 pub fn height(&self) -> usize {
117 self.height 322 self.height
118 } 323 }
324
325 // ---- entity accessors ----
326
327 /// Get the entity at grid coordinates, if any.
328 pub fn entity_at(&self, x: usize, y: usize) -> Option<&Entity> {
329 self.entities
330 .get(y * self.width + x)
331 .and_then(|e| e.as_ref())
332 }
333
334 /// Mutable reference to entity at grid coordinates.
335 pub fn entity_at_mut(&mut self, x: usize, y: usize) -> Option<&mut Entity> {
336 self.entities
337 .get_mut(y * self.width + x)
338 .and_then(|e| e.as_mut())
339 }
340
341 /// Remove and return the entity at grid coordinates.
342 pub fn take_entity(&mut self, x: usize, y: usize) -> Option<Entity> {
343 self.entities
344 .get_mut(y * self.width + x)
345 .and_then(|e| e.take())
346 }
347
348 /// Check if a cell is occupied by any entity.
349 #[allow(dead_code)]
350 pub fn is_occupied(&self, x: usize, y: usize) -> bool {
351 self.entity_at(x, y).is_some()
352 }
353
354 /// Check if the entity at a cell is a persistent hallucination.
355 #[allow(dead_code)]
356 pub fn is_persistent_hallucination(&self, x: usize, y: usize) -> bool {
357 matches!(
358 self.entity_at(x, y),
359 Some(Entity::Hallucination {
360 persistent: true,
361 ..
362 })
363 )
364 }
119} 365}
120 366
121// --------------- player --------------- 367// ---- Maze helpers ----
122 368
123pub struct Player { 369fn grid_size_for_depth(depth: u64) -> usize {
124 pub x: f64, 370 match depth {
125 pub y: f64, 371 1..=3 => 33,
126 pub angle: f64, // radians, 0 = +x (east), π/2 = +y (south) 372 4..=7 => 41,
373 8..=12 => 49,
374 _ => 57,
375 }
127} 376}
128 377
129impl Player { 378fn room_count_for_size(size: usize) -> usize {
130 pub fn new(x: f64, y: f64, angle: f64) -> Self { 379 match size {
131 Self { x, y, angle } 380 33 => 8,
381 41 => 12,
382 49 => 16,
383 _ => 20,
384 }
385}
386
387fn manhattan(x1: isize, y1: isize, x2: isize, y2: isize) -> isize {
388 (x1 - x2).abs() + (y1 - y2).abs()
389}
390
391/// Carve an L-shaped corridor between two points. Randomly picks
392/// horizontal-first or vertical-first, and widens to 2 cells occasionally.
393fn carve_corridor(
394 cells: &mut Vec<Cell>,
395 grid_w: usize,
396 x1: usize,
397 y1: usize,
398 x2: usize,
399 y2: usize,
400 rng: &mut impl Rng,
401) {
402 let cx = x1 as isize;
403 let cy = y1 as isize;
404 let tx = x2 as isize;
405 let ty = y2 as isize;
406
407 let wide = rng.gen_bool(0.3); // 30% chance of 2-wide corridor
408
409 if rng.gen_bool(0.5) {
410 // Horizontal, then vertical.
411 carve_line(cells, grid_w, cx, cy, tx, cy, wide);
412 carve_line(cells, grid_w, tx, cy, tx, ty, wide);
413 } else {
414 // Vertical, then horizontal.
415 carve_line(cells, grid_w, cx, cy, cx, ty, wide);
416 carve_line(cells, grid_w, cx, ty, tx, ty, wide);
132 } 417 }
418}
419
420/// Carve a straight line of passages from (x1,y1) to (x2,y2).
421/// If `wide` is true, also clears the adjacent parallel cell.
422fn carve_line(
423 cells: &mut Vec<Cell>,
424 grid_w: usize,
425 x1: isize,
426 y1: isize,
427 x2: isize,
428 y2: isize,
429 wide: bool,
430) {
431 let dx = (x2 - x1).signum();
432 let dy = (y2 - y1).signum();
433 let steps = (x2 - x1).abs().max((y2 - y1).abs());
434
435 let perp_dx = -dy; // perpendicular for widening
436 let perp_dy = dx;
437
438 let mut x = x1;
439 let mut y = y1;
133 440
134 pub fn rotate(&mut self, delta: f64) { 441 for _ in 0..=steps {
135 self.angle = (self.angle + delta).rem_euclid(2.0 * std::f64::consts::PI); 442 if x >= 0 && x < grid_w as isize && y >= 0 && y < grid_w as isize {
443 cells[y as usize * grid_w + x as usize] = Cell::Passage;
444 }
445 if wide {
446 let wx = x + perp_dx;
447 let wy = y + perp_dy;
448 if wx >= 0 && wx < grid_w as isize && wy >= 0 && wy < grid_w as isize {
449 cells[wy as usize * grid_w + wx as usize] = Cell::Passage;
450 }
451 }
452 x += dx;
453 y += dy;
136 } 454 }
455}
137 456
138 /// Apply a raw (dx, dy) displacement with wall-sliding collision. 457/// Place enemies, hallucinations, and pickups in random passage cells.
139 /// Checks are done per-axis so the player slides along walls naturally. 458fn place_entities(
140 pub fn try_move(&mut self, dx: f64, dy: f64, maze: &Maze) { 459 cells: &[Cell],
141 let m = 0.35; // collision margin — keeps the camera out of walls 460 entities: &mut [Option<Entity>],
461 w: usize,
462 h: usize,
463 depth: u64,
464 start_x: usize,
465 start_y: usize,
466 rng: &mut impl Rng,
467) {
468 // Collect eligible passage cells (not start, not exit).
469 let mut candidates: Vec<(usize, usize)> = Vec::new();
470 for y in 1..h - 1 {
471 for x in 1..w - 1 {
472 if cells[y * w + x] == Cell::Passage && !(x == start_x && y == start_y) {
473 candidates.push((x, y));
474 }
475 }
476 }
142 477
143 let nx = self.x + dx; 478 if candidates.is_empty() {
144 if maze.is_passage_at_float(nx + m, self.y + m) 479 return;
145 && maze.is_passage_at_float(nx + m, self.y - m) 480 }
146 && maze.is_passage_at_float(nx - m, self.y + m) 481
147 && maze.is_passage_at_float(nx - m, self.y - m) 482 // Shuffle for random placement.
148 { 483 shuffle_slice(&mut candidates, rng);
149 self.x = nx; 484 let mut next = 0usize;
485
486 // --- real enemies ---
487 let enemy_count = 3 + depth as usize * 2;
488 for _ in 0..enemy_count {
489 if next >= candidates.len() {
490 break;
150 } 491 }
492 let (x, y) = candidates[next];
493 next += 1;
151 494
152 let ny = self.y + dy; 495 let (hp_range, damage) = enemy_stats_for_depth(depth);
153 if maze.is_passage_at_float(self.x + m, ny + m) 496 let max_hp = rng.gen_range(hp_range.0..=hp_range.1);
154 && maze.is_passage_at_float(self.x - m, ny + m) 497 let dmg = if damage.0 == damage.1 {
155 && maze.is_passage_at_float(self.x + m, ny - m) 498 damage.0
156 && maze.is_passage_at_float(self.x - m, ny - m) 499 } else {
157 { 500 rng.gen_range(damage.0..=damage.1)
158 self.y = ny; 501 };
502
503 entities[y * w + x] = Some(Entity::Enemy {
504 hp: max_hp,
505 max_hp,
506 damage: dmg,
507 });
508 }
509
510 // --- hallucinations (1-2 per level) ---
511 let hall_count = rng.gen_range(1..=2);
512 for _ in 0..hall_count {
513 if next >= candidates.len() {
514 break;
515 }
516 let (x, y) = candidates[next];
517 next += 1;
518 entities[y * w + x] = Some(Entity::Hallucination {
519 persistent: false,
520 hp: 1,
521 });
522 }
523
524 // --- real pickups ---
525 let pickup_count = 2 + (depth as usize / 2);
526 for i in 0..pickup_count {
527 if next >= candidates.len() {
528 break;
529 }
530 let (x, y) = candidates[next];
531 next += 1;
532
533 let pt = if i == 0 && depth % 2 == 0 {
534 // Guaranteed weapon every even depth.
535 let base_dmg = 2 + depth as u32 / 3;
536 PickupType::Weapon {
537 base_damage: base_dmg,
538 }
539 } else {
540 random_pickup_type(rng, false)
541 };
542
543 entities[y * w + x] = Some(Entity::Pickup(pt));
544 }
545
546 // --- hallucinated pickups ---
547 let fake_count = if depth >= 3 { rng.gen_range(1..=2) } else { 0 };
548 for _ in 0..fake_count {
549 if next >= candidates.len() {
550 break;
551 }
552 let (x, y) = candidates[next];
553 next += 1;
554 let pt = random_pickup_type(rng, true);
555 entities[y * w + x] = Some(Entity::HallucinatedPickup(pt));
556 }
557}
558
559fn enemy_stats_for_depth(depth: u64) -> ((u32, u32), (u32, u32)) {
560 match depth {
561 1 => ((1, 1), (1, 1)),
562 2..=3 => ((2, 3), (1, 1)),
563 4..=6 => ((3, 5), (1, 2)),
564 7..=9 => ((5, 8), (2, 2)),
565 _ => ((8, 12), (2, 3)),
566 }
567}
568
569fn random_pickup_type(rng: &mut impl Rng, include_weapon: bool) -> PickupType {
570 let roll = rng.gen_range(0..100);
571 if include_weapon && roll < 15 {
572 PickupType::Weapon {
573 base_damage: rng.gen_range(2..=4),
159 } 574 }
575 } else if roll < 45 {
576 PickupType::Food
577 } else if roll < 75 {
578 PickupType::Pill
579 } else {
580 PickupType::Bandage
581 }
582}
583
584/// Fisher-Yates shuffle on a slice.
585fn shuffle_slice<T>(slice: &mut [T], rng: &mut impl Rng) {
586 for i in (1..slice.len()).rev() {
587 let j = rng.gen_range(0..=i);
588 slice.swap(i, j);
589 }
590}
591
592// --------------- player ---------------
593
594pub struct Player {
595 pub x: f64,
596 pub y: f64,
597 pub angle: f64, // radians, 0 = +x (east), π/2 = +y (south)
598}
599
600impl Player {
601 pub fn new(x: f64, y: f64, angle: f64) -> Self {
602 Self { x, y, angle }
160 } 603 }
161} 604}
162 605
163// --------------- top-level game --------------- 606// --------------- top-level game ---------------
164 607
608const MOVE_DURATION: f64 = 0.18; // seconds to walk one tile
609const TURN_DURATION: f64 = 0.12; // seconds for a 90° turn
610const ENEMY_ANIM_DURATION: f64 = 0.10; // seconds for enemy movement
611
612pub const MAX_HP: u32 = 5;
613pub const MAX_SANITY: u32 = 100;
614pub const MAX_WEAKNESS: u32 = 100;
615
165pub struct Game { 616pub struct Game {
166 pub maze: Maze, 617 pub maze: Maze,
167 pub player: Player, 618 pub player: Player,
168 pub wall_texture: Vec<u32>, 619 pub wall_texture: Vec<u32>,
169 pub floor_texture: Vec<u32>, 620 pub floor_texture: Vec<u32>,
621 pub exit_texture: Vec<u32>,
622
623 // ---- turn-based resources ----
624 pub hp: u32,
625 pub max_hp: u32,
626 pub sanity: u32, // 0..100
627 pub weakness: u32, // 0..100; max = HP drain
628
629 /// Whole turns elapsed (displayed in HUD as heartbeats).
630 pub heartbeat_count: u64,
631
632 // ---- state machine ----
633 pub state: GameState,
634
635 // ---- score tracking ----
636 pub kills: u64,
637 pub depth: u64,
638
639 // ---- weapon & inventory ----
640 pub weapon_base_damage: u32,
641 pub inventory: [Option<InventoryItem>; 4],
642 pub selected_slot: usize,
643
644 // ---- message log ----
645 pub message: String,
646 pub message_turns: u32,
647
648 // ---- animation: player movement ----
649 pub moving: bool,
650 move_start: (f64, f64),
651 move_target: (f64, f64),
652 move_t: f64, // 0.0 → 1.0
653
654 // ---- animation: player turn ----
655 pub turning: bool,
656 turn_start: f64,
657 turn_delta: f64, // signed radians to rotate
658 turn_t: f64,
659
660 // ---- animation: enemies ----
661 enemy_anim_t: f64,
662}
663
664fn smoothstep(t: f64) -> f64 {
665 t * t * (3.0 - 2.0 * t)
170} 666}
171 667
172impl Game { 668impl Game {
173 pub fn new() -> Self { 669 pub fn new() -> Self {
174 let mut rng = rand::thread_rng(); 670 let mut rng = rand::thread_rng();
175 let maze = Maze::generate(16, 16, &mut rng); // 33×33 grid 671 let depth = 1;
176 let player = Player::new(1.5, 1.5, 0.0); // start centre of (1,1), facing east 672 let (maze, (start_x, start_y)) = Maze::generate(depth, &mut rng);
673 let player = Player::new(start_x, start_y, 0.0); // facing east
177 let wall_texture = crate::texture::generate_wall_texture(&mut rng); 674 let wall_texture = crate::texture::generate_wall_texture(&mut rng);
178 let floor_texture = crate::texture::generate_floor_texture(&mut rng); 675 let floor_texture = crate::texture::generate_floor_texture(&mut rng);
676 let exit_texture = crate::texture::generate_exit_texture(&mut rng);
179 677
180 Self { 678 Self {
181 maze, 679 maze,
182 player, 680 player,
183 wall_texture, 681 wall_texture,
184 floor_texture, 682 floor_texture,
683 exit_texture,
684 hp: MAX_HP,
685 max_hp: MAX_HP,
686 sanity: MAX_SANITY,
687 weakness: 0,
688 heartbeat_count: 0,
689 state: GameState::WaitingForInput,
690 kills: 0,
691 depth: 1,
692 weapon_base_damage: 1, // fists
693 inventory: [None; 4],
694 selected_slot: 0,
695 message: String::new(),
696 message_turns: 0,
697 moving: false,
698 move_start: (0.0, 0.0),
699 move_target: (0.0, 0.0),
700 move_t: 0.0,
701 turning: false,
702 turn_start: 0.0,
703 turn_delta: 0.0,
704 turn_t: 0.0,
705 enemy_anim_t: 0.0,
185 } 706 }
186 } 707 }
187 708
188 pub fn rotate(&mut self, delta: f64) { 709 // ---- state transitions ------------------------------------------------
189 self.player.rotate(delta); 710
711 /// Returns true if the player may act right now.
712 pub fn can_act(&self) -> bool {
713 self.state == GameState::WaitingForInput && !self.turning
190 } 714 }
191 715
192 /// Apply forward + strafe as a single combined displacement so that 716 /// Advance animations by `dt` seconds. Called every frame.
193 /// diagonal wall-hugging is checked atomically (no two-step clip). 717 /// Returns true when the current animation phase finishes and we should
194 pub fn apply_movement(&mut self, forward: f64, strafe: f64) { 718 /// advance the state machine.
195 let a = self.player.angle; 719 pub fn update_animation(&mut self, dt: f64) -> bool {
196 let dx = a.cos() * forward + (a + std::f64::consts::FRAC_PI_2).cos() * strafe; 720 // Turn animation runs independently — turning never consumes a turn.
197 let dy = a.sin() * forward + (a + std::f64::consts::FRAC_PI_2).sin() * strafe; 721 if self.turning {
198 self.player.try_move(dx, dy, &self.maze); 722 self.turn_t += dt / TURN_DURATION;
723 if self.turn_t >= 1.0 {
724 self.player.angle =
725 (self.turn_start + self.turn_delta).rem_euclid(2.0 * std::f64::consts::PI);
726 self.turning = false;
727 } else {
728 let t = smoothstep(self.turn_t);
729 self.player.angle = self.turn_start + self.turn_delta * t;
730 }
731 }
732
733 match self.state {
734 GameState::AnimatingPlayer => {
735 let mut done = true;
736
737 // Animate tile-to-tile walk.
738 if self.moving {
739 self.move_t += dt / MOVE_DURATION;
740 if self.move_t >= 1.0 {
741 self.player.x = self.move_target.0;
742 self.player.y = self.move_target.1;
743 self.moving = false;
744 } else {
745 let t = smoothstep(self.move_t);
746 self.player.x =
747 self.move_start.0 + (self.move_target.0 - self.move_start.0) * t;
748 self.player.y =
749 self.move_start.1 + (self.move_target.1 - self.move_start.1) * t;
750 done = false;
751 }
752 }
753
754 if done {
755 self.state = GameState::ResolvingTurn;
756 self.resolve_turn();
757 self.state = GameState::WaitingForInput;
758 return true;
759 }
760 false
761 }
762
763 GameState::AnimatingEnemies => {
764 self.enemy_anim_t += dt;
765 if self.enemy_anim_t >= ENEMY_ANIM_DURATION {
766 self.enemy_anim_t = 0.0;
767 self.state = GameState::ResolvingTurn;
768 self.resolve_turn();
769 self.state = GameState::WaitingForInput;
770 return true;
771 }
772 false
773 }
774
775 _ => false,
776 }
777 }
778
779 /// Attempt to walk one tile in grid direction (dx, dy).
780 /// Handles entity interactions (enemies block, hallucinations vanish, pickups collect).
781 fn try_move_tile(&mut self, dx: isize, dy: isize) -> bool {
782 if !self.can_act() {
783 return false;
784 }
785 let cx = self.player.x.floor() as isize;
786 let cy = self.player.y.floor() as isize;
787 let tx = (cx + dx) as usize;
788 let ty = (cy + dy) as usize;
789
790 // The target cell must be a passage.
791 if !self
792 .maze
793 .is_passage_at_float(tx as f64 + 0.5, ty as f64 + 0.5)
794 {
795 return false;
796 }
797
798 // Check what's in the target cell.
799 match self.maze.entity_at(tx, ty).cloned() {
800 Some(Entity::Enemy { damage, .. }) => {
801 // Blocked by real enemy — waste the turn, take damage.
802 self.set_message("Something is blocking your way.");
803 self.hp = self.hp.saturating_sub(damage);
804 self.advance_turn();
805 if self.hp == 0 {
806 self.state = GameState::GameOver;
807 }
808 return true; // action consumed
809 }
810 Some(Entity::Hallucination {
811 persistent: true, ..
812 }) => {
813 // Persistent hallucination blocks movement.
814 self.set_message("You are frightened to death just by thinking about doing this.");
815 self.advance_turn();
816 return true;
817 }
818 Some(Entity::Hallucination {
819 persistent: false, ..
820 }) => {
821 // Non-persistent hallucination vanishes harmlessly.
822 self.maze.take_entity(tx, ty);
823 // Fall through to normal movement.
824 }
825 Some(Entity::Pickup(pt)) => {
826 // Collect the pickup, then move.
827 self.collect_pickup(pt, tx, ty);
828 // Fall through to normal movement.
829 }
830 Some(Entity::HallucinatedPickup(pt)) => {
831 // Collect the fake pickup.
832 self.collect_hallucinated_pickup(pt, tx, ty);
833 // Fall through to normal movement.
834 }
835 None => {
836 // Empty cell — normal movement.
837 }
838 }
839
840 // Normal tile movement.
841 self.move_start = (self.player.x, self.player.y);
842 self.move_target = (tx as f64 + 0.5, ty as f64 + 0.5);
843 self.moving = true;
844 self.move_t = 0.0;
845 self.state = GameState::AnimatingPlayer;
846 true
847 }
848
849 // ---- movement wrappers ----
850
851 pub fn move_forward(&mut self) -> bool {
852 let (dx, dy) = self.facing_delta();
853 self.try_move_tile(dx, dy)
854 }
855
856 pub fn move_backward(&mut self) -> bool {
857 let (dx, dy) = self.facing_delta();
858 self.try_move_tile(-dx, -dy)
859 }
860
861 pub fn strafe_left(&mut self) -> bool {
862 let (dx, dy) = self.facing_delta();
863 self.try_move_tile(dy, -dx)
864 }
865
866 pub fn strafe_right(&mut self) -> bool {
867 let (dx, dy) = self.facing_delta();
868 self.try_move_tile(-dy, dx)
869 }
870
871 pub fn turn_left(&mut self) {
872 if !self.can_act() {
873 return;
874 }
875 self.turn_start = self.player.angle;
876 self.turn_delta = -std::f64::consts::FRAC_PI_2;
877 self.turn_t = 0.0;
878 self.turning = true;
879 // Turning is free — no state change, no turn consumed.
880 }
881
882 pub fn turn_right(&mut self) {
883 if !self.can_act() {
884 return;
885 }
886 self.turn_start = self.player.angle;
887 self.turn_delta = std::f64::consts::FRAC_PI_2;
888 self.turn_t = 0.0;
889 self.turning = true;
890 // Turning is free — no state change, no turn consumed.
891 }
892
893 // ---- attack -----------------------------------------------------------
894
895 /// Roll bonus damage: 30% chance for +1, repeat on success.
896 fn roll_bonus_damage(&self, rng: &mut impl Rng) -> u32 {
897 let mut bonus = 0u32;
898 while rng.gen_bool(0.30) {
899 bonus += 1;
900 }
901 bonus
199 } 902 }
200 903
904 /// Attack the cell directly ahead.
905 pub fn attack(&mut self) -> bool {
906 if !self.can_act() {
907 return false;
908 }
909
910 let (fx, fy) = self.facing_cell();
911 let fx = fx as usize;
912 let fy = fy as usize;
913
914 let entity = self.maze.entity_at(fx, fy).cloned();
915
916 match entity {
917 Some(Entity::Enemy { hp, damage, .. }) => {
918 let mut rng = rand::thread_rng();
919 let bonus = self.roll_bonus_damage(&mut rng);
920 let total_dmg = self.weapon_base_damage + bonus;
921 let new_hp = hp.saturating_sub(total_dmg);
922
923 if new_hp == 0 {
924 // Enemy dies.
925 self.maze.take_entity(fx, fy);
926 self.kills += 1;
927 self.set_message(&format!("You slay the enemy! ({} dmg)", total_dmg));
928 } else {
929 // Enemy survives and retaliates.
930 self.maze.entity_at_mut(fx, fy).map(|e| {
931 if let Entity::Enemy { hp: ref mut h, .. } = e {
932 *h = new_hp;
933 }
934 });
935 self.hp = self.hp.saturating_sub(damage);
936 self.set_message(&format!(
937 "You hit the enemy for {} dmg. It strikes back!",
938 total_dmg
939 ));
940 if self.hp == 0 {
941 self.state = GameState::GameOver;
942 }
943 }
944 self.advance_turn();
945 true
946 }
947
948 Some(Entity::Hallucination { persistent, hp }) => {
949 self.sanity = self.sanity.saturating_sub(10);
950 let new_hp = hp.saturating_sub(1);
951
952 if !persistent || new_hp == 0 {
953 // Vanishes.
954 self.maze.take_entity(fx, fy);
955 self.set_message("The hallucination dissipates.");
956 } else {
957 // Persistent — doesn't vanish yet.
958 self.maze.entity_at_mut(fx, fy).map(|e| {
959 if let Entity::Hallucination { hp: ref mut h, .. } = e {
960 *h = new_hp;
961 }
962 });
963 self.set_message("You strike the apparition - it remains.");
964 }
965 self.advance_turn();
966 true
967 }
968
969 _ => {
970 self.set_message("You swing at the air.");
971 self.advance_turn();
972 true
973 }
974 }
975 }
976
977 // ---- inventory --------------------------------------------------------
978
979 /// Use the currently selected inventory item (Z key).
980 pub fn use_item(&mut self) {
981 if !self.can_act() {
982 return;
983 }
984 let item = match self.inventory[self.selected_slot] {
985 Some(item) => item,
986 None => {
987 self.set_message("Nothing in that slot.");
988 return;
989 }
990 };
991
992 if item.is_hallucination {
993 self.use_hallucinated_item();
994 return;
995 }
996
997 match item.pickup {
998 PickupType::Food => {
999 self.weakness = self.weakness.saturating_sub(20);
1000 self.set_message("You eat the food. Weakness fades.");
1001 }
1002 PickupType::Pill => {
1003 self.weakness = self.weakness.saturating_sub(15);
1004 self.sanity = (self.sanity + 20).min(MAX_SANITY);
1005 self.set_message("The pill clears your mind and body.");
1006 }
1007 PickupType::Bandage => {
1008 self.weakness = self.weakness.saturating_sub(15);
1009 if self.hp < self.max_hp {
1010 self.hp += 1;
1011 self.set_message("You bandage your wounds. HP restored.");
1012 } else {
1013 self.set_message("You bandage up. HP is already full.");
1014 }
1015 }
1016 PickupType::Weapon { .. } => {
1017 self.set_message("You can't use a weapon from inventory.");
1018 return;
1019 }
1020 }
1021
1022 self.inventory[self.selected_slot] = None;
1023 self.advance_turn();
1024 }
1025
1026 /// Use a hallucinated pickup — no effect, costs sanity.
1027 fn use_hallucinated_item(&mut self) {
1028 self.sanity = self.sanity.saturating_sub(5);
1029 self.set_message("It was never there...");
1030 self.inventory[self.selected_slot] = None;
1031 self.advance_turn();
1032 }
1033
1034 /// Discard the currently selected inventory item (X key).
1035 pub fn discard_item(&mut self) {
1036 if !self.can_act() {
1037 return;
1038 }
1039 if self.inventory[self.selected_slot].is_some() {
1040 self.inventory[self.selected_slot] = None;
1041 self.set_message("Item discarded.");
1042 // Discarding does NOT consume a turn.
1043 }
1044 }
1045
1046 /// Select an inventory slot (1-4 keys).
1047 pub fn select_slot(&mut self, slot: usize) {
1048 if slot < 4 {
1049 self.selected_slot = slot;
1050 }
1051 }
1052
1053 /// Collect a real pickup into inventory.
1054 fn collect_pickup(&mut self, pt: PickupType, x: usize, y: usize) {
1055 self.maze.take_entity(x, y);
1056
1057 match pt {
1058 PickupType::Weapon { base_damage } => {
1059 if base_damage > self.weapon_base_damage {
1060 self.weapon_base_damage = base_damage;
1061 self.set_message(&format!("Equipped weapon! Base damage: {}", base_damage));
1062 } else {
1063 self.set_message("This weapon is no better than what you have.");
1064 }
1065 }
1066 _ => {
1067 let item = InventoryItem {
1068 pickup: pt,
1069 is_hallucination: false,
1070 };
1071 if let Some(slot) = self.inventory.iter_mut().find(|s| s.is_none()) {
1072 *slot = Some(item);
1073 self.set_message(match pt {
1074 PickupType::Food => "Picked up food.",
1075 PickupType::Pill => "Picked up pill.",
1076 PickupType::Bandage => "Picked up bandage.",
1077 _ => "Picked up item.",
1078 });
1079 } else {
1080 self.set_message("Inventory full.");
1081 }
1082 }
1083 }
1084 }
1085
1086 /// Collect a hallucinated pickup.
1087 fn collect_hallucinated_pickup(&mut self, pt: PickupType, x: usize, y: usize) {
1088 self.maze.take_entity(x, y);
1089 let item = InventoryItem {
1090 pickup: pt,
1091 is_hallucination: true,
1092 };
1093 if let Some(slot) = self.inventory.iter_mut().find(|s| s.is_none()) {
1094 *slot = Some(item);
1095 }
1096 self.set_message("Picked up item.");
1097 }
1098
1099 // ---- turn resolution --------------------------------------------------
1100
1101 /// Advance the turn counter and state without animation.
1102 fn advance_turn(&mut self) {
1103 self.state = GameState::ResolvingTurn;
1104 self.resolve_turn();
1105 self.state = GameState::WaitingForInput;
1106 }
1107
1108 /// Apply all per-turn effects (weakness, heartbeat, HP drain, message decay).
1109 fn resolve_turn(&mut self) {
1110 self.weakness = (self.weakness + 1).min(MAX_WEAKNESS);
1111 self.heartbeat_count += 1;
1112
1113 // Message decay.
1114 if self.message_turns > 0 {
1115 self.message_turns -= 1;
1116 if self.message_turns == 0 {
1117 self.message.clear();
1118 }
1119 }
1120
1121 // HP drain at max weakness.
1122 if self.weakness >= MAX_WEAKNESS {
1123 if self.hp > 0 {
1124 self.hp -= 1;
1125 }
1126 if self.hp == 0 {
1127 self.state = GameState::GameOver;
1128 }
1129 }
1130 }
1131
1132 /// Skip the player's turn (C key). Enemies still act.
1133 pub fn skip_turn(&mut self) {
1134 if !self.can_act() {
1135 return;
1136 }
1137 self.state = GameState::AnimatingEnemies;
1138 self.enemy_anim_t = 0.0;
1139 // TODO Phase 7: enemy movement happens here
1140 }
1141
1142 /// Set a HUD message that persists for 3 turns.
1143 pub fn set_message(&mut self, msg: &str) {
1144 self.message = msg.to_string();
1145 self.message_turns = 3;
1146 }
1147
1148 // ---- cardinal-direction helpers ----
1149
1150 /// Which grid direction the player faces: (dx, dy) ∈ {±1, 0}.
1151 fn facing_delta(&self) -> (isize, isize) {
1152 let q = (self.player.angle / std::f64::consts::FRAC_PI_2).round() as i32 & 3;
1153 match q {
1154 0 => (1, 0), // east
1155 1 => (0, 1), // south
1156 2 => (-1, 0), // west
1157 _ => (0, -1), // north
1158 }
1159 }
1160
1161 /// The cell coordinate directly ahead of the player.
1162 pub fn facing_cell(&self) -> (isize, isize) {
1163 let (dx, dy) = self.facing_delta();
1164 let cx = self.player.x.floor() as isize;
1165 let cy = self.player.y.floor() as isize;
1166 (cx + dx, cy + dy)
1167 }
1168
1169 // ---- misc -------------------------------------------------------------
1170
201 pub fn check_win(&self) -> bool { 1171 pub fn check_win(&self) -> bool {
202 self.maze.is_exit(self.player.x, self.player.y) 1172 self.maze.is_exit(self.player.x, self.player.y)
203 } 1173 }
1174
1175 /// Whole heartbeats elapsed (displayed in HUD).
1176 pub fn heartbeats(&self) -> usize {
1177 self.heartbeat_count as usize
1178 }
1179
1180 /// Vertical camera bob (pixels) during tile-walk animation.
1181 pub fn camera_bob(&self) -> f64 {
1182 if self.moving {
1183 (self.move_t * 2.0 * std::f64::consts::PI).sin() * 3.0
1184 } else {
1185 0.0
1186 }
1187 }
1188
1189 /// Weakness as a 0..2 factor for the renderer's darkness formula.
1190 pub fn weakness_factor(&self) -> f64 {
1191 (self.weakness as f64 / MAX_WEAKNESS as f64) * 2.0
1192 }
204} 1193}
diff --git a/src/main.rs b/src/main.rs
index d51fdfd..391bb90 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2,7 +2,7 @@ mod game;
2mod renderer; 2mod renderer;
3mod texture; 3mod texture;
4 4
5use game::Game; 5use game::{Game, GameState};
6use minifb::{Key, Window, WindowOptions}; 6use minifb::{Key, Window, WindowOptions};
7use renderer::Renderer; 7use renderer::Renderer;
8use std::time::Instant; 8use std::time::Instant;
@@ -11,19 +11,22 @@ const SCREEN_W: usize = 640;
11const SCREEN_H: usize = 480; 11const SCREEN_H: usize = 480;
12 12
13fn main() { 13fn main() {
14 println!("=== DOOM-LIKE MAZE ==="); 14 println!("=== DUNGEON CRAWLER ===");
15 println!("Find the exit!"); 15 println!(" W / S — forward / backward");
16 println!(" W / Up — forward"); 16 println!(" A / D — strafe left / right");
17 println!(" S / Down — backward"); 17 println!(" Q / E — turn 90° left / right");
18 println!(" A / D — strafe left / right"); 18 println!(" Space — attack");
19 println!(" ← / → — turn"); 19 println!(" Z — use selected inventory item");
20 println!(" Esc — quit\n"); 20 println!(" X — discard selected inventory item");
21 println!(" 1-4 — select inventory slot");
22 println!(" C — skip turn");
23 println!(" Esc — quit\n");
21 24
22 let mut game = Game::new(); 25 let mut game = Game::new();
23 let renderer = Renderer::new(SCREEN_W, SCREEN_H); 26 let renderer = Renderer::new(SCREEN_W, SCREEN_H);
24 27
25 let mut window = Window::new( 28 let mut window = Window::new(
26 "Maze — Find the Exit!", 29 "Dungeon Crawler",
27 SCREEN_W, 30 SCREEN_W,
28 SCREEN_H, 31 SCREEN_H,
29 WindowOptions::default(), 32 WindowOptions::default(),
@@ -34,45 +37,109 @@ fn main() {
34 let mut last = Instant::now(); 37 let mut last = Instant::now();
35 let frame_budget = std::time::Duration::from_micros(16600); // ~60 fps 38 let frame_budget = std::time::Duration::from_micros(16600); // ~60 fps
36 39
40 // Track previous key state for edge-triggered actions.
41 let mut prev_w = false;
42 let mut prev_s = false;
43 let mut prev_a = false;
44 let mut prev_d = false;
45 let mut prev_q = false;
46 let mut prev_e = false;
47 let mut prev_space = false;
48 let mut prev_z = false;
49 let mut prev_x = false;
50 let mut prev_c = false;
51 let mut prev_1 = false;
52 let mut prev_2 = false;
53 let mut prev_3 = false;
54 let mut prev_4 = false;
55
37 while window.is_open() && !window.is_key_down(Key::Escape) { 56 while window.is_open() && !window.is_key_down(Key::Escape) {
38 let frame_start = Instant::now(); 57 let frame_start = Instant::now();
39 let dt = (frame_start - last).as_secs_f64().min(0.1); 58 let dt = (frame_start - last).as_secs_f64().min(0.1);
40 last = frame_start; 59 last = frame_start;
41 60
42 // ---- input ---- 61 // ---- read current key states ----
43 let move_speed = 2.8; 62 let cur_w = window.is_key_down(Key::W);
44 let turn_speed = 2.8; 63 let cur_s = window.is_key_down(Key::S);
45 let strafe_speed = 2.2; 64 let cur_a = window.is_key_down(Key::A);
65 let cur_d = window.is_key_down(Key::D);
66 let cur_q = window.is_key_down(Key::Q);
67 let cur_e = window.is_key_down(Key::E);
68 let cur_space = window.is_key_down(Key::Space);
69 let cur_z = window.is_key_down(Key::Z);
70 let cur_x = window.is_key_down(Key::X);
71 let cur_c = window.is_key_down(Key::C);
72 let cur_1 = window.is_key_down(Key::Key1);
73 let cur_2 = window.is_key_down(Key::Key2);
74 let cur_3 = window.is_key_down(Key::Key3);
75 let cur_4 = window.is_key_down(Key::Key4);
46 76
47 if window.is_key_down(Key::Left) { 77 // ---- input (edge-triggered, only in WaitingForInput) ----
48 game.rotate(-turn_speed * dt); 78 if game.can_act() {
79 if cur_w && !prev_w {
80 game.move_forward();
81 } else if cur_s && !prev_s {
82 game.move_backward();
83 } else if cur_a && !prev_a {
84 game.strafe_left();
85 } else if cur_d && !prev_d {
86 game.strafe_right();
87 } else if cur_q && !prev_q {
88 game.turn_left();
89 } else if cur_e && !prev_e {
90 game.turn_right();
91 } else if cur_space && !prev_space {
92 game.attack();
93 } else if cur_z && !prev_z {
94 game.use_item();
95 } else if cur_x && !prev_x {
96 game.discard_item();
97 } else if cur_c && !prev_c {
98 game.skip_turn();
99 } else if cur_1 && !prev_1 {
100 game.select_slot(0);
101 } else if cur_2 && !prev_2 {
102 game.select_slot(1);
103 } else if cur_3 && !prev_3 {
104 game.select_slot(2);
105 } else if cur_4 && !prev_4 {
106 game.select_slot(3);
107 }
49 } 108 }
50 if window.is_key_down(Key::Right) { 109
51 game.rotate(turn_speed * dt); 110 // Update previous states.
111 prev_w = cur_w;
112 prev_s = cur_s;
113 prev_a = cur_a;
114 prev_d = cur_d;
115 prev_q = cur_q;
116 prev_e = cur_e;
117 prev_space = cur_space;
118 prev_z = cur_z;
119 prev_x = cur_x;
120 prev_c = cur_c;
121 prev_1 = cur_1;
122 prev_2 = cur_2;
123 prev_3 = cur_3;
124 prev_4 = cur_4;
125
126 // ---- advance animations / state machine ----
127 game.update_animation(dt);
128
129 // ---- death check ----
130 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);
52 } 137 }
53 // Combine forward + strafe into one displacement (prevents two-step 138
54 // wall clipping when pressing W+D / W+A near a wall). 139 // ---- win check (placeholder — becomes staircase in Phase 1) ----
55 let fwd = if window.is_key_down(Key::W) || window.is_key_down(Key::Up) {
56 move_speed * dt
57 } else if window.is_key_down(Key::S) || window.is_key_down(Key::Down) {
58 -move_speed * dt
59 } else {
60 0.0
61 };
62 let strf = if window.is_key_down(Key::D) {
63 strafe_speed * dt
64 } else if window.is_key_down(Key::A) {
65 -strafe_speed * dt
66 } else {
67 0.0
68 };
69 game.apply_movement(fwd, strf);
70
71 // ---- win condition ----
72 if game.check_win() { 140 if game.check_win() {
73 println!("🎉 You found the exit! Generating a new maze…"); 141 println!("You found the exit in {} heartbeats!", game.heartbeats());
74 game = Game::new(); 142 std::process::exit(0);
75 last = Instant::now(); // avoid huge dt spike
76 } 143 }
77 144
78 // ---- render ---- 145 // ---- render ----
diff --git a/src/renderer.rs b/src/renderer.rs
index bce3391..eec89b2 100644
--- a/src/renderer.rs
+++ b/src/renderer.rs
@@ -1,44 +1,104 @@
1use crate::game::Game; 1use crate::game::{Entity, Game, PickupType};
2use crate::texture::TEX_SIZE; 2use crate::texture::TEX_SIZE;
3 3
4// ---- simple 5×5 ASCII glyphs ----
5// ---- simple 5×5 ASCII glyphs for letters & symbols (incomplete — add as needed) ----
6
7const LETTERS: &[(&str, [u8; 5])] = &[
8 // Uppercase A-Z
9 ("A", [0b01110, 0b10001, 0b11111, 0b10001, 0b10001]),
10 ("B", [0b11110, 0b10001, 0b11110, 0b10001, 0b11110]),
11 ("C", [0b01110, 0b10001, 0b10000, 0b10001, 0b01110]),
12 ("D", [0b11110, 0b10001, 0b10001, 0b10001, 0b11110]),
13 ("E", [0b11111, 0b10000, 0b11110, 0b10000, 0b11111]),
14 ("F", [0b11111, 0b10000, 0b11110, 0b10000, 0b10000]),
15 ("G", [0b01110, 0b10000, 0b10111, 0b10001, 0b01110]),
16 ("H", [0b10001, 0b10001, 0b11111, 0b10001, 0b10001]),
17 ("I", [0b01110, 0b00100, 0b00100, 0b00100, 0b01110]),
18 ("J", [0b00011, 0b00001, 0b00001, 0b10001, 0b01110]),
19 ("K", [0b10001, 0b10010, 0b11100, 0b10010, 0b10001]),
20 ("L", [0b10000, 0b10000, 0b10000, 0b10000, 0b11111]),
21 ("M", [0b10001, 0b11011, 0b10101, 0b10001, 0b10001]),
22 ("N", [0b10001, 0b11001, 0b10101, 0b10011, 0b10001]),
23 ("O", [0b01110, 0b10001, 0b10001, 0b10001, 0b01110]),
24 ("P", [0b11110, 0b10001, 0b11110, 0b10000, 0b10000]),
25 ("Q", [0b01110, 0b10001, 0b10001, 0b10010, 0b01101]),
26 ("R", [0b11110, 0b10001, 0b11110, 0b10010, 0b10001]),
27 ("S", [0b01110, 0b10000, 0b01110, 0b00001, 0b11110]),
28 ("T", [0b11111, 0b00100, 0b00100, 0b00100, 0b00100]),
29 ("U", [0b10001, 0b10001, 0b10001, 0b10001, 0b01110]),
30 ("V", [0b10001, 0b10001, 0b10001, 0b01010, 0b00100]),
31 ("W", [0b10001, 0b10001, 0b10101, 0b10101, 0b01010]),
32 ("X", [0b10001, 0b01010, 0b00100, 0b01010, 0b10001]),
33 ("Y", [0b10001, 0b01010, 0b00100, 0b00100, 0b00100]),
34 ("Z", [0b11111, 0b00010, 0b00100, 0b01000, 0b11111]),
35 // Digits
36 ("0", [0b01110, 0b10011, 0b10101, 0b11001, 0b01110]),
37 ("1", [0b00100, 0b01100, 0b00100, 0b00100, 0b01110]),
38 ("2", [0b01110, 0b10001, 0b00010, 0b00100, 0b11111]),
39 ("3", [0b01110, 0b10001, 0b00110, 0b10001, 0b01110]),
40 ("4", [0b00010, 0b00110, 0b01010, 0b11111, 0b00010]),
41 ("5", [0b11111, 0b10000, 0b11110, 0b10001, 0b01110]),
42 ("6", [0b01110, 0b10000, 0b11110, 0b10001, 0b01110]),
43 ("7", [0b11111, 0b00001, 0b00010, 0b00100, 0b00100]),
44 ("8", [0b01110, 0b10001, 0b01110, 0b10001, 0b01110]),
45 ("9", [0b01110, 0b10001, 0b01111, 0b00001, 0b01110]),
46 // Punctuation & symbols
47 ("!", [0b00100, 0b00100, 0b00100, 0b00000, 0b00100]),
48 ("@", [0b01110, 0b10001, 0b10111, 0b10000, 0b01110]),
49 ("+", [0b00000, 0b00100, 0b01110, 0b00100, 0b00000]),
50 (":", [0b00000, 0b00100, 0b00000, 0b00100, 0b00000]),
51 ("%", [0b10001, 0b00010, 0b00100, 0b01000, 0b10001]),
52 ("/", [0b00001, 0b00010, 0b00100, 0b01000, 0b10000]),
53 (".", [0b00000, 0b00000, 0b00000, 0b00000, 0b00100]),
54 (",", [0b00000, 0b00000, 0b00000, 0b00100, 0b01000]),
55 ("(", [0b00010, 0b00100, 0b00100, 0b00100, 0b00010]),
56 (")", [0b01000, 0b00100, 0b00100, 0b00100, 0b01000]),
57 ("-", [0b00000, 0b00000, 0b01110, 0b00000, 0b00000]),
58 ("'", [0b00100, 0b00000, 0b00000, 0b00000, 0b00000]),
59 // Unicode symbols
60 ("♥", [0b01010, 0b11111, 0b11111, 0b01110, 0b00100]),
61 ("♦", [0b00100, 0b01110, 0b11111, 0b01110, 0b00100]),
62 ("⌂", [0b01110, 0b10001, 0b10101, 0b10001, 0b11111]),
63 ("[", [0b01110, 0b01000, 0b01000, 0b01000, 0b01110]),
64 ("]", [0b01110, 0b00010, 0b00010, 0b00010, 0b01110]),
65 (" ", [0b00000; 5]),
66];
67
68const LETTER_W: usize = 5;
69const LETTER_H: usize = 5;
70
4pub struct Renderer { 71pub struct Renderer {
5 screen_w: usize, 72 screen_w: usize,
6 screen_h: usize, 73 screen_h: usize,
7 /// Precomputed: floor_dist_lut[row] = perpendicular distance to floor at that row.
8 floor_dist_lut: Vec<f64>, 74 floor_dist_lut: Vec<f64>,
9 /// Precomputed: floor_bright_lut[row] = brightness × 256 for that row (fixed-point).
10 floor_bright_lut: Vec<u32>,
11} 75}
12 76
13impl Renderer { 77impl Renderer {
14 pub fn new(screen_w: usize, screen_h: usize) -> Self { 78 pub fn new(screen_w: usize, screen_h: usize) -> Self {
15 let half_h = screen_h as f64 / 2.0; 79 let half_h = screen_h as f64 / 2.0;
16 let mut floor_dist_lut = vec![0.0f64; screen_h]; 80 let mut floor_dist_lut = vec![0.0f64; screen_h];
17 let mut floor_bright_lut = vec![0u32; screen_h];
18 81
19 // Only rows below the horizon have meaningful floor values.
20 for py in (screen_h / 2 + 1)..screen_h { 82 for py in (screen_h / 2 + 1)..screen_h {
21 let dy = py as f64 - half_h; 83 let dy = py as f64 - half_h;
22 let dist = 0.5 * screen_h as f64 / dy; 84 floor_dist_lut[py] = 0.5 * screen_h as f64 / dy;
23 floor_dist_lut[py] = dist;
24 let bright = (1.0 / (1.0 + dist * 0.15)).max(0.05);
25 floor_bright_lut[py] = (bright * 256.0) as u32;
26 } 85 }
27 86
28 Self { 87 Self {
29 screen_w, 88 screen_w,
30 screen_h, 89 screen_h,
31 floor_dist_lut, 90 floor_dist_lut,
32 floor_bright_lut,
33 } 91 }
34 } 92 }
35 93
36 /// Produce one frame into `buffer` (packed `0xAARRGGBB`).
37 pub fn render(&self, game: &Game, buffer: &mut [u32]) { 94 pub fn render(&self, game: &Game, buffer: &mut [u32]) {
38 buffer.fill(0xFF00_0000); // pure-black ceiling 95 buffer.fill(0xFF00_0000);
39 96
40 let half_h = self.screen_h as f64 / 2.0; 97 let half_h = self.screen_h as f64 / 2.0;
41 let fov = std::f64::consts::PI / 3.0; // 60° 98 let fov = std::f64::consts::PI / 3.0;
99
100 // Per-column wall distances for entity occlusion.
101 let mut wall_dists = vec![f64::MAX; self.screen_w];
42 102
43 for sx in 0..self.screen_w { 103 for sx in 0..self.screen_w {
44 let cam_x = 2.0 * sx as f64 / self.screen_w as f64 - 1.0; 104 let cam_x = 2.0 * sx as f64 / self.screen_w as f64 - 1.0;
@@ -46,15 +106,10 @@ impl Renderer {
46 let rdx = ray_angle.cos(); 106 let rdx = ray_angle.cos();
47 let rdy = ray_angle.sin(); 107 let rdy = ray_angle.sin();
48 108
49 // Floor uses perpendicular distance from the LUT, but needs
50 // Euclidean distance for correct world-position projection.
51 // Scale by 1/cos(angle_diff) so that rdx_floor / rdy_floor
52 // map floor_dist → Euclidean travel along the ray.
53 let cos_diff = (ray_angle - game.player.angle).cos().abs().max(0.001); 109 let cos_diff = (ray_angle - game.player.angle).cos().abs().max(0.001);
54 let rdx_floor = rdx / cos_diff; 110 let rdx_floor = rdx / cos_diff;
55 let rdy_floor = rdy / cos_diff; 111 let rdy_floor = rdy / cos_diff;
56 112
57 // ---- DDA setup -------------------------------------------------
58 let mut map_x = game.player.x as isize; 113 let mut map_x = game.player.x as isize;
59 let mut map_y = game.player.y as isize; 114 let mut map_y = game.player.y as isize;
60 115
@@ -84,7 +139,6 @@ impl Renderer {
84 let mut hit_side: bool; 139 let mut hit_side: bool;
85 let euclidean: f64; 140 let euclidean: f64;
86 141
87 // ---- DDA loop --------------------------------------------------
88 loop { 142 loop {
89 if side_x < side_y { 143 if side_x < side_y {
90 side_x += ddx; 144 side_x += ddx;
@@ -115,16 +169,14 @@ impl Renderer {
115 } 169 }
116 } 170 }
117 171
118 // Fisheye correction
119 let perp_dist = (euclidean * (ray_angle - game.player.angle).cos().abs()).max(0.001); 172 let perp_dist = (euclidean * (ray_angle - game.player.angle).cos().abs()).max(0.001);
173 wall_dists[sx] = perp_dist;
120 174
121 // ---- wall slice ------------------------------------------------
122 let wall_h = (self.screen_h as f64 / perp_dist) as isize; 175 let wall_h = (self.screen_h as f64 / perp_dist) as isize;
123 let draw_start = ((-wall_h / 2) + self.screen_h as isize / 2).max(0) as usize; 176 let draw_start = ((-wall_h / 2) + self.screen_h as isize / 2).max(0) as usize;
124 let draw_end = ((wall_h / 2) + self.screen_h as isize / 2) 177 let draw_end = ((wall_h / 2) + self.screen_h as isize / 2)
125 .min(self.screen_h as isize - 1) as usize; 178 .min(self.screen_h as isize - 1) as usize;
126 179
127 // Texture x-coordinate (uses Euclidean, not perp)
128 let wall_x = if !hit_side { 180 let wall_x = if !hit_side {
129 game.player.y + euclidean * rdy 181 game.player.y + euclidean * rdy
130 } else { 182 } else {
@@ -133,9 +185,10 @@ impl Renderer {
133 let wall_x = wall_x - wall_x.floor(); 185 let wall_x = wall_x - wall_x.floor();
134 let tex_x = ((wall_x * TEX_SIZE as f64) as usize).min(TEX_SIZE - 1); 186 let tex_x = ((wall_x * TEX_SIZE as f64) as usize).min(TEX_SIZE - 1);
135 187
136 // Hoist: brightness is identical for every pixel in this column
137 let wall_bright_fx = { 188 let wall_bright_fx = {
138 let b = (1.0 / (1.0 + perp_dist * 0.12)).max(0.08); 189 let w = game.weakness_factor();
190 let darken_scale = 1.0 + w * 12.0;
191 let b = 1.0 / (1.0 + perp_dist * 0.25 * darken_scale);
139 (b * 256.0) as u32 192 (b * 256.0) as u32
140 }; 193 };
141 194
@@ -143,39 +196,322 @@ impl Renderer {
143 let tex_y = ((py as f64 - half_h + wall_h as f64 / 2.0) * TEX_SIZE as f64 196 let tex_y = ((py as f64 - half_h + wall_h as f64 / 2.0) * TEX_SIZE as f64
144 / wall_h as f64) as usize; 197 / wall_h as f64) as usize;
145 let tex_y = tex_y.min(TEX_SIZE - 1); 198 let tex_y = tex_y.min(TEX_SIZE - 1);
146
147 let color = game.wall_texture[tex_y * TEX_SIZE + tex_x]; 199 let color = game.wall_texture[tex_y * TEX_SIZE + tex_x];
148 buffer[py * self.screen_w + sx] = darken_fx(color, wall_bright_fx); 200 buffer[py * self.screen_w + sx] = darken_fx(color, wall_bright_fx);
149 } 201 }
150 202
151 // ---- floor below the wall --------------------------------------
152 let floor_start = draw_end + 1; 203 let floor_start = draw_end + 1;
153 if floor_start < self.screen_h { 204 if floor_start < self.screen_h {
205 let w = game.weakness_factor();
206 let darken_scale = 1.0 + w * 12.0;
154 for py in floor_start..self.screen_h { 207 for py in floor_start..self.screen_h {
155 let floor_dist = self.floor_dist_lut[py]; 208 let floor_dist = self.floor_dist_lut[py];
209 let bright = 1.0 / (1.0 + floor_dist * 0.35 * darken_scale);
210 let bright_fx = (bright * 256.0) as u32;
156 211
157 if floor_dist > 15.0 { 212 if floor_dist > 15.0 {
158 // Far floor → just a dark pixel, skip texture & world calc. 213 let gray = darken_fx(0xFF80_8080, bright_fx);
159 let gray = darken_fx(0xFF80_8080, self.floor_bright_lut[py]);
160 buffer[py * self.screen_w + sx] = gray; 214 buffer[py * self.screen_w + sx] = gray;
161 } else { 215 } else {
162 let fx = game.player.x + floor_dist * rdx_floor; 216 let fx = game.player.x + floor_dist * rdx_floor;
163 let fy = game.player.y + floor_dist * rdy_floor; 217 let fy = game.player.y + floor_dist * rdy_floor;
164
165 let ftx = ((fx.fract().abs() * TEX_SIZE as f64) as usize).min(TEX_SIZE - 1); 218 let ftx = ((fx.fract().abs() * TEX_SIZE as f64) as usize).min(TEX_SIZE - 1);
166 let fty = ((fy.fract().abs() * TEX_SIZE as f64) as usize).min(TEX_SIZE - 1); 219 let fty = ((fy.fract().abs() * TEX_SIZE as f64) as usize).min(TEX_SIZE - 1);
220 let color =
221 if fx as usize == game.maze.exit_x && fy as usize == game.maze.exit_y {
222 game.exit_texture[fty * TEX_SIZE + ftx]
223 } else {
224 game.floor_texture[fty * TEX_SIZE + ftx]
225 };
226 buffer[py * self.screen_w + sx] = darken_fx(color, bright_fx);
227 }
228 }
229 }
230 }
231
232 // ---- entity sprites (drawn after walls so occlusion is easy) ----
233 self.draw_entities(game, buffer, &wall_dists, fov, half_h);
234
235 // ---- camera bob ----
236 let bob = game.camera_bob().round() as isize;
237 if bob != 0 {
238 if bob > 0 {
239 let shift = bob as usize;
240 for py in (shift..self.screen_h).rev() {
241 let src = (py - shift) * self.screen_w;
242 let dst = py * self.screen_w;
243 buffer.copy_within(src..src + self.screen_w, dst);
244 }
245 buffer[0..shift * self.screen_w].fill(0xFF00_0000);
246 } else {
247 let shift = (-bob) as usize;
248 for py in 0..self.screen_h - shift {
249 let src = (py + shift) * self.screen_w;
250 let dst = py * self.screen_w;
251 buffer.copy_within(src..src + self.screen_w, dst);
252 }
253 let start = (self.screen_h - shift) * self.screen_w;
254 buffer[start..].fill(0xFF00_0000);
255 }
256 }
257
258 // ---- HUD ----
259 self.draw_hud(buffer, game);
260 }
261
262 // ---- entity sprites ---------------------------------------------------
263
264 fn draw_entities(
265 &self,
266 game: &Game,
267 buffer: &mut [u32],
268 wall_dists: &[f64],
269 fov: f64,
270 half_h: f64,
271 ) {
272 let mw = game.maze.width();
273 let mh = game.maze.height();
274
275 for y in 0..mh {
276 for x in 0..mw {
277 let entity = match game.maze.entity_at(x, y) {
278 Some(e) => e,
279 None => continue,
280 };
281
282 let ex = x as f64 + 0.5;
283 let ey = y as f64 + 0.5;
284
285 let dx = ex - game.player.x;
286 let dy = ey - game.player.y;
287
288 let fx = game.player.angle.cos();
289 let fy = game.player.angle.sin();
290 let rx = -fy;
291 let ry = fx;
292
293 let cam_z = dx * fx + dy * fy; // depth
294 if cam_z <= 0.1 {
295 continue; // behind camera
296 }
297
298 let cam_x = dx * rx + dy * ry;
299
300 // Check if entity is within FOV.
301 let half_fov_tan = (fov / 2.0).tan();
302 let screen_x_norm = cam_x / (cam_z * half_fov_tan);
303 if screen_x_norm < -1.0 || screen_x_norm > 1.0 {
304 continue;
305 }
306
307 let sx = ((screen_x_norm + 1.0) * 0.5 * self.screen_w as f64) as isize;
308
309 // Entity height on screen.
310 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;
312
313 let (ch, color) = entity_char_and_color(entity);
314
315 // Check occlusion: is any wall column this entity overlaps with closer?
316 let left = (sx - entity_h / 2).max(0) as usize;
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;
322 }
323
324 let scale = (entity_h.max(6) as f64 / LETTER_H as f64).max(0.5);
325
326 self.draw_char_scaled(
327 buffer,
328 ch,
329 sx,
330 sy,
331 scale,
332 color,
333 cam_z,
334 game.weakness_factor(),
335 );
336 }
337 }
338 }
339
340 fn draw_char_scaled(
341 &self,
342 buffer: &mut [u32],
343 ch: char,
344 cx: isize,
345 cy: isize,
346 scale: f64,
347 color: u32,
348 distance: f64,
349 weakness: f64,
350 ) {
351 let glyph = match find_glyph(ch) {
352 Some(g) => g,
353 None => return,
354 };
355
356 let darken_scale = 1.0 + weakness * 12.0;
357 let bright = 1.0 / (1.0 + distance * 0.15 * darken_scale);
358 let bright_fx = (bright * 256.0) as u32;
359 let final_color = darken_fx(color, bright_fx);
360
361 let draw_w = (LETTER_W as f64 * scale) as isize;
362 let draw_h = (LETTER_H as f64 * scale) as isize;
363
364 let start_x = cx - draw_w / 2;
365 let start_y = cy - draw_h / 2;
167 366
168 let color = game.floor_texture[fty * TEX_SIZE + ftx]; 367 for gy in 0..LETTER_H {
169 buffer[py * self.screen_w + sx] = 368 let bits = glyph[gy];
170 darken_fx(color, self.floor_bright_lut[py]); 369 for gx in 0..LETTER_W {
370 if (bits >> (LETTER_W - 1 - gx)) & 1 == 0 {
371 continue;
372 }
373 let px_start = start_x + (gx as f64 * scale) as isize;
374 let py_start = start_y + (gy as f64 * scale) as isize;
375 let px_end = start_x + ((gx + 1) as f64 * scale) as isize;
376 let py_end = start_y + ((gy + 1) as f64 * scale) as isize;
377
378 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) {
380 let idx = py as usize * self.screen_w + px as usize;
381 buffer[idx] = final_color;
382 }
383 }
384 }
385 }
386 }
387
388 // ---- HUD -------------------------------------------------------------
389
390 fn draw_hud(&self, buffer: &mut [u32], game: &Game) {
391 let bar_h = 40usize;
392 let bar_y = self.screen_h - bar_h;
393
394 // Background.
395 for py in bar_y..self.screen_h {
396 for px in 0..self.screen_w {
397 buffer[py * self.screen_w + px] = 0xFF_222222;
398 }
399 }
400
401 let x = 8usize;
402 let y = bar_y + 6;
403
404 // Format the HUD string.
405 let hp_str = format!("♥{}/{}", game.hp, game.max_hp);
406 let san_str = format!("♦{}%", game.sanity);
407 let weak_str = format!("⌂{}%", game.weakness);
408 let beats_str = format!("♥♥{}", game.heartbeats());
409 let wpn_str = format!("WPN:{}", game.weapon_base_damage);
410 let depth_str = format!("DEPTH:{}", game.depth);
411
412 let inv_str: String = game
413 .inventory
414 .iter()
415 .enumerate()
416 .map(|(i, item)| match item {
417 Some(item) => {
418 let ch = pickup_char(item.pickup);
419 if i == game.selected_slot {
420 format!("[{}]", ch)
421 } else {
422 format!(" {} ", ch)
423 }
424 }
425 None => {
426 if i == game.selected_slot {
427 "[ ]".to_string()
428 } else {
429 " ".to_string()
430 }
431 }
432 })
433 .collect();
434
435 let full = format!(
436 "{} {} {} {} {} {} {}",
437 hp_str, san_str, weak_str, beats_str, wpn_str, inv_str, depth_str
438 );
439
440 self.draw_text(buffer, x, y, &full, 0xFF_CCCCCC);
441
442 // ---- message log above the bar ----
443 if !game.message.is_empty() {
444 let msg_y = bar_y.saturating_sub(LETTER_H + 4);
445 self.draw_text(buffer, 8, msg_y, &game.message, 0xFF_DDAA44);
446 }
447 }
448
449 fn draw_text(&self, buffer: &mut [u32], mut x: usize, y: usize, text: &str, color: u32) {
450 for ch in text.to_uppercase().chars() {
451 let glyph = match find_glyph(ch) {
452 Some(g) => g,
453 None => {
454 x += LETTER_W + 1;
455 continue;
456 }
457 };
458
459 for gy in 0..LETTER_H {
460 let bits = glyph[gy];
461 for gx in 0..LETTER_W {
462 if (bits >> (LETTER_W - 1 - gx)) & 1 != 0 {
463 let px = x + gx;
464 let py = y + gy;
465 if px < self.screen_w && py < self.screen_h {
466 buffer[py * self.screen_w + px] = color;
467 }
171 } 468 }
172 } 469 }
173 } 470 }
471 x += LETTER_W + 1; // 1px gap
472 }
473 }
474}
475
476// ---- helpers ----
477
478fn find_glyph(ch: char) -> Option<[u8; 5]> {
479 let s = ch.to_string();
480 for (name, glyph) in LETTERS {
481 if *name == s {
482 return Some(*glyph);
174 } 483 }
175 } 484 }
485 None
486}
487
488fn entity_char_and_color(entity: &Entity) -> (char, u32) {
489 match entity {
490 Entity::Enemy { .. } => ('@', 0xFF_CC3333),
491 Entity::Hallucination { .. } => ('@', 0xFF_CC3333),
492 Entity::Pickup(pt) => (pickup_char(*pt), pickup_color(*pt)),
493 Entity::HallucinatedPickup(pt) => (pickup_char(*pt), pickup_color(*pt)),
494 }
495}
496
497fn pickup_char(pt: PickupType) -> char {
498 match pt {
499 PickupType::Food => 'F',
500 PickupType::Pill => '0',
501 PickupType::Bandage => '+',
502 PickupType::Weapon { .. } => '!',
503 }
504}
505
506fn pickup_color(pt: PickupType) -> u32 {
507 match pt {
508 PickupType::Food => 0xFF_33CC33,
509 PickupType::Pill => 0xFF_33CCCC,
510 PickupType::Bandage => 0xFF_CCCCCC,
511 PickupType::Weapon { .. } => 0xFF_CCCC33,
512 }
176} 513}
177 514
178/// Integer darken: `bright_fx` is brightness × 256 in fixed-point.
179#[inline(always)] 515#[inline(always)]
180fn darken_fx(color: u32, bright_fx: u32) -> u32 { 516fn darken_fx(color: u32, bright_fx: u32) -> u32 {
181 let r = (((color >> 16) & 0xFF) * bright_fx) >> 8; 517 let r = (((color >> 16) & 0xFF) * bright_fx) >> 8;
diff --git a/src/texture.rs b/src/texture.rs
index bdbb15c..e5720c0 100644
--- a/src/texture.rs
+++ b/src/texture.rs
@@ -60,6 +60,49 @@ pub fn generate_wall_texture(rng: &mut impl Rng) -> Vec<u32> {
60 tex 60 tex
61} 61}
62 62
63/// Gray stone-tile floor with a dark hole in the centre — the exit staircase.
64pub fn generate_exit_texture(rng: &mut impl Rng) -> Vec<u32> {
65 let mut tex = generate_floor_texture(rng);
66
67 let cx = TEX_SIZE as f64 / 2.0;
68 let cy = TEX_SIZE as f64 / 2.0;
69 let hole_radius = TEX_SIZE as f64 * 0.35;
70 let fade_zone = TEX_SIZE as f64 * 0.08;
71
72 for y in 0..TEX_SIZE {
73 for x in 0..TEX_SIZE {
74 let dx = x as f64 - cx;
75 let dy = y as f64 - cy;
76 let dist = (dx * dx + dy * dy).sqrt();
77
78 if dist < hole_radius {
79 // Deep black void.
80 let edge = (dist / hole_radius).clamp(0.0, 1.0);
81 // Slight blueish tint near the edge for depth.
82 let r = (8.0 * (1.0 - edge)) as u8;
83 let g = (6.0 * (1.0 - edge)) as u8;
84 let b = (12.0 * edge + 4.0 * (1.0 - edge)) as u8;
85 tex[y * TEX_SIZE + x] =
86 0xFF00_0000 | ((r as u32) << 16) | ((g as u32) << 8) | (b as u32);
87 } else if dist < hole_radius + fade_zone {
88 // Fade zone: blend from floor to darkness.
89 let t = ((dist - hole_radius) / fade_zone).clamp(0.0, 1.0);
90 let floor_color = tex[y * TEX_SIZE + x];
91 let fr = ((floor_color >> 16) & 0xFF) as f64;
92 let fg = ((floor_color >> 8) & 0xFF) as f64;
93 let fb = (floor_color & 0xFF) as f64;
94
95 let r = (fr * t + 8.0 * (1.0 - t)) as u8;
96 let g = (fg * t + 6.0 * (1.0 - t)) as u8;
97 let b = (fb * t + 12.0 * (1.0 - t)) as u8;
98 tex[y * TEX_SIZE + x] =
99 0xFF00_0000 | ((r as u32) << 16) | ((g as u32) << 8) | (b as u32);
100 }
101 }
102 }
103 tex
104}
105
63/// Gray stone-tile floor — 64×64, checkerboard with grout and subtle colour tint. 106/// Gray stone-tile floor — 64×64, checkerboard with grout and subtle colour tint.
64pub fn generate_floor_texture(rng: &mut impl Rng) -> Vec<u32> { 107pub fn generate_floor_texture(rng: &mut impl Rng) -> Vec<u32> {
65 let seed = rng.gen::<u64>(); 108 let seed = rng.gen::<u64>();
@@ -68,7 +111,11 @@ pub fn generate_floor_texture(rng: &mut impl Rng) -> Vec<u32> {
68 for y in 0..TEX_SIZE { 111 for y in 0..TEX_SIZE {
69 for x in 0..TEX_SIZE { 112 for x in 0..TEX_SIZE {
70 let h = hash(x as u32, y as u32, seed); 113 let h = hash(x as u32, y as u32, seed);
71 let h2 = hash(x.wrapping_add(2000) as u32, y.wrapping_add(2000) as u32, seed); 114 let h2 = hash(
115 x.wrapping_add(2000) as u32,
116 y.wrapping_add(2000) as u32,
117 seed,
118 );
72 119
73 let tile_x = x / 16; 120 let tile_x = x / 16;
74 let tile_y = y / 16; 121 let tile_y = y / 16;