#![forbid(unsafe_code)]
pub mod bsp;
pub mod constants;
pub mod framebuffer;
pub mod geometry;
pub mod map;
pub mod palette;
pub mod player;
pub mod renderer;
pub mod tables;
pub mod wad;
pub mod wad_types;
use ftui_render::cell::PackedRgba;
use crate::canvas::Painter;
use self::constants::*;
use self::framebuffer::DoomFramebuffer;
use self::map::DoomMap;
use self::palette::DoomPalette;
use self::player::Player;
use self::renderer::{DoomRenderer, RenderStats};
use self::wad::WadFile;
#[derive(Debug)]
pub struct DoomEngine {
map: Option<DoomMap>,
pub player: Player,
palette: DoomPalette,
renderer: DoomRenderer,
framebuffer: DoomFramebuffer,
tick_accumulator: f64,
pub frame: u64,
pub time: f64,
pub fire_flash: f32,
pub show_minimap: bool,
pub show_perf: bool,
pub show_crosshair: bool,
pub original_palette: bool,
pub vsync: bool,
pub show_hud: bool,
pub last_stats: RenderStats,
fb_width: u32,
fb_height: u32,
}
impl DoomEngine {
pub fn new() -> Self {
let fb_width = SCREENWIDTH;
let fb_height = SCREENHEIGHT;
Self {
map: None,
player: Player::default(),
palette: DoomPalette::default(),
renderer: DoomRenderer::new(fb_width, fb_height),
framebuffer: DoomFramebuffer::new(fb_width, fb_height),
tick_accumulator: 0.0,
frame: 0,
time: 0.0,
fire_flash: 0.0,
show_minimap: false,
show_perf: false,
show_crosshair: true,
original_palette: false,
vsync: true,
show_hud: true,
last_stats: RenderStats::default(),
fb_width,
fb_height,
}
}
pub fn load_wad(&mut self, wad_data: Vec<u8>, map_name: &str) -> Result<(), String> {
let wad = WadFile::parse(wad_data).map_err(|e| e.to_string())?;
if let Ok(colors) = wad.parse_playpal() {
let colormaps = wad.parse_colormap().unwrap_or_default();
self.palette = DoomPalette::from_wad(colors, colormaps);
}
let map = DoomMap::load(&wad, map_name).map_err(|e| e.to_string())?;
if let Some((x, y, angle)) = map.player_start() {
self.player.spawn(x, y, angle);
}
self.map = Some(map);
Ok(())
}
pub fn load_test_map(&mut self) {
self.map = Some(generate_test_map());
self.player.spawn(0.0, 0.0, 0.0);
}
pub fn update(&mut self, dt: f64) {
self.time += dt;
self.tick_accumulator += dt;
while self.tick_accumulator >= DOOM_TICK_SECS {
self.tick_accumulator -= DOOM_TICK_SECS;
self.game_tick();
}
if self.fire_flash > 0.0 {
self.fire_flash = (self.fire_flash - dt as f32 * 8.0).max(0.0);
}
}
fn game_tick(&mut self) {
if let Some(map) = self.map.take() {
self.player.tick(&map);
self.map = Some(map);
}
}
pub fn render(&mut self, painter: &mut Painter, _pw: u16, _ph: u16, stride: usize) {
if self.framebuffer.width != self.fb_width || self.framebuffer.height != self.fb_height {
self.framebuffer.resize(self.fb_width, self.fb_height);
self.renderer.resize(self.fb_width, self.fb_height);
}
if let Some(map) = self.map.take() {
self.renderer
.render(&mut self.framebuffer, &map, &self.player, &self.palette);
self.last_stats = self.renderer.stats.clone();
self.map = Some(map);
} else {
self.render_no_map();
}
if self.show_crosshair {
self.draw_crosshair();
}
if self.fire_flash > 0.0 {
self.draw_muzzle_flash();
}
if self.show_minimap {
self.draw_minimap();
}
self.framebuffer.blit_to_painter(painter, stride);
self.frame += 1;
}
pub fn move_forward(&mut self, amount: f32) {
self.player.move_forward(amount);
}
pub fn strafe(&mut self, amount: f32) {
self.player.strafe(amount);
}
pub fn look(&mut self, yaw: f32, pitch: f32) {
self.player.look(yaw, pitch);
}
pub fn fire(&mut self) {
self.fire_flash = 1.0;
}
pub fn toggle_noclip(&mut self) {
self.player.noclip = !self.player.noclip;
}
pub fn toggle_god_mode(&mut self) {
self.player.god_mode = !self.player.god_mode;
}
pub fn toggle_run(&mut self) {
self.player.running = !self.player.running;
}
fn render_no_map(&mut self) {
self.framebuffer.clear();
let w = self.framebuffer.width;
let h = self.framebuffer.height;
let cos_a = self.player.angle.cos();
let sin_a = self.player.angle.sin();
for x in 0..w {
let screen_x = (x as f32 / w as f32) * 2.0 - 1.0;
let ray_cos = cos_a - screen_x * sin_a * 0.5;
let ray_sin = sin_a + screen_x * cos_a * 0.5;
let mut dist = 0.0f32;
let mut hit = false;
let step = 2.0;
for _ in 0..100 {
dist += step;
let mx = self.player.x + ray_cos * dist;
let my = self.player.y + ray_sin * dist;
let gx = (mx / 128.0).floor() as i32;
let gy = (my / 128.0).floor() as i32;
if !(-2..=2).contains(&gx) || !(-2..=2).contains(&gy) {
hit = true;
break;
}
}
if hit {
let corrected = dist * (screen_x * 0.5).cos();
let wall_h = (128.0 / corrected * (h as f32 / 2.0)).min(h as f32);
let top = ((h as f32 - wall_h) / 2.0) as u32;
let bottom = ((h as f32 + wall_h) / 2.0) as u32;
let light = (1.0 / (1.0 + dist / 300.0)).clamp(0.0, 1.0);
let r = (160.0 * light) as u8;
let g = (120.0 * light) as u8;
let b = (80.0 * light) as u8;
self.framebuffer
.draw_column(x, top, bottom, PackedRgba::rgb(r, g, b));
for y in 0..top {
let sky_t = y as f32 / (h as f32 / 2.0);
let sr = (80.0 + 80.0 * sky_t) as u8;
let sg = (120.0 + 80.0 * sky_t) as u8;
let sb = (200.0 + 40.0 * sky_t.min(1.0)) as u8;
self.framebuffer
.set_pixel(x, y, PackedRgba::rgb(sr, sg, sb));
}
for y in bottom..h {
let floor_t = (y - bottom) as f32 / (h - bottom).max(1) as f32;
let fr = (70.0 + 50.0 * floor_t) as u8;
let fg = (60.0 + 40.0 * floor_t) as u8;
let fb_c = (50.0 + 30.0 * floor_t) as u8;
self.framebuffer
.set_pixel(x, y, PackedRgba::rgb(fr, fg, fb_c));
}
}
}
}
fn draw_crosshair(&mut self) {
let cx = self.framebuffer.width / 2;
let cy = self.framebuffer.height / 2;
let color = PackedRgba::rgb(255, 255, 255);
let size = 3;
for i in 1..=size {
self.framebuffer.set_pixel(cx + i, cy, color);
self.framebuffer.set_pixel(cx - i, cy, color);
self.framebuffer.set_pixel(cx, cy + i, color);
self.framebuffer.set_pixel(cx, cy - i, color);
}
}
fn draw_muzzle_flash(&mut self) {
let w = self.framebuffer.width;
let h = self.framebuffer.height;
let intensity = self.fire_flash;
let cx = w / 2;
let cy = h - h / 6;
let radius = (w / 8) as f32 * intensity;
for y in (cy.saturating_sub(radius as u32))..h.min(cy + radius as u32) {
for x in (cx.saturating_sub(radius as u32))..w.min(cx + radius as u32) {
let dx = x as f32 - cx as f32;
let dy = y as f32 - cy as f32;
let dist = (dx * dx + dy * dy).sqrt();
if dist < radius {
let falloff = 1.0 - dist / radius;
let flash = falloff * intensity;
let existing = self.framebuffer.get_pixel(x, y);
let r = (existing.r() as f32 + 255.0 * flash).min(255.0) as u8;
let g = (existing.g() as f32 + 200.0 * flash).min(255.0) as u8;
let b = (existing.b() as f32 + 100.0 * flash).min(255.0) as u8;
self.framebuffer.set_pixel(x, y, PackedRgba::rgb(r, g, b));
}
}
}
}
fn draw_minimap(&mut self) {
let map = match &self.map {
Some(m) => m,
None => return,
};
let map_size = 80u32; let margin = 4u32;
let ox = self.framebuffer.width - map_size - margin;
let oy = margin;
for y in oy..oy + map_size {
for x in ox..ox + map_size {
self.framebuffer
.set_pixel(x, y, PackedRgba::rgba(0, 0, 0, 180));
}
}
if map.vertices.is_empty() {
return;
}
let mut min_x = f32::MAX;
let mut min_y = f32::MAX;
let mut max_x = f32::MIN;
let mut max_y = f32::MIN;
for v in &map.vertices {
min_x = min_x.min(v.x);
min_y = min_y.min(v.y);
max_x = max_x.max(v.x);
max_y = max_y.max(v.y);
}
let range_x = (max_x - min_x).max(1.0);
let range_y = (max_y - min_y).max(1.0);
let scale = (map_size as f32 - 4.0) / range_x.max(range_y);
let map_to_screen = |mx: f32, my: f32| -> (u32, u32) {
let sx = ox + 2 + ((mx - min_x) * scale) as u32;
let sy = oy + 2 + ((max_y - my) * scale) as u32; (sx.min(ox + map_size - 1), sy.min(oy + map_size - 1))
};
let line_color = PackedRgba::rgb(0, 180, 0);
for linedef in &map.linedefs {
if linedef.v1 >= map.vertices.len() || linedef.v2 >= map.vertices.len() {
continue;
}
let v1 = &map.vertices[linedef.v1];
let v2 = &map.vertices[linedef.v2];
let (sx1, sy1) = map_to_screen(v1.x, v1.y);
let (sx2, sy2) = map_to_screen(v2.x, v2.y);
draw_line_fb(&mut self.framebuffer, sx1, sy1, sx2, sy2, line_color);
}
let (px, py) = map_to_screen(self.player.x, self.player.y);
let player_color = PackedRgba::rgb(255, 255, 0);
for dy in -1i32..=1 {
for dx in -1i32..=1 {
self.framebuffer.set_pixel(
(px as i32 + dx).max(0) as u32,
(py as i32 + dy).max(0) as u32,
player_color,
);
}
}
let dir_len = 6.0;
let dir_x = px as f32 + self.player.angle.cos() * dir_len;
let dir_y = py as f32 - self.player.angle.sin() * dir_len;
draw_line_fb(
&mut self.framebuffer,
px,
py,
dir_x as u32,
dir_y as u32,
player_color,
);
}
}
impl Default for DoomEngine {
fn default() -> Self {
let mut engine = Self::new();
engine.load_test_map();
engine
}
}
fn draw_line_fb(fb: &mut DoomFramebuffer, x0: u32, y0: u32, x1: u32, y1: u32, color: PackedRgba) {
let mut x0 = x0 as i32;
let mut y0 = y0 as i32;
let x1 = x1 as i32;
let y1 = y1 as i32;
let dx = (x1 - x0).abs();
let dy = -(y1 - y0).abs();
let sx = if x0 < x1 { 1 } else { -1 };
let sy = if y0 < y1 { 1 } else { -1 };
let mut err = dx + dy;
loop {
fb.set_pixel(x0 as u32, y0 as u32, color);
if x0 == x1 && y0 == y1 {
break;
}
let e2 = 2 * err;
if e2 >= dy {
err += dy;
x0 += sx;
}
if e2 <= dx {
err += dx;
y0 += sy;
}
}
}
fn generate_test_map() -> DoomMap {
use self::map::*;
let mut vertices = Vec::new();
let mut linedefs = Vec::new();
let mut sidedefs = Vec::new();
let mut sectors = Vec::new();
let mut segs = Vec::new();
let mut subsectors = Vec::new();
let mut nodes = Vec::new();
let room_size = 256.0f32;
let corridor_width = 64.0f32;
let wall_height = 128.0f32;
sectors.push(Sector {
floor_height: 0.0,
ceiling_height: wall_height,
floor_texture: "FLOOR0_1".into(),
ceiling_texture: "CEIL1_1".into(),
light_level: 200,
special: 0,
tag: 0,
});
sectors.push(Sector {
floor_height: 0.0,
ceiling_height: wall_height - 16.0,
floor_texture: "FLOOR0_1".into(),
ceiling_texture: "CEIL1_1".into(),
light_level: 160,
special: 0,
tag: 0,
});
sectors.push(Sector {
floor_height: 16.0,
ceiling_height: wall_height + 32.0,
floor_texture: "FLOOR0_1".into(),
ceiling_texture: "F_SKY1".into(),
light_level: 220,
special: 0,
tag: 0,
});
sectors.push(Sector {
floor_height: -16.0,
ceiling_height: wall_height - 32.0,
floor_texture: "FLOOR0_1".into(),
ceiling_texture: "CEIL1_1".into(),
light_level: 80,
special: 0,
tag: 0,
});
vertices.push(Vertex {
x: -room_size,
y: -room_size,
}); vertices.push(Vertex {
x: room_size,
y: -room_size,
}); vertices.push(Vertex {
x: room_size,
y: room_size,
}); vertices.push(Vertex {
x: -room_size,
y: room_size,
});
vertices.push(Vertex {
x: room_size,
y: -corridor_width,
}); vertices.push(Vertex {
x: room_size * 2.0,
y: -corridor_width,
}); vertices.push(Vertex {
x: room_size * 2.0,
y: corridor_width,
}); vertices.push(Vertex {
x: room_size,
y: corridor_width,
});
vertices.push(Vertex {
x: -corridor_width,
y: room_size,
}); vertices.push(Vertex {
x: corridor_width,
y: room_size,
}); vertices.push(Vertex {
x: corridor_width,
y: room_size * 2.0,
}); vertices.push(Vertex {
x: -corridor_width,
y: room_size * 2.0,
});
vertices.push(Vertex {
x: -corridor_width,
y: -room_size,
}); vertices.push(Vertex {
x: corridor_width,
y: -room_size,
}); vertices.push(Vertex {
x: corridor_width,
y: -room_size - 128.0,
}); vertices.push(Vertex {
x: -corridor_width,
y: -room_size - 128.0,
});
let main_side = sidedefs.len();
sidedefs.push(SideDef {
x_offset: 0.0,
y_offset: 0.0,
upper_texture: "-".into(),
lower_texture: "-".into(),
middle_texture: "STARTAN3".into(),
sector: 0,
});
let corr_side = sidedefs.len();
sidedefs.push(SideDef {
x_offset: 0.0,
y_offset: 0.0,
upper_texture: "-".into(),
lower_texture: "-".into(),
middle_texture: "STARG3".into(),
sector: 1,
});
let north_side = sidedefs.len();
sidedefs.push(SideDef {
x_offset: 0.0,
y_offset: 0.0,
upper_texture: "STARTAN3".into(),
lower_texture: "STARTAN3".into(),
middle_texture: "-".into(),
sector: 2,
});
let south_side = sidedefs.len();
sidedefs.push(SideDef {
x_offset: 0.0,
y_offset: 0.0,
upper_texture: "-".into(),
lower_texture: "-".into(),
middle_texture: "STARG3".into(),
sector: 3,
});
linedefs.push(LineDef {
v1: 0,
v2: 1,
flags: wad_types::ML_BLOCKING,
special: 0,
tag: 0,
front_sidedef: Some(main_side),
back_sidedef: None,
});
linedefs.push(LineDef {
v1: 1,
v2: 4,
flags: wad_types::ML_BLOCKING,
special: 0,
tag: 0,
front_sidedef: Some(main_side),
back_sidedef: None,
});
linedefs.push(LineDef {
v1: 4,
v2: 7,
flags: wad_types::ML_TWOSIDED,
special: 0,
tag: 0,
front_sidedef: Some(main_side),
back_sidedef: Some(corr_side),
});
linedefs.push(LineDef {
v1: 7,
v2: 2,
flags: wad_types::ML_BLOCKING,
special: 0,
tag: 0,
front_sidedef: Some(main_side),
back_sidedef: None,
});
linedefs.push(LineDef {
v1: 2,
v2: 9,
flags: wad_types::ML_BLOCKING,
special: 0,
tag: 0,
front_sidedef: Some(main_side),
back_sidedef: None,
});
linedefs.push(LineDef {
v1: 9,
v2: 8,
flags: wad_types::ML_TWOSIDED,
special: 0,
tag: 0,
front_sidedef: Some(main_side),
back_sidedef: Some(north_side),
});
linedefs.push(LineDef {
v1: 8,
v2: 3,
flags: wad_types::ML_BLOCKING,
special: 0,
tag: 0,
front_sidedef: Some(main_side),
back_sidedef: None,
});
linedefs.push(LineDef {
v1: 3,
v2: 0,
flags: wad_types::ML_BLOCKING,
special: 0,
tag: 0,
front_sidedef: Some(main_side),
back_sidedef: None,
});
linedefs.push(LineDef {
v1: 4,
v2: 5,
flags: wad_types::ML_BLOCKING,
special: 0,
tag: 0,
front_sidedef: Some(corr_side),
back_sidedef: None,
});
linedefs.push(LineDef {
v1: 5,
v2: 6,
flags: wad_types::ML_BLOCKING,
special: 0,
tag: 0,
front_sidedef: Some(corr_side),
back_sidedef: None,
});
linedefs.push(LineDef {
v1: 6,
v2: 7,
flags: wad_types::ML_BLOCKING,
special: 0,
tag: 0,
front_sidedef: Some(corr_side),
back_sidedef: None,
});
linedefs.push(LineDef {
v1: 9,
v2: 10,
flags: wad_types::ML_BLOCKING,
special: 0,
tag: 0,
front_sidedef: Some(north_side),
back_sidedef: None,
});
linedefs.push(LineDef {
v1: 10,
v2: 11,
flags: wad_types::ML_BLOCKING,
special: 0,
tag: 0,
front_sidedef: Some(north_side),
back_sidedef: None,
});
linedefs.push(LineDef {
v1: 11,
v2: 8,
flags: wad_types::ML_BLOCKING,
special: 0,
tag: 0,
front_sidedef: Some(north_side),
back_sidedef: None,
});
linedefs.push(LineDef {
v1: 12,
v2: 13,
flags: wad_types::ML_TWOSIDED,
special: 0,
tag: 0,
front_sidedef: Some(main_side),
back_sidedef: Some(south_side),
});
linedefs.push(LineDef {
v1: 13,
v2: 14,
flags: wad_types::ML_BLOCKING,
special: 0,
tag: 0,
front_sidedef: Some(south_side),
back_sidedef: None,
});
linedefs.push(LineDef {
v1: 14,
v2: 15,
flags: wad_types::ML_BLOCKING,
special: 0,
tag: 0,
front_sidedef: Some(south_side),
back_sidedef: None,
});
linedefs.push(LineDef {
v1: 15,
v2: 12,
flags: wad_types::ML_BLOCKING,
special: 0,
tag: 0,
front_sidedef: Some(south_side),
back_sidedef: None,
});
for (i, linedef) in linedefs.iter().enumerate() {
segs.push(Seg {
v1: linedef.v1,
v2: linedef.v2,
angle: geometry::point_to_angle(
vertices[linedef.v1].x,
vertices[linedef.v1].y,
vertices[linedef.v2].x,
vertices[linedef.v2].y,
),
linedef: i,
direction: 0,
offset: 0.0,
});
}
subsectors.push(SubSector {
num_segs: 8,
first_seg: 0,
});
subsectors.push(SubSector {
num_segs: 3,
first_seg: 8,
});
subsectors.push(SubSector {
num_segs: 3,
first_seg: 11,
});
subsectors.push(SubSector {
num_segs: 4,
first_seg: 14,
});
nodes.push(Node {
x: room_size,
y: 0.0,
dx: 0.0,
dy: 1.0,
bbox_right: [room_size * 2.0, -corridor_width, room_size, corridor_width],
bbox_left: [room_size * 2.0, -room_size - 128.0, -room_size, room_size],
right_child: NodeChild::SubSector(1), left_child: NodeChild::Node(1),
});
nodes.push(Node {
x: 0.0,
y: 0.0,
dx: 1.0,
dy: 0.0,
bbox_right: [room_size * 2.0, 0.0, -room_size, room_size],
bbox_left: [0.0, -room_size - 128.0, -room_size, room_size],
right_child: NodeChild::Node(2),
left_child: NodeChild::Node(3),
});
nodes.push(Node {
x: 0.0,
y: room_size,
dx: 1.0,
dy: 0.0,
bbox_right: [room_size * 2.0, room_size, -corridor_width, corridor_width],
bbox_left: [room_size, -room_size, -room_size, room_size],
right_child: NodeChild::SubSector(2), left_child: NodeChild::SubSector(0), });
nodes.push(Node {
x: 0.0,
y: -room_size,
dx: 1.0,
dy: 0.0,
bbox_right: [0.0, -room_size, -room_size, room_size],
bbox_left: [
-room_size,
-room_size - 128.0,
-corridor_width,
corridor_width,
],
right_child: NodeChild::SubSector(0), left_child: NodeChild::SubSector(3), });
let things = vec![map::Thing {
x: 0.0,
y: 0.0,
angle: 0.0,
thing_type: wad_types::THING_PLAYER1,
flags: wad_types::MTF_EASY | wad_types::MTF_NORMAL | wad_types::MTF_HARD,
}];
DoomMap {
name: "TEST".into(),
vertices,
linedefs,
sidedefs,
sectors,
segs,
subsectors,
nodes,
things,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn engine_default_creates_test_map() {
let engine = DoomEngine::default();
assert!(engine.map.is_some());
assert_eq!(engine.player.health, 100);
}
#[test]
fn engine_update_advances_time() {
let mut engine = DoomEngine::default();
engine.update(0.1);
assert!(engine.time > 0.0);
}
#[test]
fn engine_fire_sets_flash() {
let mut engine = DoomEngine::default();
engine.fire();
assert!((engine.fire_flash - 1.0).abs() < 0.01);
}
#[test]
fn engine_toggles() {
let mut engine = DoomEngine::default();
assert!(!engine.player.noclip);
engine.toggle_noclip();
assert!(engine.player.noclip);
engine.toggle_noclip();
assert!(!engine.player.noclip);
}
#[test]
fn test_map_has_player_start() {
let map = generate_test_map();
assert!(map.player_start().is_some());
}
#[test]
fn test_map_has_bsp_structure() {
let map = generate_test_map();
assert!(!map.nodes.is_empty());
assert!(!map.subsectors.is_empty());
assert!(!map.segs.is_empty());
}
#[test]
fn render_to_framebuffer() {
let mut engine = DoomEngine::default();
let mut painter = Painter::new(240, 160, crate::canvas::Mode::Braille);
engine.render(&mut painter, 120, 40, 1);
assert!(engine.frame > 0);
}
#[test]
fn new_engine_has_no_map() {
let engine = DoomEngine::new();
assert!(engine.map.is_none());
assert_eq!(engine.frame, 0);
assert_eq!(engine.time, 0.0);
assert_eq!(engine.fire_flash, 0.0);
}
#[test]
fn new_engine_default_flags() {
let engine = DoomEngine::new();
assert!(!engine.show_minimap);
assert!(!engine.show_perf);
assert!(engine.show_crosshair);
assert!(!engine.original_palette);
assert!(engine.vsync);
assert!(engine.show_hud);
}
#[test]
fn load_test_map_sets_map() {
let mut engine = DoomEngine::new();
assert!(engine.map.is_none());
engine.load_test_map();
assert!(engine.map.is_some());
}
#[test]
fn load_test_map_spawns_player_at_origin() {
let mut engine = DoomEngine::new();
engine.load_test_map();
assert!((engine.player.x).abs() < 0.01);
assert!((engine.player.y).abs() < 0.01);
}
#[test]
fn move_forward_changes_player_velocity() {
let mut engine = DoomEngine::default();
let old_x = engine.player.x;
engine.move_forward(1.0);
let speed_sq =
engine.player.mom_x * engine.player.mom_x + engine.player.mom_y * engine.player.mom_y;
assert!(
speed_sq > 0.0 || engine.player.x != old_x,
"move_forward should affect player"
);
}
#[test]
fn strafe_changes_player_velocity() {
let mut engine = DoomEngine::default();
engine.strafe(1.0);
let speed_sq =
engine.player.mom_x * engine.player.mom_x + engine.player.mom_y * engine.player.mom_y;
assert!(speed_sq > 0.0, "strafe should add velocity");
}
#[test]
fn look_changes_player_angle() {
let mut engine = DoomEngine::default();
let original_angle = engine.player.angle;
engine.look(0.5, 0.0);
assert!(
(engine.player.angle - original_angle).abs() > 0.01,
"look should change angle"
);
}
#[test]
fn toggle_god_mode_flips() {
let mut engine = DoomEngine::default();
assert!(!engine.player.god_mode);
engine.toggle_god_mode();
assert!(engine.player.god_mode);
engine.toggle_god_mode();
assert!(!engine.player.god_mode);
}
#[test]
fn toggle_run_flips() {
let mut engine = DoomEngine::default();
assert!(!engine.player.running);
engine.toggle_run();
assert!(engine.player.running);
engine.toggle_run();
assert!(!engine.player.running);
}
#[test]
fn update_accumulates_time() {
let mut engine = DoomEngine::default();
engine.update(0.01);
engine.update(0.02);
assert!((engine.time - 0.03).abs() < 1e-6);
}
#[test]
fn update_fires_game_tick_when_enough_time() {
let mut engine = DoomEngine::default();
engine.player.mom_x = 100.0;
engine.update(DOOM_TICK_SECS + 0.001);
}
#[test]
fn fire_flash_decays_over_time() {
let mut engine = DoomEngine::default();
engine.fire();
assert!((engine.fire_flash - 1.0).abs() < 0.01);
engine.update(0.5);
assert!(
engine.fire_flash < 1.0,
"flash should decay: {}",
engine.fire_flash
);
}
#[test]
fn fire_flash_reaches_zero_eventually() {
let mut engine = DoomEngine::default();
engine.fire();
for _ in 0..100 {
engine.update(0.05);
}
assert!(
engine.fire_flash.abs() < 0.01,
"flash should reach ~0 after 5s: {}",
engine.fire_flash
);
}
#[test]
fn render_without_map_does_not_panic() {
let mut engine = DoomEngine::new();
let mut painter = Painter::new(120, 80, crate::canvas::Mode::Braille);
engine.render(&mut painter, 60, 20, 1);
assert_eq!(engine.frame, 1);
}
#[test]
fn render_increments_frame_counter() {
let mut engine = DoomEngine::default();
let mut painter = Painter::new(120, 80, crate::canvas::Mode::Braille);
engine.render(&mut painter, 60, 20, 1);
assert_eq!(engine.frame, 1);
engine.render(&mut painter, 60, 20, 1);
assert_eq!(engine.frame, 2);
}
#[test]
fn render_with_crosshair_disabled() {
let mut engine = DoomEngine {
show_crosshair: false,
..DoomEngine::default()
};
let mut painter = Painter::new(120, 80, crate::canvas::Mode::Braille);
engine.render(&mut painter, 60, 20, 1);
assert_eq!(engine.frame, 1);
}
#[test]
fn render_with_minimap_enabled() {
let mut engine = DoomEngine {
show_minimap: true,
..DoomEngine::default()
};
let mut painter = Painter::new(240, 160, crate::canvas::Mode::Braille);
engine.render(&mut painter, 120, 40, 1);
assert_eq!(engine.frame, 1);
}
#[test]
fn render_with_muzzle_flash() {
let mut engine = DoomEngine::default();
engine.fire();
let mut painter = Painter::new(240, 160, crate::canvas::Mode::Braille);
engine.render(&mut painter, 120, 40, 1);
assert_eq!(engine.frame, 1);
}
#[test]
fn draw_line_horizontal() {
let mut fb = DoomFramebuffer::new(20, 10);
let color = PackedRgba::rgb(255, 0, 0);
draw_line_fb(&mut fb, 2, 5, 8, 5, color);
for x in 2..=8 {
assert_eq!(fb.get_pixel(x, 5), color, "pixel at ({x}, 5) should be set");
}
}
#[test]
fn draw_line_vertical() {
let mut fb = DoomFramebuffer::new(10, 20);
let color = PackedRgba::rgb(0, 255, 0);
draw_line_fb(&mut fb, 5, 2, 5, 8, color);
for y in 2..=8 {
assert_eq!(fb.get_pixel(5, y), color, "pixel at (5, {y}) should be set");
}
}
#[test]
fn draw_line_single_point() {
let mut fb = DoomFramebuffer::new(10, 10);
let color = PackedRgba::rgb(0, 0, 255);
draw_line_fb(&mut fb, 5, 5, 5, 5, color);
assert_eq!(fb.get_pixel(5, 5), color);
}
#[test]
fn draw_line_diagonal() {
let mut fb = DoomFramebuffer::new(10, 10);
let color = PackedRgba::rgb(255, 255, 0);
draw_line_fb(&mut fb, 0, 0, 5, 5, color);
assert_eq!(fb.get_pixel(0, 0), color);
assert_eq!(fb.get_pixel(5, 5), color);
}
#[test]
fn test_map_has_sectors() {
let map = generate_test_map();
assert!(!map.sectors.is_empty());
}
#[test]
fn test_map_has_linedefs_and_sidedefs() {
let map = generate_test_map();
assert!(!map.linedefs.is_empty());
assert!(!map.sidedefs.is_empty());
}
#[test]
fn test_map_has_vertices() {
let map = generate_test_map();
assert!(!map.vertices.is_empty());
}
#[test]
fn default_engine_has_test_map_loaded() {
let engine = DoomEngine::default();
assert!(engine.map.is_some());
let map = engine.map.as_ref().unwrap();
assert_eq!(map.name, "TEST");
}
#[test]
fn full_gameplay_sequence() {
let mut engine = DoomEngine::default();
engine.move_forward(1.0);
engine.strafe(0.5);
engine.look(0.1, 0.0);
engine.update(DOOM_TICK_SECS * 3.0);
engine.fire();
engine.toggle_run();
engine.toggle_noclip();
engine.toggle_god_mode();
let mut painter = Painter::new(120, 80, crate::canvas::Mode::Braille);
engine.render(&mut painter, 60, 20, 1);
assert_eq!(engine.frame, 1);
assert!(engine.time > 0.0);
assert!(engine.player.running);
assert!(engine.player.noclip);
assert!(engine.player.god_mode);
}
#[test]
fn draw_line_reverse_horizontal() {
let mut fb = DoomFramebuffer::new(20, 10);
let color = PackedRgba::rgb(255, 0, 0);
draw_line_fb(&mut fb, 8, 5, 2, 5, color);
for x in 2..=8 {
assert_eq!(fb.get_pixel(x, 5), color, "pixel at ({x}, 5)");
}
}
#[test]
fn draw_line_reverse_vertical() {
let mut fb = DoomFramebuffer::new(10, 20);
let color = PackedRgba::rgb(0, 255, 0);
draw_line_fb(&mut fb, 5, 8, 5, 2, color);
for y in 2..=8 {
assert_eq!(fb.get_pixel(5, y), color, "pixel at (5, {y})");
}
}
#[test]
fn draw_line_steep_slope() {
let mut fb = DoomFramebuffer::new(10, 20);
let color = PackedRgba::rgb(128, 128, 0);
draw_line_fb(&mut fb, 2, 1, 4, 10, color);
assert_eq!(fb.get_pixel(2, 1), color);
assert_eq!(fb.get_pixel(4, 10), color);
}
#[test]
fn draw_line_gentle_slope() {
let mut fb = DoomFramebuffer::new(20, 10);
let color = PackedRgba::rgb(0, 128, 128);
draw_line_fb(&mut fb, 1, 2, 10, 4, color);
assert_eq!(fb.get_pixel(1, 2), color);
assert_eq!(fb.get_pixel(10, 4), color);
}
#[test]
fn draw_line_at_origin() {
let mut fb = DoomFramebuffer::new(10, 10);
let color = PackedRgba::rgb(200, 200, 200);
draw_line_fb(&mut fb, 0, 0, 0, 0, color);
assert_eq!(fb.get_pixel(0, 0), color);
}
#[test]
fn draw_line_reverse_diagonal() {
let mut fb = DoomFramebuffer::new(10, 10);
let color = PackedRgba::rgb(100, 50, 200);
draw_line_fb(&mut fb, 7, 7, 2, 2, color);
assert_eq!(fb.get_pixel(7, 7), color);
assert_eq!(fb.get_pixel(2, 2), color);
for i in 2..=7 {
assert_eq!(fb.get_pixel(i, i), color, "pixel at ({i}, {i})");
}
}
#[test]
fn lifecycle_zero_dt_update() {
let mut engine = DoomEngine::default();
engine.update(0.0);
assert_eq!(engine.time, 0.0);
assert_eq!(engine.frame, 0);
}
#[test]
fn lifecycle_very_small_dt_no_tick() {
let mut engine = DoomEngine::default();
let tiny_dt = DOOM_TICK_SECS * 0.01;
engine.player.mom_x = 100.0;
let old_x = engine.player.x;
engine.update(tiny_dt);
assert!(engine.time > 0.0);
assert!(
(engine.player.x - old_x).abs() < 0.01,
"no tick should have fired"
);
}
#[test]
fn lifecycle_update_without_render() {
let mut engine = DoomEngine::default();
for _ in 0..10 {
engine.update(DOOM_TICK_SECS);
}
assert!(engine.time > 0.0);
assert_eq!(engine.frame, 0, "frame should not advance without render");
}
#[test]
fn lifecycle_render_without_update() {
let mut engine = DoomEngine::default();
let mut painter = Painter::new(120, 80, crate::canvas::Mode::Braille);
engine.render(&mut painter, 60, 20, 1);
assert_eq!(engine.frame, 1);
assert_eq!(engine.time, 0.0, "time should not advance without update");
}
#[test]
fn lifecycle_fire_flash_fully_decays() {
let mut engine = DoomEngine::default();
engine.fire();
assert!((engine.fire_flash - 1.0).abs() < f32::EPSILON);
engine.update(0.2);
assert_eq!(engine.fire_flash, 0.0, "flash should be clamped to 0.0");
}
#[test]
fn lifecycle_all_controls_in_sequence() {
let mut engine = DoomEngine::default();
engine.move_forward(1.0);
engine.strafe(-1.0);
engine.look(0.3, 0.1);
engine.fire();
engine.toggle_noclip();
engine.toggle_god_mode();
engine.toggle_run();
engine.update(DOOM_TICK_SECS * 5.0);
let mut painter = Painter::new(120, 80, crate::canvas::Mode::Braille);
engine.render(&mut painter, 60, 20, 1);
engine.render(&mut painter, 60, 20, 1);
assert_eq!(engine.frame, 2);
assert!(engine.player.noclip);
assert!(engine.player.god_mode);
assert!(engine.player.running);
}
#[test]
fn lifecycle_render_various_strides() {
let mut engine = DoomEngine::default();
let mut painter = Painter::new(240, 160, crate::canvas::Mode::Braille);
for stride in [1, 2, 3, 4] {
engine.render(&mut painter, 120, 40, stride);
}
assert_eq!(engine.frame, 4);
}
#[test]
fn render_minimap_without_map_is_noop() {
let mut engine = DoomEngine::new(); engine.show_minimap = true;
let mut painter = Painter::new(240, 160, crate::canvas::Mode::Braille);
engine.render(&mut painter, 120, 40, 1);
assert_eq!(engine.frame, 1);
}
#[test]
fn draw_crosshair_sets_arm_pixels() {
let mut engine = DoomEngine {
show_crosshair: true,
show_minimap: false,
..DoomEngine::default()
};
let mut painter = Painter::new(
engine.fb_width as u16 * 2,
engine.fb_height as u16 * 2,
crate::canvas::Mode::Braille,
);
engine.render(
&mut painter,
engine.fb_width as u16,
engine.fb_height as u16,
1,
);
let cx = engine.framebuffer.width / 2;
let cy = engine.framebuffer.height / 2;
let white = PackedRgba::rgb(255, 255, 255);
for i in 1..=3u32 {
assert_eq!(engine.framebuffer.get_pixel(cx + i, cy), white);
assert_eq!(engine.framebuffer.get_pixel(cx - i, cy), white);
assert_eq!(engine.framebuffer.get_pixel(cx, cy + i), white);
assert_eq!(engine.framebuffer.get_pixel(cx, cy - i), white);
}
}
#[test]
fn draw_muzzle_flash_zero_intensity_skipped() {
let mut engine = DoomEngine {
fire_flash: 0.0,
show_crosshair: false,
..DoomEngine::default()
};
let mut painter = Painter::new(240, 160, crate::canvas::Mode::Braille);
engine.render(&mut painter, 120, 40, 1);
assert_eq!(engine.frame, 1);
}
#[test]
fn render_resizes_framebuffer_on_dimension_change() {
let mut engine = DoomEngine::default();
let mut painter = Painter::new(480, 320, crate::canvas::Mode::Braille);
engine.render(&mut painter, 240, 80, 1);
let old_w = engine.framebuffer.width;
let old_h = engine.framebuffer.height;
engine.fb_width = 160;
engine.fb_height = 100;
engine.render(&mut painter, 80, 25, 1);
assert_eq!(engine.framebuffer.width, 160);
assert_eq!(engine.framebuffer.height, 100);
assert!(
engine.framebuffer.width != old_w || engine.framebuffer.height != old_h,
"framebuffer should have been resized"
);
assert_eq!(engine.frame, 2);
}
#[test]
fn test_map_has_four_sectors() {
let map = generate_test_map();
assert_eq!(map.sectors.len(), 4);
}
#[test]
fn test_map_has_things_with_player_start() {
let map = generate_test_map();
assert!(!map.things.is_empty());
let start = map.player_start();
assert!(start.is_some());
let (x, y, _angle) = start.unwrap();
assert!((x).abs() < 0.01);
assert!((y).abs() < 0.01);
}
#[test]
fn game_tick_with_no_map_is_noop() {
let mut engine = DoomEngine::new(); engine.player.mom_x = 10.0;
let old_x = engine.player.x;
engine.update(DOOM_TICK_SECS * 3.0);
assert_eq!(engine.player.x, old_x, "no map means no tick processing");
}
#[test]
fn multiple_game_ticks_in_single_update() {
let mut engine = DoomEngine::default();
engine.player.mom_x = 10.0;
let old_x = engine.player.x;
engine.update(DOOM_TICK_SECS * 3.5);
assert!(
(engine.player.x - old_x).abs() > 1.0,
"multiple ticks should move player, dx={}",
engine.player.x - old_x
);
}
#[test]
fn render_all_overlays_simultaneously() {
let mut engine = DoomEngine {
show_crosshair: true,
show_minimap: true,
..Default::default()
};
engine.fire();
let mut painter = Painter::new(480, 320, crate::canvas::Mode::Braille);
engine.render(&mut painter, 240, 80, 1);
assert_eq!(engine.frame, 1);
}
#[test]
fn test_map_sky_ceiling_sector() {
let map = generate_test_map();
assert!(
map.sectors[2].is_sky_ceiling(),
"sector 2 should have sky ceiling"
);
assert!(
!map.sectors[0].is_sky_ceiling(),
"sector 0 should not have sky ceiling"
);
}
#[test]
fn test_map_two_sided_linedefs_present() {
let map = generate_test_map();
let two_sided_count = map.linedefs.iter().filter(|l| l.is_two_sided()).count();
assert!(
two_sided_count >= 3,
"test map should have at least 3 two-sided linedefs, got {two_sided_count}"
);
}
#[test]
fn test_map_subsector_seg_ranges_valid() {
let map = generate_test_map();
for ss in &map.subsectors {
let end = ss.first_seg + ss.num_segs as usize;
assert!(
end <= map.segs.len(),
"subsector seg range {}-{} exceeds segs len {}",
ss.first_seg,
end,
map.segs.len()
);
}
}
#[test]
fn test_map_node_children_valid() {
let map = generate_test_map();
for (i, node) in map.nodes.iter().enumerate() {
match node.right_child {
map::NodeChild::Node(n) => assert!(
n < map.nodes.len(),
"node {i} right child node index {n} out of bounds"
),
map::NodeChild::SubSector(s) => assert!(
s < map.subsectors.len(),
"node {i} right child subsector index {s} out of bounds"
),
}
match node.left_child {
map::NodeChild::Node(n) => assert!(
n < map.nodes.len(),
"node {i} left child node index {n} out of bounds"
),
map::NodeChild::SubSector(s) => assert!(
s < map.subsectors.len(),
"node {i} left child subsector index {s} out of bounds"
),
}
}
}
#[test]
fn render_player_rotation_no_crash() {
let mut engine = DoomEngine::default();
let mut painter = Painter::new(240, 160, crate::canvas::Mode::Braille);
for angle in [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0] {
engine.player.angle = angle;
engine.render(&mut painter, 120, 40, 1);
}
assert_eq!(engine.frame, 7);
}
#[test]
fn engine_debug_impl() {
let engine = DoomEngine::default();
let debug_str = format!("{:?}", engine);
assert!(!debug_str.is_empty());
assert!(debug_str.contains("DoomEngine"));
}
}