glyph serialization
This commit is contained in:
@@ -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"),
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 (0–255) to get the displayable character.
|
/// Index this array by a byte value (0–255) to get the character. The low control
|
||||||
/// The low control range (0x00–0x1F) maps to CP437's graphic glyphs (hearts,
|
/// range (0x01–0x1F) 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] = [
|
||||||
// 0x00–0x0F
|
// 0x00–0x0F (0x00 is the transparent sentinel, not a space)
|
||||||
' ', '☺', '☻', '♡', '♦', '♣', '♠', '•', '◘', '○', '◙', '♂', '♀', '♪', '♫', '☼',
|
'\0', '☺', '☻', '♡', '♦', '♣', '♠', '•', '◘', '○', '◙', '♂', '♀', '♪', '♫', '☼',
|
||||||
// 0x10–0x1F
|
// 0x10–0x1F
|
||||||
'►', '◄', '↕', '‼', '¶', '§', '▬', '↨', '↑', '↓', '→', '←', '∟', '↔', '▲', '▼',
|
'►', '◄', '↕', '‼', '¶', '§', '▬', '↨', '↑', '↓', '→', '←', '∟', '↔', '▲', '▼',
|
||||||
// 0x20–0x2F (ASCII space onward)
|
// 0x20–0x2F (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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user