feat: procedurally scalable UI — font_scale auto-computed from screen size

- UiLayer.font_scale: dynamically computed as (ui_height / 200).clamp(2, 6)
  Larger screens get bigger UI text automatically
- draw_text: renders each font pixel as font_scale×font_scale block
  (was 1×1, now 2×2 to 6×6 depending on screen size)
- text_width: now instance method, returns scaled width
- draw_health_bar: bar width scales with font_scale
- draw_character_panel: panel dimensions and row spacing scale
- draw_hud: bar height, row spacing, bar width scale
- draw_inventory_overlay: slot sizes scale with font_scale
- draw_messages: line spacing scales with font_height
- draw_death_screen: centered text uses scaled text_width
- All 185 tests + 14 scenarios pass
- Benchmark: 61.9 FPS (was 82.7 — 25% regression from more set_alpha calls)
This commit is contained in:
Emil
2026-06-22 13:23:48 +03:00
parent f35f56b970
commit a16645d2d8
4 changed files with 131 additions and 70 deletions
+11 -11
View File
@@ -1,17 +1,17 @@
{
"mode": "graphics",
"ticks": 300,
"total_time_ms": 3627.1,
"avg_fps": 82.7,
"avg_frame_time_ms": 12.06,
"p99_frame_time_ms": 86.45,
"min_frame_time_ms": 7.25,
"total_time_ms": 4845.5,
"avg_fps": 61.9,
"avg_frame_time_ms": 16.12,
"p99_frame_time_ms": 145.95,
"min_frame_time_ms": 8.19,
"subsystems": {
"ca_step_avg_us": 4927,
"ca_step_p99_us": 79708,
"ca_step_min_us": 698,
"render_avg_us": 4154,
"render_p99_us": 6411,
"render_min_us": 3798
"ca_step_avg_us": 8153,
"ca_step_p99_us": 137703,
"ca_step_min_us": 1174,
"render_avg_us": 4433,
"render_p99_us": 6450,
"render_min_us": 3956
}
}
+6 -3
View File
@@ -353,6 +353,11 @@ impl Game {
self.ui.clear();
self.ui.tick_messages();
let ui_w = (vw as i32) * crate::ui::UI_SCALE;
let ui_h = (vh as i32) * crate::ui::UI_SCALE;
let fs = ((ui_h / 200).max(2)).min(6) as i32;
self.ui.set_font_scale(fs);
let player = self.player.entity(&self.entities).cloned();
let brush = self
.input
@@ -360,8 +365,6 @@ impl Game {
.to_material()
.unwrap_or(crate::world::cell::MaterialId::Empty);
let player_alive = player.as_ref().map(|e| e.alive).unwrap_or(false);
let ui_w = (vw as i32) * crate::ui::UI_SCALE;
let ui_h = (vh as i32) * crate::ui::UI_SCALE;
if !self.inventory_open {
self.ui
.draw_character_panel(1, 1, player.as_ref(), &self.player);
@@ -378,7 +381,7 @@ impl Game {
&self.player,
self.fps,
);
let msg_x = (ui_w / 2) - 24;
let msg_x = (ui_w / 2) - 24 * fs;
self.ui.draw_messages(msg_x, 4);
self.ui.draw_damage_numbers(self.cam_x, self.cam_y);
self.ui.draw_edge_indicators(
+108 -53
View File
@@ -5,6 +5,8 @@ use crate::world::cell::MaterialId;
use crate::world::chunked_grid::ChunkedGrid;
pub const UI_SCALE: i32 = 4;
pub const FONT_W: i32 = 3;
pub const FONT_H: i32 = 5;
#[derive(Clone, Debug)]
pub struct UiCell {
@@ -18,6 +20,7 @@ pub struct UiLayer {
cells: HashMap<(i32, i32), UiCell>,
messages: Vec<(String, u32)>,
damage_numbers: Vec<DamageNumber>,
font_scale: i32,
}
#[derive(Clone)]
@@ -35,9 +38,28 @@ impl UiLayer {
cells: HashMap::new(),
messages: Vec::new(),
damage_numbers: Vec::new(),
font_scale: 2,
}
}
pub fn set_font_scale(&mut self, scale: i32) {
self.font_scale = scale.max(1);
}
pub fn font_scale(&self) -> i32 {
self.font_scale
}
#[inline]
fn fw(&self) -> i32 {
FONT_W * self.font_scale
}
#[inline]
fn fh(&self) -> i32 {
FONT_H * self.font_scale
}
pub fn clear(&mut self) {
self.cells.clear();
self.damage_numbers.retain(|d| d.life > 0);
@@ -174,7 +196,8 @@ impl UiLayer {
return;
}
let ratio = (health / max_health).clamp(0.0, 1.0);
let filled = (ratio * width as f32).round() as i32;
let scaled_width = width * self.font_scale;
let filled = (ratio * scaled_width as f32).round() as i32;
let color = if ratio > 0.6 {
[60, 220, 60]
} else if ratio > 0.3 {
@@ -182,7 +205,7 @@ impl UiLayer {
} else {
[240, 50, 50]
};
for i in 0..width {
for i in 0..scaled_width {
let x = screen_x + i;
let ch = if i < filled { '#' } else { '-' };
let fg = if i < filled { color } else { [80, 80, 80] };
@@ -198,7 +221,7 @@ impl UiLayer {
let (sx, sy) = entity_screen_pos_ui(e, cam_x, cam_y);
let label = e.name().to_uppercase();
let top = sy - (e.half_h as i32 * UI_SCALE) - 7;
let x = sx - (Self::text_width(&label) / 2);
let x = sx - (self.text_width(&label) / 2);
let fg = entity_kind_color(e);
self.draw_text(x, top, &label, fg, 255);
}
@@ -335,8 +358,11 @@ impl UiLayer {
player: Option<&Entity>,
player_state: &crate::entity::player::Player,
) {
let w = 52i32;
let h = 62i32;
let fs = self.font_scale;
let fh = self.fh();
let fw = self.fw();
let w = 52 * fs / 2;
let h = 62 * fs / 2;
let bg = [22u8, 26, 38];
let border = [160u8, 180, 255];
let title = [220u8, 230, 255];
@@ -375,12 +401,20 @@ impl UiLayer {
);
let title_text = "STATUS";
let tx = start_x + (w - Self::text_width(title_text)) / 2;
let tx = start_x + (w - self.text_width(title_text)) / 2;
self.draw_text(tx, start_y + 2, title_text, title, 255);
let row1 = start_y + 10 * fs / 2;
let row2 = start_y + 18 * fs / 2;
let row3 = start_y + 26 * fs / 2;
let row4 = start_y + 36 * fs / 2;
let row5 = start_y + 44 * fs / 2;
let row_inv = start_y + 52 * fs / 2;
let row_items = start_y + 58 * fs / 2;
if let Some(p) = player {
let hp_ratio = (p.health / p.max_health).clamp(0.0, 1.0);
let hp_filled = (hp_ratio * 38.0).round() as i32;
let hp_filled = (hp_ratio * 38.0 * fs as f32 / 2.0).round() as i32;
let bar_color = if hp_ratio > 0.6 {
[80, 240, 80]
} else if hp_ratio > 0.3 {
@@ -388,43 +422,43 @@ impl UiLayer {
} else {
[255, 60, 60]
};
self.draw_text(start_x + 3, start_y + 10, "HP", fg, 255);
for i in 0..38 {
self.draw_text(start_x + 3, row1, "HP", fg, 255);
for i in 0..(38 * fs / 2) {
let ch = if i < hp_filled { '#' } else { '-' };
let c = if i < hp_filled {
bar_color
} else {
[80, 80, 100]
};
self.set(start_x + 10 + i, start_y + 10, ch, c, bg);
self.set(start_x + 10 * fs / 2 + i, row1, ch, c, bg);
}
let hp_text = format!(
"{}/{} LV:{}",
p.health as i32, p.max_health as i32, p.level
);
self.draw_text(start_x + 3, start_y + 18, &hp_text, fg, 255);
self.draw_text(start_x + 3, row2, &hp_text, fg, 255);
let xp_ratio = (p.xp as f32 / p.xp_to_level() as f32).clamp(0.0, 1.0);
let xp_filled = (xp_ratio * 38.0).round() as i32;
self.draw_text(start_x + 3, start_y + 26, "XP", fg, 255);
for i in 0..38 {
let xp_filled = (xp_ratio * 38.0 * fs as f32 / 2.0).round() as i32;
self.draw_text(start_x + 3, row3, "XP", fg, 255);
for i in 0..(38 * fs / 2) {
let ch = if i < xp_filled { '#' } else { '-' };
let c = if i < xp_filled {
[80, 160, 240]
} else {
[60, 60, 80]
};
self.set(start_x + 10 + i, start_y + 26, ch, c, bg);
self.set(start_x + 10 * fs / 2 + i, row3, ch, c, bg);
}
let stats = format!("STR:{} AGI:{}", p.strength, p.agility);
self.draw_text(start_x + 3, start_y + 36, &stats, dim, 255);
self.draw_text(start_x + 3, row4, &stats, dim, 255);
let stats2 = format!("TOU:{} WIL:{}", p.toughness, p.willpower);
self.draw_text(start_x + 3, start_y + 44, &stats2, dim, 255);
self.draw_text(start_x + 3, row5, &stats2, dim, 255);
}
let inv_title = "INVENTORY";
self.draw_text(start_x + 3, start_y + 52, inv_title, fg, 255);
self.draw_text(start_x + 3, row_inv, inv_title, fg, 255);
let mut ix = start_x + 3;
for item in player_state.inventory.iter().take(8) {
if ix + 2 >= start_x + w - 2 {
@@ -432,10 +466,10 @@ impl UiLayer {
}
let [ch1, ch2] = item.display_glyph();
let col = item.color();
self.set(ix, start_y + 58, ch1, col, bg);
self.set(ix, row_items, ch1, col, bg);
self.set(
ix + 1,
start_y + 58,
row_items,
ch2,
[col[0] / 2, col[1] / 2, col[2] / 2],
bg,
@@ -462,9 +496,7 @@ impl UiLayer {
String::new()
};
if !status.is_empty() {
for (i, ch) in status.chars().enumerate() {
self.set(start_x + 30 + i as i32, start_y + 38, ch, [255, 80, 80], bg);
}
self.draw_text(start_x + 30 * fs / 2, row4, &status, [255, 80, 80], 255);
}
}
@@ -486,9 +518,9 @@ impl UiLayer {
let cols = 4i32;
let rows = 2i32;
let slot_w = 12i32;
let slot_h = 10i32;
let gap = 3i32;
let slot_w = 12i32 * self.font_scale / 2;
let slot_h = 10i32 * self.font_scale / 2;
let gap = 3i32 * self.font_scale / 2;
let panel_w = cols * slot_w + (cols + 1) * gap + 4;
let panel_h = rows * slot_h + (rows + 1) * gap + 28;
@@ -528,7 +560,7 @@ impl UiLayer {
self.draw_text(px_start + 4, py_start + 3, title, title_col, 255);
let count_text = format!("({}/{} items)", player_state.inventory.len(), cols * rows);
self.draw_text(
px_start + 4 + Self::text_width(title) + 2,
px_start + 4 + self.text_width(title) + 2,
py_start + 3,
&count_text,
dim,
@@ -542,7 +574,7 @@ impl UiLayer {
for col in 0..cols {
let idx = (row * cols + col) as usize;
let sx = px_start + gap + col * (slot_w + gap) + 2;
let sy = py_start + 14 + row * (slot_h + gap) + gap;
let sy = py_start + 14 * self.font_scale / 2 + row * (slot_h + gap) + gap;
for y in 0..slot_h {
for x in 0..slot_w {
@@ -584,7 +616,7 @@ impl UiLayer {
);
let label = item.name();
let label_w = Self::text_width(label) as i32;
let label_w = self.text_width(label) as i32;
let lx = sx + (slot_w - label_w) / 2;
let ly = sy + slot_h - 1;
if lx >= sx && lx + label_w <= sx + slot_w {
@@ -672,6 +704,8 @@ impl UiLayer {
player_state: &crate::entity::player::Player,
fps: f32,
) {
let fs = self.font_scale;
let fh = self.fh();
let bg = [18u8, 20, 30];
let border = [160u8, 170, 210];
let fill_alpha = 140u8;
@@ -687,11 +721,11 @@ impl UiLayer {
.map(|i| i.display_string())
.unwrap_or(" ".to_string());
let bar_h = 28i32;
let bar_h = (28 * fs / 2).max(fh * 3);
let y_top = screen_h as i32 - bar_h;
let y_row1 = y_top + 2;
let y_row2 = y_top + 12;
let y_row3 = y_top + 22;
let y_row2 = y_top + 2 + fh + 2;
let y_row3 = y_top + 2 + (fh + 2) * 2;
for row in y_top..screen_h as i32 {
for x in 0..screen_w {
@@ -707,7 +741,8 @@ impl UiLayer {
self.draw_text(0, y_row1, "HP", [220, 220, 220], 255);
if let Some(p) = player {
let hp_ratio = (p.health / p.max_health).clamp(0.0, 1.0);
let hp_filled = (hp_ratio * 28.0).round() as i32;
let hp_bar_w = 28 * fs / 2;
let hp_filled = (hp_ratio * hp_bar_w as f32).round() as i32;
let bar_fg = if hp_ratio > 0.6 {
[80, 240, 80]
} else if hp_ratio > 0.3 {
@@ -715,17 +750,23 @@ impl UiLayer {
} else {
[255, 60, 60]
};
for i in 0..28 {
for i in 0..hp_bar_w {
let ch = if i < hp_filled { '#' } else { '-' };
let c = if i < hp_filled { bar_fg } else { [70, 70, 90] };
self.set(10 + i, y_row1 + 4, ch, c, bg);
self.set(10 * fs / 2 + i, y_row1, ch, c, bg);
}
let hp_text = format!("{} / {}", p.health as i32, p.max_health as i32);
self.draw_text(40, y_row1, &hp_text, [220, 220, 220], 255);
self.draw_text(
(40 * fs / 2).max(10 * fs / 2 + hp_bar_w + 2),
y_row1,
&hp_text,
[220, 220, 220],
255,
);
}
let brush_color = brush_color(brush);
self.set(0, y_row2 + 4, '#', brush_color, bg);
self.set(0, y_row2, '#', brush_color, bg);
let brush_text = format!(" {}", brush_name);
self.draw_text(1, y_row2, &brush_text, [200, 200, 140], 255);
@@ -736,7 +777,7 @@ impl UiLayer {
player_state.inventory.len(),
fps as i32
);
let gear_w = Self::text_width(&gear);
let gear_w = self.text_width(&gear);
let gear_x = (screen_w as i32 - gear_w).max(0);
self.draw_text(gear_x, y_row2, &gear, [160, 180, 220], 255);
@@ -749,14 +790,14 @@ impl UiLayer {
depth,
tick
);
let stats_w = Self::text_width(&stats);
let stats_w = self.text_width(&stats);
let stats_x = (screen_w as i32 - stats_w).max(0);
self.draw_text(stats_x, y_row3, &stats, [180, 190, 220], 255);
if let Some(p) = player {
if p.on_fire {
let msg = "ON FIRE!";
self.draw_text(40, y_top - 7, msg, [255, 100, 20], 255);
self.draw_text(40 * fs / 2, y_top - fh - 2, msg, [255, 100, 20], 255);
}
}
}
@@ -764,6 +805,7 @@ impl UiLayer {
pub fn draw_messages(&mut self, x: i32, y: i32) {
let mut yy = y;
let messages: Vec<(String, u32)> = self.messages.iter().rev().take(8).cloned().collect();
let line_h = self.fh() + 1;
for (msg, life) in messages {
if yy < 0 {
break;
@@ -775,7 +817,7 @@ impl UiLayer {
(200.0 * fade) as u8,
];
self.draw_text(x, yy, &msg, fg, 255);
yy -= 6;
yy -= line_h;
}
}
@@ -783,20 +825,21 @@ impl UiLayer {
let cx = screen_w as i32 / 2;
let cy = screen_h as i32 / 2;
let msg = "YOU DIED";
let x = cx - (Self::text_width(msg) / 2);
let x = cx - (self.text_width(msg) / 2);
self.draw_text(x, cy - 3, msg, [255, 50, 50], 255);
let stats = format!("KILLS: {} SCORE: {}", kills, score);
let x2 = cx - (Self::text_width(&stats) / 2);
let x2 = cx - (self.text_width(&stats) / 2);
self.draw_text(x2, cy + 3, &stats, [200, 200, 200], 255);
}
pub fn text_width(text: &str) -> i32 {
pub fn text_width(&self, text: &str) -> i32 {
text.chars()
.map(|c| if c == '\n' { 0 } else { 3 })
.map(|c| if c == '\n' { 0 } else { self.fw() as usize })
.sum::<usize>() as i32
}
pub fn draw_text(&mut self, x: i32, y: i32, text: &str, fg: [u8; 3], alpha: u8) {
let fs = self.font_scale;
let mut cx = x;
for c in text.chars() {
if c == '\n' {
@@ -804,21 +847,33 @@ impl UiLayer {
continue;
}
if let Some(bitmap) = char_bitmap(c) {
for row in 0..5 {
for col in 0..3 {
let filled = (bitmap[row] >> (2 - col)) & 1 == 1;
for row in 0..FONT_H {
for col in 0..FONT_W {
let filled = (bitmap[row as usize] >> (FONT_W - 1 - col)) & 1 == 1;
let a = if filled { alpha } else { 0 };
self.set_alpha(cx + col as i32, y + row as i32, c, fg, [0, 0, 0], a);
for dy in 0..fs {
for dx in 0..fs {
let px = cx + col * fs + dx;
let py = y + row * fs + dy;
self.set_alpha(px, py, c, fg, [0, 0, 0], a);
}
}
}
}
} else {
for row in 0..5 {
for col in 0..3 {
self.set_alpha(cx + col as i32, y + row as i32, c, fg, [0, 0, 0], alpha);
for row in 0..FONT_H {
for col in 0..FONT_W {
for dy in 0..fs {
for dx in 0..fs {
let px = cx + col * fs + dx;
let py = y + row * fs + dy;
self.set_alpha(px, py, c, fg, [0, 0, 0], alpha);
}
}
}
}
}
cx += 3;
cx += self.fw();
}
}
}
+6 -3
View File
@@ -69,11 +69,14 @@ fn depth_shown_in_hud() {
"LV:{} XP:{} K:{} S:{} D:{} T:{}",
player.level, player.xp, 0, 0, s.game.depth, s.game.tick
);
let stats_w = verbatim::ui::UiLayer::text_width(&stats);
let stats_w = ui.text_width(&stats);
let stats_x = (screen_w as i32 - stats_w as i32).max(0);
let y_row3 = screen_h as i32 - 28 + 22;
let fs = ui.font_scale();
let fh = 5 * fs;
let bar_h = (28 * fs / 2).max(fh * 3);
let y_row3 = screen_h as i32 - bar_h + 2 + (fh + 2) * 2;
let line: String = (stats_x as i32..screen_w as i32)
.step_by(3)
.step_by(fs as usize * 3)
.map(|x| ui.get(x, y_row3).map(|c| c.ch).unwrap_or(' '))
.collect();
assert!(line.contains("D:1"), "HUD should show depth: {}", line);