glyph serialization
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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
@@ -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<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user