From d6bd77c13348acf5cc629b0866c5905176b08dcc Mon Sep 17 00:00:00 2001 From: Emil Date: Mon, 22 Jun 2026 17:54:20 +0300 Subject: [PATCH] feat: camera zoom, day/night cycle, tooltip, crosshair - Camera zoom: +/- keys adjust cell size 4-24px, grid_w/h recalculated Works in both graphics and ascii renderers via GpuRenderer trait - Day/night cycle: ambient_light_at_tick() modulates brightness by sine Applied to terminal renderer (CPU lighting path) - Tooltip: shows material name + temperature at mouse cursor position Tracked via CursorMoved event, world coords computed from screen pos - Crosshair: dotted line from player to mouse cursor, fading alpha Shows aiming trajectory for projectile combat All 185 tests + 14 scenarios pass. 162 FPS benchmark. --- AGENTS.md | 1 + benchmark_results.json | 24 +++++++------- src/game.rs | 67 +++++++++++++++++++++++++++++++++++++- src/main.rs | 25 ++++++++++++++ src/render/graphics.rs | 23 +++++++++++-- src/render/lighting.rs | 9 +++++ src/render/vulkan.rs | 23 +++++++++++-- src/render/window_input.rs | 17 ++++++++++ 8 files changed, 172 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e433606..a42fabb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,7 @@ GPU (`--mode ascii` / `--mode graphics`): - `r` — drop first inventory item - `1`–`0` / `x` — paint material brush - `y` / `u` / `i` / `o` — move camera offset +- `+` / `-` — zoom in / out - `m` — toggle audio - `q` / `esc` — quit diff --git a/benchmark_results.json b/benchmark_results.json index da325bf..cf9f095 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -1,17 +1,17 @@ { "mode": "graphics", - "ticks": 600, - "total_time_ms": 4229.7, - "avg_fps": 141.9, - "avg_frame_time_ms": 7.02, - "p99_frame_time_ms": 17.35, - "min_frame_time_ms": 5.45, + "ticks": 300, + "total_time_ms": 1847.9, + "avg_fps": 162.3, + "avg_frame_time_ms": 6.13, + "p99_frame_time_ms": 15.95, + "min_frame_time_ms": 4.61, "subsystems": { - "ca_step_avg_us": 2425, - "ca_step_p99_us": 11400, - "ca_step_min_us": 600, - "render_avg_us": 3863, - "render_p99_us": 5805, - "render_min_us": 3218 + "ca_step_avg_us": 1708, + "ca_step_p99_us": 11597, + "ca_step_min_us": 332, + "render_avg_us": 3717, + "render_p99_us": 6538, + "render_min_us": 3295 } } \ No newline at end of file diff --git a/src/game.rs b/src/game.rs index c10ad95..0514abf 100644 --- a/src/game.rs +++ b/src/game.rs @@ -51,6 +51,11 @@ pub struct Game { pub inventory_mouse_y: i32, pub seed: u64, pub cache_dir: Option, + pub mouse_world_pos: (i32, i32), + pub mouse_ui_x: i32, + pub mouse_ui_y: i32, + pub show_tooltip: bool, + pub show_crosshair: bool, } impl Game { @@ -94,6 +99,11 @@ impl Game { inventory_mouse_y: 0, seed: 0x1234567890ABCDEF, cache_dir: None, + mouse_world_pos: (0, 0), + mouse_ui_x: 0, + mouse_ui_y: 0, + show_tooltip: false, + show_crosshair: false, } } @@ -137,6 +147,11 @@ impl Game { inventory_mouse_y: 0, seed, cache_dir, + mouse_world_pos: (0, 0), + mouse_ui_x: 0, + mouse_ui_y: 0, + show_tooltip: false, + show_crosshair: false, } } @@ -239,7 +254,7 @@ impl Game { self.cam_y, vw, vh, - lighting::ambient_light(), + lighting::ambient_light_at_tick(self.tick), ); if let Err(e) = renderer.render( @@ -418,6 +433,56 @@ impl Game { .draw_death_screen(ui_w as usize, ui_h as usize, self.kills, self.score); } + if self.show_crosshair && !self.inventory_open { + let (px, py) = self.player.center(&self.entities); + let psx = (px as i32 - self.cam_x) * crate::ui::UI_SCALE; + let psy = (py as i32 - self.cam_y) * crate::ui::UI_SCALE; + let (mx, my) = (self.mouse_ui_x, self.mouse_ui_y); + let dx = mx - psx; + let dy = my - psy; + let dist = ((dx * dx + dy * dy) as f32).sqrt().max(1.0); + let steps = (dist / 6.0) as i32; + for i in 1..steps { + let t = i as f32 / steps as f32; + let x = (psx as f32 + dx as f32 * t) as i32; + let y = (psy as f32 + dy as f32 * t) as i32; + if x >= 0 && x < ui_w && y >= 0 && y < ui_h { + let alpha = (180.0 * (1.0 - t * 0.4)) as u8; + self.ui + .set_alpha(x, y, '.', [255, 200, 80], [0, 0, 0], alpha); + } + } + } + + if self.show_tooltip && !self.inventory_open { + let (mx, my) = self.mouse_world_pos; + if self.grid.in_bounds(mx, my) { + let cell = self.grid.get(mx, my); + let temp = self.grid.get_temp(mx, my); + let mat_name = match cell.material { + MaterialId::Empty => "Empty", + MaterialId::Sand => "Sand", + MaterialId::Water => "Water", + MaterialId::Stone => "Stone", + MaterialId::Lava => "Lava", + MaterialId::Wood => "Wood", + MaterialId::Flesh => "Flesh", + MaterialId::Bone => "Bone", + MaterialId::Steam => "Steam", + MaterialId::Fire => "Fire", + MaterialId::Acid => "Acid", + MaterialId::Smoke => "Smoke", + MaterialId::Grass => "Grass", + MaterialId::Dirt => "Dirt", + MaterialId::Stairs => "Stairs", + }; + let tip = format!("{} {:.0}C", mat_name, temp); + let tx = (self.mouse_ui_x + 4).min(ui_w - 100); + let ty = (self.mouse_ui_y + 4).min(ui_h - 20); + self.ui.draw_text(tx, ty, &tip, [220, 220, 240], 220); + } + } + for e in self.entities.all() { if !e.alive || e.kind == EntityKind::Corpse { continue; diff --git a/src/main.rs b/src/main.rs index 5d6e61a..2e4ae1f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -72,6 +72,7 @@ trait GpuRenderer { ); fn grid_w(&self) -> usize; fn grid_h(&self) -> usize; + fn adjust_zoom(&mut self, delta: i32); } impl GpuRenderer for verbatim::render::vulkan::VulkanRenderer { @@ -98,6 +99,9 @@ impl GpuRenderer for verbatim::render::vulkan::VulkanRenderer { fn grid_h(&self) -> usize { verbatim::render::vulkan::VulkanRenderer::grid_h(self) } + fn adjust_zoom(&mut self, delta: i32) { + verbatim::render::vulkan::VulkanRenderer::adjust_zoom(self, delta) + } } impl GpuRenderer for verbatim::render::graphics::GraphicsRenderer { @@ -124,6 +128,9 @@ impl GpuRenderer for verbatim::render::graphics::GraphicsRenderer { fn grid_h(&self) -> usize { verbatim::render::graphics::GraphicsRenderer::grid_h(self) } + fn adjust_zoom(&mut self, delta: i32) { + verbatim::render::graphics::GraphicsRenderer::adjust_zoom(self, delta) + } } fn main() { @@ -257,6 +264,17 @@ fn run_gpu_mode(title: &str) { } WindowEvent::CursorMoved { position, .. } => { input.on_mouse_move(position.x, position.y); + game.mouse_ui_x = position.x as i32; + game.mouse_ui_y = position.y as i32; + let vw = renderer.grid_w() as i32; + let vh = renderer.grid_h() as i32; + game.show_tooltip = true; + game.show_crosshair = true; + if vw > 0 && vh > 0 { + let wx = game.cam_x + (position.x as i32 * vw / 1600); + let wy = game.cam_y + (position.y as i32 * vh / 900); + game.mouse_world_pos = (wx, wy); + } } WindowEvent::MouseInput { state, button, .. } => { input.on_mouse_button(button, state); @@ -299,6 +317,13 @@ fn run_gpu_mode(title: &str) { game.audio.toggle(); } + if input.zoom_in { + renderer.adjust_zoom(-2); + } + if input.zoom_out { + renderer.adjust_zoom(2); + } + if input.jump { let on_ground = game.check_on_ground(); if on_ground { diff --git a/src/render/graphics.rs b/src/render/graphics.rs index eea8601..0eb8185 100644 --- a/src/render/graphics.rs +++ b/src/render/graphics.rs @@ -12,6 +12,8 @@ const CHAR_W: u32 = 8; const CHAR_H: u32 = 8; const UI_CELL_SIZE: u32 = 2; const MAX_FRAMES: usize = 2; +const MIN_CHAR: u32 = 4; +const MAX_CHAR: u32 = 24; fn entity_priority(kind: crate::entity::EntityKind) -> u32 { use crate::entity::EntityKind; @@ -73,6 +75,7 @@ struct PushConstants { pub struct GraphicsRenderer { grid_w: usize, grid_h: usize, + char_size: u32, entry: ash::Entry, instance: ash::Instance, @@ -709,6 +712,7 @@ impl GraphicsRenderer { Ok(Self { grid_w, grid_h, + char_size: CHAR_W, entry, instance, surface, @@ -1111,6 +1115,21 @@ impl GraphicsRenderer { self.grid_h } + pub fn adjust_zoom(&mut self, delta: i32) { + let new_size = + (self.char_size as i32 + delta).clamp(MIN_CHAR as i32, MAX_CHAR as i32) as u32; + if new_size == self.char_size { + return; + } + self.char_size = new_size; + let new_grid_w = (self.swapchain_extent.width / self.char_size) as usize; + let new_grid_h = (self.swapchain_extent.height / self.char_size) as usize; + if new_grid_w > 0 && new_grid_h > 0 { + self.grid_w = new_grid_w; + self.grid_h = new_grid_h; + } + } + fn check_resize(&mut self) { let sl = ash::khr::surface::Instance::new(&self.entry, &self.instance); let caps = match unsafe { @@ -1235,8 +1254,8 @@ impl GraphicsRenderer { }; // Reallocate instance buffer if grid size changed - let new_grid_w = (new_extent.width / CHAR_W) as usize; - let new_grid_h = (new_extent.height / CHAR_H) as usize; + let new_grid_w = (new_extent.width / self.char_size) as usize; + let new_grid_h = (new_extent.height / self.char_size) as usize; let new_count = new_grid_w * new_grid_h; if new_count != self.instance_count { diff --git a/src/render/lighting.rs b/src/render/lighting.rs index 003568c..9402d61 100644 --- a/src/render/lighting.rs +++ b/src/render/lighting.rs @@ -241,6 +241,15 @@ pub fn ambient_light() -> [u8; 3] { [160, 160, 180] } +pub fn ambient_light_at_tick(tick: u64) -> [u8; 3] { + let phase = (tick as f32 * 0.001).sin(); + let brightness = 0.5 + 0.5 * phase; + let r = (80.0 + 100.0 * brightness) as u8; + let g = (80.0 + 100.0 * brightness) as u8; + let b = (100.0 + 100.0 * brightness) as u8; + [r, g, b] +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/render/vulkan.rs b/src/render/vulkan.rs index 13805b5..e005183 100644 --- a/src/render/vulkan.rs +++ b/src/render/vulkan.rs @@ -12,6 +12,8 @@ use crate::world::grid::{MAX_WORLD_H, MAX_WORLD_W}; const CHAR_W: u32 = 8; const CHAR_H: u32 = 8; const UI_CELL_SIZE: u32 = 2; +const MIN_CHAR: u32 = 4; +const MAX_CHAR: u32 = 24; const ATLAS_COLS: usize = 16; const ATLAS_ROWS: usize = 8; const ATLAS_W: u32 = (ATLAS_COLS as u32) * CHAR_W; @@ -82,6 +84,7 @@ struct PushConstants { pub struct VulkanRenderer { grid_w: usize, grid_h: usize, + char_size: u32, entry: ash::Entry, instance: ash::Instance, @@ -276,6 +279,7 @@ impl VulkanRenderer { Ok(Self { grid_w, grid_h, + char_size: CHAR_W, entry, instance, surface, @@ -708,6 +712,21 @@ impl VulkanRenderer { self.grid_h } + pub fn adjust_zoom(&mut self, delta: i32) { + let new_size = + (self.char_size as i32 + delta).clamp(MIN_CHAR as i32, MAX_CHAR as i32) as u32; + if new_size == self.char_size { + return; + } + self.char_size = new_size; + let new_grid_w = (self.swapchain_extent.width / self.char_size) as usize; + let new_grid_h = (self.swapchain_extent.height / self.char_size) as usize; + if new_grid_w > 0 && new_grid_h > 0 { + self.grid_w = new_grid_w; + self.grid_h = new_grid_h; + } + } + fn check_resize(&mut self) { let sl = ash::khr::surface::Instance::new(&self.entry, &self.instance); let caps = match unsafe { @@ -828,8 +847,8 @@ impl VulkanRenderer { .expect("cmd_bufs") }; - let new_grid_w = (new_extent.width / CHAR_W) as usize; - let new_grid_h = (new_extent.height / CHAR_H) as usize; + let new_grid_w = (new_extent.width / self.char_size) as usize; + let new_grid_h = (new_extent.height / self.char_size) as usize; let new_count = new_grid_w * new_grid_h; if new_count != self.instance_count { diff --git a/src/render/window_input.rs b/src/render/window_input.rs index 532222e..9fb04d4 100644 --- a/src/render/window_input.rs +++ b/src/render/window_input.rs @@ -19,6 +19,8 @@ pub struct WindowInput { pub cam_down: bool, pub quit: bool, pub toggle_audio: bool, + pub zoom_in: bool, + pub zoom_out: bool, pub paint: Option, pub mouse_x: f64, pub mouse_y: f64, @@ -41,6 +43,8 @@ pub struct WindowInput { use_item_was_down: bool, drop_item_was_down: bool, audio_was_down: bool, + zoom_in_was_down: bool, + zoom_out_was_down: bool, down_keys: HashSet, } @@ -64,6 +68,8 @@ impl WindowInput { cam_down: false, quit: false, toggle_audio: false, + zoom_in: false, + zoom_out: false, paint: None, mouse_x: 0.0, mouse_y: 0.0, @@ -86,6 +92,8 @@ impl WindowInput { use_item_was_down: false, drop_item_was_down: false, audio_was_down: false, + zoom_in_was_down: false, + zoom_out_was_down: false, down_keys: HashSet::new(), } } @@ -211,6 +219,15 @@ impl WindowInput { self.toggle_audio = now_audio && !self.audio_was_down; self.audio_was_down = now_audio; + let now_zoom_in = keys.contains(&KeyCode::Equal) || keys.contains(&KeyCode::NumpadAdd); + self.zoom_in = now_zoom_in && !self.zoom_in_was_down; + self.zoom_in_was_down = now_zoom_in; + + let now_zoom_out = + keys.contains(&KeyCode::Minus) || keys.contains(&KeyCode::NumpadSubtract); + self.zoom_out = now_zoom_out && !self.zoom_out_was_down; + self.zoom_out_was_down = now_zoom_out; + self.paint = None; if keys.contains(&KeyCode::Digit1) { self.paint = Some(1);