UI in device pixels on the character grid with fixed integer font sizes, ASCII bars; 3D render scale for a pixel look

- text only at 1x/2x/4x times an integer interface factor (auto or 1-4 in the settings)
- high DPI rendering, own 2D camera in device pixels, mouse converted
- toolbar buttons 6 columns, integer icon scale, minimap integer pixels per tile
- all bars (top bar, unit/tower/gate HP) as CP437 block characters
- 3D world optionally rendered at 1/2, 1/3, 1/4 and upscaled with nearest filtering

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 08:42:50 +01:00
1 parent 889ca66dbf
commit 50d013bb28
8 files changed
+344 -213

No files matched your search

+60 -25
View File
@@ -681,6 +681,8 @@ pub struct R3d {
pub outlines: bool, // tile outlines on/off
wall_sub: usize, // wall face subdivisions (detail)
pub max_lights: usize,
pub render_div: u32, // 3D resolution divisor (pixel look)
target: Option<(RenderTarget, u32, u32)>, // low resolution render target
}
fn rgba(u: u32) -> Color {
@@ -710,7 +712,7 @@ fn chunk_sig(map: &Map, cx: i32, cz: i32, fill_bucket: u8) -> u64 {
impl R3d {
pub fn new() -> R3d {
R3d { lit: lit_material(), glow: glow_material(), chunks: Vec::new(), ncx: 0, ncz: 0, outlines: true, wall_sub: 3, max_lights: MAX_LIGHTS }
R3d { lit: lit_material(), glow: glow_material(), chunks: Vec::new(), ncx: 0, ncz: 0, outlines: true, wall_sub: 3, max_lights: MAX_LIGHTS, render_div: 1, target: None }
}
// Changing the wall detail rebuilds all chunks
@@ -854,7 +856,28 @@ impl R3d {
let m = cam.matrix();
self.update_chunks(g, gfx, &m);
EYE.with(|e| e.set(cam.eye_dir()));
set_camera(&cam.camera());
// pixel look: render the world at 1/div resolution and scale it up by exactly div
let div = self.render_div.max(1);
let d = crate::ui::dpr();
let (pw, ph) = ((screen_width() * d).round(), (screen_height() * d).round());
let mut cam3 = cam.camera();
if div > 1 {
let (tw, th) = ((pw / div as f32).ceil() as u32, (ph / div as f32).ceil() as u32);
if self.target.as_ref().map_or(true, |(_, w, h)| *w != tw || *h != th) {
let rt = render_target_ex(tw, th, RenderTargetParams { sample_count: 1, depth: true });
rt.texture.set_filter(FilterMode::Nearest);
self.target = Some((rt, tw, th));
}
let (rt, tw, th) = self.target.as_ref().unwrap();
cam3.render_target = Some(rt.clone());
cam3.aspect = Some(*tw as f32 / *th as f32);
} else {
self.target = None;
}
set_camera(&cam3);
if div > 1 {
clear_background(Color::from_rgba(4, 3, 5, 255));
}
// ---- lit geometry
let (lp, lc) = self.lights(g, cam, t);
@@ -919,9 +942,13 @@ impl R3d {
draw_fx(f, t);
}
gl_use_default_material();
set_default_camera();
crate::ui::begin_ui();
if let (true, Some((rt, tw, th))) = (div > 1, self.target.as_ref()) {
let size = vec2((*tw * div) as f32, (*th * div) as f32);
draw_texture_ex(&rt.texture, 0.0, 0.0, WHITE, DrawTextureParams { dest_size: Some(size), flip_y: true, ..Default::default() });
}
// ---- 2D overlay: health bars, sleep marks, levels, gate HP
// ---- 2D overlay in device pixels: health bars, sleep marks, levels, gate HP
self.draw_2d(g, gfx, cam, &m, t);
}
@@ -999,15 +1026,25 @@ impl R3d {
}
fn draw_2d(&self, g: &Game, gfx: &Gfx, cam: &Cam, m: &Mat4, t: f32) {
let px_per_tile = screen_height() / cam.zoom;
use crate::ui::{ascii_bar, dpr, F, S, SMALL};
let d = dpr();
// projected positions are CSS pixels; the overlay is drawn in whole device pixels
let proj = |p: Vec3| {
let v = Cam::project(m, p) * d;
vec2(v.x.floor(), v.y.floor())
};
let small = SMALL();
let cw = GW * small; // width of one small block character
let px_per_tile = screen_height() / cam.zoom * d;
for e in &g.ents {
if !e.alive || e.held {
continue;
}
let big = matches!(e.kind, EK::Ogre | EK::Paladin | EK::Jailer);
let head = Cam::project(m, vec3(e.x, if big { 0.9 } else { 0.72 }, e.y));
let w = (px_per_tile * 0.6).clamp(12.0, 40.0);
if e.hp < e.max_hp || e.hero {
let head = proj(vec3(e.x, if big { 0.9 } else { 0.72 }, e.y));
// bar width in block characters, depending on the zoom
let cols = ((px_per_tile * 0.6 / cw) as usize).clamp(3, 8);
if (e.hp < e.max_hp || e.hero) && e.captive == 0 {
let f = (e.hp / e.max_hp).clamp(0.0, 1.0);
let c = if e.hero {
Color::from_rgba(120, 180, 255, 255)
@@ -1018,40 +1055,38 @@ impl R3d {
} else {
Color::from_rgba(230, 60, 50, 255)
};
draw_rectangle(head.x - w * 0.5 - 1.0, head.y - 1.0, w + 2.0, 4.0, Color::new(0.0, 0.0, 0.0, 0.8));
draw_rectangle(head.x - w * 0.5, head.y, w * f, 2.0, c);
let x = head.x - (cols as f32 * cw * 0.5).floor();
ascii_bar(gfx, x, head.y - GH * small, cols, f, c, small);
}
if e.guard {
gfx.glyph(0x0F, head.x - 3.0, head.y - 11.0, 1.0, Color::from_rgba(255, 220, 90, 255));
gfx.glyph(0x0F, head.x - (GW * small * 0.5).floor(), head.y - 2.0 * GH * small, small, Color::from_rgba(255, 220, 90, 255));
}
if e.level > 1 && px_per_tile >= 24.0 {
gfx.text_sh(&e.level.to_string(), head.x - w * 0.5 - 8.0, head.y - 3.0, 1.0, Color::from_rgba(255, 220, 90, 255));
if e.level > 1 && px_per_tile >= 24.0 * F() as f32 {
let x = head.x - (cols as f32 * cw * 0.5).floor() - 2.0 * cw;
gfx.text_sh(&e.level.to_string(), x, head.y - GH * small, small, Color::from_rgba(255, 220, 90, 255));
}
if matches!(e.job, Job::Sleep(_)) && !e.moving {
let k = (t * 1.5 + e.anim) % 1.0;
gfx.glyph(b'z', head.x + w * 0.3, head.y - 6.0 - k * 14.0, 2.0, Color::new(0.8, 0.85, 1.0, 1.0 - k));
let rise = (k * 14.0 * F() as f32).floor();
gfx.glyph(b'z', head.x + cw * 2.0, head.y - GH * S() - rise, S(), Color::new(0.8, 0.85, 1.0, 1.0 - k));
}
if e.carry > 0 {
let p = Cam::project(m, vec3(e.x, 0.35, e.y));
draw_circle(p.x + w * 0.35, p.y, 3.0, Color::from_rgba(250, 210, 60, 255));
let p = proj(vec3(e.x, 0.35, e.y));
gfx.glyph(0x0F, p.x + cw * 2.0, p.y - GH * small, small, Color::from_rgba(250, 210, 60, 255));
}
}
for tw in &g.towers {
if tw.hp < tw.max_hp() {
let (x, z) = tw.pos();
let p = Cam::project(m, vec3(x, tower_height(tw) + 0.9, z));
let w = 40.0;
let f = (tw.hp / tw.max_hp()).clamp(0.0, 1.0);
draw_rectangle(p.x - w * 0.5 - 1.0, p.y - 1.0, w + 2.0, 5.0, BLACK);
draw_rectangle(p.x - w * 0.5, p.y, w * f, 3.0, Color::from_rgba(255, 160, 60, 255));
let p = proj(vec3(x, tower_height(tw) + 0.9, z));
ascii_bar(gfx, p.x - (3.0 * cw).floor(), p.y, 6, tw.hp / tw.max_hp(), Color::from_rgba(255, 160, 60, 255), small);
}
}
if g.gate_hp > 0.0 && g.gate_hp < GATE_HP {
let (gx, gz) = g.gate_center();
let p = Cam::project(m, vec3(gx, 2.4, gz));
let w = 100.0;
draw_rectangle(p.x - w * 0.5 - 1.0, p.y - 1.0, w + 2.0, 8.0, BLACK);
draw_rectangle(p.x - w * 0.5, p.y, w * g.gate_hp / GATE_HP, 6.0, Color::from_rgba(120, 180, 255, 255));
let p = proj(vec3(gx, 2.4, gz));
let cols = 12;
ascii_bar(gfx, p.x - (cols as f32 * GW * S() * 0.5).floor(), p.y, cols, g.gate_hp / GATE_HP, Color::from_rgba(120, 180, 255, 255), S());
}
}
}