glyph serialization

This commit is contained in:
2026-07-26 23:29:43 -05:00
parent 9844671c46
commit ab5f22fe76
20 changed files with 408 additions and 143 deletions
+3 -3
View File
@@ -71,8 +71,8 @@ impl From<SendArg> for Dynamic {
pub enum Action { pub enum Action {
/// Move the source object one cell in a direction (subject to passability). /// Move the source object one cell in a direction (subject to passability).
Move(Direction), Move(Direction),
/// Set the source object's glyph tile index. /// Set the source object's glyph character.
SetTile(u32), SetTile(char),
/// Set the source object's light radius in cells (0 = no light). Zero time /// 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). /// cost. Applied to the source [`ObjectDef::light`](crate::object_def::ObjectDef::light).
SetLight(u32), SetLight(u32),
@@ -134,7 +134,7 @@ impl Debug for Action {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Action::Move(dir) => write!(f, "Move({:?})", dir), 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::SetLight(r) => write!(f, "SetLight({r})"),
Action::SetTag { .. } => write!(f, "SetTag"), Action::SetTag { .. } => write!(f, "SetTag"),
Action::Say(_, _) => write!(f, "Say"), Action::Say(_, _) => write!(f, "Say"),
+5 -5
View File
@@ -128,17 +128,17 @@ impl Board {
let sensors = self.sensors.iter().filter(|&s| s.x == x && s.y == y); let sensors = self.sensors.iter().filter(|&s| s.x == x && s.y == y);
// Is there a sensor above the grid? // 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; return above.scripting.glyph;
} }
// Does the grid have a good 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; return glyph;
} }
// Is there a sensor below the grid? // 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; return below.scripting.glyph;
} }
@@ -798,7 +798,7 @@ pub(crate) mod tests {
draw_layer: DrawLayer::Above, draw_layer: DrawLayer::Above,
scripting: ScriptAttributes { scripting: ScriptAttributes {
id: board.next_object_id, 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 { optics: Optics {
glow: 4, glow: 4,
opaque: false opaque: false
@@ -916,7 +916,7 @@ pub(crate) mod tests {
// Player parked at (2,0) so it doesn't overlap either asserted cell. // Player parked at (2,0) so it doesn't overlap either asserted cell.
let mut board = open_board(3, 1, (2, 0)); let mut board = open_board(3, 1, (2, 0));
let floor_glyph = Glyph { let floor_glyph = Glyph {
tile: '.' as u32, tile: '.',
fg: Rgba8 { fg: Rgba8 {
r: 10, r: 10,
g: 20, g: 20,
+30 -30
View File
@@ -101,8 +101,8 @@ macro_rules! builtins {
} }
// Shorthand helpers used only within the builtins! invocation below. // 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. // `g(ch, r, g, b)` builds a Glyph drawing `ch` in the given fg on a black bg.
const fn g(tile: u32, r: u8, gr: u8, b: u8) -> Glyph { const fn g(tile: char, r: u8, gr: u8, b: u8) -> Glyph {
Glyph { Glyph {
tile, tile,
fg: Rgba8 { r, g: gr, b, a: 255 }, 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! { builtins! {
Gem => ["gem" => g(4, 0x50, 0x50, 0xFF)] { Gem => ["gem" => g('♦', 0x50, 0x50, 0xFF)] {
enter: EnterResponse::Grab, enter: EnterResponse::Grab,
optics: Optics { opaque: false, glow: 0 }, optics: Optics { opaque: false, glow: 0 },
script: ScriptKey::Builtin("gem"), script: ScriptKey::Builtin("gem"),
}, },
Heart => ["heart" => g(3, 0xCC, 0x22, 0x22)] { Heart => ["heart" => g('♡', 0xCC, 0x22, 0x22)] {
enter: EnterResponse::Grab, enter: EnterResponse::Grab,
optics: Optics { opaque: false, glow: 0 }, optics: Optics { opaque: false, glow: 0 },
script: ScriptKey::Builtin("heart"), script: ScriptKey::Builtin("heart"),
}, },
Pusher => [ Pusher => [
"pusher_north" => g(30, 0xAA, 0xAA, 0xAA), "pusher_north" => g('▲', 0xAA, 0xAA, 0xAA),
"pusher_south" => g(31, 0xAA, 0xAA, 0xAA), "pusher_south" => g('▼', 0xAA, 0xAA, 0xAA),
"pusher_east" => g(16, 0xAA, 0xAA, 0xAA), "pusher_east" => g('►', 0xAA, 0xAA, 0xAA),
"pusher_west" => g(17, 0xAA, 0xAA, 0xAA), "pusher_west" => g('◄', 0xAA, 0xAA, 0xAA),
] { ] {
enter: EnterResponse::Block, enter: EnterResponse::Block,
optics: Optics { opaque: true, glow: 0 }, optics: Optics { opaque: true, glow: 0 },
script: ScriptKey::Builtin("pusher"), script: ScriptKey::Builtin("pusher"),
}, },
Spinner => [ Spinner => [
"spinner_cw" => g(47, 0xAA, 0xAA, 0xAA), "spinner_cw" => g('/', 0xAA, 0xAA, 0xAA),
"spinner_ccw" => g(92, 0xAA, 0xAA, 0xAA), "spinner_ccw" => g('\\', 0xAA, 0xAA, 0xAA),
] { ] {
enter: EnterResponse::Block, enter: EnterResponse::Block,
optics: Optics { opaque: true, glow: 0 }, optics: Optics { opaque: true, glow: 0 },
@@ -142,10 +142,10 @@ builtins! {
// Solid, see-through (opaque: false), unpushable teleporters. Each direction's // Solid, see-through (opaque: false), unpushable teleporters. Each direction's
// default glyph is the first frame of its animation loop (see transporter.rhai). // default glyph is the first frame of its animation loop (see transporter.rhai).
Transporter => [ Transporter => [
"transporter_north" => g(94, 0x55, 0xFF, 0xFF), // '^' "transporter_north" => g('^', 0x55, 0xFF, 0xFF), // '^'
"transporter_south" => g(118, 0x55, 0xFF, 0xFF), // 'v' "transporter_south" => g('v', 0x55, 0xFF, 0xFF), // 'v'
"transporter_east" => g(41, 0x55, 0xFF, 0xFF), // ')' "transporter_east" => g(')', 0x55, 0xFF, 0xFF), // ')'
"transporter_west" => g(40, 0x55, 0xFF, 0xFF), // '(' "transporter_west" => g('(', 0x55, 0xFF, 0xFF), // '('
] { ] {
enter: EnterResponse::Hook, enter: EnterResponse::Hook,
optics: Optics { opaque: false, glow: 0 }, optics: Optics { opaque: false, glow: 0 },
@@ -166,24 +166,24 @@ builtins! {
script: ScriptKey::Builtin("key"), script: ScriptKey::Builtin("key"),
}, },
Wall => ["wall" => Glyph { Wall => ["wall" => Glyph {
tile: 35, tile: '#',
fg: Rgba8 { r: 0x80, g: 0x80, b: 0x80, a: 255 }, fg: Rgba8 { r: 0x80, g: 0x80, b: 0x80, a: 255 },
bg: Rgba8 { r: 0x60, g: 0x60, b: 0x60, a: 255 }}] { bg: Rgba8 { r: 0x60, g: 0x60, b: 0x60, a: 255 }}] {
enter: EnterResponse::Block, enter: EnterResponse::Block,
optics: Optics { opaque: true, glow: 0 }, optics: Optics { opaque: true, glow: 0 },
script: ScriptKey::None 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), enter: EnterResponse::Push(Pushable::Any),
optics: Optics { opaque: true, glow: 0 }, optics: Optics { opaque: true, glow: 0 },
script: ScriptKey::None 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), enter: EnterResponse::Push(Pushable::Horizontal),
optics: Optics { opaque: true, glow: 0 }, optics: Optics { opaque: true, glow: 0 },
script: ScriptKey::None 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), enter: EnterResponse::Push(Pushable::Vertical),
optics: Optics { opaque: true, glow: 0 }, optics: Optics { opaque: true, glow: 0 },
script: ScriptKey::None script: ScriptKey::None
@@ -209,18 +209,18 @@ mod tests {
#[test] #[test]
fn builtin_names_glyphs_and_round_trip() { 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 [ for (name, tile) in [
("gem", 4u32), ("gem", '♦'),
("pusher_north", 30), ("pusher_north", '▲'),
("pusher_south", 31), ("pusher_south", '▼'),
("pusher_east", 16), ("pusher_east", '►'),
("pusher_west", 17), ("pusher_west", '◄'),
("spinner_cw", 47), ("spinner_cw", '/'),
("spinner_ccw", 92), ("spinner_ccw", '\\'),
("key_red", 12), ("key_red", '♀'),
("key_blue", 12), ("key_blue", '♀'),
("key_white", 12), ("key_white", '♀'),
] { ] {
let (builtin, kind) = Builtin::from_name(name) let (builtin, kind) = Builtin::from_name(name)
.unwrap_or_else(|| panic!("'{name}' should parse as a builtin")); .unwrap_or_else(|| panic!("'{name}' should parse as a builtin"));
@@ -228,7 +228,7 @@ mod tests {
assert_eq!( assert_eq!(
builtin.default_glyph_for(kind).tile, builtin.default_glyph_for(kind).tile,
tile, tile,
"'{name}' has the correct default tile" "'{name}' draws the correct default character"
); );
} }
} }
+64 -19
View File
@@ -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. //! A [`Glyph`](crate::glyph::Glyph) stores the character it draws directly, but a
//! The default kiln font is IBM CP437, where the tile index equals the CP437 //! map file may also write a tile as a `u8` — the IBM CP437 code point — which is
//! code point. A terminal can't draw the bitmap font, so a front-end reinterprets //! resolved through this table at load time. It stays in kiln-core (not a
//! the tile index as a character via this table and prints that character with //! front-end) because it is the *meaning* of that numeric shorthand, shared by the
//! the cell's foreground/background colors. This lives in kiln-core (not a //! map-file deserializer and by the editor's glyph picker.
//! 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.
/// CP437 code point → Unicode scalar value. /// CP437 code point → Unicode scalar value.
/// ///
/// Index this array by a byte value (0255) to get the displayable character. /// Index this array by a byte value (0255) to get the character. The low control
/// The low control range (0x000x1F) maps to CP437's graphic glyphs (hearts, /// range (0x010x1F) maps to CP437's graphic glyphs (hearts, arrows, musical
/// arrows, musical notes, etc.) rather than ASCII control codes, matching how /// notes, etc.) rather than ASCII control codes, matching how these byte values
/// these byte values render in a DOS/ZZT-style font. Code 0x00 maps to a blank /// render in a DOS/ZZT-style font.
/// space since a literal NUL is not displayable. ///
/// 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] = [ const CP437: [char; 256] = [
// 0x000x0F // 0x000x0F (0x00 is the transparent sentinel, not a space)
' ', '☺', '☻', '♡', '♦', '♣', '♠', '•', '◘', '○', '◙', '♂', '♀', '♪', '♫', '☼', '\0', '☺', '☻', '♡', '♦', '♣', '♠', '•', '◘', '○', '◙', '♂', '♀', '♪', '♫', '☼',
// 0x100x1F // 0x100x1F
'►', '◄', '↕', '‼', '¶', '§', '▬', '↨', '↑', '↓', '→', '←', '∟', '↔', '▲', '▼', '►', '◄', '↕', '‼', '¶', '§', '▬', '↨', '↑', '↓', '→', '←', '∟', '↔', '▲', '▼',
// 0x200x2F (ASCII space onward) // 0x200x2F (ASCII space onward)
@@ -50,12 +54,13 @@ const CP437: [char; 256] = [
'≡', '±', '≥', '≤', '⌠', '⌡', '÷', '≈', '°', '∙', '·', '√', 'ⁿ', '²', '■', '\u{00A0}', '≡', '±', '≥', '≤', '⌠', '⌡', '÷', '≈', '°', '∙', '·', '√', 'ⁿ', '²', '■', '\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 /// 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 /// 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 { pub fn tile_to_char(tile: u32) -> char {
if tile < 256 { if tile < 256 {
CP437[tile as usize] 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<u32> {
CP437.iter().position(|&c| c == ch).map(|i| i as u32)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -73,7 +88,7 @@ mod tests {
// The printable ASCII range must map to itself. // 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), '@'); // player tile 64
assert_eq!(tile_to_char('#' as u32), '#'); // wall tile 35 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] #[test]
@@ -88,4 +103,34 @@ mod tests {
// A surrogate code point is not a valid char → blank. // A surrogate code point is not a valid char → blank.
assert_eq!(tile_to_char(0xD800), ' '); 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);
}
} }
+4 -3
View File
@@ -163,14 +163,15 @@ impl FloorBiome {
if rng.next_bool(Probability::new(prob)) { if rng.next_bool(Probability::new(prob)) {
let ch = chars[rng.next_lim_usize(chars.len())]; let ch = chars[rng.next_lim_usize(chars.len())];
Glyph { Glyph {
tile: ch as u32, tile: ch,
fg: lighten(bg, 35), // a lighter shade of the same ground fg: lighten(bg, 35), // a lighter shade of the same ground
bg, bg,
} }
} else { } 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 { Glyph {
tile: 32, tile: ' ',
fg: bg, fg: bg,
bg, bg,
} }
+1 -1
View File
@@ -727,7 +727,7 @@ mod tests {
assert_eq!(g.fg, base.fg, "fg unchanged"); assert_eq!(g.fg, base.fg, "fg unchanged");
assert_eq!(g.bg, base.bg, "bg unchanged"); assert_eq!(g.bg, base.bg, "bg unchanged");
} }
assert_eq!(seq, vec![47, 0xC4, 92, 0xB3, 47]); assert_eq!(seq, vec!['/', '─', '\\', '│', '/']);
} }
#[test] #[test]
+204 -23
View File
@@ -7,20 +7,24 @@ use crate::utils::LogSink;
/// The visual representation of a single board cell. /// The visual representation of a single board cell.
/// ///
/// `Glyph` holds everything needed to draw one cell on screen: which tile /// `Glyph` holds everything needed to draw one cell on screen: which character to
/// index to display and what colors to use. It is stored per-cell (not per /// display and what colors to use. It is stored per-cell (not per archetype), so
/// archetype), so individual cells can vary their appearance independently. /// 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` values come from the map file palette and are set at load time. /// `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 /// The player is the only entity whose glyph is hardcoded at runtime
/// (see [`Glyph::player`]). /// (see [`Glyph::player`]).
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] #[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct Glyph { pub struct Glyph {
/// Which tile to draw /// The character to draw, or `'\0'` — the see-through sentinel — for a cell
pub tile: u32, /// 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. /// Foreground color, applied to non-background pixels of the tile.
/// ///
/// Serialized as an `"#RRGGBB"` hex string — see [`parse_color`]. /// Serialized as an `"#RRGGBB"` hex string — see [`parse_color`].
@@ -49,7 +53,7 @@ impl Hash for Glyph {
impl Registerable for Glyph { impl Registerable for Glyph {
fn register(engine: &mut Engine, _log_sink: LogSink) { fn register(engine: &mut Engine, _log_sink: LogSink) {
engine.register_type_with_name::<Glyph>("Glyph"); engine.register_type_with_name::<Glyph>("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| { engine.register_get("fg", |g: &mut Glyph| {
format!("#{:02x}{:02x}{:02x}", g.fg.r, g.fg.g, g.fg.b) format!("#{:02x}{:02x}{:02x}", g.fg.r, g.fg.g, g.fg.b)
}); });
@@ -60,7 +64,7 @@ impl Registerable for Glyph {
} }
impl 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 /// 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 /// file palette. It will be removed once the player becomes a scripted
@@ -68,41 +72,137 @@ impl Glyph {
#[rustfmt::skip] #[rustfmt::skip]
pub const fn player() -> Self { pub const fn player() -> Self {
Self { Self {
tile: 64, tile: '@',
fg: Rgba8 { r: 255, g: 255, b: 255, a: 255 }, // white fg: Rgba8 { r: 255, g: 255, b: 255, a: 255 }, // white
bg: Rgba8 { r: 0, g: 0, b: 200, a: 255 }, // dark blue 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 /// A cell holding this glyph contributes nothing to drawing, so whatever the
/// layer shows through. It is what an `empty` palette entry resolves to, and /// next level of [`Board::glyph_at`](crate::board::Board::glyph_at)'s
/// what is left behind when a solid is pushed/moved off a cell. /// 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] #[rustfmt::skip]
pub const fn transparent() -> Self { pub const fn transparent() -> Self {
Self { Self {
tile: 0, tile: '\0',
fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
bg: 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] #[rustfmt::skip]
pub const fn portal() -> Self { pub const fn portal() -> Self {
Self { Self {
tile: 240, tile: '',
fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
bg: Rgba8 { r: 255, g: 255, b: 255, 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 /// The `tile` value for a glyph that draws nothing — the `serde` default, used
// string (used directly as the display char) or a `u8` (looked up in CP437). That /// when a map file omits the field.
// change turns `Glyph::tile` into a `char`, with `'\0'` as the transparent fn transparent_tile() -> char {
// sentinel and `CP437[0]` remapped to `'\0'` to match. 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<S: Serializer>(tile: &char, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&tile.to_string())
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<char, D::Error> {
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<E: Error>(self, text: &str) -> Result<char, E> {
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<E: Error>(self, index: i64) -> Result<char, E> {
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<E: Error>(self, index: u64) -> Result<char, E> {
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`]. /// Parses an `"#RRGGBB"` hex color into an opaque [`Rgba8`].
/// ///
@@ -172,6 +272,87 @@ mod tests {
use super::{color_to_hex, parse_color, Glyph}; use super::{color_to_hex, parse_color, Glyph};
use color::Rgba8; 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<Glyph, toml::de::Error> {
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::<Glyph>(&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] #[test]
fn parse_color_accepts_six_digits_with_optional_hash() { fn parse_color_accepts_six_digits_with_optional_hash() {
let expected = Rgba8 { r: 0x11, g: 0x22, b: 0x33, a: 255 }; let expected = Rgba8 { r: 0x11, g: 0x22, b: 0x33, a: 255 };
@@ -209,7 +390,7 @@ mod tests {
#[test] #[test]
fn glyph_colors_round_trip_through_toml_as_hex() { fn glyph_colors_round_trip_through_toml_as_hex() {
let glyph = Glyph { let glyph = Glyph {
tile: 35, tile: '#',
fg: Rgba8 { r: 0x80, g: 0x80, b: 0x80, a: 255 }, fg: Rgba8 { r: 0x80, g: 0x80, b: 0x80, a: 255 },
bg: Rgba8 { r: 0x60, g: 0x60, b: 0x60, a: 255 }, bg: Rgba8 { r: 0x60, g: 0x60, b: 0x60, a: 255 },
}; };
+1 -1
View File
@@ -22,7 +22,7 @@ impl KeyType {
KeyType::White => Rgba8 { r: 0xFF, g: 0xFF, b: 0xFF, a: 255 } 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 } }
} }
} }
+1 -1
View File
@@ -41,7 +41,7 @@ impl ObjectDef {
#[rustfmt::skip] #[rustfmt::skip]
pub fn default_glyph() -> Glyph { pub fn default_glyph() -> Glyph {
Glyph { Glyph {
tile: 63, tile: '?',
fg: Rgba8 { r: 255, g: 255, b: 0, a: 255 }, // yellow fg: Rgba8 { r: 255, g: 255, b: 0, a: 255 }, // yellow
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
} }
+9 -2
View File
@@ -402,8 +402,15 @@ fn register_write_api(engine: &mut Engine, board: BoardRef, log_sink: LogSink) {
}); });
let b = board.clone(); let b = board.clone();
engine.register_fn("set_tile", move |ctx: NativeCallContext, tile: i64| { let sink = log_sink.clone();
emit(&b, source_of(&ctx), Action::SetTile(tile as u32)); 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 // set_light(radius): change the source object's emitted light radius in cells
+1 -1
View File
@@ -41,7 +41,7 @@ fn tick(me, dt) {
// Animate the glyph one frame per rotation (changing only the character): the // Animate the glyph one frame per rotation (changing only the character): the
// line spins '/'-'\'-, slash-swapped for counter-clockwise. Frame state lives in // line spins '/'-'\'-, slash-swapped for counter-clockwise. Frame state lives in
// the board Registry, keyed per spinner, since script scope resets each tick. // 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 fkey = `spin_${me.id}`;
let f = Board.registry.get_or(fkey, 0); let f = Board.registry.get_or(fkey, 0);
set_tile(frames[f % 4]); set_tile(frames[f % 4]);
+5 -5
View File
@@ -28,12 +28,12 @@ fn opposite_tag(me) {
else { "BUILTIN_transporter_east" } 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) { fn frames(me) {
if me.has_tag("BUILTIN_transporter_north") { [94, 45, 94, 126] } // ^ - ^ ~ if me.has_tag("BUILTIN_transporter_north") { ["^", "-", "^", "~"] }
else if me.has_tag("BUILTIN_transporter_south") { [118, 95, 118, 45] } // v _ v - else if me.has_tag("BUILTIN_transporter_south") { ["v", "_", "v", "-"] }
else if me.has_tag("BUILTIN_transporter_east") { [41, 124, 41, 62] } // ) | ) > else if me.has_tag("BUILTIN_transporter_east") { [")", "|", ")", ">"] }
else { [40, 124, 40, 60] } // ( | ( < else { ["(", "|", "(", "<"] }
} }
fn tick(me, dt) { fn tick(me, dt) {
+7 -7
View File
@@ -68,8 +68,8 @@ fn move_into_a_wall_or_edge_is_a_noop() {
#[test] #[test]
fn set_tile_command_changes_the_source_glyph() { 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); }"); let (game, id) = game_with_mover(5, 3, (0, 0), (2, 1), "fn init(me) { set_tile(\"*\"); }");
assert_eq!(glyph(&game, id).tile, 7); assert_eq!(glyph(&game, id).tile, '*');
} }
#[test] #[test]
@@ -164,10 +164,10 @@ fn queue_length_reports_pending_actions() {
1, 1,
(0, 0), (0, 0),
(1, 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!(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] #[test]
@@ -186,7 +186,7 @@ fn queue_clear_drops_pending_actions() {
#[test] #[test]
fn blocked_reports_solid_and_clear() { 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. // Solid ahead (a wall): blocked() is true.
let mut board = open_board(3, 1, (0, 0)); 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); wall_at(&mut board, 2, 0);
let mut game = GameState::with_scripts(board, scripts_from(&[("b", src)])); let mut game = GameState::with_scripts(board, scripts_from(&[("b", src)]));
game.run_init(); game.run_init();
assert_eq!(glyph(&game, id).tile, 9); assert_eq!(glyph(&game, id).tile, 'Y');
// Open ahead, nothing pending: blocked() is false. // Open ahead, nothing pending: blocked() is false.
let mut board = open_board(3, 1, (0, 0)); let mut board = open_board(3, 1, (0, 0));
let id = object_at(&mut board, 1, 0, "b", EnterResponse::Block); let id = object_at(&mut board, 1, 0, "b", EnterResponse::Block);
let mut game = GameState::with_scripts(board, scripts_from(&[("b", src)])); let mut game = GameState::with_scripts(board, scripts_from(&[("b", src)]));
game.run_init(); game.run_init();
assert_eq!(glyph(&game, id).tile, 7); assert_eq!(glyph(&game, id).tile, 'N');
} }
+1 -1
View File
@@ -20,7 +20,7 @@ fn pushing_a_crate_reveals_the_floor_underneath() {
// reveal the floor glyph, not black. // reveal the floor glyph, not black.
let mut board = open_board(4, 1, (0, 0)); let mut board = open_board(4, 1, (0, 0));
let floor_glyph = Glyph { let floor_glyph = Glyph {
tile: ',' as u32, tile: ',',
fg: Rgba8 { r: 40, g: 60, b: 40, a: 255 }, fg: Rgba8 { r: 40, g: 60, b: 40, a: 255 },
bg: Rgba8 { r: 5, g: 10, b: 5, a: 255 }, bg: Rgba8 { r: 5, g: 10, b: 5, a: 255 },
}; };
+2 -2
View File
@@ -167,8 +167,8 @@ fn start_map_greeter_runs_init() {
.all_ids() .all_ids()
.into_iter() .into_iter()
.filter_map(|id| game.board().get_hookable(id).map(|o| o.glyph().tile)) .filter_map(|id| game.board().get_hookable(id).map(|o| o.glyph().tile))
.any(|tile| tile == 2); .any(|tile| tile == '☻');
assert!(has_smiley, "greeter set_tile(2) should change its glyph"); assert!(has_smiley, "greeter set_tile(\"\") should change its glyph");
} }
#[test] #[test]
+2 -2
View File
@@ -15,7 +15,7 @@ use crate::log::{LogWidget, log_preview_line};
use crate::render::{BoardWidget, board_to_screen, screen_to_board}; use crate::render::{BoardWidget, board_to_screen, screen_to_board};
use crate::ui::Ui; use crate::ui::Ui;
use crate::utils::{glyph_to_span, rgba8_to_color}; 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::game::GameState;
use kiln_core::glyph::Glyph; use kiln_core::glyph::Glyph;
use kiln_core::log::LogLine; use kiln_core::log::LogLine;
@@ -510,7 +510,7 @@ fn draw_footer_lines<'a>(
) -> Vec<Line<'a>> { ) -> Vec<Line<'a>> {
// A one-cell preview of the current glyph in its own fg/bg colors. // A one-cell preview of the current glyph in its own fg/bg colors.
let preview = Span::styled( let preview = Span::styled(
tile_to_char(ed.current_glyph.tile).to_string(), glyph_char(ed.current_glyph).to_string(),
Style::default() Style::default()
.fg(rgba8_to_color(ed.current_glyph.fg)) .fg(rgba8_to_color(ed.current_glyph.fg))
.bg(rgba8_to_color(ed.current_glyph.bg)), .bg(rgba8_to_color(ed.current_glyph.bg)),
+48 -27
View File
@@ -20,7 +20,7 @@
use color::Rgba8; use color::Rgba8;
use kiln_core::colors::NAMED_COLORS; 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_core::glyph::Glyph;
use kiln_ui::dialog::DialogResult; use kiln_ui::dialog::DialogResult;
use kiln_ui::text_field::TextField; 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). /// The strip slot index that means "custom color" (one past the named colors).
const CUSTOM: usize = NAMED_COLORS.len(); 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, /// 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. /// with an editable hex field used when the custom slot is selected.
/// ///
@@ -126,8 +137,13 @@ type GlyphCallback<Ctx> = Box<dyn FnOnce(Option<Glyph>, &mut Ctx)>;
pub struct GlyphDialog<Ctx> { pub struct GlyphDialog<Ctx> {
/// Title shown in the window's top border. /// Title shown in the window's top border.
title: String, title: String,
/// The currently selected tile index (`0..256`). /// The currently selected CP437 index (`0..256`).
tile: u32, ///
/// 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. /// Foreground color selector.
fg: ColorPicker, fg: ColorPicker,
/// Background color selector. /// Background color selector.
@@ -139,10 +155,14 @@ pub struct GlyphDialog<Ctx> {
} }
impl<Ctx> GlyphDialog<Ctx> { impl<Ctx> GlyphDialog<Ctx> {
/// Builds a glyph picker seeded from `initial`: tile selection starts at /// Builds a glyph picker seeded from `initial`: the grid selects the CP437
/// `initial.tile`, and each color picker is seeded from `initial.fg`/`initial.bg` /// slot holding `initial.tile`, and each color picker is seeded from
/// (a matching named swatch if any, else the custom slot). `on_done` receives /// `initial.fg`/`initial.bg` (a matching named swatch if any, else the custom
/// `Some(glyph)` on OK or `None` on cancel, plus `&mut Ctx`. /// 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( pub fn new(
title: impl Into<String>, title: impl Into<String>,
initial: Glyph, initial: Glyph,
@@ -150,7 +170,7 @@ impl<Ctx> GlyphDialog<Ctx> {
) -> Self { ) -> Self {
Self { Self {
title: title.into(), title: title.into(),
tile: initial.tile, index: char_to_tile(initial.tile).unwrap_or(0),
fg: ColorPicker::new(initial.fg), fg: ColorPicker::new(initial.fg),
bg: ColorPicker::new(initial.bg), bg: ColorPicker::new(initial.bg),
focus: Focus::Grid, focus: Focus::Grid,
@@ -211,7 +231,7 @@ impl<Ctx> GlyphDialog<Ctx> {
/// The chosen [`Glyph`], or `None` if either color is currently unresolved. /// The chosen [`Glyph`], or `None` if either color is currently unresolved.
fn glyph(&self) -> Option<Glyph> { fn glyph(&self) -> Option<Glyph> {
Some(Glyph { Some(Glyph {
tile: self.tile, tile: tile_to_char(self.index),
fg: self.fg.color()?, fg: self.fg.color()?,
bg: self.bg.color()?, bg: self.bg.color()?,
}) })
@@ -280,7 +300,7 @@ impl<Ctx> GlyphDialog<Ctx> {
/// Moves the grid selection one cell for an arrow key, clamped at the edges. /// Moves the grid selection one cell for an arrow key, clamped at the edges.
fn move_grid(&mut self, code: KeyCode) { 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 { let (col, row) = match code {
KeyCode::Left => (col.saturating_sub(1), row), KeyCode::Left => (col.saturating_sub(1), row),
KeyCode::Right => ((col + 1).min(GRID_COLS - 1), row), KeyCode::Right => ((col + 1).min(GRID_COLS - 1), row),
@@ -288,7 +308,7 @@ impl<Ctx> GlyphDialog<Ctx> {
KeyCode::Down => (col, (row + 1).min(GRID_ROWS - 1)), KeyCode::Down => (col, (row + 1).min(GRID_ROWS - 1)),
_ => (col, row), _ => (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` /// Draws the 32×8 character grid into `area`, centered horizontally. When `colors`
@@ -308,7 +328,7 @@ impl<Ctx> GlyphDialog<Ctx> {
if x >= area.right() || y >= area.bottom() { if x >= area.right() || y >= area.bottom() {
continue; continue;
} }
let style = if tile == self.tile { let style = if tile == self.index {
if self.focus == Focus::Grid { if self.focus == Focus::Grid {
// Bright cursor so the selection reads even against the colored grid. // Bright cursor so the selection reads even against the colored grid.
Style::default().fg(Color::Black).bg(CURSOR_BG) Style::default().fg(Color::Black).bg(CURSOR_BG)
@@ -319,7 +339,7 @@ impl<Ctx> GlyphDialog<Ctx> {
base base
}; };
if let Some(cell) = buf.cell_mut((x, y)) { 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); cell.set_style(style);
} }
} }
@@ -430,7 +450,7 @@ impl<Ctx> GlyphDialog<Ctx> {
let footer = Line::from(vec![ let footer = Line::from(vec![
Span::styled("[enter] ", key_style), Span::styled("[enter] ", key_style),
Span::styled("OK ", ok_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(" [esc] ", key_style),
Span::styled("Cancel", Style::default().fg(Color::White).bg(BG)), Span::styled("Cancel", Style::default().fg(Color::White).bg(BG)),
]); ]);
@@ -567,10 +587,11 @@ mod tests {
use super::*; use super::*;
use ratatui::crossterm::event::{KeyEvent, KeyModifiers}; 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 { fn sample() -> Glyph {
Glyph { Glyph {
tile: 5, tile: '',
fg: Rgba8 { fg: Rgba8 {
r: 0xFF, r: 0xFF,
g: 0x00, g: 0x00,
@@ -594,7 +615,7 @@ mod tests {
#[test] #[test]
fn seed_custom_colors_go_to_custom_slot() { fn seed_custom_colors_go_to_custom_slot() {
let d: GlyphDialog<()> = GlyphDialog::new("t", sample(), |_, _| {}); 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!(d.fg.is_custom() && d.bg.is_custom());
assert_eq!(d.fg.field.value(), "FF0000"); assert_eq!(d.fg.field.value(), "FF0000");
assert_eq!(d.bg.field.value(), "0000FF"); assert_eq!(d.bg.field.value(), "0000FF");
@@ -604,7 +625,7 @@ mod tests {
#[test] #[test]
fn seed_matching_named_color_selects_its_swatch() { fn seed_matching_named_color_selects_its_swatch() {
let g = Glyph { let g = Glyph {
tile: 1, tile: '',
fg: NAMED_COLORS[4].1, // Red fg: NAMED_COLORS[4].1, // Red
bg: NAMED_COLORS[0].1, // Black bg: NAMED_COLORS[0].1, // Black
}; };
@@ -634,7 +655,7 @@ mod tests {
fn tab_cycles_focus_skipping_unreachable_custom_fields() { fn tab_cycles_focus_skipping_unreachable_custom_fields() {
// A named-only glyph: neither color is custom, so the field stops are skipped. // A named-only glyph: neither color is custom, so the field stops are skipped.
let g = Glyph { let g = Glyph {
tile: 0, tile: '\0',
fg: NAMED_COLORS[1].1, fg: NAMED_COLORS[1].1,
bg: NAMED_COLORS[2].1, bg: NAMED_COLORS[2].1,
}; };
@@ -667,20 +688,20 @@ mod tests {
#[test] #[test]
fn arrows_move_grid_selection_and_clamp() { fn arrows_move_grid_selection_and_clamp() {
let mut g = sample(); 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, |_, _| {}); let mut d: GlyphDialog<()> = GlyphDialog::new("t", g, |_, _| {});
d.handle_event(&key(KeyCode::Right)); d.handle_event(&key(KeyCode::Right));
assert_eq!(d.tile, 1); assert_eq!(d.index, 1);
d.handle_event(&key(KeyCode::Down)); 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)); d.handle_event(&key(KeyCode::Left));
assert_eq!(d.tile, GRID_COLS); assert_eq!(d.index, GRID_COLS);
d.handle_event(&key(KeyCode::Up)); d.handle_event(&key(KeyCode::Up));
assert_eq!(d.tile, 0); assert_eq!(d.index, 0);
// Clamps at the top-left corner. // Clamps at the top-left corner.
d.handle_event(&key(KeyCode::Up)); d.handle_event(&key(KeyCode::Up));
d.handle_event(&key(KeyCode::Left)); d.handle_event(&key(KeyCode::Left));
assert_eq!(d.tile, 0); assert_eq!(d.index, 0);
} }
#[test] #[test]
@@ -707,11 +728,11 @@ mod tests {
let mut out: Option<Option<Glyph>> = None; let mut out: Option<Option<Glyph>> = None;
let mut d: GlyphDialog<Option<Option<Glyph>>> = let mut d: GlyphDialog<Option<Option<Glyph>>> =
GlyphDialog::new("t", sample(), |g, c| *c = Some(g)); 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)); let res = d.handle_event(&key(KeyCode::Enter));
d.finish(res, &mut out); d.finish(res, &mut out);
let chosen = out.unwrap().unwrap(); 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.fg, sample().fg);
assert_eq!(chosen.bg, sample().bg); assert_eq!(chosen.bg, sample().bg);
+2 -3
View File
@@ -5,11 +5,10 @@
//! and background colors. Objects are drawn over their floor cell, and the //! and background colors. Objects are drawn over their floor cell, and the
//! player is drawn on top of everything. //! 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 color::Rgba8;
use kiln_core::Board; use kiln_core::Board;
use kiln_core::Lighting; use kiln_core::Lighting;
use kiln_core::cp437::tile_to_char;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::widgets::Widget; 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)), Some(l) => (l.tint(bx, by, glyph.fg), l.tint(bx, by, glyph.bg)),
None => (glyph.fg, 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_fg(rgba8_to_color(fg))
.set_bg(rgba8_to_color(bg)); .set_bg(rgba8_to_color(bg));
} else { } else {
+2 -2
View File
@@ -8,7 +8,7 @@
use std::collections::VecDeque; use std::collections::VecDeque;
use crate::utils::rgba8_to_color; use crate::utils::rgba8_to_color;
use kiln_core::Builtin; use kiln_core::Builtin;
use kiln_core::cp437::tile_to_char; use crate::utils::glyph_char;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::{Color, Style}; 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 // 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). // sidebar matches what gems look like on the board (no hardcoded ♦/color).
let gem_glyph = Builtin::Gem.default_glyph_for("gem"); 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)); 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. // Build the key row: 8 ♀ glyphs, colored when held, near-black when absent.
+16 -5
View File
@@ -1,5 +1,4 @@
use color::Rgba8; use color::Rgba8;
use kiln_core::cp437::tile_to_char;
use kiln_core::glyph::Glyph; use kiln_core::glyph::Glyph;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::prelude::Color; use ratatui::prelude::Color;
@@ -14,15 +13,27 @@ pub fn rgba8_to_color(c: Rgba8) -> Color {
Color::Rgb(c.r, c.g, c.b) 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> { pub fn glyph_to_span(glyph: Glyph) -> Span<'static> {
let ch = tile_to_char(glyph.tile).to_string();
let style = Style::default() let style = Style::default()
.fg(rgba8_to_color(glyph.fg)) .fg(rgba8_to_color(glyph.fg))
.bg(rgba8_to_color(glyph.bg)); .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. /// Returns true if two `Rect`s share at least one cell.