summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-07-09 23:27:55 +0200
committersalmonik <raisersoft@proton.me>2026-07-09 23:27:55 +0200
commit3460d889aa2e7af1a60d218a1a8589027b4e4e63 (patch)
tree89e66acd05fba086835337bfa95b36710dc8c4da /src
downloaddungeon-3460d889aa2e7af1a60d218a1a8589027b4e4e63.tar.gz
dungeon-3460d889aa2e7af1a60d218a1a8589027b4e4e63.zip
init
Diffstat (limited to '')
-rw-r--r--src/game.rs204
-rw-r--r--src/main.rs90
-rw-r--r--src/renderer.rs196
-rw-r--r--src/texture.rs102
4 files changed, 592 insertions, 0 deletions
diff --git a/src/game.rs b/src/game.rs
new file mode 100644
index 0000000..4bedecc
--- /dev/null
+++ b/src/game.rs
@@ -0,0 +1,204 @@
1use rand::Rng;
2
3// --------------- maze grid ---------------
4
5#[derive(Clone, Copy, PartialEq, Eq)]
6pub enum Cell {
7 Wall,
8 Passage,
9 Exit,
10}
11
12pub struct Maze {
13 cells: Vec<Cell>,
14 width: usize,
15 height: usize,
16}
17
18impl Maze {
19 /// Recursive-backtracker perfect maze. `room_w × room_h` is the logical
20 /// room count; the actual grid will be `(room_w*2+1) × (room_h*2+1)`.
21 pub fn generate(room_w: usize, room_h: usize, rng: &mut impl Rng) -> Self {
22 let w = room_w * 2 + 1;
23 let h = room_h * 2 + 1;
24
25 let mut cells = vec![Cell::Wall; w * h];
26 cells[1 * w + 1] = Cell::Passage;
27
28 let mut stack = vec![(1usize, 1usize)];
29
30 while let Some(&(cy, cx)) = stack.last() {
31 let mut unvisited = Vec::new();
32
33 for (dy, dx) in [(-2isize, 0isize), (2, 0), (0, -2), (0, 2)] {
34 let ny = cy as isize + dy;
35 let nx = cx as isize + dx;
36 if ny >= 1 && ny < (h - 1) as isize && nx >= 1 && nx < (w - 1) as isize {
37 if cells[ny as usize * w + nx as usize] == Cell::Wall {
38 unvisited.push((ny as usize, nx as usize));
39 }
40 }
41 }
42
43 if unvisited.is_empty() {
44 stack.pop();
45 } else {
46 let idx = rng.gen_range(0..unvisited.len());
47 let (ny, nx) = unvisited[idx];
48
49 // carve the wall between current cell and chosen neighbour
50 let wall_y = (cy + ny) / 2;
51 let wall_x = (cx + nx) / 2;
52 cells[wall_y * w + wall_x] = Cell::Passage;
53 cells[ny * w + nx] = Cell::Passage;
54
55 stack.push((ny, nx));
56 }
57 }
58
59 // ---- place exit in a far-away passage ----
60 let mut passages: Vec<(f64, usize, usize)> = Vec::new();
61 for y in (1..h).step_by(2) {
62 for x in (1..w).step_by(2) {
63 if cells[y * w + x] == Cell::Passage && !(y == 1 && x == 1) {
64 let dist = ((y as f64 - 1.0).powi(2) + (x as f64 - 1.0).powi(2)).sqrt();
65 passages.push((dist, y, x));
66 }
67 }
68 }
69
70 if !passages.is_empty() {
71 passages.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap());
72 let far_count = (passages.len() / 4).max(1);
73 let idx = rng.gen_range(0..far_count);
74 let (_, ey, ex) = passages[idx];
75 cells[ey * w + ex] = Cell::Exit;
76 }
77
78 Self {
79 cells,
80 width: w,
81 height: h,
82 }
83 }
84
85 #[inline]
86 pub fn is_wall(&self, y: usize, x: usize) -> bool {
87 self.cells
88 .get(y * self.width + x)
89 .map_or(true, |&c| c == Cell::Wall)
90 }
91
92 /// Floating-point query — out-of-bounds is treated as wall.
93 #[inline]
94 pub fn is_passage_at_float(&self, x: f64, y: f64) -> bool {
95 let xi = x as usize;
96 let yi = y as usize;
97 if xi >= self.width || yi >= self.height {
98 return false;
99 }
100 self.cells[yi * self.width + xi] != Cell::Wall
101 }
102
103 #[inline]
104 pub fn is_exit(&self, x: f64, y: f64) -> bool {
105 let xi = x as usize;
106 let yi = y as usize;
107 if xi >= self.width || yi >= self.height {
108 return false;
109 }
110 self.cells[yi * self.width + xi] == Cell::Exit
111 }
112
113 pub fn width(&self) -> usize {
114 self.width
115 }
116 pub fn height(&self) -> usize {
117 self.height
118 }
119}
120
121// --------------- player ---------------
122
123pub struct Player {
124 pub x: f64,
125 pub y: f64,
126 pub angle: f64, // radians, 0 = +x (east), π/2 = +y (south)
127}
128
129impl Player {
130 pub fn new(x: f64, y: f64, angle: f64) -> Self {
131 Self { x, y, angle }
132 }
133
134 pub fn rotate(&mut self, delta: f64) {
135 self.angle = (self.angle + delta).rem_euclid(2.0 * std::f64::consts::PI);
136 }
137
138 /// Apply a raw (dx, dy) displacement with wall-sliding collision.
139 /// Checks are done per-axis so the player slides along walls naturally.
140 pub fn try_move(&mut self, dx: f64, dy: f64, maze: &Maze) {
141 let m = 0.35; // collision margin — keeps the camera out of walls
142
143 let nx = self.x + dx;
144 if maze.is_passage_at_float(nx + m, self.y + m)
145 && maze.is_passage_at_float(nx + m, self.y - m)
146 && maze.is_passage_at_float(nx - m, self.y + m)
147 && maze.is_passage_at_float(nx - m, self.y - m)
148 {
149 self.x = nx;
150 }
151
152 let ny = self.y + dy;
153 if maze.is_passage_at_float(self.x + m, ny + m)
154 && maze.is_passage_at_float(self.x - m, ny + m)
155 && maze.is_passage_at_float(self.x + m, ny - m)
156 && maze.is_passage_at_float(self.x - m, ny - m)
157 {
158 self.y = ny;
159 }
160 }
161}
162
163// --------------- top-level game ---------------
164
165pub struct Game {
166 pub maze: Maze,
167 pub player: Player,
168 pub wall_texture: Vec<u32>,
169 pub floor_texture: Vec<u32>,
170}
171
172impl Game {
173 pub fn new() -> Self {
174 let mut rng = rand::thread_rng();
175 let maze = Maze::generate(16, 16, &mut rng); // 33×33 grid
176 let player = Player::new(1.5, 1.5, 0.0); // start centre of (1,1), facing east
177 let wall_texture = crate::texture::generate_wall_texture(&mut rng);
178 let floor_texture = crate::texture::generate_floor_texture(&mut rng);
179
180 Self {
181 maze,
182 player,
183 wall_texture,
184 floor_texture,
185 }
186 }
187
188 pub fn rotate(&mut self, delta: f64) {
189 self.player.rotate(delta);
190 }
191
192 /// Apply forward + strafe as a single combined displacement so that
193 /// diagonal wall-hugging is checked atomically (no two-step clip).
194 pub fn apply_movement(&mut self, forward: f64, strafe: f64) {
195 let a = self.player.angle;
196 let dx = a.cos() * forward + (a + std::f64::consts::FRAC_PI_2).cos() * strafe;
197 let dy = a.sin() * forward + (a + std::f64::consts::FRAC_PI_2).sin() * strafe;
198 self.player.try_move(dx, dy, &self.maze);
199 }
200
201 pub fn check_win(&self) -> bool {
202 self.maze.is_exit(self.player.x, self.player.y)
203 }
204}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..d51fdfd
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,90 @@
1mod game;
2mod renderer;
3mod texture;
4
5use game::Game;
6use minifb::{Key, Window, WindowOptions};
7use renderer::Renderer;
8use std::time::Instant;
9
10const SCREEN_W: usize = 640;
11const SCREEN_H: usize = 480;
12
13fn main() {
14 println!("=== DOOM-LIKE MAZE ===");
15 println!("Find the exit!");
16 println!(" W / Up — forward");
17 println!(" S / Down — backward");
18 println!(" A / D — strafe left / right");
19 println!(" ← / → — turn");
20 println!(" Esc — quit\n");
21
22 let mut game = Game::new();
23 let renderer = Renderer::new(SCREEN_W, SCREEN_H);
24
25 let mut window = Window::new(
26 "Maze — Find the Exit!",
27 SCREEN_W,
28 SCREEN_H,
29 WindowOptions::default(),
30 )
31 .expect("Failed to create window");
32
33 let mut framebuffer = vec![0u32; SCREEN_W * SCREEN_H];
34 let mut last = Instant::now();
35 let frame_budget = std::time::Duration::from_micros(16600); // ~60 fps
36
37 while window.is_open() && !window.is_key_down(Key::Escape) {
38 let frame_start = Instant::now();
39 let dt = (frame_start - last).as_secs_f64().min(0.1);
40 last = frame_start;
41
42 // ---- input ----
43 let move_speed = 2.8;
44 let turn_speed = 2.8;
45 let strafe_speed = 2.2;
46
47 if window.is_key_down(Key::Left) {
48 game.rotate(-turn_speed * dt);
49 }
50 if window.is_key_down(Key::Right) {
51 game.rotate(turn_speed * dt);
52 }
53 // Combine forward + strafe into one displacement (prevents two-step
54 // wall clipping when pressing W+D / W+A near a wall).
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() {
73 println!("🎉 You found the exit! Generating a new maze…");
74 game = Game::new();
75 last = Instant::now(); // avoid huge dt spike
76 }
77
78 // ---- render ----
79 renderer.render(&game, &mut framebuffer);
80 window
81 .update_with_buffer(&framebuffer, SCREEN_W, SCREEN_H)
82 .unwrap();
83
84 // Sleep the remainder of the frame budget (saves CPU).
85 let elapsed = frame_start.elapsed();
86 if elapsed < frame_budget {
87 std::thread::sleep(frame_budget - elapsed);
88 }
89 }
90}
diff --git a/src/renderer.rs b/src/renderer.rs
new file mode 100644
index 0000000..0792537
--- /dev/null
+++ b/src/renderer.rs
@@ -0,0 +1,196 @@
1use crate::game::Game;
2use crate::texture::TEX_SIZE;
3
4pub struct Renderer {
5 screen_w: usize,
6 screen_h: usize,
7 /// Precomputed: floor_dist_lut[row] = perpendicular distance to floor at that row.
8 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}
12
13impl Renderer {
14 pub fn new(screen_w: usize, screen_h: usize) -> Self {
15 let half_h = screen_h as f64 / 2.0;
16 let mut floor_dist_lut = vec![0.0f64; screen_h];
17 let mut floor_bright_lut = vec![0u32; screen_h];
18
19 // Only rows below the horizon have meaningful floor values.
20 for py in (screen_h / 2 + 1)..screen_h {
21 let dy = py as f64 - half_h;
22 let dist = 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 }
27
28 Self {
29 screen_w,
30 screen_h,
31 floor_dist_lut,
32 floor_bright_lut,
33 }
34 }
35
36 /// Produce one frame into `buffer` (packed `0xAARRGGBB`).
37 pub fn render(&self, game: &Game, buffer: &mut [u32]) {
38 buffer.fill(0xFF00_0000); // pure-black ceiling
39
40 let half_h = self.screen_h as f64 / 2.0;
41 let fov = std::f64::consts::PI / 3.0; // 60°
42
43 for sx in 0..self.screen_w {
44 let cam_x = 2.0 * sx as f64 / self.screen_w as f64 - 1.0;
45 let ray_angle = game.player.angle + (fov / 2.0) * cam_x;
46 let rdx = ray_angle.cos();
47 let rdy = ray_angle.sin();
48
49 // ---- DDA setup -------------------------------------------------
50 let mut map_x = game.player.x as isize;
51 let mut map_y = game.player.y as isize;
52
53 let ddx = if rdx == 0.0 {
54 f64::MAX
55 } else {
56 (1.0 / rdx).abs()
57 };
58 let ddy = if rdy == 0.0 {
59 f64::MAX
60 } else {
61 (1.0 / rdy).abs()
62 };
63
64 let (step_x, mut side_x) = if rdx < 0.0 {
65 (-1isize, (game.player.x - map_x as f64) * ddx)
66 } else {
67 (1isize, (map_x as f64 + 1.0 - game.player.x) * ddx)
68 };
69
70 let (step_y, mut side_y) = if rdy < 0.0 {
71 (-1isize, (game.player.y - map_y as f64) * ddy)
72 } else {
73 (1isize, (map_y as f64 + 1.0 - game.player.y) * ddy)
74 };
75
76 let mut hit_side: bool;
77 let euclidean: f64;
78
79 // ---- DDA loop --------------------------------------------------
80 loop {
81 if side_x < side_y {
82 side_x += ddx;
83 map_x += step_x;
84 hit_side = false;
85 } else {
86 side_y += ddy;
87 map_y += step_y;
88 hit_side = true;
89 }
90
91 if map_y < 0
92 || map_y >= game.maze.height() as isize
93 || map_x < 0
94 || map_x >= game.maze.width() as isize
95 {
96 euclidean = 1.0;
97 break;
98 }
99
100 if game.maze.is_wall(map_y as usize, map_x as usize) {
101 euclidean = if !hit_side {
102 (map_x as f64 - game.player.x + (1.0 - step_x as f64) / 2.0) / rdx
103 } else {
104 (map_y as f64 - game.player.y + (1.0 - step_y as f64) / 2.0) / rdy
105 };
106 break;
107 }
108 }
109
110 // Fisheye correction
111 let perp_dist = (euclidean * (ray_angle - game.player.angle).cos().abs()).max(0.001);
112
113 // ---- wall slice ------------------------------------------------
114 let wall_h = (self.screen_h as f64 / perp_dist) as isize;
115 let draw_start = ((-wall_h / 2) + self.screen_h as isize / 2).max(0) as usize;
116 let draw_end = ((wall_h / 2) + self.screen_h as isize / 2)
117 .min(self.screen_h as isize - 1) as usize;
118
119 // Texture x-coordinate (uses Euclidean, not perp)
120 let wall_x = if !hit_side {
121 game.player.y + euclidean * rdy
122 } else {
123 game.player.x + euclidean * rdx
124 };
125 let wall_x = wall_x - wall_x.floor();
126 let tex_x = ((wall_x * TEX_SIZE as f64) as usize).min(TEX_SIZE - 1);
127
128 // Hoist: brightness is identical for every pixel in this column
129 let wall_bright_fx = {
130 let b = (1.0 / (1.0 + perp_dist * 0.12)).max(0.08);
131 (b * 256.0) as u32
132 };
133
134 for py in draw_start..=draw_end {
135 let tex_y = ((py as f64 - half_h + wall_h as f64 / 2.0) * TEX_SIZE as f64
136 / wall_h as f64) as usize;
137 let tex_y = tex_y.min(TEX_SIZE - 1);
138
139 let color = game.wall_texture[tex_y * TEX_SIZE + tex_x];
140 buffer[py * self.screen_w + sx] = darken_fx(color, wall_bright_fx);
141 }
142
143 // ---- floor below the wall --------------------------------------
144 let floor_start = draw_end + 1;
145 if floor_start < self.screen_h {
146 for py in floor_start..self.screen_h {
147 let floor_dist = self.floor_dist_lut[py];
148
149 if floor_dist > 15.0 {
150 // Far floor → just a dark pixel, skip texture & world calc.
151 let gray = darken_fx(0xFF80_8080, self.floor_bright_lut[py]);
152 buffer[py * self.screen_w + sx] = gray;
153 } else {
154 let fx = game.player.x + floor_dist * rdx;
155 let fy = game.player.y + floor_dist * rdy;
156
157 let ftx = ((fx.fract().abs() * TEX_SIZE as f64) as usize).min(TEX_SIZE - 1);
158 let fty = ((fy.fract().abs() * TEX_SIZE as f64) as usize).min(TEX_SIZE - 1);
159
160 let color = game.floor_texture[fty * TEX_SIZE + ftx];
161 buffer[py * self.screen_w + sx] =
162 darken_fx(color, self.floor_bright_lut[py]);
163 }
164 }
165 }
166 }
167
168 // ---- crosshair -----------------------------------------------------
169 let cx = self.screen_w / 2;
170 let cy = self.screen_h / 2;
171 let cross_color = 0xFF00_FF00;
172 for i in 0..=4isize {
173 if cy as isize - i >= 0 {
174 buffer[(cy - i as usize) * self.screen_w + cx] = cross_color;
175 }
176 if (cy + i as usize) < self.screen_h {
177 buffer[(cy + i as usize) * self.screen_w + cx] = cross_color;
178 }
179 if cx as isize - i >= 0 {
180 buffer[cy * self.screen_w + (cx - i as usize)] = cross_color;
181 }
182 if (cx + i as usize) < self.screen_w {
183 buffer[cy * self.screen_w + (cx + i as usize)] = cross_color;
184 }
185 }
186 }
187}
188
189/// Integer darken: `bright_fx` is brightness × 256 in fixed-point.
190#[inline(always)]
191fn darken_fx(color: u32, bright_fx: u32) -> u32 {
192 let r = (((color >> 16) & 0xFF) * bright_fx) >> 8;
193 let g = (((color >> 8) & 0xFF) * bright_fx) >> 8;
194 let b = ((color & 0xFF) * bright_fx) >> 8;
195 0xFF00_0000 | (r << 16) | (g << 8) | b
196}
diff --git a/src/texture.rs b/src/texture.rs
new file mode 100644
index 0000000..bdbb15c
--- /dev/null
+++ b/src/texture.rs
@@ -0,0 +1,102 @@
1use rand::Rng;
2
3pub const TEX_SIZE: usize = 64;
4
5/// Deterministic hash for coherent procedural noise.
6fn hash(x: u32, y: u32, seed: u64) -> u64 {
7 let mut h = (x as u64).wrapping_mul(0x9e3779b9);
8 h = h.wrapping_add((y as u64).wrapping_mul(0x517cc1b727220a95));
9 h = h.wrapping_add(seed);
10 h = (h ^ (h >> 33)).wrapping_mul(0xc6a4a7935bd1e995);
11 h = (h ^ (h >> 29)).wrapping_mul(0x5bd1e995);
12 h
13}
14
15/// Brown cracked wall texture — 64×64, brick pattern with mortar lines and fissures.
16pub fn generate_wall_texture(rng: &mut impl Rng) -> Vec<u32> {
17 let seed = rng.gen::<u64>();
18 let mut tex = vec![0u32; TEX_SIZE * TEX_SIZE];
19
20 for y in 0..TEX_SIZE {
21 for x in 0..TEX_SIZE {
22 let h = hash(x as u32, y as u32, seed);
23
24 let r = 105u8.wrapping_add((h % 50) as u8);
25 let g = 55u8.wrapping_add(((h >> 8) % 35) as u8);
26 let b = 22u8.wrapping_add(((h >> 16) % 25) as u8);
27
28 // Brick mortar every 16 pixels
29 let mortar = y % 16 == 0 || y % 16 == 15;
30
31 let (r, g, b) = if mortar {
32 (42, 20, 8)
33 } else {
34 let crack_hash = hash(x.wrapping_add(1000) as u32, y as u32, seed);
35 let crack_val = (crack_hash % 100) as u8;
36 if crack_val < 8 {
37 // dark crack
38 let factor = 0.30 + (crack_val as f64 / 8.0) * 0.25;
39 (
40 (r as f64 * factor) as u8,
41 (g as f64 * factor) as u8,
42 (b as f64 * factor) as u8,
43 )
44 } else if crack_val < 14 {
45 // light crack highlight
46 (
47 (r.saturating_add(25)).min(255),
48 (g.saturating_add(18)).min(255),
49 (b.saturating_add(10)).min(255),
50 )
51 } else {
52 (r, g, b)
53 }
54 };
55
56 tex[y * TEX_SIZE + x] =
57 0xFF00_0000 | ((r as u32) << 16) | ((g as u32) << 8) | (b as u32);
58 }
59 }
60 tex
61}
62
63/// Gray stone-tile floor — 64×64, checkerboard with grout and subtle colour tint.
64pub fn generate_floor_texture(rng: &mut impl Rng) -> Vec<u32> {
65 let seed = rng.gen::<u64>();
66 let mut tex = vec![0u32; TEX_SIZE * TEX_SIZE];
67
68 for y in 0..TEX_SIZE {
69 for x in 0..TEX_SIZE {
70 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);
72
73 let tile_x = x / 16;
74 let tile_y = y / 16;
75 let is_light_tile = (tile_x + tile_y) % 2 == 0;
76
77 let local_x = x % 16;
78 let local_y = y % 16;
79 let is_grout = local_x == 0 || local_y == 0;
80
81 let base_gray: u8 = if is_light_tile { 115 } else { 95 };
82 let noise = (h % 15) as u8;
83
84 let tint_r = (h2 % 8) as i16 - 4i16;
85 let tint_b = ((h2 >> 8) % 8) as i16 - 4i16;
86
87 let gray = if is_grout {
88 base_gray.saturating_sub(30).saturating_add(noise / 3)
89 } else {
90 base_gray.saturating_add(noise / 2)
91 };
92
93 let r = (gray as i16 + tint_r).clamp(0, 255) as u8;
94 let g = gray;
95 let b = (gray as i16 + tint_b).clamp(0, 255) as u8;
96
97 tex[y * TEX_SIZE + x] =
98 0xFF00_0000 | ((r as u32) << 16) | ((g as u32) << 8) | (b as u32);
99 }
100 }
101 tex
102}