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.
This commit is contained in:
Emil
2026-06-22 17:54:20 +03:00
parent f09c44f7a0
commit d6bd77c133
8 changed files with 172 additions and 17 deletions
+1
View File
@@ -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
+12 -12
View File
@@ -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
}
}
+66 -1
View File
@@ -51,6 +51,11 @@ pub struct Game {
pub inventory_mouse_y: i32,
pub seed: u64,
pub cache_dir: Option<String>,
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;
+25
View File
@@ -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<R: GpuRenderer>(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<R: GpuRenderer>(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 {
+21 -2
View File
@@ -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 {
+9
View File
@@ -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::*;
+21 -2
View File
@@ -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 {
+17
View File
@@ -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<u8>,
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<KeyCode>,
}
@@ -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);