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:
+11
-11
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user