Switch to the Dinowyde font with DINOBYTE for missing glyphs

Unicode glyph lookup instead of CP437 codes, rope and double-strand
frames with clean title ends, progress bars in eighth blocks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-05 15:32:42 +01:00
1 parent d99166a746
commit d76e74b4b0
11 files changed
+225 -65

No files matched your search

+191 -39
View File
@@ -85,53 +85,136 @@ pub struct Gfx {
pub atlas: Texture2D,
pub sprites: Texture2D,
pub cursors: Texture2D, // mouse pointer frames, 16x16 each (see CURSOR_*)
pub font: Texture2D, // DINOBYTE, CP437, 16x16 glyphs of 6x8 px
pub font: Texture2D, // Dinowyde (glyphs of 6x8 px ordered by Unicode, see font_slot) and DINOBYTE
pub font_map: FontMap, // which glyphs the two fonts hold
}
// ------------------------------------------------------------------ bitmap font (CP437)
// ------------------------------------------------------------------ bitmap font (Dinowyde)
//
// assets/dinowyde_1x.png: 6x8 px glyphs, ordered by Unicode in five columns of 16 glyphs
// (96 px) and 48 rows each:
// column 0: U+0000-02FF (Basic Latin, Latin-1, Latin Extended A/B, IPA, spacing modifiers)
// column 1: U+0310-05FF (combining marks from U+0310, Greek, Cyrillic, Armenian, Hebrew;
// U+0300-030F have no slot)
// column 2: U+0600-06FF (Arabic) in rows 0-15, U+2000-21FF (punctuation ... arrows) in rows 16-47
// column 3: U+2200-24FF (mathematical operators, technical, ...)
// column 4: U+2500-27FF (box drawing, blocks, geometric shapes, ...)
pub const GW: f32 = 6.0; // glyph width
pub const GH: f32 = 8.0; // glyph height
const FONT_COLS: usize = 80; // glyphs per row of the sheet
const FONT_ROWS: usize = 48;
// place of a character in the sheet (glyph index row * FONT_COLS + column), if the sheet has a slot for it
pub fn font_slot(ch: char) -> Option<usize> {
let u = ch as usize;
let (block, off) = match u {
0x0000..=0x02FF => (0, u),
0x0310..=0x05FF => (1, u - 0x0310),
0x0600..=0x06FF => (2, u - 0x0600),
0x2000..=0x21FF => (2, 0x100 + u - 0x2000),
0x2200..=0x24FF => (3, u - 0x2200),
0x2500..=0x27FF => (4, u - 0x2500),
_ => return None,
};
Some((off / 16) * FONT_COLS + block * 16 + off % 16)
}
// Characters Dinowyde does not draw yet (arrows, card suits, smileys, notes, ...) come from the
// DINOBYTE font (assets/dbyte_1x.png, CP437 order, 16x16 glyphs), which sits right of the
// Dinowyde sheet in the font texture.
const DBYTE_X: usize = FONT_COLS * GW as usize; // x of the DINOBYTE sheet in the font texture
const CP437_LOW: &str = " ☺☻♥♦♣♠•◘○◙♂♀♪♫☼►◄↕‼¶§▬↨↑↓→←∟↔▲▼";
const CP437_HIGH: &str = "ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■\u{a0}";
pub fn cp437(ch: char) -> u8 {
// the CP437 code of a character (DINOBYTE), if it has one
fn cp437(ch: char) -> Option<u8> {
// DINOBYTE has ö (0x94) and ò (0x95) swapped
match ch {
'ö' => return 0x95,
'ò' => return 0x94,
'ö' => return Some(0x95),
'ò' => return Some(0x94),
'⌂' => return Some(127),
_ => {}
}
let u = ch as u32;
if (32..127).contains(&u) {
return u as u8;
return Some(u as u8);
}
if ch == '⌂' {
return 127;
if let Some(i) = CP437_LOW.chars().position(|c| c == ch).filter(|&i| i > 0) {
return Some(i as u8);
}
if let Some(i) = CP437_LOW.chars().position(|c| c == ch) {
if i > 0 {
return i as u8;
CP437_HIGH.chars().position(|c| c == ch).map(|i| 128 + i as u8)
}
// stand-ins for characters neither font draws: similar glyphs
fn font_substitute(ch: char) -> Option<char> {
Some(match ch {
'✓' | '✔' => '√',
'ẞ' => 'ß',
'–' | '—' => '-',
'„' | '“' | '”' => '"',
'‚' | '‘' | '’' => '\'',
'…' => '.',
_ => return None,
})
}
// which glyphs the two sheets hold
pub struct FontMap {
has: Vec<bool>, // Dinowyde slots with a glyph
dbyte: [bool; 256], // DINOBYTE codes with a glyph
}
fn glyph_drawn(data: &[[u8; 4]], w: usize, gx: usize, gy: usize) -> bool {
(0..GH as usize).any(|y| (0..GW as usize).any(|x| data.get((gy + y) * w + gx + x).map_or(false, |p| p[3] > 0)))
}
// the font texture: Dinowyde, and DINOBYTE right of it; white glyphs on a transparent
// background (for tinting)
pub fn load_font() -> (Image, FontMap) {
let wyde = Image::from_file_with_format(include_bytes!("../assets/dinowyde_1x.png"), Some(ImageFormat::Png)).expect("font");
let byte = Image::from_file_with_format(include_bytes!("../assets/dbyte_1x.png"), Some(ImageFormat::Png)).expect("font");
let (w, h) = (DBYTE_X + byte.width as usize, (wyde.height as usize).max(byte.height as usize));
let mut img = Image::gen_image_color(w as u16, h as u16, Color::new(0.0, 0.0, 0.0, 0.0));
{
let out = img.get_image_data_mut();
let ww = wyde.width as usize;
for (i, p) in wyde.get_image_data().iter().enumerate() {
if p[3] > 128 {
out[(i / ww) * w + i % ww] = [255, 255, 255, 255];
}
}
let bw = byte.width as usize;
for (i, p) in byte.get_image_data().iter().enumerate() {
if p[3] > 128 && p[0] > 128 {
out[(i / bw) * w + DBYTE_X + i % bw] = [255, 255, 255, 255];
}
}
}
if let Some(i) = CP437_HIGH.chars().position(|c| c == ch) {
return 128 + i as u8;
}
match ch {
'ẞ' => 0xE1,
'–' | '—' => b'-',
'„' | '“' | '”' => b'"',
'‚' | '‘' | '’' => b'\'',
'…' => b'.',
'×' => b'x',
_ => b'?',
let data = img.get_image_data();
let has = (0..FONT_COLS * FONT_ROWS).map(|g| glyph_drawn(data, w, (g % FONT_COLS) * GW as usize, (g / FONT_COLS) * GH as usize)).collect();
let mut dbyte = [false; 256];
for (c, d) in dbyte.iter_mut().enumerate() {
*d = glyph_drawn(data, w, DBYTE_X + (c % 16) * GW as usize, (c / 16) * GH as usize);
}
(img, FontMap { has, dbyte })
}
// where in the font texture a character's glyph is (top-left pixel): Dinowyde, else
// DINOBYTE, else a stand-in, else '?'
pub fn font_src(map: &FontMap, ch: char) -> (f32, f32) {
let find = |c: char| -> Option<(f32, f32)> {
if let Some(g) = font_slot(c).filter(|&g| map.has.get(g).copied().unwrap_or(false)) {
return Some(((g % FONT_COLS) as f32 * GW, (g / FONT_COLS) as f32 * GH));
}
cp437(c).filter(|&k| map.dbyte[k as usize]).map(|k| ((DBYTE_X + (k as usize % 16) * GW as usize) as f32, (k / 16) as f32 * GH))
};
find(ch).or_else(|| font_substitute(ch).and_then(find)).or_else(|| find('?')).unwrap_or((0.0, 0.0))
}
// frame glyphs: [top-left, top-right, bottom-left, bottom-right, horizontal, vertical]
pub const BOX_SINGLE: [u8; 6] = [0xDA, 0xBF, 0xC0, 0xD9, 0xC4, 0xB3];
pub const BOX_DOUBLE: [u8; 6] = [0xC9, 0xBB, 0xC8, 0xBC, 0xCD, 0xBA];
pub const BOX_SINGLE: [char; 6] = ['┌', '┐', '└', '┘', '─', '│'];
pub const BOX_DOUBLE: [char; 6] = ['╔', '╗', '╚', '╝', '═', '║'];
fn hash(x: i32, y: i32, s: u32) -> u32 {
let mut h = (x as u32).wrapping_mul(374761393) ^ (y as u32).wrapping_mul(668265263) ^ s.wrapping_mul(2246822519);
@@ -1772,17 +1855,13 @@ impl Gfx {
atlas.set_filter(FilterMode::Nearest);
let sprites = Texture2D::from_image(&gen_sprites());
sprites.set_filter(FilterMode::Nearest);
// load the font; transparent background, white glyphs (for tinting)
let mut img = Image::from_file_with_format(include_bytes!("../assets/dbyte_1x.png"), Some(ImageFormat::Png)).expect("Schrift");
for px in img.get_image_data_mut().iter_mut() {
let on = px[3] > 128 && px[0] > 128;
*px = if on { [255, 255, 255, 255] } else { [0, 0, 0, 0] };
}
// the fonts (Dinowyde, DINOBYTE for what it lacks)
let (img, font_map) = load_font();
let font = Texture2D::from_image(&img);
font.set_filter(FilterMode::Nearest);
let cursors = Texture2D::from_image(&gen_cursors());
cursors.set_filter(FilterMode::Nearest);
Gfx { atlas, sprites, cursors, font }
Gfx { atlas, sprites, cursors, font, font_map }
}
pub fn cell(id: u32) -> Rect {
@@ -1881,9 +1960,10 @@ impl Gfx {
);
}
// draw a single CP437 glyph (x, y = top-left corner)
pub fn glyph(&self, code: u8, x: f32, y: f32, scale: f32, color: Color) {
let src = Rect::new((code % 16) as f32 * GW, (code / 16) as f32 * GH, GW, GH);
// draw a single character (x, y = top-left corner)
pub fn glyph(&self, ch: char, x: f32, y: f32, scale: f32, color: Color) {
let (sx, sy) = font_src(&self.font_map, ch);
let src = Rect::new(sx, sy, GW, GH);
draw_texture_ex(
&self.font,
x.floor(),
@@ -1898,7 +1978,7 @@ impl Gfx {
let mut cx = x;
for ch in s.chars() {
if ch != ' ' {
self.glyph(cp437(ch), cx, y, scale, color);
self.glyph(ch, cx, y, scale, color);
}
cx += GW * scale;
}
@@ -1971,7 +2051,7 @@ impl Gfx {
self.text(s, x, y, scale, color);
}
// window with a CP437 frame; size in characters (cols x rows incl. frame)
// window with a box-drawing frame; size in characters (cols x rows incl. frame)
pub fn window(&self, x: f32, y: f32, cols: i32, rows: i32, scale: f32, double: bool, frame: Color, bg: Color) {
let cw = GW * scale;
let ch = GH * scale;
@@ -2003,9 +2083,21 @@ impl Gfx {
if !title.is_empty() {
let cw = GW * scale;
let t = format!(" {} ", title);
let tx = x + 2.0 * cw;
draw_rectangle(tx, y, self.measure(&t, scale), GH * scale, bg);
self.text(&t, tx, y, scale, tcol);
let n = t.chars().count() as i32;
// the frame line ends cleanly on both sides of the title (rope ends, or the double
// strand's caps), if there is room for them
let caps = n + 5 <= cols;
let tx = x + if caps { 1.0 } else { 2.0 } * cw;
let w = cw * (n + if caps { 2 } else { 0 }) as f32;
draw_rectangle(tx, y, w, GH * scale, bg);
if caps {
let (l, r) = if double { ('╡', '╞') } else { ('╴', '╶') };
self.glyph(l, tx, y, scale, frame);
self.text(&t, tx + cw, y, scale, tcol);
self.glyph(r, tx + cw * (n + 1) as f32, y, scale, frame);
} else {
self.text(&t, tx, y, scale, tcol);
}
}
}
}
@@ -2057,3 +2149,63 @@ mod doc_images {
}
}
}
#[cfg(test)]
mod font_tests {
use super::*;
// the glyph of a character as rows of '#' and '.'
fn bitmap(img: &Image, ch: char) -> Vec<String> {
let g = font_slot(ch).expect("slot");
let (gx, gy) = ((g % FONT_COLS) * 6, (g / FONT_COLS) * 8);
let w = img.width as usize;
let d = img.get_image_data();
(0..8).map(|y| (0..6).map(|x| if d[(gy + y) * w + gx + x][3] > 0 { '#' } else { '.' }).collect()).collect()
}
#[test]
fn dinowyde_slots_match_the_sheet() {
let (img, _) = load_font();
assert_eq!(bitmap(&img, 'A'), ["......", ".###..", "#...#.", "#...#.", "#####.", "#...#.", "#...#.", "......"]);
assert_eq!(bitmap(&img, 'Ж'), ["......", "#.#.#.", "#.#.#.", ".###..", "#.#.#.", "#.#.#.", "#.#.#.", "......"]);
assert_eq!(bitmap(&img, 'Ο')[2..4], [".###..", "#...#."], "Greek capital omicron");
assert!(bitmap(&img, '▀')[0..4].iter().all(|r| r == "######"), "upper half block");
assert_eq!(bitmap(&img, '–')[4], ".###..", "en dash");
assert_eq!(bitmap(&img, '—')[4], "#####.", "em dash");
}
#[test]
fn missing_glyphs_come_from_dinobyte() {
let (_, map) = load_font();
let from_dbyte = |ch: char| font_src(&map, ch).0 >= DBYTE_X as f32;
assert!(!from_dbyte('A') && !from_dbyte('ö') && !from_dbyte('Ж') && !from_dbyte('─'), "Dinowyde first");
for ch in ['↑', '↓', '→', '←', '☺', '♥', '♪', '☼'] {
assert!(from_dbyte(ch), "{} from DINOBYTE", ch);
}
}
#[test]
fn every_text_of_the_game_has_its_glyphs() {
let (_, map) = load_font();
let question = font_src(&map, '?');
let mut missing = std::collections::BTreeSet::new();
for file in [include_str!("../lang/en.lang"), include_str!("../lang/de.lang")] {
for ch in file.chars().filter(|c| !c.is_control() && *c != ' ') {
if ch != '?' && font_src(&map, ch) == question {
missing.insert(ch);
}
}
}
// interface symbols used in the code
for ch in ['█', '░', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '▼', '▲', '☻', '≡', '☼', '●', '○', '✓', '—', '·', '↑', '↓', '→', '←', '♥', '♪', '┌', '┐', '└', '┘', '─', '│', '╴', '╶', '╔', '╗', '╚', '╝', '═', '║', '╡', '╞'] {
if font_src(&map, ch) == question {
missing.insert(ch);
}
}
assert!(missing.is_empty(), "no glyph for {:?}", missing);
// Cyrillic and Greek are there for later languages
for ch in "АБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыьэюяёΑΒΓΔαβγδ".chars() {
assert_ne!(font_src(&map, ch), question, "{}", ch);
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Menus: title screen, pause menu, savegame list (server) and the settings dialog
// (tabs, values, key bindings).
// Drawn as CP437 windows on top of the (paused) game world.
// Drawn as text-mode windows on top of the (paused) game world.
use crate::campaign::{missions, Progress};
use crate::campaign_doc::CampaignDoc;
+3 -3
View File
@@ -1536,7 +1536,7 @@ impl R3d {
ascii_bar(gfx, x, head.y - GH * small, cols, f, c, small);
}
if e.guard {
gfx.glyph(0x0F, head.x - (GW * small * 0.5).floor(), head.y - 2.0 * GH * small, small, Color::from_rgba(255, 220, 90, 255));
gfx.glyph('☼', head.x - (GW * small * 0.5).floor(), head.y - 2.0 * GH * small, small, Color::from_rgba(255, 220, 90, 255));
}
// rank badge left of the health bar
if e.level > 1 && px_per_tile >= 20.0 * F() as f32 {
@@ -1547,11 +1547,11 @@ impl R3d {
if matches!(e.job, Job::Sleep(_)) && !e.moving {
let k = (t * 1.5 + e.anim) % 1.0;
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));
gfx.glyph('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 = proj(vec3(e.disp_x, 0.35, e.disp_y));
gfx.glyph(0x0F, p.x + cw * 2.0, p.y - GH * small, small, Color::from_rgba(250, 210, 60, 255));
gfx.glyph('☼', p.x + cw * 2.0, p.y - GH * small, small, Color::from_rgba(250, 210, 60, 255));
}
}
for tw in &g.towers {
+15 -14
View File
@@ -1,4 +1,4 @@
// In-canvas user interface: CP437 windows with the DINOBYTE bitmap font.
// In-canvas user interface: text-mode windows with the Dinowyde bitmap font.
// All texts come from the language files (see i18n.rs).
use crate::game::*;
@@ -114,21 +114,22 @@ pub fn begin_ui() {
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
// bar made of block characters: full blocks, an eighth block for the rest, shaded background
const EIGHTHS: [char; 7] = ['▏', '▎', '▍', '▌', '▋', '▊', '▉'];
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 eighths = (frac.clamp(0.0, 1.0) * cols as f32 * 8.0).round() as usize;
let (full, part) = (eighths / 8, eighths % 8);
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);
gfx.glyph('█', px, y, scale, col);
} else {
gfx.glyph(0xB0, px, y, scale, dim);
gfx.glyph('░', px, y, scale, dim);
if k == full && part > 0 {
gfx.glyph(EIGHTHS[part - 1], px, y, scale, col);
}
}
}
}
@@ -729,11 +730,11 @@ impl Ui {
let locked = !tools.iter().any(|&t| g.tool_allowed(t));
let r = tool_button(bx, by, bw, ti.icon, key, &label, has, self.open_cat == Some(c) || hovered, locked);
if has {
gfx.glyph(0x1F, bx + (bw - CW()) * 0.5, tb_y, S(), if pulse { WHITE } else { FRAME_HI });
gfx.glyph('▼', bx + (bw - CW()) * 0.5, tb_y, S(), if pulse { WHITE } else { FRAME_HI });
}
if tools.len() > 1 {
// small arrow: this button opens a row
gfx.glyph(0x1E, r.x + r.w - GW * SMALL() - 2.0 * px, r.y + 2.0 * px, SMALL(), if self.open_cat == Some(c) { FRAME_HI } else { DIM });
gfx.glyph('▲', r.x + r.w - GW * SMALL() - 2.0 * px, r.y + 2.0 * px, SMALL(), if self.open_cat == Some(c) { FRAME_HI } else { DIM });
}
self.touch_btns.push((r, TBtn::Cat(c)));
}
@@ -743,7 +744,7 @@ impl Ui {
let hovered = !touch_ui() && r.contains(vec2(mx, my));
draw_rectangle(r.x, r.y, r.w, r.h, if hovered || self.show_overview { 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 * px, if self.show_overview { FRAME_HI } else { Color::new(0.35, 0.28, 0.2, 1.0) });
gfx.glyph(0x02, bx + (bw - GW * TITLE()) * 0.5, by + 2.0 * px, TITLE(), FRAME_HI);
gfx.glyph('☻', bx + (bw - GW * TITLE()) * 0.5, by + 2.0 * px, TITLE(), FRAME_HI);
let ol = tr("cat.overview");
let maxc = ((bw - 2.0 * px) / (GW * SMALL())).floor() as usize;
let ol: String = ol.chars().take(maxc).collect();
@@ -755,7 +756,7 @@ impl Ui {
let hovered = !touch_ui() && r.contains(vec2(mx, my));
draw_rectangle(r.x, r.y, r.w, r.h, 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 * px, Color::new(0.35, 0.28, 0.2, 1.0));
gfx.glyph(0xF0, bx + (bw - GW * TITLE()) * 0.5, by + 2.0 * px, TITLE(), FRAME_HI);
gfx.glyph('≡', bx + (bw - GW * TITLE()) * 0.5, by + 2.0 * px, TITLE(), FRAME_HI);
let ml = tr("cat.menu");
let maxc = ((bw - 2.0 * px) / (GW * SMALL())).floor() as usize;
let ml: String = ml.chars().take(maxc).collect();