From 50d013bb28126292f1f58b922e76945a2b0aa854 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Tue, 3 Nov 2026 08:42:50 +0100 Subject: [PATCH] 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 --- DESIGN.md | 5 +- lang/de.lang | 3 + lang/en.lang | 3 + src/main.rs | 23 ++-- src/menu.rs | 106 +++++++++------- src/render3d.rs | 85 +++++++++---- src/settings.rs | 6 +- src/ui.rs | 326 +++++++++++++++++++++++++++++------------------- 8 files changed, 344 insertions(+), 213 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 9f21529..ac70bcb 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -6,7 +6,10 @@ An original dungeon management game (genre: dungeon building & defence) that run - Rust + **macroquad** (WebGL), target `wasm32-unknown-unknown` - User interface drawn entirely **in the canvas** - Font: **DINOBYTE** bitmap font (CP437, 6×8 px, CC BY 4.0, see CREDITS.md) for all texts, integer scaled -- All UI windows use **CP437 frames** (single lines for panels, double lines for dialogs); bars (HP/mana/gate) are pixel bars +- All UI windows use **CP437 frames** (single lines for panels, double lines for dialogs); **all bars are CP437 block characters** (█ ▌ ░) +- Text only in **fixed integer sizes**: 1×, 2× and 4× the 6×8 glyph size, times an integer **interface factor** (automatic from the device resolution, or 1×–4× in the settings) +- The interface is drawn in **device pixels** (high DPI) and laid out on the **character grid**, so every font pixel has exactly the same size +- **3D resolution** setting: the world can be rendered at 1/1, 1/2, 1/3 or 1/4 resolution and scaled up by exactly that factor (pixel look); the interface stays sharp - Sound: event system + macroquad audio prepared (volume, mute key M); sounds will be generated later - Languages: all in-game texts live in **language files** (`lang/*.lang`, key = text); English is the reference/fallback, German included, more can be added. The language is chosen on first start and stored in the browser. - Repository language: **English** (code, comments, docs, commit messages) diff --git a/lang/de.lang b/lang/de.lang index 8f3c914..419e541 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -333,3 +333,6 @@ dir.s = Süden dir.sw = Südwesten dir.w = Westen dir.nw = Nordwesten + +val.auto = Auto ({0}x) +set.render_scale = 3D-Auflösung diff --git a/lang/en.lang b/lang/en.lang index e755bcc..db7083a 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -334,3 +334,6 @@ dir.s = south dir.sw = south-west dir.w = west dir.nw = north-west + +val.auto = Auto ({0}x) +set.render_scale = 3D resolution diff --git a/src/main.rs b/src/main.rs index c8f4084..3f093f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,7 +36,7 @@ fn conf() -> macroquad::conf::Conf { window_title: "DungeonCraft".into(), window_width: 1280, window_height: 800, - high_dpi: false, + high_dpi: true, ..Default::default() }, draw_call_vertex_capacity: 40000, @@ -67,10 +67,11 @@ fn next_seed(seed: u32) -> u32 { fn apply_settings(s: &Settings, r3d: &mut R3d) { i18n::set_language(&s.language); - ui::set_ui_scale(s.ui_scale as f32); + ui::set_ui_factor(s.ui_factor); r3d.outlines = s.outlines; r3d.set_wall_detail(s.wall_subdivisions()); r3d.max_lights = s.max_lights as usize; + r3d.render_div = s.render_scale.clamp(1, 4) as u32; audio::set_volumes(s.music_volume, s.volume, s.muted); } @@ -238,7 +239,8 @@ async fn main() { loop { let dt = get_frame_time().min(0.1); let now = get_time(); - let (mx, my) = mouse_position(); + let (mx, my) = mouse_position(); // CSS pixels (3D picking) + let (ux, uy) = ui::mouse(); // device pixels (interface) let mut settings_changed = false; // ---------------- savegame files: file picker result or dropped onto the window @@ -272,7 +274,7 @@ async fn main() { let dialog = ui.show_langsel || ui.show_help; let in_menu = menu.open(); let playing = !in_menu && !dialog; - let over_ui = ui.over_ui(mx, my); + let over_ui = ui.over_ui(ux, uy); let picked = app.cam.pick_tile(&app.game.map, mx, my); let hover_tile = picked.map(|(x, z, _)| (x, z)); let ground = app.cam.ground_point(mx, my); @@ -436,7 +438,7 @@ async fn main() { // ---------------- left button if is_mouse_button_pressed(MouseButton::Left) { if dialog { - match ui.click(mx, my, &app.game) { + match ui.click(ux, uy, &app.game) { UiAction::SelectLanguage(code) => { app.settings.language = code; settings_changed = true; @@ -446,7 +448,7 @@ async fn main() { _ => {} } } else if in_menu { - match menu.click(mx, my, &mut app.settings) { + match menu.click(ux, uy, &mut app.settings) { MenuAction::SettingsChanged => settings_changed = true, MenuAction::Resume => {} MenuAction::NewGame => { @@ -483,7 +485,7 @@ async fn main() { MenuAction::None | MenuAction::Consumed => {} } } else { - match ui.click(mx, my, &app.game) { + match ui.click(ux, uy, &app.game) { UiAction::SelectTool(t) => { ui.tool = t; app.game.sfx_ui(game::Sfx::Click); @@ -520,8 +522,8 @@ async fn main() { } } if inp.minimap_drag { - if is_mouse_button_down(MouseButton::Left) && ui.minimap_contains(mx, my) { - let (x, y) = ui.minimap_world(mx, my); + if is_mouse_button_down(MouseButton::Left) && ui.minimap_contains(ux, uy) { + let (x, y) = ui.minimap_world(ux, uy); app.cam.tx = x; app.cam.tz = y; } else if !is_mouse_button_down(MouseButton::Left) { @@ -591,8 +593,9 @@ async fn main() { #[allow(clippy::too_many_arguments)] fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut Menu, inp: &Input, now: f64) { let (mx, my) = mouse_position(); + let (ux, uy) = ui::mouse(); let blocked = menu.open() || ui.show_help || ui.show_langsel; - let over_ui = blocked || ui.over_ui(mx, my); + let over_ui = blocked || ui.over_ui(ux, uy); let hover_tile = app.cam.pick_tile(&app.game.map, mx, my).map(|(x, z, _)| (x, z)); let ground = app.cam.ground_point(mx, my); clear_background(Color::from_rgba(4, 3, 5, 255)); diff --git a/src/menu.rs b/src/menu.rs index 285a19d..ce9c197 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -5,7 +5,7 @@ use crate::gfx::*; use crate::i18n::{languages, tr, trf}; use crate::map::{MAX_SIZE, MIN_SIZE}; use crate::settings::{all_actions, key_name, Action, Settings}; -use crate::ui::{tool_info, CH, CW, DIM, FRAME, FRAME_HI, GOLD, S, TOOLS, TXT}; +use crate::ui::{grid_center, grid_center_y, mouse, sh, sw, tool_info, CH, CW, DIM, FRAME, FRAME_HI, GOLD, S, SMALL, TITLE, TOOLS, TXT}; use macroquad::prelude::*; #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -34,6 +34,7 @@ pub enum Item { WallDetail, Lights, UiScale, + RenderScale, GameSpeed, Hints, } @@ -43,7 +44,7 @@ fn tab_items(tab: usize) -> &'static [Item] { match tab { 0 => &[Language, Volume, MusicVolume, Mute], 1 => &[RotateSpeed, ZoomSpeed, EdgeScroll, InvertMouse], - 2 => &[Outlines, WallDetail, Lights, UiScale], + 2 => &[Outlines, WallDetail, Lights, UiScale, RenderScale], 3 => &[GameSpeed, Hints], _ => &[], } @@ -63,6 +64,7 @@ fn item_label(it: Item) -> String { Item::WallDetail => "set.wall_detail", Item::Lights => "set.lights", Item::UiScale => "set.ui_scale", + Item::RenderScale => "set.render_scale", Item::GameSpeed => "set.game_speed", Item::Hints => "set.hints", }) @@ -93,7 +95,14 @@ fn item_value(it: Item, s: &Settings) -> String { _ => "val.medium", }), Item::Lights => s.max_lights.to_string(), - Item::UiScale => tr(if s.ui_scale >= 3 { "val.large" } else { "val.normal" }), + Item::UiScale => { + if s.ui_factor == 0 { + trf("val.auto", &[crate::ui::F().to_string()]) + } else { + format!("{}x", s.ui_factor) + } + } + Item::RenderScale => format!("1/{}", s.render_scale.max(1)), Item::GameSpeed => format!("{}x", s.game_speed as i32), Item::Hints => onoff(s.hints), } @@ -126,7 +135,8 @@ fn item_change(it: Item, s: &mut Settings, d: i32) { Item::Outlines => s.outlines = !s.outlines, Item::WallDetail => s.wall_detail = cycle(&[1u8, 2, 3], s.wall_detail, d), Item::Lights => s.max_lights = cycle(&[8u8, 16, 24], s.max_lights, d), - Item::UiScale => s.ui_scale = cycle(&[2u8, 3], s.ui_scale, d), + Item::UiScale => s.ui_factor = cycle(&[0u8, 1, 2, 3, 4], s.ui_factor, d), + Item::RenderScale => s.render_scale = cycle(&[1u8, 2, 3, 4], s.render_scale.max(1), d), Item::GameSpeed => s.game_speed = cycle(&[1.0f32, 2.0], s.game_speed, d), Item::Hints => s.hints = !s.hints, } @@ -285,8 +295,8 @@ impl Menu { pub fn draw(&mut self, gfx: &Gfx, settings: &Settings, status: Option<&str>) { self.buttons.clear(); let Some(page) = self.page else { return }; - let sw = screen_width(); - let sh = screen_height(); + let sw = sw(); + let sh = sh(); draw_rectangle(0.0, 0.0, sw, sh, Color::new(0.0, 0.0, 0.0, if page == Page::Title { 0.55 } else { 0.45 })); match page { Page::Title => self.draw_title(gfx), @@ -299,8 +309,8 @@ impl Menu { let a = all_actions()[action]; let text = trf("set.press_key", &[action_label(a)]); let cols = (text.chars().count() as i32 + 8).max(36); - let x = (sw - cols as f32 * CW()) * 0.5; - let y = sh * 0.4; + let x = grid_center(sw, cols as f32 * CW()); + let y = (sh * 0.4 / CH()).floor() * CH(); gfx.window(x, y, cols, 5, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.98)); gfx.text(&text, x + CW() * 4.0, y + CH() * 1.5, S(), TXT); let esc = tr("set.esc_cancel"); @@ -309,7 +319,7 @@ impl Menu { } if let Some(st) = status { let w = gfx.measure(st, S()) + CW() * 4.0; - let x = (sw - w) * 0.5; + let x = grid_center(sw, w); let y = sh - CH() * 9.0; draw_rectangle(x, y, w, CH() * 2.0, Color::new(0.05, 0.03, 0.04, 0.95)); draw_rectangle_lines(x, y, w, CH() * 2.0, 2.0, GOLD); @@ -318,24 +328,24 @@ impl Menu { } fn column(&mut self, gfx: &Gfx, x: f32, y: f32, w: f32, items: &[(Btn, String)]) { - let (mx, my) = mouse_position(); + let (mx, my) = mouse(); for (k, (b, label)) in items.iter().enumerate() { - let r = Rect::new(x, y + k as f32 * CH() * 3.0, w, CH() * 2.2); + let r = Rect::new(x, y + k as f32 * CH() * 3.0, w, CH() * 2.0); button(gfx, r, label, r.contains(vec2(mx, my)), FRAME); self.buttons.push((r, *b)); } } fn draw_title(&mut self, gfx: &Gfx) { - let sw = screen_width(); - let sh = screen_height(); - let big = (S() * 4.0).min((sw / (GW * 14.0)).floor()).max(S()); + let sw = sw(); + let sh = sh(); + let big = TITLE(); let title = "DungeonCraft"; - let ty = sh * 0.12; - gfx.text(title, (sw - gfx.measure(title, big)) * 0.5 + big, ty + big, big, Color::new(0.0, 0.0, 0.0, 0.8)); - gfx.text(title, (sw - gfx.measure(title, big)) * 0.5, ty, big, FRAME_HI); + let ty = (sh * 0.12 / CH()).floor() * CH(); + gfx.text(title, grid_center(sw, gfx.measure(title, big)) + big, ty + big, big, Color::new(0.0, 0.0, 0.0, 0.8)); + gfx.text(title, grid_center(sw, gfx.measure(title, big)), ty, big, FRAME_HI); let sub = tr("menu.subtitle"); - gfx.text(&sub, (sw - gfx.measure(&sub, S())) * 0.5, ty + GH * big + CH(), S(), TXT); + gfx.text(&sub, grid_center(sw, gfx.measure(&sub, S())), ty + GH * big + CH(), S(), TXT); let mut items = Vec::new(); if self.has_autosave { items.push((Btn::Continue, tr("menu.continue"))); @@ -346,35 +356,35 @@ impl Menu { items.push((Btn::Help, tr("menu.help"))); let w = 26.0 * CW(); let y = ty + GH * big + CH() * 4.0; - self.column(gfx, (sw - w) * 0.5, y, w, &items); + self.column(gfx, grid_center(sw, w), y, w, &items); let hint = tr("menu.drop_hint"); - gfx.text(&hint, (sw - gfx.measure(&hint, (S() / 2.0).max(1.0))) * 0.5, sh - CH() * 7.5, (S() / 2.0).max(1.0), DIM); + gfx.text(&hint, grid_center(sw, gfx.measure(&hint, SMALL())), sh - CH() * 8.0, SMALL(), DIM); let credit = tr("help.font"); - gfx.text(&credit, (sw - gfx.measure(&credit, (S() / 2.0).max(1.0))) * 0.5, sh - CH() * 6.5, (S() / 2.0).max(1.0), DIM); + gfx.text(&credit, grid_center(sw, gfx.measure(&credit, SMALL())), sh - CH() * 7.0, SMALL(), DIM); } // New game: choose the map size (width and height separately) fn draw_new_game(&mut self, gfx: &Gfx, s: &Settings) { - let sw = screen_width(); - let sh = screen_height(); - let (mx, my) = mouse_position(); + let sw = sw(); + let sh = sh(); + let (mx, my) = mouse(); let cols = 44; let rows = 17; - let x = (sw - cols as f32 * CW()) * 0.5; - let y = ((sh - rows as f32 * CH()) * 0.5).max(0.0); + let x = grid_center(sw, cols as f32 * CW()); + let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0); gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("menu.new"), FRAME_HI); let lines = [("newgame.width", s.map_w, 0), ("newgame.height", s.map_h, 1)]; for (k, &(key, val, which)) in lines.iter().enumerate() { let by = y + CH() * (2.0 + k as f32 * 2.5); - gfx.text(&tr(key), x + CW() * 3.0, by + CH() * 0.4, S(), TXT); + gfx.text(&tr(key), x + CW() * 3.0, by + CH() * 0.5, S(), TXT); let vw = 14.0 * CW(); let vx = x + cols as f32 * CW() - vw - CW() * 3.0; - let left = Rect::new(vx, by, CW() * 2.5, CH() * 1.8); - let right = Rect::new(vx + vw - CW() * 2.5, by, CW() * 2.5, CH() * 1.8); + let left = Rect::new(vx, by, CW() * 2.5, CH() * 2.0); + let right = Rect::new(vx + vw - CW() * 2.5, by, CW() * 2.5, CH() * 2.0); button(gfx, left, "<", left.contains(vec2(mx, my)), FRAME); button(gfx, right, ">", right.contains(vec2(mx, my)), FRAME); let v = val.to_string(); - gfx.text(&v, vx + (vw - gfx.measure(&v, S())) * 0.5, by + CH() * 0.4, S(), GOLD); + gfx.text(&v, vx + (vw - gfx.measure(&v, S())) * 0.5, by + CH() * 0.5, S(), GOLD); let (l, r) = if which == 0 { (Btn::MapW(-1), Btn::MapW(1)) } else { (Btn::MapH(-1), Btn::MapH(1)) }; self.buttons.push((left, l)); self.buttons.push((right, r)); @@ -389,8 +399,8 @@ impl Menu { } fn draw_pause(&mut self, gfx: &Gfx) { - let sw = screen_width(); - let sh = screen_height(); + let sw = sw(); + let sh = sh(); let items = vec![ (Btn::Resume, tr("menu.resume")), (Btn::Save, tr("menu.save")), @@ -401,21 +411,21 @@ impl Menu { ]; let cols = 32; let rows = items.len() as i32 * 3 + 4; - let x = (sw - cols as f32 * CW()) * 0.5; - let y = ((sh - rows as f32 * CH()) * 0.5).max(0.0); + let x = grid_center(sw, cols as f32 * CW()); + let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0); gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("menu.paused"), FRAME_HI); let w = (cols - 6) as f32 * CW(); self.column(gfx, x + CW() * 3.0, y + CH() * 2.0, w, &items); } fn draw_settings(&mut self, gfx: &Gfx, s: &Settings, tab: usize) { - let sw = screen_width(); - let sh = screen_height(); - let (mx, my) = mouse_position(); + let sw = sw(); + let sh = sh(); + let (mx, my) = mouse(); let cols = ((sw / CW()) as i32 - 2).min(66); let rows = ((sh / CH()) as i32 - 2).min(34); - let x = ((sw - cols as f32 * CW()) * 0.5).max(0.0); - let y = ((sh - rows as f32 * CH()) * 0.5).max(0.0); + let x = (grid_center(sw, cols as f32 * CW())).max(0.0); + let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0); gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("menu.settings"), FRAME_HI); // tabs let mut tx = x + CW() * 2.0; @@ -437,7 +447,7 @@ impl Menu { let acts = all_actions(); let half = (acts.len() + 1) / 2; let colw = (cols as f32 - 6.0) * CW() * 0.5; - let rowh = CH() * 1.35; + let rowh = CH(); for (k, a) in acts.iter().enumerate() { let c = k / half; let r = k % half; @@ -456,32 +466,32 @@ impl Menu { self.buttons.push((rect, Btn::Key(k))); } let hint = tr("set.keys_hint"); - gfx.text(&hint, x + CW() * 2.0, top + half as f32 * rowh + CH() * 0.5, (S() / 2.0).max(1.0), DIM); + gfx.text(&hint, x + CW() * 2.0, top + half as f32 * rowh + CH() * 0.5, SMALL(), DIM); let rw = gfx.measure(&tr("set.reset_keys"), S()) + CW() * 4.0; - let rr = Rect::new(x + cols as f32 * CW() - rw - CW() * 2.0, y + (rows - 4) as f32 * CH(), rw, CH() * 2.2); + let rr = Rect::new(x + cols as f32 * CW() - rw - CW() * 2.0, y + (rows - 4) as f32 * CH(), rw, CH() * 2.0); button(gfx, rr, &tr("set.reset_keys"), rr.contains(vec2(mx, my)), FRAME); self.buttons.push((rr, Btn::ResetKeys)); } else { for (i, &it) in tab_items(tab).iter().enumerate() { let by = top + i as f32 * CH() * 2.5; - gfx.text(&item_label(it), x + CW() * 3.0, by + CH() * 0.4, S(), TXT); + gfx.text(&item_label(it), x + CW() * 3.0, by + CH() * 0.5, S(), TXT); // value with < > buttons let vw = 18.0 * CW(); let vx = x + cols as f32 * CW() - vw - CW() * 3.0; - let left = Rect::new(vx, by, CW() * 2.5, CH() * 1.8); - let right = Rect::new(vx + vw - CW() * 2.5, by, CW() * 2.5, CH() * 1.8); + let left = Rect::new(vx, by, CW() * 2.5, CH() * 2.0); + let right = Rect::new(vx + vw - CW() * 2.5, by, CW() * 2.5, CH() * 2.0); button(gfx, left, "<", left.contains(vec2(mx, my)), FRAME); button(gfx, right, ">", right.contains(vec2(mx, my)), FRAME); let val = item_value(it, s); - gfx.text(&val, vx + (vw - gfx.measure(&val, S())) * 0.5, by + CH() * 0.4, S(), GOLD); - let mid = Rect::new(vx + CW() * 2.5, by, vw - CW() * 5.0, CH() * 1.8); + gfx.text(&val, vx + (vw - gfx.measure(&val, S())) * 0.5, by + CH() * 0.5, S(), GOLD); + let mid = Rect::new(vx + CW() * 2.5, by, vw - CW() * 5.0, CH() * 2.0); self.buttons.push((left, Btn::Change(i, -1))); self.buttons.push((right, Btn::Change(i, 1))); self.buttons.push((mid, Btn::Change(i, 1))); } } let bw = gfx.measure(&tr("menu.back"), S()) + CW() * 6.0; - let br = Rect::new(x + CW() * 2.0, y + (rows - 4) as f32 * CH(), bw, CH() * 2.2); + let br = Rect::new(x + CW() * 2.0, y + (rows - 4) as f32 * CH(), bw, CH() * 2.0); button(gfx, br, &tr("menu.back"), br.contains(vec2(mx, my)), FRAME); self.buttons.push((br, Btn::Back)); } diff --git a/src/render3d.rs b/src/render3d.rs index 807e9c9..200c6f4 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -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()); } } } diff --git a/src/settings.rs b/src/settings.rs index 4045758..e116b52 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -141,7 +141,8 @@ pub struct Settings { pub outlines: bool, pub wall_detail: u8, // 1 low, 2 medium, 3 high pub max_lights: u8, // 8, 16 or 24 - pub ui_scale: u8, // font scale 2 (normal) or 3 (large) + pub ui_factor: u8, // interface size: 0 = automatic, 1..4 = fixed integer factor + pub render_scale: u8, // 3D world resolution divisor: 1 = full, 2..4 = pixel look // game pub game_speed: f32, // 1 or 2 pub hints: bool, @@ -165,7 +166,8 @@ impl Default for Settings { outlines: true, wall_detail: 2, max_lights: 24, - ui_scale: 2, + ui_factor: 0, + render_scale: 1, game_speed: 1.0, hints: true, map_w: 64, diff --git a/src/ui.rs b/src/ui.rs index 5fca5e6..62791cc 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -9,18 +9,58 @@ use crate::render3d::{minimap_color, Cam}; use crate::settings::{key_name, Action, Settings}; use macroquad::prelude::*; -// UI font scale (setting: 2 = normal, 3 = large); character cell size follows it +// ---- interface scaling +// The interface is drawn in device pixels (see begin_ui). Every size is an integer multiple +// of the font's 6x8 pixel glyphs: small text = 1*F, normal text = 2*F, titles = 4*F, where F +// is the interface factor (automatic from the resolution or fixed in the settings). thread_local! { - static UI_SCALE: std::cell::Cell = std::cell::Cell::new(2.0); + static UI_FACTOR_SETTING: std::cell::Cell = std::cell::Cell::new(0); } -pub fn set_ui_scale(s: f32) { - UI_SCALE.with(|c| c.set(s)); + +pub fn set_ui_factor(f: u8) { + UI_FACTOR_SETTING.with(|c| c.set(f)); +} + +// device pixels per CSS pixel +pub fn dpr() -> f32 { + screen_dpi_scale() +} +// screen size and mouse in device pixels +pub fn sw() -> f32 { + (screen_width() * dpr()).round() +} +pub fn sh() -> f32 { + (screen_height() * dpr()).round() +} +pub fn mouse() -> (f32, f32) { + let (x, y) = mouse_position(); + (x * dpr(), y * dpr()) +} + +// interface factor (integer) +#[allow(non_snake_case)] +pub fn F() -> u32 { + let set = UI_FACTOR_SETTING.with(|c| c.get()); + if set > 0 { + return set as u32; + } + ((sw() / 1280.0).min(sh() / 760.0).floor() as u32).clamp(1, 4) +} +// text scales: 1x, 2x and 4x the glyph size, times F +#[allow(non_snake_case)] +pub fn SMALL() -> f32 { + F() as f32 } #[allow(non_snake_case)] pub fn S() -> f32 { - UI_SCALE.with(|c| c.get()) + 2.0 * F() as f32 } #[allow(non_snake_case)] +pub fn TITLE() -> f32 { + 4.0 * F() as f32 +} +// character cell of normal text = the layout grid +#[allow(non_snake_case)] pub fn CW() -> f32 { GW * S() } @@ -28,10 +68,44 @@ pub fn CW() -> f32 { pub fn CH() -> f32 { GH * S() } -// factor relative to the normal scale, for pixel sizes such as buttons and icons +// pixel factor for icons and other pixel art #[allow(non_snake_case)] pub fn K() -> f32 { - S() / 2.0 + F() as f32 +} + +// x position that centres a width on the character grid +pub fn grid_center(total: f32, w: f32) -> f32 { + (((total - w) * 0.5) / CW()).floor() * CW() +} + +pub fn grid_center_y(total: f32, h: f32) -> f32 { + (((total - h) * 0.5) / CH()).floor() * CH() +} + +// 2D camera mapping one unit to one device pixel (top-left origin) +pub fn begin_ui() { + let (w, h) = (sw(), sh()); + set_camera(&Camera2D { target: vec2(w * 0.5, h * 0.5), zoom: vec2(2.0 / w, 2.0 / h), ..Default::default() }); +} + +// bar made of CP437 block characters: full blocks, a half block, shaded rest +pub fn ascii_bar(gfx: &Gfx, x: f32, y: f32, cols: usize, frac: f32, col: Color, scale: f32) { + let f = frac.clamp(0.0, 1.0) * cols as f32; + let full = f.floor() as usize; + let half = f - full as f32 >= 0.5; + let dim = Color::new(col.r * 0.35, col.g * 0.35, col.b * 0.35, col.a); + for k in 0..cols { + let px = x + k as f32 * GW * scale; + if k < full { + gfx.glyph(0xDB, px, y, scale, col); + } else if k == full && half { + gfx.glyph(0xB0, px, y, scale, dim); + gfx.glyph(0xDD, px, y, scale, col); + } else { + gfx.glyph(0xB0, px, y, scale, dim); + } + } } pub const FRAME: Color = Color::new(0.62, 0.52, 0.36, 1.0); @@ -176,12 +250,6 @@ fn wrap(s: &str, cols: usize) -> Vec { out } -fn bar(x: f32, y: f32, w: f32, h: f32, frac: f32, col: Color) { - draw_rectangle(x - 1.0, y - 1.0, w + 2.0, h + 2.0, Color::new(0.0, 0.0, 0.0, 1.0)); - draw_rectangle(x, y, w, h, Color::new(col.r * 0.25, col.g * 0.25, col.b * 0.25, 1.0)); - draw_rectangle(x, y, w * frac.clamp(0.0, 1.0), h, col); - draw_rectangle(x, y, w * frac.clamp(0.0, 1.0), (h * 0.3).max(1.0), Color::new(1.0, 1.0, 1.0, 0.25)); -} fn mmss(t: f32) -> String { let t = t.max(0.0) as i32; @@ -259,64 +327,86 @@ impl Ui { pub fn draw(&mut self, g: &Game, gfx: &Gfx, cam: &Cam, settings: &Settings, info: &[(String, Color)], now: f64) { self.rects.clear(); let keys: Vec = (0..TOOLS.len()).map(|i| key_name(settings.key(Action::Tool(i)))).collect(); - let sw = screen_width(); - let sh = screen_height(); - let (mx, my) = mouse_position(); + let sw = sw(); + let sh = sh(); + let (mx, my) = mouse(); - // ---------------- top bar - let cols = (sw / CW()).ceil() as i32; + // ---------------- top bar (everything on the character grid) + let cols = (sw / CW()).floor() as i32; gfx.window(0.0, 0.0, cols, 3, S(), false, FRAME, BG); self.rects.push(Rect::new(0.0, 0.0, sw, 3.0 * CH())); let y = CH(); - let mut x = CW() * 2.0; - let seg = |label: &str, lc: Color, value: &str, vc: Color, x: &mut f32| { - gfx.text(label, *x, y, S(), lc); - *x += gfx.measure(label, S()) + CW(); - gfx.text(value, *x, y, S(), vc); - *x += gfx.measure(value, S()) + CW() * 2.0; + let mut c = 2usize; // current column + let col_x = |c: usize| c as f32 * CW(); + let put = |s: &str, color: Color, c: &mut usize| { + gfx.text(s, col_x(*c), y, S(), color); + *c += s.chars().count(); }; - seg(&tr("hud.gold"), GOLD, &format!("{}/{}", g.gold as i32, g.gold_cap() as i32), TXT, &mut x); - let mana = tr("hud.mana"); - gfx.text(&mana, x, y, S(), MANA); - x += gfx.measure(&mana, S()) + CW() * 0.5; - bar(x, y + 3.0, 70.0, 10.0, g.mana / MANA_MAX, MANA); - x += 70.0 + CW() * 0.5; - gfx.text(&format!("{}", g.mana as i32), x, y, S(), TXT); - x += CW() * 5.0; - let core = tr("hud.core"); - gfx.text(&core, x, y, S(), RED); - x += gfx.measure(&core, S()) + CW() * 0.5; - bar(x, y + 3.0, 70.0, 10.0, g.core_hp / CORE_HP, RED); - x += 70.0 + CW() * 1.5; - let gate = tr("hud.gate"); - gfx.text(&gate, x, y, S(), BLUE); - x += gfx.measure(&gate, S()) + CW() * 0.5; - bar(x, y + 3.0, 70.0, 10.0, g.gate_hp / GATE_HP, BLUE); - x += 70.0 + CW() * 1.5; + let bar = |frac: f32, color: Color, c: &mut usize| { + ascii_bar(gfx, col_x(*c), y, 6, frac, color, S()); + *c += 6; + }; + put(&tr("hud.gold"), GOLD, &mut c); + c += 1; + put(&format!("{}/{}", g.gold as i32, g.gold_cap() as i32), TXT, &mut c); + c += 2; + put(&tr("hud.mana"), MANA, &mut c); + c += 1; + bar(g.mana / MANA_MAX, MANA, &mut c); + c += 1; + put(&format!("{:<3}", g.mana as i32), TXT, &mut c); + c += 2; + put(&tr("hud.core"), RED, &mut c); + c += 1; + bar(g.core_hp / CORE_HP, RED, &mut c); + c += 2; + put(&tr("hud.gate"), BLUE, &mut c); + c += 1; + bar(g.gate_hp / GATE_HP, BLUE, &mut c); + c += 2; let cap = g.counts[1].min(30); - seg(&tr("hud.creatures"), TXT, &format!("{}/{}", g.creature_count(), cap), TXT, &mut x); - seg(&tr("hud.workers"), TXT, &format!("{}", g.worker_count()), TXT, &mut x); + let mut segs: Vec<(String, Color, String)> = vec![ + (tr("hud.creatures"), TXT, format!("{}/{}", g.creature_count(), cap)), + (tr("hud.workers"), TXT, g.worker_count().to_string()), + ]; let wave_col = if g.wave_timer < 30.0 { RED } else { TXT }; - seg(&trf("hud.wave", &[(g.wave + 1).to_string()]), wave_col, &mmss(g.wave_timer), wave_col, &mut x); - seg(&tr("hud.army"), TXT, &format!("{}", g.army_value() as i32), TXT, &mut x); + segs.push((trf("hud.wave", &[(g.wave + 1).to_string()]), wave_col, mmss(g.wave_timer))); + segs.push((tr("hud.army"), TXT, (g.army_value() as i32).to_string())); + if !g.hand.is_empty() { + segs.push((tr("hud.hand"), FRAME_HI, format!("{}/8", g.hand.len()))); + } let vol = if settings.muted { tr("hud.off") } else { format!("{}%", (settings.volume * 100.0).round() as i32) }; - // volume only if it still fits into the bar - if x + gfx.measure(&format!("♪ {}", vol), S()) < sw - CW() * 2.0 { - seg("♪", DIM, &vol, DIM, &mut x); + segs.push(("\u{266a}".to_string(), DIM, vol)); + for (label, lc, value) in segs { + // only what still fits into the bar + if c + label.chars().count() + value.chars().count() + 2 > cols as usize - 1 { + break; + } + put(&label, lc, &mut c); + c += 1; + put(&value, if lc == DIM { DIM } else { TXT }, &mut c); + c += 2; } - // ---------------- toolbar + // ---------------- toolbar: buttons of 6 x 3 characters, icons and labels on the grid let tb_rows = 5; let tb_y = sh - tb_rows as f32 * CH(); + let tb_y = (tb_y / CH()).floor() * CH(); + let tb_h = sh - tb_y; + let tb_rows = (tb_h / CH()).floor() as i32; gfx.window(0.0, tb_y, cols, tb_rows, S(), false, FRAME, BG); - self.rects.push(Rect::new(0.0, tb_y, sw, tb_rows as f32 * CH())); + self.rects.push(Rect::new(0.0, tb_y, sw, tb_h)); self.tool_rects.clear(); - let bw = (80.0 * K()).min(((sw - CW() * 2.0 - 150.0) / TOOLS.len() as f32 - 4.0 * K()).max(48.0)); let mut hover_tool = None; + let bw = 6.0 * CW(); + let px = K(); // one pixel of the font, times the factor for (i, &t) in TOOLS.iter().enumerate() { - let bx = CW() + i as f32 * (bw + 4.0 * K()); - let by = tb_y + CH() * 0.75; - let r = Rect::new(bx, by, bw, CH() * 3.5); + let bx = CW() + i as f32 * bw; + let by = tb_y + CH(); + if bx + bw > sw - CW() { + break; + } + let r = Rect::new(bx + px, by, bw - 2.0 * px, 3.0 * CH()); self.tool_rects.push(r); let hovered = r.contains(vec2(mx, my)); if hovered { @@ -324,43 +414,27 @@ impl Ui { } let sel = self.tool == t; draw_rectangle(r.x, r.y, r.w, r.h, if sel { Color::new(0.35, 0.22, 0.1, 1.0) } else if hovered { Color::new(0.18, 0.13, 0.1, 1.0) } else { Color::new(0.1, 0.07, 0.07, 1.0) }); - draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if sel { FRAME_HI } else { Color::new(0.35, 0.28, 0.2, 1.0) }); + draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0 * px, if sel { FRAME_HI } else { Color::new(0.35, 0.28, 0.2, 1.0) }); let ti = tool_info(t); - let icon = 32.0 * K(); - let small = (S() / 2.0).floor().max(1.0); - gfx.draw_cell(ti.icon, bx + (bw - icon) * 0.5, by + 4.0 * K(), icon, icon, WHITE); - gfx.text(&keys[i], bx + 3.0, by + 3.0, small, if sel { FRAME_HI } else { DIM }); - let lw = gfx.measure(&ti.label, small); - gfx.text(&ti.label, bx + (bw - lw) * 0.5, by + 42.0 * K(), small, if sel { FRAME_HI } else { TXT }); - } - // hand contents - let hx = CW() + TOOLS.len() as f32 * (bw + 4.0 * K()) + CW(); - if hx + 120.0 < sw - 200.0 { - gfx.text(&tr("hud.hand"), hx, tb_y + CH(), S(), TXT); - for (k, id) in g.hand.iter().enumerate() { - if let Some(e) = g.ents.iter().find(|e| e.id == *id) { - let sx = hx + (k % 4) as f32 * 26.0; - let sy = tb_y + CH() * 2.2 + (k / 4) as f32 * 24.0; - draw_texture_ex( - &gfx.sprites, - sx, - sy, - WHITE, - DrawTextureParams { dest_size: Some(vec2(20.0, 20.0)), source: Some(Gfx::sprite_rect(e.kind, false)), ..Default::default() }, - ); - } - } - if g.hand.is_empty() { - gfx.text(&tr("hud.empty"), hx, tb_y + CH() * 2.5, 1.0, DIM); - } + // icon: the 16 px atlas cell at 2*F, centred on the grid + let icon = 32.0 * px; + gfx.draw_cell(ti.icon, bx + (bw - icon) * 0.5, by + 4.0 * px, icon, icon, WHITE); + gfx.text(&keys[i], bx + 3.0 * px, by + 3.0 * px, SMALL(), if sel { FRAME_HI } else { DIM }); + // label in small text, centred on whole small-character cells + let label: String = ti.label.chars().take(11).collect(); + let lw = gfx.measure(&label, SMALL()); + let lx = bx + ((bw - lw) / (GW * SMALL()) / 2.0).floor() * GW * SMALL(); + gfx.text(&label, lx, by + 2.0 * CH() + 4.0 * px, SMALL(), if sel { FRAME_HI } else { TXT }); } - // ---------------- minimap - let mm_cols = 16; - let mm_rows = 12; + // ---------------- minimap: whole device pixels per tile + let ms = ((14.0 * CW()) / mw().max(mh()) as f32).floor().max(1.0); + let (map_w, map_h) = (mw() as f32 * ms, mh() as f32 * ms); + let mm_cols = ((map_w / CW()).ceil() as i32 + 2).max(10); + let mm_rows = (map_h / CH()).ceil() as i32 + 2; let mm_w = mm_cols as f32 * CW(); let mm_h = mm_rows as f32 * CH(); - let mm_x = sw - mm_w; + let mm_x = ((sw - mm_w) / CW()).floor() * CW(); let mm_y = tb_y - mm_h; gfx.window_titled(mm_x, mm_y, mm_cols, mm_rows, S(), false, FRAME, BG, &tr("ui.map"), FRAME_HI); self.rects.push(Rect::new(mm_x, mm_y, mm_w, mm_h)); @@ -383,39 +457,37 @@ impl Ui { } self.minimap_tex.update(&self.minimap_img); } - // keep the map's aspect ratio inside the window - let avail = (mm_w - CW() * 2.0).min(mm_h - CH() * 2.0); - let ms = avail / mw().max(mh()) as f32; - let (map_w, map_h) = (mw() as f32 * ms, mh() as f32 * ms); - let mrx = mm_x + (mm_w - map_w) * 0.5; - let mry = mm_y + CH() + (mm_h - CH() * 2.0 - map_h) * 0.5; + let mrx = (mm_x + (mm_w - map_w) * 0.5).floor(); + let mry = (mm_y + (mm_h - map_h) * 0.5).floor(); self.minimap_rect = Rect::new(mrx, mry, map_w, map_h); draw_texture_ex(&self.minimap_tex, mrx, mry, WHITE, DrawTextureParams { dest_size: Some(vec2(map_w, map_h)), ..Default::default() }); + let dot = (2.0 * px).max(ms); for e in &g.ents { if e.alive && !e.held { let c = if e.hero { Color::new(0.6, 0.85, 1.0, 1.0) } else if e.kind == EK::Worker { Color::new(1.0, 0.6, 0.3, 1.0) } else { Color::new(1.0, 0.25, 0.25, 1.0) }; - draw_rectangle(mrx + e.x * ms - 1.0, mry + e.y * ms - 1.0, 3.0, 3.0, c); + draw_rectangle((mrx + e.x.floor() * ms).floor(), (mry + e.y.floor() * ms).floor(), dot, dot, c); } } if let Some(f) = g.flag { let (fx, fy) = xy(f); - draw_rectangle(mrx + fx as f32 * ms - 2.0, mry + fy as f32 * ms - 2.0, 5.0, 5.0, RED); + draw_rectangle(mrx + fx as f32 * ms - px, mry + fy as f32 * ms - px, dot + 2.0 * px, dot + 2.0 * px, RED); } // expected entry points of the hero wave (blinking) if (now * 3.0) as i64 % 2 == 0 { for &(ex, ey) in &g.planned_entries { - let (px, py) = (mrx + ex as f32 * ms, mry + ey as f32 * ms); - draw_rectangle_lines(px - 4.0, py - 4.0, 9.0, 9.0, 2.0, Color::new(0.6, 0.85, 1.0, 1.0)); + let (ppx, ppy) = (mrx + ex as f32 * ms, mry + ey as f32 * ms); + draw_rectangle_lines(ppx - 4.0 * px, ppy - 4.0 * px, 9.0 * px, 9.0 * px, 2.0 * px, Color::new(0.6, 0.85, 1.0, 1.0)); } } - // camera footprint on the ground - let corners = [cam.ground_point(0.0, 0.0), cam.ground_point(sw, 0.0), cam.ground_point(sw, sh), cam.ground_point(0.0, sh)]; + // camera footprint on the ground (the 3D camera works in CSS pixels) + let (lw, lh) = (screen_width(), screen_height()); + let corners = [cam.ground_point(0.0, 0.0), cam.ground_point(lw, 0.0), cam.ground_point(lw, lh), cam.ground_point(0.0, lh)]; for k in 0..4 { let a = corners[k]; let b = corners[(k + 1) % 4]; let clampp = |p: Vec2| vec2(mrx + p.x.clamp(0.0, mw() as f32) * ms, mry + p.y.clamp(0.0, mh() as f32) * ms); let (pa, pb) = (clampp(a), clampp(b)); - draw_line(pa.x, pa.y, pb.x, pb.y, 1.5, WHITE); + draw_line(pa.x, pa.y, pb.x, pb.y, px, WHITE); } // ---------------- info panel @@ -471,8 +543,8 @@ impl Ui { } lines.push((trf("ui.key", &[keys[i].clone()]), DIM)); let rows = lines.len() as i32 + 2; - let tx = (mx - tcols as f32 * CW() * 0.5).clamp(0.0, sw - tcols as f32 * CW()); - let ty = tb_y - rows as f32 * CH() - 4.0; + let tx = (((mx - tcols as f32 * CW() * 0.5) / CW()).floor() * CW()).clamp(0.0, sw - tcols as f32 * CW()); + let ty = tb_y - rows as f32 * CH(); gfx.window_titled(tx, ty, tcols as i32, rows, S(), false, FRAME_HI, BG, &ti.name, FRAME_HI); for (k, (l, c)) in lines.iter().enumerate() { gfx.text(l, tx + CW() * 2.0, ty + CH() * (k as f32 + 1.0), S(), *c); @@ -483,8 +555,8 @@ impl Ui { // Dialogs on top of everything (help, language selection, end screen, pause) pub fn draw_dialogs(&mut self, g: &Game, gfx: &Gfx) { - let sw = screen_width(); - let sh = screen_height(); + let sw = sw(); + let sh = sh(); if self.show_langsel { self.draw_langsel(gfx); } else if g.state != 0 { @@ -493,13 +565,13 @@ impl Ui { self.draw_help(gfx); } else if g.paused { let w = 24; - let x = (sw - w as f32 * CW()) * 0.5; + let x = grid_center(sw, w as f32 * CW()); let y = sh * 0.35; gfx.window(x, y, w, 5, S(), true, FRAME_HI, BG); let t = tr("ui.pause"); - gfx.text(&t, (sw - gfx.measure(&t, 3.0)) * 0.5, y + CH(), 3.0, FRAME_HI); + gfx.text(&t, grid_center(sw, gfx.measure(&t, TITLE())), y + CH(), TITLE(), FRAME_HI); let t2 = tr("ui.pause_hint"); - gfx.text(&t2, (sw - gfx.measure(&t2, S())) * 0.5, y + CH() * 3.0, S(), DIM); + gfx.text(&t2, grid_center(sw, gfx.measure(&t2, S())), y + CH() * 3.0, S(), DIM); } } @@ -529,10 +601,10 @@ impl Ui { let lines: Vec<(String, Color)> = keys.iter().map(|(k, c)| (if k.is_empty() { String::new() } else { tr(k) }, *c)).collect(); let cols = 74; let rows = lines.len() as i32 + 4; - let sw = screen_width(); - let sh = screen_height(); - let x = ((sw - cols as f32 * CW()) * 0.5).max(0.0); - let y = ((sh - rows as f32 * CH()) * 0.5).max(0.0); + let sw = sw(); + let sh = sh(); + let x = (grid_center(sw, cols as f32 * CW())).max(0.0); + let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0); gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), "DungeonCraft", FRAME_HI); for (k, (l, c)) in lines.iter().enumerate() { gfx.text(l, x + CW() * 3.0, y + CH() * (k as f32 + 2.0), S(), *c); @@ -542,22 +614,22 @@ impl Ui { // First start: choose the language (names shown in their own language) fn draw_langsel(&mut self, gfx: &Gfx) { - let sw = screen_width(); - let sh = screen_height(); + let sw = sw(); + let sh = sh(); draw_rectangle(0.0, 0.0, sw, sh, Color::new(0.0, 0.0, 0.0, 0.6)); let langs = languages(); let cols = 50; let rows = 6 + langs.len() as i32 * 3; - let x = ((sw - cols as f32 * CW()) * 0.5).max(0.0); - let y = ((sh - rows as f32 * CH()) * 0.5).max(0.0); + let x = (grid_center(sw, cols as f32 * CW())).max(0.0); + let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0); // the title shows all language names, since no language is chosen yet let title = langs.iter().map(|(c, _)| crate::i18n::tr_in(c, "langsel.title")).collect::>().join(" / "); gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &title, FRAME_HI); self.lang_rects.clear(); - let (mx, my) = mouse_position(); + let (mx, my) = mouse(); for (k, (code, name)) in langs.iter().enumerate() { let bw = 24.0 * CW(); - let bx = (sw - bw) * 0.5; + let bx = grid_center(sw, bw); let by = y + CH() * (2.0 + k as f32 * 3.0); let r = Rect::new(bx, by, bw, CH() * 2.0); let hov = r.contains(vec2(mx, my)); @@ -569,35 +641,35 @@ impl Ui { let hint_y = y + CH() * (2.0 + langs.len() as f32 * 3.0 + 0.5); for (k, (c, _)) in langs.iter().enumerate() { let h = crate::i18n::tr_in(c, "langsel.hint"); - gfx.text(&h, x + CW() * 2.0, hint_y + CH() * k as f32, 1.0, DIM); + gfx.text(&h, x + CW() * 2.0, hint_y + CH() * k as f32, SMALL(), DIM); } self.rects.push(Rect::new(0.0, 0.0, sw, sh)); } fn draw_end(&mut self, g: &Game, gfx: &Gfx) { - let sw = screen_width(); - let sh = screen_height(); + let sw = sw(); + let sh = sh(); draw_rectangle(0.0, 0.0, sw, sh, Color::new(0.0, 0.0, 0.0, 0.5)); let win = g.state == 1; let cols = 44; let rows = 11; - let x = (sw - cols as f32 * CW()) * 0.5; - let y = (sh - rows as f32 * CH()) * 0.5; + let x = grid_center(sw, cols as f32 * CW()); + let y = grid_center_y(sh, rows as f32 * CH()); let col = if win { GOLD } else { RED }; gfx.window(x, y, cols, rows, S(), true, col, Color::new(0.05, 0.03, 0.04, 0.97)); let title = tr(if win { "end.victory" } else { "end.defeat" }); - gfx.text(&title, (sw - gfx.measure(&title, 4.0)) * 0.5, y + CH() * 1.5, 4.0, col); + gfx.text(&title, grid_center(sw, gfx.measure(&title, TITLE())), y + CH(), TITLE(), col); let sub = tr(if win { "end.victory_sub" } else { "end.defeat_sub" }); - gfx.text(&sub, (sw - gfx.measure(&sub, S())) * 0.5, y + CH() * 4.5, S(), TXT); + gfx.text(&sub, grid_center(sw, gfx.measure(&sub, S())), y + CH() * 4.5, S(), TXT); let st = trf("end.stats", &[mmss(g.time), g.wave.to_string(), (g.army_value() as i32).to_string()]); - gfx.text(&st, (sw - gfx.measure(&st, S())) * 0.5, y + CH() * 6.0, S(), DIM); + gfx.text(&st, grid_center(sw, gfx.measure(&st, S())), y + CH() * 6.0, S(), DIM); let b = format!(" {} ", tr("end.new")); let b = b.as_str(); let bw = gfx.measure(b, S()) + CW() * 2.0; - let bx = (sw - bw) * 0.5; + let bx = grid_center(sw, bw); let by = y + CH() * 7.75; self.end_button = Rect::new(bx, by, bw, CH() * 2.0); - let (mx, my) = mouse_position(); + let (mx, my) = mouse(); let hov = self.end_button.contains(vec2(mx, my)); draw_rectangle(bx, by, bw, CH() * 2.0, if hov { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.15, 0.1, 0.08, 1.0) }); draw_rectangle_lines(bx, by, bw, CH() * 2.0, 2.0, col);