feat: window mode with minifb — direct keyboard polling, no terminal
New --mode window (now default): - minifb creates a real OS window (no terminal needed) - fontdue rasterizes DejaVu Sans Mono glyphs to pixel buffer - ASCII characters rendered as colored pixels — same aesthetic - Keyboard polled via get_pressed_keys() every frame: - Instant response, no terminal delay - Proper multi-key support (W+A strafing works) - No Release event hacks needed - No key repeat timeout hacks - Instant stop when key released Controls: WASD/arrows, 1-9/0 paint, X erase, HJKL camera, Q/Esc quit --mode terminal still available as fallback 109 tests, 0 warnings, 0 failures
This commit is contained in:
@@ -8,6 +8,8 @@ crossterm = "0.28"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
minifb = "0.27"
|
||||
fontdue = "0.9"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
+96
-1
@@ -1,13 +1,16 @@
|
||||
use clap::Parser;
|
||||
use verbatim::game::Game;
|
||||
use verbatim::render::terminal::TerminalRenderer;
|
||||
use verbatim::render::window::WindowRenderer;
|
||||
use verbatim::render::window_input::WindowInput;
|
||||
use verbatim::world::cell::MaterialId;
|
||||
use verbatim::ai;
|
||||
use std::io::Write;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "verbatim", about = "ASCII physics RPG - Noita meets Caves of Qud")]
|
||||
struct Cli {
|
||||
#[arg(long, default_value = "terminal")]
|
||||
#[arg(long, default_value = "window")]
|
||||
mode: String,
|
||||
|
||||
#[arg(long, default_value_t = 0)]
|
||||
@@ -27,6 +30,9 @@ fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.mode.as_str() {
|
||||
"window" => {
|
||||
run_window_mode();
|
||||
}
|
||||
"terminal" => {
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
let _ = crossterm::terminal::disable_raw_mode();
|
||||
@@ -194,3 +200,92 @@ fn player_info(game: &Game) -> String {
|
||||
"None".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn run_window_mode() {
|
||||
let mut renderer = WindowRenderer::new();
|
||||
let mut game = Game::new();
|
||||
game.init_world();
|
||||
|
||||
let mut input = WindowInput::new();
|
||||
let vw = renderer.width();
|
||||
let vh = renderer.height();
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
let fixed_dt = Duration::from_millis(16);
|
||||
let mut last_time = Instant::now();
|
||||
let mut accumulator = Duration::ZERO;
|
||||
|
||||
while renderer.is_open() && game.running {
|
||||
let now = Instant::now();
|
||||
let frame_time = now.duration_since(last_time);
|
||||
last_time = now;
|
||||
accumulator += frame_time;
|
||||
|
||||
while accumulator >= fixed_dt {
|
||||
game.fixed_update();
|
||||
accumulator -= fixed_dt;
|
||||
}
|
||||
|
||||
let keys = renderer.get_pressed_keys();
|
||||
input.update(&keys);
|
||||
|
||||
if input.quit {
|
||||
break;
|
||||
}
|
||||
|
||||
if input.jump {
|
||||
let on_ground = game.check_on_ground();
|
||||
game.player.jump(&mut game.entities, on_ground);
|
||||
}
|
||||
|
||||
if input.left {
|
||||
game.player.move_left(&mut game.entities);
|
||||
} else if input.right {
|
||||
game.player.move_right(&mut game.entities);
|
||||
} else {
|
||||
game.player.stop_horizontal(&mut game.entities);
|
||||
}
|
||||
|
||||
if input.cam_left { game.cam_x -= 3; }
|
||||
if input.cam_right { game.cam_x += 3; }
|
||||
if input.cam_up { game.cam_y -= 3; }
|
||||
if input.cam_down { game.cam_y += 3; }
|
||||
|
||||
if let Some(brush_id) = input.paint {
|
||||
let mat = match brush_id {
|
||||
1 => MaterialId::Sand,
|
||||
2 => MaterialId::Water,
|
||||
3 => MaterialId::Stone,
|
||||
4 => MaterialId::Lava,
|
||||
5 => MaterialId::Wood,
|
||||
6 => MaterialId::Acid,
|
||||
7 => MaterialId::Grass,
|
||||
8 => MaterialId::Dirt,
|
||||
9 => MaterialId::Fire,
|
||||
0 => MaterialId::Flesh,
|
||||
99 => MaterialId::Empty,
|
||||
_ => continue,
|
||||
};
|
||||
let cx = game.cam_x + (vw as i32 / 2);
|
||||
let cy = game.cam_y + (vh as i32 / 2);
|
||||
let r = 2;
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
if dx * dx + dy * dy <= r * r + 1 {
|
||||
if mat == MaterialId::Empty {
|
||||
game.grid.set(cx + dx, cy + dy, verbatim::world::cell::Cell::empty());
|
||||
} else {
|
||||
game.grid.set_material(cx + dx, cy + dy, mat);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (px, py) = game.player.center(&game.entities);
|
||||
game.cam_x = px as i32 - (vw as i32 / 2);
|
||||
game.cam_y = py as i32 - (vh as i32 / 2);
|
||||
|
||||
renderer.render(&game.grid, &game.entities, game.cam_x, game.cam_y);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod terminal;
|
||||
pub mod window;
|
||||
pub mod window_input;
|
||||
|
||||
use crate::entity::EntityManager;
|
||||
use crate::world::grid::Grid;
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
use minifb::{Key, KeyRepeat, Window, WindowOptions};
|
||||
use fontdue::{Font, FontSettings};
|
||||
use crate::entity::{EntityManager, EntityKind};
|
||||
use crate::world::cell::MaterialId;
|
||||
use crate::world::grid::Grid;
|
||||
use crate::world::material::MaterialRegistry;
|
||||
|
||||
const CHAR_W: usize = 8;
|
||||
const CHAR_H: usize = 16;
|
||||
|
||||
pub struct WindowRenderer {
|
||||
window: Window,
|
||||
font: Font,
|
||||
glyph_cache: std::collections::HashMap<(char, [u8; 3]), Vec<u32>>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
pixels: Vec<u32>,
|
||||
}
|
||||
|
||||
impl WindowRenderer {
|
||||
pub fn new() -> Self {
|
||||
let font_bytes: &[u8] = include_bytes!("../../assets/DejaVuSansMono.ttf");
|
||||
let font = Font::from_bytes(font_bytes, FontSettings {
|
||||
collection_index: 0,
|
||||
scale: CHAR_H as f32,
|
||||
load_substitutions: false,
|
||||
}).expect("Failed to load font");
|
||||
|
||||
let width = 160;
|
||||
let height = 50;
|
||||
|
||||
let window = Window::new(
|
||||
"Verbatim",
|
||||
width * CHAR_W,
|
||||
height * CHAR_H,
|
||||
WindowOptions {
|
||||
resize: true,
|
||||
..WindowOptions::default()
|
||||
},
|
||||
).expect("Failed to create window");
|
||||
|
||||
Self {
|
||||
window,
|
||||
font,
|
||||
glyph_cache: std::collections::HashMap::new(),
|
||||
width,
|
||||
height,
|
||||
pixels: vec![0u32; width * CHAR_W * height * CHAR_H],
|
||||
}
|
||||
}
|
||||
|
||||
fn rgb_to_u32(r: u8, g: u8, b: u8) -> u32 {
|
||||
((r as u32) << 16) | ((g as u32) << 8) | (b as u32)
|
||||
}
|
||||
|
||||
fn get_glyph(&mut self, ch: char, fg: [u8; 3]) -> Vec<u32> {
|
||||
let key = (ch, fg);
|
||||
if !self.glyph_cache.contains_key(&key) {
|
||||
let (metrics, bitmap) = self.font.rasterize(ch, CHAR_H as f32);
|
||||
let gw = metrics.width;
|
||||
let gh = metrics.height;
|
||||
let mut pixels = vec![0u32; CHAR_W * CHAR_H];
|
||||
for y in 0..gh.min(CHAR_H) {
|
||||
for x in 0..gw.min(CHAR_W) {
|
||||
let alpha = bitmap[y * gw + x] as f32 / 255.0;
|
||||
if alpha > 0.01 {
|
||||
let px = (x as i32 + metrics.xmin).max(0) as usize;
|
||||
let py = (y as i32 + CHAR_H as i32 - gh as i32 - metrics.ymin).max(0) as usize;
|
||||
if px < CHAR_W && py < CHAR_H {
|
||||
pixels[py * CHAR_W + px] = Self::rgb_to_u32(
|
||||
(fg[0] as f32 * alpha) as u8,
|
||||
(fg[1] as f32 * alpha) as u8,
|
||||
(fg[2] as f32 * alpha) as u8,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.glyph_cache.insert(key, pixels);
|
||||
}
|
||||
self.glyph_cache[&key].clone()
|
||||
}
|
||||
|
||||
fn draw_cell(&mut self, col: usize, row: usize, ch: char, fg: [u8; 3], bg: [u8; 3]) {
|
||||
let bg_u32 = Self::rgb_to_u32(bg[0], bg[1], bg[2]);
|
||||
let glyph = self.get_glyph(ch, fg);
|
||||
|
||||
let base_x = col * CHAR_W;
|
||||
let base_y = row * CHAR_H;
|
||||
let screen_w = self.width * CHAR_W;
|
||||
let screen_h = self.height * CHAR_H;
|
||||
|
||||
for y in 0..CHAR_H {
|
||||
for x in 0..CHAR_W {
|
||||
let px = base_x + x;
|
||||
let py = base_y + y;
|
||||
if px >= screen_w || py >= screen_h {
|
||||
continue;
|
||||
}
|
||||
let gp = glyph[y * CHAR_W + x];
|
||||
if gp != 0 {
|
||||
self.pixels[py * screen_w + px] = gp;
|
||||
} else {
|
||||
self.pixels[py * screen_w + px] = bg_u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&mut self, grid: &Grid, entities: &EntityManager, cam_x: i32, cam_y: i32) {
|
||||
let reg = MaterialRegistry::instance();
|
||||
|
||||
let mut entity_map = std::collections::HashMap::new();
|
||||
for e in entities.all() {
|
||||
for b in &e.bodies {
|
||||
if !b.alive { continue; }
|
||||
let sx = b.x as i32 - cam_x;
|
||||
let sy = b.y as i32 - cam_y;
|
||||
if sx >= 0 && sx < self.width as i32 && sy >= 0 && sy < self.height as i32 {
|
||||
let ch = match e.kind {
|
||||
EntityKind::Player if e.alive => '@',
|
||||
EntityKind::Goblin if e.alive => 'g',
|
||||
_ => '%',
|
||||
};
|
||||
let fg = if e.on_fire {
|
||||
[255, 160, 40]
|
||||
} else if !e.alive {
|
||||
[100, 60, 60]
|
||||
} else {
|
||||
match e.kind {
|
||||
EntityKind::Player => [255, 255, 100],
|
||||
EntityKind::Goblin => [100, 220, 100],
|
||||
_ => [180, 50, 50],
|
||||
}
|
||||
};
|
||||
entity_map.insert((sx, sy), (ch, fg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for dy in 0..self.height {
|
||||
for dx in 0..self.width {
|
||||
let wx = cam_x + dx as i32;
|
||||
let wy = cam_y + dy as i32;
|
||||
|
||||
if let Some(&(ch, fg)) = entity_map.get(&(dx as i32, dy as i32)) {
|
||||
self.draw_cell(dx, dy, ch, fg, [10, 10, 15]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if !grid.in_bounds(wx, wy) {
|
||||
self.draw_cell(dx, dy, '?', [80, 80, 80], [10, 10, 15]);
|
||||
continue;
|
||||
}
|
||||
|
||||
let cell = grid.get(wx, wy);
|
||||
let mat = reg.get(cell.material);
|
||||
if cell.is_empty() {
|
||||
self.draw_cell(dx, dy, ' ', [mat.color_fg.0, mat.color_fg.1, mat.color_fg.2], [10, 10, 15]);
|
||||
} else {
|
||||
let fg = if cell.material == MaterialId::Lava {
|
||||
let r = 200u8.saturating_add(cell.variant / 2);
|
||||
[r, 60, 20]
|
||||
} else {
|
||||
[mat.color_fg.0, mat.color_fg.1, mat.color_fg.2]
|
||||
};
|
||||
let bg = [mat.color_bg.0, mat.color_bg.1, mat.color_bg.2];
|
||||
self.draw_cell(dx, dy, mat.display_char, fg, bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.window.update_with_buffer(&self.pixels, self.width * CHAR_W, self.height * CHAR_H)
|
||||
.expect("update_with_buffer failed");
|
||||
}
|
||||
|
||||
pub fn is_open(&self) -> bool {
|
||||
self.window.is_open()
|
||||
}
|
||||
|
||||
pub fn get_keys(&self) -> Vec<Key> {
|
||||
self.window.get_keys()
|
||||
}
|
||||
|
||||
pub fn get_pressed_keys(&self) -> Vec<Key> {
|
||||
self.window.get_keys_pressed(KeyRepeat::No)
|
||||
}
|
||||
|
||||
pub fn width(&self) -> usize { self.width }
|
||||
pub fn height(&self) -> usize { self.height }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use minifb::Key;
|
||||
|
||||
pub struct WindowInput {
|
||||
pub left: bool,
|
||||
pub right: bool,
|
||||
pub jump: bool,
|
||||
pub cam_left: bool,
|
||||
pub cam_right: bool,
|
||||
pub cam_up: bool,
|
||||
pub cam_down: bool,
|
||||
pub quit: bool,
|
||||
pub paint: Option<u8>,
|
||||
}
|
||||
|
||||
impl WindowInput {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
left: false, right: false, jump: false,
|
||||
cam_left: false, cam_right: false, cam_up: false, cam_down: false,
|
||||
quit: false, paint: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, keys: &[Key]) {
|
||||
let was_jump = self.jump;
|
||||
let now_left = keys.contains(&Key::A) || keys.contains(&Key::Left);
|
||||
let now_right = keys.contains(&Key::D) || keys.contains(&Key::Right);
|
||||
let now_jump = keys.contains(&Key::W) || keys.contains(&Key::Space) || keys.contains(&Key::Up);
|
||||
let now_cam_left = keys.contains(&Key::H);
|
||||
let now_cam_right = keys.contains(&Key::L);
|
||||
let now_cam_up = keys.contains(&Key::K);
|
||||
let now_cam_down = keys.contains(&Key::J);
|
||||
|
||||
self.left = now_left && !now_right;
|
||||
self.right = now_right && !now_left;
|
||||
if now_left && now_right {
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
}
|
||||
self.jump = now_jump && !was_jump;
|
||||
self.cam_left = now_cam_left;
|
||||
self.cam_right = now_cam_right;
|
||||
self.cam_up = now_cam_up;
|
||||
self.cam_down = now_cam_down;
|
||||
self.quit = keys.contains(&Key::Q) || keys.contains(&Key::Escape);
|
||||
|
||||
self.paint = None;
|
||||
if keys.contains(&Key::Key1) { self.paint = Some(1); }
|
||||
else if keys.contains(&Key::Key2) { self.paint = Some(2); }
|
||||
else if keys.contains(&Key::Key3) { self.paint = Some(3); }
|
||||
else if keys.contains(&Key::Key4) { self.paint = Some(4); }
|
||||
else if keys.contains(&Key::Key5) { self.paint = Some(5); }
|
||||
else if keys.contains(&Key::Key6) { self.paint = Some(6); }
|
||||
else if keys.contains(&Key::Key7) { self.paint = Some(7); }
|
||||
else if keys.contains(&Key::Key8) { self.paint = Some(8); }
|
||||
else if keys.contains(&Key::Key9) { self.paint = Some(9); }
|
||||
else if keys.contains(&Key::Key0) { self.paint = Some(0); }
|
||||
else if keys.contains(&Key::X) { self.paint = Some(99); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user