neurodoom 0.6.7

Deterministic no_std Doom engine with semantic and depth perception buffers for AI
Documentation
//! Playable single-player Doom — renders neurodoom in a minifb window
//! and progresses through the episode when the player reaches an exit.
//!
//! Run: cargo run --release --example classic
//!
//! Controls:
//!   W/S       — forward/back
//!   A/D       — strafe left/right
//!   Left/Right — turn
//!   Space     — use (open doors, hit switches, exit)
//!   E/Ctrl    — shoot
//!   Tab       — cycle view (normal -> semantic -> depth)
//!   Esc       — quit

use minifb::{Key, Window, WindowOptions};

use neurodoom::engine::ClassicEngine;
use neurodoom::math::{SCREENHEIGHT, SCREENWIDTH};
use neurodoom::render::SemanticClass;
use neurodoom::rules::PlayerAction;
use neurodoom::types::Button;
use neurodoom::world::{LevelExit, PeerId};

#[path = "common.rs"]
mod common;

const SCALE: usize = 3;

fn main() {
    let (wad_data, wad_path) = common::load_wad()
        .unwrap_or_else(|e| panic!("failed to read WAD at {wad_path:?}: {e}",
            wad_path = std::env::var_os("DOOM_WAD").unwrap_or_else(|| common::DEFAULT_WAD_PATH.into())));

    let mut map_name = common::arg_or("E1M1");
    eprintln!("Loaded WAD {wad_path:?}, starting on {map_name}");

    let mut engine = ClassicEngine::new(&wad_data, &map_name)
        .unwrap_or_else(|e| panic!("failed to init engine: {e}"));

    let mut window = Window::new(
        "neurodoom — WASD:move ←→:turn Space:use E:shoot Tab:view 1-7:weapon",
        SCREENWIDTH * SCALE,
        SCREENHEIGHT * SCALE,
        WindowOptions::default(),
    )
    .expect("failed to create window");

    window.set_target_fps(35);

    arm_player(&mut engine);

    eprintln!(
        "Map things: {}, Entities: {}",
        engine.map().things.len(),
        engine.world().entity_count()
    );

    let mut fb = vec![0u32; SCREENWIDTH * SCREENHEIGHT];
    // 0 = normal, 1 = semantic, 2 = depth
    let mut view_mode: u8 = 0;

    while window.is_open() && !window.is_key_down(Key::Escape) {
        let cmd = build_cmd(&window);

        if window.is_key_pressed(Key::Tab, minifb::KeyRepeat::No) {
            view_mode = (view_mode + 1) % 3;
        }

        engine.tick_single(PeerId(0), cmd);

        // Level progression: when the current map's exit is triggered,
        // pick the next map and reload. Ends the game at end-of-episode.
        if engine.level_complete() {
            let exit = engine.level_exit();
            match next_map(&map_name, exit == LevelExit::Secret) {
                Some(next) => {
                    eprintln!("{map_name} cleared ({exit:?}); loading {next}");
                    map_name = next;
                    if let Err(e) = engine.load_map(&wad_data, &map_name) {
                        eprintln!("could not load {map_name}: {e}");
                        break;
                    }
                    engine.spawn_map_things(PeerId(0));
                    arm_player(&mut engine);
                }
                None => {
                    eprintln!("{map_name} cleared ({exit:?}); end of episode");
                    break;
                }
            }
        }

        match view_mode {
            1 => {
                for (i, &class) in engine.semantic_buffer().iter().enumerate() {
                    fb[i] = SemanticClass::from_u8(class).to_rgb();
                }
            }
            2 => {
                for (i, &d) in engine.depth_buffer().iter().enumerate() {
                    fb[i] = common::depth_to_rgb(d);
                }
            }
            _ => {
                let rgba = engine.framebuffer();
                for (i, pixel) in fb.iter_mut().enumerate() {
                    let base = i * 4;
                    let r = rgba[base] as u32;
                    let g = rgba[base + 1] as u32;
                    let b = rgba[base + 2] as u32;
                    *pixel = (r << 16) | (g << 8) | b;
                }
            }
        }

        window
            .update_with_buffer(&fb, SCREENWIDTH, SCREENHEIGHT)
            .unwrap();
    }
}

/// Hand the player a usable loadout every time a map is (re)loaded.
/// Plasma (6) and BFG (7) sprites don't exist in shareware doom1.wad, so
/// those weapon slots stay un-owned.
fn arm_player(engine: &mut ClassicEngine) {
    if let Some(pid) = engine.world().controlled_by(PeerId(0))
        && let Some(ps) = engine.world_mut().player_state_mut(pid)
    {
        // [Fist, Pistol, Shotgun, Chaingun, Rocket, Plasma, BFG, Chainsaw, SSG]
        ps.weapon_owned = [true, true, true, true, true, false, false, true, false];
        ps.ammo = [999, 99, 999, 99];
    }
}

/// Next map in Doom 1's episodic progression. Returns `None` at the end
/// of an episode (after ExM8). Secret exits on ExM3 route to ExM9; ExM9
/// loops back into the episode at ExM4.
fn next_map(current: &str, secret: bool) -> Option<String> {
    let b = current.as_bytes();
    if b.len() != 4 || b[0] != b'E' || b[2] != b'M' {
        return None;
    }
    let ep = b[1].checked_sub(b'0')?;
    let map = b[3].checked_sub(b'0')?;
    if !(1..=3).contains(&ep) || !(1..=9).contains(&map) {
        return None;
    }
    let next_map = match (map, secret) {
        (3, true) => 9,   // ExM3 secret exit -> ExM9
        (9, _) => 4,      // ExM9 -> ExM4
        (8, _) => return None, // end of episode
        (m, _) => m + 1,
    };
    Some(format!("E{ep}M{next_map}"))
}

fn build_cmd(window: &Window) -> PlayerAction {
    let mut cmd = PlayerAction::default();

    if window.is_key_down(Key::W) || window.is_key_down(Key::Up) {
        cmd.forward_move = 25;
    }
    if window.is_key_down(Key::S) || window.is_key_down(Key::Down) {
        cmd.forward_move = -25;
    }
    if window.is_key_down(Key::A) {
        cmd.side_move = -20;
    }
    if window.is_key_down(Key::D) {
        cmd.side_move = 20;
    }
    if window.is_key_down(Key::Left) {
        cmd.angle_turn = 512;
    }
    if window.is_key_down(Key::Right) {
        cmd.angle_turn = -512;
    }
    if window.is_key_down(Key::Space) {
        cmd.buttons |= Button::Use;
    }
    if window.is_key_down(Key::E) || window.is_key_down(Key::LeftCtrl) {
        cmd.buttons |= Button::Attack;
    }

    // Weapon selection via number keys
    // 1=Fist 2=Pistol 3=Shotgun 4=Chaingun 5=Rocket 6=Chainsaw
    for (key, slot) in [
        (Key::Key1, 1), (Key::Key2, 2), (Key::Key3, 3), (Key::Key4, 4),
        (Key::Key5, 5), (Key::Key6, 8),
    ] {
        if window.is_key_down(key) {
            cmd.weapon_select = slot;
            break;
        }
    }

    cmd
}