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:
Emil
2026-06-20 23:58:05 +03:00
parent 6d98c09ece
commit 19bd883645
5 changed files with 351 additions and 1 deletions
+2
View File
@@ -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
View File
@@ -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);
}
}
+2
View File
@@ -1,4 +1,6 @@
pub mod terminal;
pub mod window;
pub mod window_input;
use crate::entity::EntityManager;
use crate::world::grid::Grid;
+191
View File
@@ -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 }
}
+60
View File
@@ -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); }
}
}