diff --git a/kiln-core/src/action.rs b/kiln-core/src/action.rs index 34d7857..94f943d 100644 --- a/kiln-core/src/action.rs +++ b/kiln-core/src/action.rs @@ -71,8 +71,8 @@ impl From for Dynamic { pub enum Action { /// Move the source object one cell in a direction (subject to passability). Move(Direction), - /// Set the source object's glyph tile index. - SetTile(u32), + /// Set the source object's glyph character. + SetTile(char), /// Set the source object's light radius in cells (0 = no light). Zero time /// cost. Applied to the source [`ObjectDef::light`](crate::object_def::ObjectDef::light). SetLight(u32), @@ -134,7 +134,7 @@ impl Debug for Action { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Action::Move(dir) => write!(f, "Move({:?})", dir), - Action::SetTile(i) => write!(f, "SetTile({i})"), + Action::SetTile(ch) => write!(f, "SetTile({ch:?})"), Action::SetLight(r) => write!(f, "SetLight({r})"), Action::SetTag { .. } => write!(f, "SetTag"), Action::Say(_, _) => write!(f, "Say"), diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index 3290348..6d9344f 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -128,17 +128,17 @@ impl Board { let sensors = self.sensors.iter().filter(|&s| s.x == x && s.y == y); // Is there a sensor above the grid? - if let Some(above) = sensors.clone().find(|s| s.draw_layer == DrawLayer::Above && s.scripting.glyph.tile != 0) { + if let Some(above) = sensors.clone().find(|s| s.draw_layer == DrawLayer::Above && s.scripting.glyph.is_visible()) { return above.scripting.glyph; } // Does the grid have a good glyph? - if let Some(glyph) = grid_glyph && glyph.tile != 0 { + if let Some(glyph) = grid_glyph && glyph.is_visible() { return glyph; } // Is there a sensor below the grid? - if let Some(below) = sensors.clone().find(|s| s.draw_layer == DrawLayer::Below && s.scripting.glyph.tile != 0) { + if let Some(below) = sensors.clone().find(|s| s.draw_layer == DrawLayer::Below && s.scripting.glyph.is_visible()) { return below.scripting.glyph; } @@ -798,7 +798,7 @@ pub(crate) mod tests { draw_layer: DrawLayer::Above, scripting: ScriptAttributes { id: board.next_object_id, - glyph: Glyph { tile: 1, fg: Rgba8 { r: 255, g: 0, b: 0, a: 255 }, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 } }, + glyph: Glyph { tile: '☺', fg: Rgba8 { r: 255, g: 0, b: 0, a: 255 }, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 } }, optics: Optics { glow: 4, opaque: false @@ -916,7 +916,7 @@ pub(crate) mod tests { // Player parked at (2,0) so it doesn't overlap either asserted cell. let mut board = open_board(3, 1, (2, 0)); let floor_glyph = Glyph { - tile: '.' as u32, + tile: '.', fg: Rgba8 { r: 10, g: 20, diff --git a/kiln-core/src/builtin.rs b/kiln-core/src/builtin.rs index 290ac59..edbabe1 100644 --- a/kiln-core/src/builtin.rs +++ b/kiln-core/src/builtin.rs @@ -101,8 +101,8 @@ macro_rules! builtins { } // Shorthand helpers used only within the builtins! invocation below. -// `g(tile, r, g, b)` builds a Glyph with the given tile and fg on black bg. -const fn g(tile: u32, r: u8, gr: u8, b: u8) -> Glyph { +// `g(ch, r, g, b)` builds a Glyph drawing `ch` in the given fg on a black bg. +const fn g(tile: char, r: u8, gr: u8, b: u8) -> Glyph { Glyph { tile, fg: Rgba8 { r, g: gr, b, a: 255 }, @@ -111,29 +111,29 @@ const fn g(tile: u32, r: u8, gr: u8, b: u8) -> Glyph { } builtins! { - Gem => ["gem" => g(4, 0x50, 0x50, 0xFF)] { + Gem => ["gem" => g('♦', 0x50, 0x50, 0xFF)] { enter: EnterResponse::Grab, optics: Optics { opaque: false, glow: 0 }, script: ScriptKey::Builtin("gem"), }, - Heart => ["heart" => g(3, 0xCC, 0x22, 0x22)] { + Heart => ["heart" => g('♡', 0xCC, 0x22, 0x22)] { enter: EnterResponse::Grab, optics: Optics { opaque: false, glow: 0 }, script: ScriptKey::Builtin("heart"), }, Pusher => [ - "pusher_north" => g(30, 0xAA, 0xAA, 0xAA), - "pusher_south" => g(31, 0xAA, 0xAA, 0xAA), - "pusher_east" => g(16, 0xAA, 0xAA, 0xAA), - "pusher_west" => g(17, 0xAA, 0xAA, 0xAA), + "pusher_north" => g('▲', 0xAA, 0xAA, 0xAA), + "pusher_south" => g('▼', 0xAA, 0xAA, 0xAA), + "pusher_east" => g('►', 0xAA, 0xAA, 0xAA), + "pusher_west" => g('◄', 0xAA, 0xAA, 0xAA), ] { enter: EnterResponse::Block, optics: Optics { opaque: true, glow: 0 }, script: ScriptKey::Builtin("pusher"), }, Spinner => [ - "spinner_cw" => g(47, 0xAA, 0xAA, 0xAA), - "spinner_ccw" => g(92, 0xAA, 0xAA, 0xAA), + "spinner_cw" => g('/', 0xAA, 0xAA, 0xAA), + "spinner_ccw" => g('\\', 0xAA, 0xAA, 0xAA), ] { enter: EnterResponse::Block, optics: Optics { opaque: true, glow: 0 }, @@ -142,10 +142,10 @@ builtins! { // Solid, see-through (opaque: false), unpushable teleporters. Each direction's // default glyph is the first frame of its animation loop (see transporter.rhai). Transporter => [ - "transporter_north" => g(94, 0x55, 0xFF, 0xFF), // '^' - "transporter_south" => g(118, 0x55, 0xFF, 0xFF), // 'v' - "transporter_east" => g(41, 0x55, 0xFF, 0xFF), // ')' - "transporter_west" => g(40, 0x55, 0xFF, 0xFF), // '(' + "transporter_north" => g('^', 0x55, 0xFF, 0xFF), // '^' + "transporter_south" => g('v', 0x55, 0xFF, 0xFF), // 'v' + "transporter_east" => g(')', 0x55, 0xFF, 0xFF), // ')' + "transporter_west" => g('(', 0x55, 0xFF, 0xFF), // '(' ] { enter: EnterResponse::Hook, optics: Optics { opaque: false, glow: 0 }, @@ -166,24 +166,24 @@ builtins! { script: ScriptKey::Builtin("key"), }, Wall => ["wall" => Glyph { - tile: 35, + tile: '#', fg: Rgba8 { r: 0x80, g: 0x80, b: 0x80, a: 255 }, bg: Rgba8 { r: 0x60, g: 0x60, b: 0x60, a: 255 }}] { enter: EnterResponse::Block, optics: Optics { opaque: true, glow: 0 }, script: ScriptKey::None }, - Crate => ["crate" => g(254, 0xaa, 0xaa, 0xaa)] { // CP437 ■ (small filled square) + Crate => ["crate" => g('■', 0xaa, 0xaa, 0xaa)] { // CP437 ■ (small filled square) enter: EnterResponse::Push(Pushable::Any), optics: Optics { opaque: true, glow: 0 }, script: ScriptKey::None }, - HCrate => ["hcrate" => g(29, 0xaa, 0xaa, 0xaa)] { // CP437 ↔ (left-right arrow) — pushable east/west + HCrate => ["hcrate" => g('↔', 0xaa, 0xaa, 0xaa)] { // CP437 ↔ (left-right arrow) — pushable east/west enter: EnterResponse::Push(Pushable::Horizontal), optics: Optics { opaque: true, glow: 0 }, script: ScriptKey::None }, - VCrate => ["vcrate" => g(18, 0xaa, 0xaa, 0xaa)] { // CP437 ↕ (up-down arrow) — pushable north/south + VCrate => ["vcrate" => g('↕', 0xaa, 0xaa, 0xaa)] { // CP437 ↕ (up-down arrow) — pushable north/south enter: EnterResponse::Push(Pushable::Vertical), optics: Optics { opaque: true, glow: 0 }, script: ScriptKey::None @@ -209,18 +209,18 @@ mod tests { #[test] fn builtin_names_glyphs_and_round_trip() { - // All known aliases must parse, round-trip via name(), and give the right tile. + // All known aliases must parse, round-trip via name(), and draw the right char. for (name, tile) in [ - ("gem", 4u32), - ("pusher_north", 30), - ("pusher_south", 31), - ("pusher_east", 16), - ("pusher_west", 17), - ("spinner_cw", 47), - ("spinner_ccw", 92), - ("key_red", 12), - ("key_blue", 12), - ("key_white", 12), + ("gem", '♦'), + ("pusher_north", '▲'), + ("pusher_south", '▼'), + ("pusher_east", '►'), + ("pusher_west", '◄'), + ("spinner_cw", '/'), + ("spinner_ccw", '\\'), + ("key_red", '♀'), + ("key_blue", '♀'), + ("key_white", '♀'), ] { let (builtin, kind) = Builtin::from_name(name) .unwrap_or_else(|| panic!("'{name}' should parse as a builtin")); @@ -228,7 +228,7 @@ mod tests { assert_eq!( builtin.default_glyph_for(kind).tile, tile, - "'{name}' has the correct default tile" + "'{name}' draws the correct default character" ); } } diff --git a/kiln-core/src/cp437.rs b/kiln-core/src/cp437.rs index a8feea9..3ed914b 100644 --- a/kiln-core/src/cp437.rs +++ b/kiln-core/src/cp437.rs @@ -1,23 +1,27 @@ -//! CP437 → Unicode mapping for interpreting glyph tile indices as characters. +//! CP437 → Unicode mapping, the numeric shorthand for a glyph's character. //! -//! kiln stores each cell's visual as a `tile: u32` index into a bitmap font. -//! The default kiln font is IBM CP437, where the tile index equals the CP437 -//! code point. A terminal can't draw the bitmap font, so a front-end reinterprets -//! the tile index as a character via this table and prints that character with -//! the cell's foreground/background colors. This lives in kiln-core (not a -//! front-end) because it is the *meaning* of a tile index under the default font, -//! shared by every renderer and by the editor's glyph picker. +//! A [`Glyph`](crate::glyph::Glyph) stores the character it draws directly, but a +//! map file may also write a tile as a `u8` — the IBM CP437 code point — which is +//! resolved through this table at load time. It stays in kiln-core (not a +//! front-end) because it is the *meaning* of that numeric shorthand, shared by the +//! map-file deserializer and by the editor's glyph picker. /// CP437 code point → Unicode scalar value. /// -/// Index this array by a byte value (0–255) to get the displayable character. -/// The low control range (0x00–0x1F) maps to CP437's graphic glyphs (hearts, -/// arrows, musical notes, etc.) rather than ASCII control codes, matching how -/// these byte values render in a DOS/ZZT-style font. Code 0x00 maps to a blank -/// space since a literal NUL is not displayable. +/// Index this array by a byte value (0–255) to get the character. The low control +/// range (0x01–0x1F) maps to CP437's graphic glyphs (hearts, arrows, musical +/// notes, etc.) rather than ASCII control codes, matching how these byte values +/// render in a DOS/ZZT-style font. +/// +/// Code `0x00` is the exception: it maps to `'\0'`, the see-through sentinel that +/// [`Glyph::transparent`](crate::glyph::Glyph::transparent) uses, **not** to a +/// space. A literal space is code 32. Keeping those distinct is what lets +/// `tile = 0` in a map file mean "draw nothing, show what is beneath" while +/// `tile = 32` means "paint a blank over it", and it makes every entry in this +/// table unique so [`char_to_tile`] is well defined. const CP437: [char; 256] = [ - // 0x00–0x0F - ' ', '☺', '☻', '♡', '♦', '♣', '♠', '•', '◘', '○', '◙', '♂', '♀', '♪', '♫', '☼', + // 0x00–0x0F (0x00 is the transparent sentinel, not a space) + '\0', '☺', '☻', '♡', '♦', '♣', '♠', '•', '◘', '○', '◙', '♂', '♀', '♪', '♫', '☼', // 0x10–0x1F '►', '◄', '↕', '‼', '¶', '§', '▬', '↨', '↑', '↓', '→', '←', '∟', '↔', '▲', '▼', // 0x20–0x2F (ASCII space onward) @@ -50,12 +54,13 @@ const CP437: [char; 256] = [ '≡', '±', '≥', '≤', '⌠', '⌡', '÷', '≈', '°', '∙', '·', '√', 'ⁿ', '²', '■', '\u{00A0}', ]; -/// Converts a glyph tile index into a displayable character. +/// Converts a CP437 tile index into the character it denotes. /// -/// Tile indices in `0..256` use the [`CP437`] table. Larger indices (which a +/// Indices in `0..256` use the [`CP437`] table. Larger indices (which a /// non-default font could in principle reference) fall back to interpreting the /// index as a raw Unicode scalar, then to a blank space if that is not a valid -/// or printable character. +/// character. Map files cap the numeric form at `u8`, so only in-table indices +/// arrive from disk. pub fn tile_to_char(tile: u32) -> char { if tile < 256 { CP437[tile as usize] @@ -64,6 +69,16 @@ pub fn tile_to_char(tile: u32) -> char { } } +/// The CP437 index denoting `ch`, or `None` if the character has no slot. +/// +/// The inverse of [`tile_to_char`] over `0..256`. Every table entry is distinct +/// (see the `cp437_table_has_no_duplicate_characters` test), so the answer is +/// unambiguous. Used by the editor's glyph picker, which navigates by index; a +/// glyph carrying a character outside CP437 simply has no slot to highlight. +pub fn char_to_tile(ch: char) -> Option { + CP437.iter().position(|&c| c == ch).map(|i| i as u32) +} + #[cfg(test)] mod tests { use super::*; @@ -73,7 +88,7 @@ mod tests { // The printable ASCII range must map to itself. assert_eq!(tile_to_char('@' as u32), '@'); // player tile 64 assert_eq!(tile_to_char('#' as u32), '#'); // wall tile 35 - assert_eq!(tile_to_char(' ' as u32), ' '); // empty tile 32 + assert_eq!(tile_to_char(' ' as u32), ' '); // literal space is 32 } #[test] @@ -88,4 +103,34 @@ mod tests { // A surrogate code point is not a valid char → blank. assert_eq!(tile_to_char(0xD800), ' '); } + + #[test] + fn index_zero_is_the_transparent_sentinel_not_a_space() { + // `tile = 0` in a map file must mean "draw nothing", distinct from the + // literal space at 32 — otherwise an empty cell would paint over the floor. + assert_eq!(tile_to_char(0), '\0'); + assert_eq!(tile_to_char(32), ' '); + } + + #[test] + fn cp437_table_has_no_duplicate_characters() { + // char_to_tile is only well defined if the mapping is injective. This also + // guards the 0/32 split above: reintroducing a space at index 0 fails here. + let mut seen = std::collections::HashSet::new(); + for (index, &ch) in CP437.iter().enumerate() { + assert!( + seen.insert(ch), + "character {ch:?} appears twice, second time at index {index}" + ); + } + } + + #[test] + fn char_to_tile_inverts_tile_to_char() { + for index in 0u32..256 { + assert_eq!(char_to_tile(tile_to_char(index)), Some(index)); + } + // A character with no CP437 slot has no index. + assert_eq!(char_to_tile('🦀'), None); + } } diff --git a/kiln-core/src/floor.rs b/kiln-core/src/floor.rs index a7b0b72..869c8b7 100644 --- a/kiln-core/src/floor.rs +++ b/kiln-core/src/floor.rs @@ -163,14 +163,15 @@ impl FloorBiome { if rng.next_bool(Probability::new(prob)) { let ch = chars[rng.next_lim_usize(chars.len())]; Glyph { - tile: ch as u32, + tile: ch, fg: lighten(bg, 35), // a lighter shade of the same ground bg, } } else { - // Bare ground: a space (its fg never shows). + // Bare ground: a literal space, which paints over whatever is + // beneath rather than revealing it (unlike the transparent sentinel). Glyph { - tile: 32, + tile: ' ', fg: bg, bg, } diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 8d5bf25..288c4da 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -727,7 +727,7 @@ mod tests { assert_eq!(g.fg, base.fg, "fg unchanged"); assert_eq!(g.bg, base.bg, "bg unchanged"); } - assert_eq!(seq, vec![47, 0xC4, 92, 0xB3, 47]); + assert_eq!(seq, vec!['/', '─', '\\', '│', '/']); } #[test] diff --git a/kiln-core/src/glyph.rs b/kiln-core/src/glyph.rs index 1b702e1..f91d7c3 100644 --- a/kiln-core/src/glyph.rs +++ b/kiln-core/src/glyph.rs @@ -7,20 +7,24 @@ use crate::utils::LogSink; /// The visual representation of a single board cell. /// -/// `Glyph` holds everything needed to draw one cell on screen: which tile -/// index to display and what colors to use. It is stored per-cell (not per -/// archetype), so individual cells can vary their appearance independently. -/// -/// `tile` is a left-to-right, top-to-bottom index into the board's bitmap -/// font. For the default CP437 font this matches the ASCII/CP437 code point. +/// `Glyph` holds everything needed to draw one cell on screen: which character to +/// display and what colors to use. It is stored per-cell (not per archetype), so +/// individual cells can vary their appearance independently. /// /// `Glyph` values come from the map file palette and are set at load time. /// The player is the only entity whose glyph is hardcoded at runtime /// (see [`Glyph::player`]). #[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] pub struct Glyph { - /// Which tile to draw - pub tile: u32, + /// The character to draw, or `'\0'` — the see-through sentinel — for a cell + /// that draws nothing (see [`Glyph::transparent`] / [`Glyph::is_visible`]). + /// + /// In a map file this accepts either a single-character string used verbatim + /// (`tile = "#"`, `tile = "░"`) or a `u8` CP437 index resolved through + /// [`cp437::tile_to_char`](crate::cp437::tile_to_char) (`tile = 176`). + /// Omitting the field entirely gives the transparent sentinel. + #[serde(with = "self::tile", default = "transparent_tile", skip_serializing_if = "is_transparent")] + pub tile: char, /// Foreground color, applied to non-background pixels of the tile. /// /// Serialized as an `"#RRGGBB"` hex string — see [`parse_color`]. @@ -49,7 +53,7 @@ impl Hash for Glyph { impl Registerable for Glyph { fn register(engine: &mut Engine, _log_sink: LogSink) { engine.register_type_with_name::("Glyph"); - engine.register_get("tile", |g: &mut Glyph| g.tile); + engine.register_get("tile", |g: &mut Glyph| g.tile.to_string()); engine.register_get("fg", |g: &mut Glyph| { format!("#{:02x}{:02x}{:02x}", g.fg.r, g.fg.g, g.fg.b) }); @@ -60,7 +64,7 @@ impl Registerable for Glyph { } impl Glyph { - /// Returns the glyph used to render the player: tile 64 (`@`) in white on dark blue. + /// Returns the glyph used to render the player: `@` in white on dark blue. /// /// This is the only hardcoded glyph; all other glyphs come from the map /// file palette. It will be removed once the player becomes a scripted @@ -68,41 +72,137 @@ impl Glyph { #[rustfmt::skip] pub const fn player() -> Self { Self { - tile: 64, + tile: '@', fg: Rgba8 { r: 255, g: 255, b: 255, a: 255 }, // white bg: Rgba8 { r: 0, g: 0, b: 200, a: 255 }, // dark blue } } - /// A fully transparent glyph: tile `0` (the see-through sentinel) on black. + /// A fully transparent glyph: `'\0'` (the see-through sentinel) on black. /// - /// A layer cell holding this glyph contributes nothing to drawing, so a lower - /// layer shows through. It is what an `empty` palette entry resolves to, and - /// what is left behind when a solid is pushed/moved off a cell. + /// A cell holding this glyph contributes nothing to drawing, so whatever the + /// next level of [`Board::glyph_at`](crate::board::Board::glyph_at)'s + /// precedence finds — a sensor, the floor — shows through. It is what an + /// `empty` palette entry resolves to, and what is left behind when a solid is + /// pushed or moved off a cell. + /// + /// Distinct from a literal space (`' '`), which paints a blank *over* the + /// floor rather than revealing it. #[rustfmt::skip] pub const fn transparent() -> Self { Self { - tile: 0, + tile: '\0', fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, } } - /// The default glyph for a portal: CP437 char 240 (`≡`), black on white. + /// The default glyph for a portal: `≡` (CP437 240), black on white. #[rustfmt::skip] pub const fn portal() -> Self { Self { - tile: 240, + tile: '≡', fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, bg: Rgba8 { r: 255, g: 255, b: 255, a: 255 }, } } + + /// Whether this glyph draws anything at all. + /// + /// `false` for the [`transparent`](Glyph::transparent) sentinel, which is how + /// [`Board::glyph_at`](crate::board::Board::glyph_at) decides to fall through + /// to whatever lies beneath. Note a literal space *is* visible: it paints a + /// blank over the floor. + pub const fn is_visible(&self) -> bool { + self.tile != '\0' + } } -// TODO make TileIndex work again: `tile` should accept either a single-character -// string (used directly as the display char) or a `u8` (looked up in CP437). That -// change turns `Glyph::tile` into a `char`, with `'\0'` as the transparent -// sentinel and `CP437[0]` remapped to `'\0'` to match. +/// The `tile` value for a glyph that draws nothing — the `serde` default, used +/// when a map file omits the field. +fn transparent_tile() -> char { + Glyph::transparent().tile +} + +/// Whether `tile` is the transparent sentinel; keeps it out of saved map files, +/// since omitting the field is how it round-trips. +fn is_transparent(tile: &char) -> bool { + *tile == '\0' +} + +/// `serde` adapter for [`Glyph::tile`], accepting a character or a CP437 index. +/// +/// A map file may write either form: +/// +/// ```toml +/// tile = "░" # a single-character string, used verbatim +/// tile = 176 # a u8 CP437 index, resolved through the cp437 table +/// ``` +/// +/// Both yield the same `char`. Omitting the field gives `'\0'` (see +/// [`Glyph::transparent`]), and the sentinel is skipped when serializing, so it +/// round-trips as an absent key rather than an unprintable NUL. +/// +/// Hand-written rather than an `#[serde(untagged)]` enum: untagged collapses every +/// failure into "data did not match any variant", which is useless when the input +/// is a hand-edited map. +mod tile { + use crate::cp437::tile_to_char; + use serde::de::{Error, Unexpected, Visitor}; + use serde::{Deserializer, Serializer}; + use std::fmt; + + pub fn serialize(tile: &char, s: S) -> Result { + s.serialize_str(&tile.to_string()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + d.deserialize_any(TileVisitor) + } + + struct TileVisitor; + + impl<'de> Visitor<'de> for TileVisitor { + type Value = char; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a single-character string, or a CP437 index in 0..=255") + } + + fn visit_str(self, text: &str) -> Result { + let mut chars = text.chars(); + match (chars.next(), chars.next()) { + (Some(ch), None) => Ok(ch), + _ => Err(E::invalid_value( + Unexpected::Str(text), + &"exactly one character", + )), + } + } + + // TOML integers arrive as i64; a negative or oversized index is a clear + // authoring error rather than something to silently clamp. + fn visit_i64(self, index: i64) -> Result { + match u8::try_from(index) { + Ok(byte) => Ok(tile_to_char(byte as u32)), + Err(_) => Err(E::invalid_value( + Unexpected::Signed(index), + &"a CP437 index in 0..=255", + )), + } + } + + fn visit_u64(self, index: u64) -> Result { + match u8::try_from(index) { + Ok(byte) => Ok(tile_to_char(byte as u32)), + Err(_) => Err(E::invalid_value( + Unexpected::Unsigned(index), + &"a CP437 index in 0..=255", + )), + } + } + } +} /// Parses an `"#RRGGBB"` hex color into an opaque [`Rgba8`]. /// @@ -172,6 +272,87 @@ mod tests { use super::{color_to_hex, parse_color, Glyph}; use color::Rgba8; + /// Deserializes a `Glyph` from a TOML body, with the colors filled in so each + /// test only has to state the `tile` form it is exercising. + fn glyph_with_tile(tile_line: &str) -> Result { + toml::from_str(&format!("{tile_line}\nfg = \"#FFFFFF\"\nbg = \"#000000\"\n")) + } + + #[test] + fn tile_accepts_a_character_or_a_cp437_index() { + // A single-character string is used verbatim… + assert_eq!(glyph_with_tile(r##"tile = "#""##).unwrap().tile, '#'); + assert_eq!(glyph_with_tile(r#"tile = "░""#).unwrap().tile, '░'); + // …and a u8 is resolved through the CP437 table to the same character. + assert_eq!(glyph_with_tile("tile = 35").unwrap().tile, '#'); + assert_eq!(glyph_with_tile("tile = 176").unwrap().tile, '░'); + } + + #[test] + fn tile_index_and_character_forms_agree() { + // The two spellings are interchangeable across the whole table. + for index in 0u32..256 { + let by_index = glyph_with_tile(&format!("tile = {index}")).unwrap(); + let ch = crate::cp437::tile_to_char(index); + assert_eq!(by_index.tile, ch); + // The character form round-trips too, except the sentinel, which has + // no string spelling — it is written by omitting the field instead. + if ch != '\0' { + // A TOML basic string needs these two escaped; every other CP437 + // character stands for itself. + let escaped = match ch { + '"' => r#"\""#.to_string(), + '\\' => r"\\".to_string(), + other => other.to_string(), + }; + let by_char = glyph_with_tile(&format!("tile = \"{escaped}\"")).unwrap(); + assert_eq!(by_char.tile, ch, "index {index} disagrees"); + } + } + } + + #[test] + fn tile_rejects_a_multi_character_string_or_an_out_of_range_index() { + // Silently taking the first character would hide a typo. + let err = glyph_with_tile(r#"tile = "ab""#).unwrap_err().to_string(); + assert!(err.contains("one character"), "{err}"); + + // The numeric form is a CP437 index, so it caps at u8. + assert!(glyph_with_tile("tile = 256").is_err()); + assert!(glyph_with_tile("tile = -1").is_err()); + // An empty string is not a character. + assert!(glyph_with_tile(r#"tile = """#).is_err()); + } + + #[test] + fn omitting_tile_gives_the_transparent_sentinel() { + // A glyphless sensor or trigger just leaves the key out. + let glyph = glyph_with_tile("").unwrap(); + assert_eq!(glyph.tile, '\0'); + assert!(!glyph.is_visible()); + } + + #[test] + fn a_transparent_tile_round_trips_as_an_omitted_key() { + // Serializing '\0' as a string would emit an unprintable NUL into the map + // file, so the field is skipped and the serde default restores it. + let text = toml::to_string(&Glyph::transparent()).unwrap(); + assert!(!text.contains("tile"), "transparent tile is omitted: {text}"); + assert_eq!(toml::from_str::(&text).unwrap(), Glyph::transparent()); + } + + #[test] + fn a_literal_space_is_visible_and_distinct_from_transparent() { + // `tile = 32` paints a blank over the floor; `tile = 0` reveals it. Keeping + // these apart is why CP437 index 0 is the sentinel rather than a space. + let space = glyph_with_tile("tile = 32").unwrap(); + let transparent = glyph_with_tile("tile = 0").unwrap(); + assert_eq!(space.tile, ' '); + assert!(space.is_visible()); + assert_eq!(transparent.tile, '\0'); + assert!(!transparent.is_visible()); + } + #[test] fn parse_color_accepts_six_digits_with_optional_hash() { let expected = Rgba8 { r: 0x11, g: 0x22, b: 0x33, a: 255 }; @@ -209,7 +390,7 @@ mod tests { #[test] fn glyph_colors_round_trip_through_toml_as_hex() { let glyph = Glyph { - tile: 35, + tile: '#', fg: Rgba8 { r: 0x80, g: 0x80, b: 0x80, a: 255 }, bg: Rgba8 { r: 0x60, g: 0x60, b: 0x60, a: 255 }, }; diff --git a/kiln-core/src/keys.rs b/kiln-core/src/keys.rs index f19495f..ef6586c 100644 --- a/kiln-core/src/keys.rs +++ b/kiln-core/src/keys.rs @@ -22,7 +22,7 @@ impl KeyType { KeyType::White => Rgba8 { r: 0xFF, g: 0xFF, b: 0xFF, a: 255 } }; - Glyph { tile: 12, fg, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 } } + Glyph { tile: '♀', fg, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 } } } } diff --git a/kiln-core/src/object_def.rs b/kiln-core/src/object_def.rs index e1f2c60..b82f87f 100644 --- a/kiln-core/src/object_def.rs +++ b/kiln-core/src/object_def.rs @@ -41,7 +41,7 @@ impl ObjectDef { #[rustfmt::skip] pub fn default_glyph() -> Glyph { Glyph { - tile: 63, + tile: '?', fg: Rgba8 { r: 255, g: 255, b: 0, a: 255 }, // yellow bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, } diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index eebc46e..f03b729 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -402,8 +402,15 @@ fn register_write_api(engine: &mut Engine, board: BoardRef, log_sink: LogSink) { }); let b = board.clone(); - engine.register_fn("set_tile", move |ctx: NativeCallContext, tile: i64| { - emit(&b, source_of(&ctx), Action::SetTile(tile as u32)); + let sink = log_sink.clone(); + engine.register_fn("set_tile", move |ctx: NativeCallContext, tile: ImmutableString| { + // A glyph is one character; anything else is a script bug worth reporting + // rather than silently truncating. + let mut chars = tile.chars(); + match (chars.next(), chars.next()) { + (Some(ch), None) => emit(&b, source_of(&ctx), Action::SetTile(ch)), + _ => sink.error(format!("set_tile: expected a single character, got {tile:?}")), + } }); // set_light(radius): change the source object's emitted light radius in cells diff --git a/kiln-core/src/scripts/spinner.rhai b/kiln-core/src/scripts/spinner.rhai index 5e4f36d..829b789 100644 --- a/kiln-core/src/scripts/spinner.rhai +++ b/kiln-core/src/scripts/spinner.rhai @@ -41,7 +41,7 @@ fn tick(me, dt) { // Animate the glyph one frame per rotation (changing only the character): the // line spins '/'-'\'-, slash-swapped for counter-clockwise. Frame state lives in // the board Registry, keyed per spinner, since script scope resets each tick. - let frames = if cw { [47, 0xc4, 92, 0xb3] } else { [92, 0xc4, 47, 0xb3] }; + let frames = if cw { ["/", "\u2500", "\\", "\u2502"] } else { ["\\", "\u2500", "/", "\u2502"] }; let fkey = `spin_${me.id}`; let f = Board.registry.get_or(fkey, 0); set_tile(frames[f % 4]); diff --git a/kiln-core/src/scripts/transporter.rhai b/kiln-core/src/scripts/transporter.rhai index 9dcbc91..92662b6 100644 --- a/kiln-core/src/scripts/transporter.rhai +++ b/kiln-core/src/scripts/transporter.rhai @@ -28,12 +28,12 @@ fn opposite_tag(me) { else { "BUILTIN_transporter_east" } } -// The 4-frame animation loop for this direction (CP437 tile codes). +// The 4-frame animation loop for this direction. fn frames(me) { - if me.has_tag("BUILTIN_transporter_north") { [94, 45, 94, 126] } // ^ - ^ ~ - else if me.has_tag("BUILTIN_transporter_south") { [118, 95, 118, 45] } // v _ v - - else if me.has_tag("BUILTIN_transporter_east") { [41, 124, 41, 62] } // ) | ) > - else { [40, 124, 40, 60] } // ( | ( < + if me.has_tag("BUILTIN_transporter_north") { ["^", "-", "^", "~"] } + else if me.has_tag("BUILTIN_transporter_south") { ["v", "_", "v", "-"] } + else if me.has_tag("BUILTIN_transporter_east") { [")", "|", ")", ">"] } + else { ["(", "|", "(", "<"] } } fn tick(me, dt) { diff --git a/kiln-core/src/tests/actions.rs b/kiln-core/src/tests/actions.rs index 9354b97..5b5f92e 100644 --- a/kiln-core/src/tests/actions.rs +++ b/kiln-core/src/tests/actions.rs @@ -68,8 +68,8 @@ fn move_into_a_wall_or_edge_is_a_noop() { #[test] fn set_tile_command_changes_the_source_glyph() { - let (game, id) = game_with_mover(5, 3, (0, 0), (2, 1), "fn init(me) { set_tile(7); }"); - assert_eq!(glyph(&game, id).tile, 7); + let (game, id) = game_with_mover(5, 3, (0, 0), (2, 1), "fn init(me) { set_tile(\"*\"); }"); + assert_eq!(glyph(&game, id).tile, '*'); } #[test] @@ -164,10 +164,10 @@ fn queue_length_reports_pending_actions() { 1, (0, 0), (1, 0), - "fn init(me) { set_tile(5); set_tile(6); log(`len=${me.queue.length}`); }", + r#"fn init(me) { set_tile("5"); set_tile("6"); log(`len=${me.queue.length}`); }"#, ); assert!(log_texts(&game).iter().any(|t| t == "len=2")); - assert_eq!(glyph(&game, id).tile, 6); // last set_tile won + assert_eq!(glyph(&game, id).tile, '6'); // last set_tile won } #[test] @@ -186,7 +186,7 @@ fn queue_clear_drops_pending_actions() { #[test] fn blocked_reports_solid_and_clear() { - let src = "fn init(me) { if me.blocked(East) { set_tile(9); } else { set_tile(7); } }"; + let src = r#"fn init(me) { if me.blocked(East) { set_tile("Y"); } else { set_tile("N"); } }"#; // Solid ahead (a wall): blocked() is true. let mut board = open_board(3, 1, (0, 0)); @@ -194,12 +194,12 @@ fn blocked_reports_solid_and_clear() { wall_at(&mut board, 2, 0); let mut game = GameState::with_scripts(board, scripts_from(&[("b", src)])); game.run_init(); - assert_eq!(glyph(&game, id).tile, 9); + assert_eq!(glyph(&game, id).tile, 'Y'); // Open ahead, nothing pending: blocked() is false. let mut board = open_board(3, 1, (0, 0)); let id = object_at(&mut board, 1, 0, "b", EnterResponse::Block); let mut game = GameState::with_scripts(board, scripts_from(&[("b", src)])); game.run_init(); - assert_eq!(glyph(&game, id).tile, 7); + assert_eq!(glyph(&game, id).tile, 'N'); } diff --git a/kiln-core/src/tests/movement.rs b/kiln-core/src/tests/movement.rs index d1856b0..57e8f12 100644 --- a/kiln-core/src/tests/movement.rs +++ b/kiln-core/src/tests/movement.rs @@ -20,7 +20,7 @@ fn pushing_a_crate_reveals_the_floor_underneath() { // reveal the floor glyph, not black. let mut board = open_board(4, 1, (0, 0)); let floor_glyph = Glyph { - tile: ',' as u32, + tile: ',', fg: Rgba8 { r: 40, g: 60, b: 40, a: 255 }, bg: Rgba8 { r: 5, g: 10, b: 5, a: 255 }, }; diff --git a/kiln-core/src/tests/scripting.rs b/kiln-core/src/tests/scripting.rs index 9034a5b..5351b9c 100644 --- a/kiln-core/src/tests/scripting.rs +++ b/kiln-core/src/tests/scripting.rs @@ -167,8 +167,8 @@ fn start_map_greeter_runs_init() { .all_ids() .into_iter() .filter_map(|id| game.board().get_hookable(id).map(|o| o.glyph().tile)) - .any(|tile| tile == 2); - assert!(has_smiley, "greeter set_tile(2) should change its glyph"); + .any(|tile| tile == '☻'); + assert!(has_smiley, "greeter set_tile(\"☻\") should change its glyph"); } #[test] diff --git a/kiln-tui/src/editor.rs b/kiln-tui/src/editor.rs index 5e09ee7..1c66c7f 100644 --- a/kiln-tui/src/editor.rs +++ b/kiln-tui/src/editor.rs @@ -15,7 +15,7 @@ use crate::log::{LogWidget, log_preview_line}; use crate::render::{BoardWidget, board_to_screen, screen_to_board}; use crate::ui::Ui; use crate::utils::{glyph_to_span, rgba8_to_color}; -use kiln_core::cp437::tile_to_char; +use crate::utils::glyph_char; use kiln_core::game::GameState; use kiln_core::glyph::Glyph; use kiln_core::log::LogLine; @@ -510,7 +510,7 @@ fn draw_footer_lines<'a>( ) -> Vec> { // A one-cell preview of the current glyph in its own fg/bg colors. let preview = Span::styled( - tile_to_char(ed.current_glyph.tile).to_string(), + glyph_char(ed.current_glyph).to_string(), Style::default() .fg(rgba8_to_color(ed.current_glyph.fg)) .bg(rgba8_to_color(ed.current_glyph.bg)), diff --git a/kiln-tui/src/glyph_dialog.rs b/kiln-tui/src/glyph_dialog.rs index 5b04c21..8279895 100644 --- a/kiln-tui/src/glyph_dialog.rs +++ b/kiln-tui/src/glyph_dialog.rs @@ -20,7 +20,7 @@ use color::Rgba8; use kiln_core::colors::NAMED_COLORS; -use kiln_core::cp437::tile_to_char; +use kiln_core::cp437::{char_to_tile, tile_to_char}; use kiln_core::glyph::Glyph; use kiln_ui::dialog::DialogResult; use kiln_ui::text_field::TextField; @@ -55,6 +55,17 @@ const GRID_ROWS: u32 = 8; /// The strip slot index that means "custom color" (one past the named colors). const CUSTOM: usize = NAMED_COLORS.len(); +/// The character drawn for CP437 slot `index` in the picker grid and preview. +/// +/// Slot 0 holds `'\0'`, the transparent sentinel, which has no printable form — +/// it shows as a blank, matching how a transparent cell renders on the board (see +/// [`crate::utils::glyph_char`]). The slot is still selectable: picking it is how +/// you author an invisible glyph. +fn glyph_char_for(index: u32) -> char { + let ch = tile_to_char(index); + if ch == '\0' { ' ' } else { ch } +} + /// A single fg/bg color selector: a strip of the named swatches plus a custom slot, /// with an editable hex field used when the custom slot is selected. /// @@ -126,8 +137,13 @@ type GlyphCallback = Box, &mut Ctx)>; pub struct GlyphDialog { /// Title shown in the window's top border. title: String, - /// The currently selected tile index (`0..256`). - tile: u32, + /// The currently selected CP437 index (`0..256`). + /// + /// Kept as an index, not the `char` a [`Glyph`] stores, because the picker is + /// a 32x8 grid over the CP437 table and navigates by row/column arithmetic. + /// Conversion happens only when seeding from ([`char_to_tile`]) and emitting + /// to ([`tile_to_char`]) a `Glyph`. + index: u32, /// Foreground color selector. fg: ColorPicker, /// Background color selector. @@ -139,10 +155,14 @@ pub struct GlyphDialog { } impl GlyphDialog { - /// Builds a glyph picker seeded from `initial`: tile selection starts at - /// `initial.tile`, and each color picker is seeded from `initial.fg`/`initial.bg` - /// (a matching named swatch if any, else the custom slot). `on_done` receives - /// `Some(glyph)` on OK or `None` on cancel, plus `&mut Ctx`. + /// Builds a glyph picker seeded from `initial`: the grid selects the CP437 + /// slot holding `initial.tile`, and each color picker is seeded from + /// `initial.fg`/`initial.bg` (a matching named swatch if any, else the custom + /// slot). `on_done` receives `Some(glyph)` on OK or `None` on cancel, plus + /// `&mut Ctx`. + /// + /// A glyph whose character has no CP437 slot (a map may carry any character) + /// falls back to slot 0, the transparent sentinel. pub fn new( title: impl Into, initial: Glyph, @@ -150,7 +170,7 @@ impl GlyphDialog { ) -> Self { Self { title: title.into(), - tile: initial.tile, + index: char_to_tile(initial.tile).unwrap_or(0), fg: ColorPicker::new(initial.fg), bg: ColorPicker::new(initial.bg), focus: Focus::Grid, @@ -211,7 +231,7 @@ impl GlyphDialog { /// The chosen [`Glyph`], or `None` if either color is currently unresolved. fn glyph(&self) -> Option { Some(Glyph { - tile: self.tile, + tile: tile_to_char(self.index), fg: self.fg.color()?, bg: self.bg.color()?, }) @@ -280,7 +300,7 @@ impl GlyphDialog { /// Moves the grid selection one cell for an arrow key, clamped at the edges. fn move_grid(&mut self, code: KeyCode) { - let (col, row) = (self.tile % GRID_COLS, self.tile / GRID_COLS); + let (col, row) = (self.index % GRID_COLS, self.index / GRID_COLS); let (col, row) = match code { KeyCode::Left => (col.saturating_sub(1), row), KeyCode::Right => ((col + 1).min(GRID_COLS - 1), row), @@ -288,7 +308,7 @@ impl GlyphDialog { KeyCode::Down => (col, (row + 1).min(GRID_ROWS - 1)), _ => (col, row), }; - self.tile = row * GRID_COLS + col; + self.index = row * GRID_COLS + col; } /// Draws the 32×8 character grid into `area`, centered horizontally. When `colors` @@ -308,7 +328,7 @@ impl GlyphDialog { if x >= area.right() || y >= area.bottom() { continue; } - let style = if tile == self.tile { + let style = if tile == self.index { if self.focus == Focus::Grid { // Bright cursor so the selection reads even against the colored grid. Style::default().fg(Color::Black).bg(CURSOR_BG) @@ -319,7 +339,7 @@ impl GlyphDialog { base }; if let Some(cell) = buf.cell_mut((x, y)) { - cell.set_symbol(&tile_to_char(tile).to_string()); + cell.set_symbol(&glyph_char_for(tile).to_string()); cell.set_style(style); } } @@ -430,7 +450,7 @@ impl GlyphDialog { let footer = Line::from(vec![ Span::styled("[enter] ", key_style), Span::styled("OK ", ok_style), - Span::styled(tile_to_char(self.tile).to_string(), preview_style), + Span::styled(glyph_char_for(self.index).to_string(), preview_style), Span::styled(" [esc] ", key_style), Span::styled("Cancel", Style::default().fg(Color::White).bg(BG)), ]); @@ -567,10 +587,11 @@ mod tests { use super::*; use ratatui::crossterm::event::{KeyEvent, KeyModifiers}; - /// A glyph with custom (non-named) colors for seeding tests. + /// A glyph with custom (non-named) colors for seeding tests. Its character + /// is CP437 slot 5 (`♣`), so the picker seeds its grid selection there. fn sample() -> Glyph { Glyph { - tile: 5, + tile: '♣', fg: Rgba8 { r: 0xFF, g: 0x00, @@ -594,7 +615,7 @@ mod tests { #[test] fn seed_custom_colors_go_to_custom_slot() { let d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {}); - assert_eq!(d.tile, 5); + assert_eq!(d.index, 5, "seeded to the CP437 slot holding the glyph's char"); assert!(d.fg.is_custom() && d.bg.is_custom()); assert_eq!(d.fg.field.value(), "FF0000"); assert_eq!(d.bg.field.value(), "0000FF"); @@ -604,7 +625,7 @@ mod tests { #[test] fn seed_matching_named_color_selects_its_swatch() { let g = Glyph { - tile: 1, + tile: '☺', fg: NAMED_COLORS[4].1, // Red bg: NAMED_COLORS[0].1, // Black }; @@ -634,7 +655,7 @@ mod tests { fn tab_cycles_focus_skipping_unreachable_custom_fields() { // A named-only glyph: neither color is custom, so the field stops are skipped. let g = Glyph { - tile: 0, + tile: '\0', fg: NAMED_COLORS[1].1, bg: NAMED_COLORS[2].1, }; @@ -667,20 +688,20 @@ mod tests { #[test] fn arrows_move_grid_selection_and_clamp() { let mut g = sample(); - g.tile = 0; + g.tile = '\0'; // CP437 slot 0, the grid's top-left let mut d: GlyphDialog<()> = GlyphDialog::new("t", g, |_, _| {}); d.handle_event(&key(KeyCode::Right)); - assert_eq!(d.tile, 1); + assert_eq!(d.index, 1); d.handle_event(&key(KeyCode::Down)); - assert_eq!(d.tile, 1 + GRID_COLS); + assert_eq!(d.index, 1 + GRID_COLS); d.handle_event(&key(KeyCode::Left)); - assert_eq!(d.tile, GRID_COLS); + assert_eq!(d.index, GRID_COLS); d.handle_event(&key(KeyCode::Up)); - assert_eq!(d.tile, 0); + assert_eq!(d.index, 0); // Clamps at the top-left corner. d.handle_event(&key(KeyCode::Up)); d.handle_event(&key(KeyCode::Left)); - assert_eq!(d.tile, 0); + assert_eq!(d.index, 0); } #[test] @@ -707,11 +728,11 @@ mod tests { let mut out: Option> = None; let mut d: GlyphDialog>> = GlyphDialog::new("t", sample(), |g, c| *c = Some(g)); - d.handle_event(&key(KeyCode::Right)); // tile 5 -> 6 + d.handle_event(&key(KeyCode::Right)); // CP437 slot 5 -> 6 let res = d.handle_event(&key(KeyCode::Enter)); d.finish(res, &mut out); let chosen = out.unwrap().unwrap(); - assert_eq!(chosen.tile, 6); + assert_eq!(chosen.tile, tile_to_char(6)); assert_eq!(chosen.fg, sample().fg); assert_eq!(chosen.bg, sample().bg); diff --git a/kiln-tui/src/render.rs b/kiln-tui/src/render.rs index 005f603..ab7d0aa 100644 --- a/kiln-tui/src/render.rs +++ b/kiln-tui/src/render.rs @@ -5,11 +5,10 @@ //! and background colors. Objects are drawn over their floor cell, and the //! player is drawn on top of everything. -use crate::utils::rgba8_to_color; +use crate::utils::{glyph_char, rgba8_to_color}; use color::Rgba8; use kiln_core::Board; use kiln_core::Lighting; -use kiln_core::cp437::tile_to_char; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::widgets::Widget; @@ -162,7 +161,7 @@ impl Widget for BoardWidget<'_> { Some(l) => (l.tint(bx, by, glyph.fg), l.tint(bx, by, glyph.bg)), None => (glyph.fg, glyph.bg), }; - cell.set_char(tile_to_char(glyph.tile)) + cell.set_char(glyph_char(glyph)) .set_fg(rgba8_to_color(fg)) .set_bg(rgba8_to_color(bg)); } else { diff --git a/kiln-tui/src/status.rs b/kiln-tui/src/status.rs index adf527a..34d0502 100644 --- a/kiln-tui/src/status.rs +++ b/kiln-tui/src/status.rs @@ -8,7 +8,7 @@ use std::collections::VecDeque; use crate::utils::rgba8_to_color; use kiln_core::Builtin; -use kiln_core::cp437::tile_to_char; +use crate::utils::glyph_char; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Color, Style}; @@ -45,7 +45,7 @@ impl Widget for StatusSidebarWidget { // Draw the gem indicator from the gem archetype's default glyph, so the // sidebar matches what gems look like on the board (no hardcoded ♦/color). let gem_glyph = Builtin::Gem.default_glyph_for("gem"); - let gem_char = tile_to_char(gem_glyph.tile).to_string(); + let gem_char = glyph_char(gem_glyph).to_string(); let gem_style = Style::default().fg(rgba8_to_color(gem_glyph.fg)); // Build the key row: 8 ♀ glyphs, colored when held, near-black when absent. diff --git a/kiln-tui/src/utils.rs b/kiln-tui/src/utils.rs index 7a7ccbe..1e25f8a 100644 --- a/kiln-tui/src/utils.rs +++ b/kiln-tui/src/utils.rs @@ -1,5 +1,4 @@ use color::Rgba8; -use kiln_core::cp437::tile_to_char; use kiln_core::glyph::Glyph; use ratatui::layout::Rect; use ratatui::prelude::Color; @@ -14,15 +13,27 @@ pub fn rgba8_to_color(c: Rgba8) -> Color { Color::Rgb(c.r, c.g, c.b) } -/// Converts a [`Glyph`] to a single-character styled [`Span`]. +/// The character this terminal front-end draws for `glyph`. /// -/// The tile index is mapped to a CP437 character; fg and bg are both applied. +/// A glyph carries its character directly, so this is almost the identity — the +/// one case that needs handling is the transparent sentinel `'\0'`, which means +/// "draw nothing". By the time a glyph reaches a renderer, `Board::glyph_at` has +/// already exhausted its precedence chain, so there is nothing underneath left to +/// reveal and the cell is simply blank. Writing the NUL through to the terminal +/// buffer would emit an unprintable character instead. +/// +/// How a sentinel looks on screen is a front-end decision, which is why this +/// lives here rather than in kiln-core. +pub fn glyph_char(glyph: Glyph) -> char { + if glyph.is_visible() { glyph.tile } else { ' ' } +} + +/// Converts a [`Glyph`] to a single-character styled [`Span`], applying fg and bg. pub fn glyph_to_span(glyph: Glyph) -> Span<'static> { - let ch = tile_to_char(glyph.tile).to_string(); let style = Style::default() .fg(rgba8_to_color(glyph.fg)) .bg(rgba8_to_color(glyph.bg)); - Span::styled(ch, style) + Span::styled(glyph_char(glyph).to_string(), style) } /// Returns true if two `Rect`s share at least one cell.