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 {
/// 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"),
+5 -5
View File
@@ -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,
+30 -30
View File
@@ -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"
);
}
}
+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.
//! 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 (0255) to get the displayable character.
/// The low control range (0x000x1F) 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 (0255) to get the character. The low control
/// range (0x010x1F) 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] = [
// 0x000x0F
' ', '☺', '☻', '♡', '♦', '♣', '♠', '•', '◘', '○', '◙', '♂', '♀', '♪', '♫', '☼',
// 0x000x0F (0x00 is the transparent sentinel, not a space)
'\0', '☺', '☻', '♡', '♦', '♣', '♠', '•', '◘', '○', '◙', '♂', '♀', '♪', '♫', '☼',
// 0x100x1F
'►', '◄', '↕', '‼', '¶', '§', '▬', '↨', '↑', '↓', '→', '←', '∟', '↔', '▲', '▼',
// 0x200x2F (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<u32> {
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);
}
}
+4 -3
View File
@@ -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,
}
+1 -1
View File
@@ -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]
+204 -23
View File
@@ -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>("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<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`].
///
@@ -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<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]
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 },
};
+1 -1
View File
@@ -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 } }
}
}
+1 -1
View File
@@ -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 },
}
+9 -2
View File
@@ -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
+1 -1
View File
@@ -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]);
+5 -5
View File
@@ -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) {
+7 -7
View File
@@ -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');
}
+1 -1
View File
@@ -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 },
};
+2 -2
View File
@@ -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]