diff --git a/kiln-core/src/archetype.rs b/kiln-core/src/archetype.rs index 9d7b1da..89e9d37 100644 --- a/kiln-core/src/archetype.rs +++ b/kiln-core/src/archetype.rs @@ -1,15 +1,16 @@ use crate::glyph::Glyph; use crate::utils::{Behavior, Pushable}; use color::Rgba8; +use crate::game::KeyType; /// Declares the set of script-backed archetype families. /// /// Each entry specifies: /// - A `Variant` name (becomes a [`Builtin`] enum variant). -/// - A `["name" => tile, …]` list: one map-file keyword per alias with the tile -/// index for the editor glyph. All aliases in a family share `behavior`, `fg`, -/// `bg`, and embedded Rhai `script`. -/// - `behavior`, `fg`, `bg`: per-family defaults. +/// - A `["name" => Glyph { … }, …]` list: one map-file keyword per alias with the +/// default [`Glyph`] for the editor. Per-alias glyphs allow aliases in the same +/// family to differ in color (e.g. the eight `Key` variants). +/// - `behavior`: shared across all aliases in the family. /// - `script`: the embedded Rhai source; `include_str!` paths are relative to this /// file, so `include_str!("scripts/pusher.rhai")` resolves to /// `kiln-core/src/scripts/pusher.rhai`. @@ -21,10 +22,8 @@ use color::Rgba8; macro_rules! builtins { ( $( - $variant:ident => [ $( $name:literal => $tile:literal ),+ $(,)? ] { + $variant:ident => [ $( $name:literal => $glyph:expr ),+ $(,)? ] { behavior: $behavior:expr, - fg: $fg:expr, - bg: $bg:expr, script: $script:expr $(,)? } ),+ $(,)? @@ -66,16 +65,14 @@ macro_rules! builtins { } } - /// Returns the glyph for `alias`: the family's fg/bg with the alias's tile. + /// Returns the default glyph for `alias`. Each alias owns its own glyph, + /// so aliases within a family can differ in color (e.g. colored keys). /// Falls back to a transparent glyph for unrecognized aliases (shouldn't /// happen in practice since aliases are all from the macro). pub(crate) fn default_glyph_for(self, alias: &str) -> Glyph { - // The outer repetition brings $fg/$bg into scope; the inner one selects - // the tile for the specific alias. Both are statically resolved at - // compile time. match alias { $( - $( $name => Glyph { tile: $tile, fg: $fg, bg: $bg }, )+ + $( $name => $glyph, )+ )+ _ => Glyph::transparent(), } @@ -91,38 +88,48 @@ macro_rules! builtins { }; } -builtins! { - Gem => ["gem" => 4] { - behavior: Behavior { solid: true, opaque: false, pushable: Pushable::Any, grab: true }, - fg: Rgba8 { r: 0x50, g: 0x50, b: 0xFF, a: 255 }, +// 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 { + Glyph { + tile, + fg: Rgba8 { r, g: gr, b, a: 255 }, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, + } +} + +builtins! { + Gem => ["gem" => g(4, 0x50, 0x50, 0xFF)] { + behavior: Behavior { solid: true, opaque: false, pushable: Pushable::Any, grab: true }, script: include_str!("scripts/gem.rhai"), }, - Pusher => ["pusher_north" => 30, "pusher_south" => 31, "pusher_east" => 16, "pusher_west" => 17] { + 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), + ] { behavior: Behavior { solid: true, opaque: true, pushable: Pushable::No, grab: false }, - fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }, - bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, script: include_str!("scripts/pusher.rhai"), }, - Spinner => ["spinner_cw" => 47, "spinner_ccw" => 92] { + Spinner => [ + "spinner_cw" => g(47, 0xAA, 0xAA, 0xAA), + "spinner_ccw" => g(92, 0xAA, 0xAA, 0xAA), + ] { behavior: Behavior { solid: true, opaque: true, pushable: Pushable::No, grab: false }, - fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }, - bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, script: include_str!("scripts/spinner.rhai"), }, - Key => [ - "key_red" => 12, - "key_orange" => 12, - "key_yellow" => 12, - "key_green" => 12, - "key_blue" => 12, - "key_cyan" => 12, - "key_purple" => 12, - "key_white" => 12, + Key => [ // TODO these should refer to the key colors in Keyring + "key_blue" => KeyType::Blue.glyph(), + "key_green" => KeyType::Green.glyph(), + "key_cyan" => KeyType::Cyan.glyph(), + "key_red" => KeyType::Red.glyph(), + "key_purple" => KeyType::Purple.glyph(), + "key_orange" => KeyType::Orange.glyph(), + "key_yellow" => KeyType::Yellow.glyph(), + "key_white" => KeyType::White.glyph(), ] { behavior: Behavior { solid: true, opaque: false, pushable: Pushable::Any, grab: true }, - fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }, - bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, script: include_str!("scripts/key.rhai"), } } @@ -325,6 +332,9 @@ mod tests { ("pusher_west", 17), ("spinner_cw", 47), ("spinner_ccw", 92), + ("key_red", 12), + ("key_blue", 12), + ("key_white", 12), ] { let arch = Archetype::try_from(name) .unwrap_or_else(|_| panic!("'{name}' should parse as a builtin")); @@ -336,4 +346,29 @@ mod tests { ); } } + + #[test] + fn key_aliases_have_distinct_fg_colors() { + use crate::game::KeyType; + // Each alias must match the corresponding KeyType glyph — single source of truth. + let cases = [ + ("key_blue", KeyType::Blue), + ("key_green", KeyType::Green), + ("key_cyan", KeyType::Cyan), + ("key_red", KeyType::Red), + ("key_purple", KeyType::Purple), + ("key_orange", KeyType::Orange), + ("key_yellow", KeyType::Yellow), + ("key_white", KeyType::White), + ]; + for (name, key_type) in cases { + let arch = Archetype::try_from(name) + .unwrap_or_else(|_| panic!("'{name}' should parse")); + assert_eq!( + arch.default_glyph().fg, + key_type.glyph().fg, + "'{name}' fg doesn't match KeyType" + ); + } + } } diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index eb45622..3c061ad 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -11,6 +11,28 @@ use std::time::Duration; /// How long a `say()` speech bubble stays on screen, in seconds. pub const SAY_DURATION: f64 = 3.0; +#[derive(Copy, Clone, Debug)] +pub enum KeyType { + Red, Orange, Yellow, Green, Blue, Cyan, Purple, White +} + +impl KeyType { + pub fn glyph(self) -> Glyph { + let fg = match self { + KeyType::Red => Rgba8 { r: 0xFF, g: 0x50, b: 0x50, a: 255 }, + KeyType::Orange => Rgba8 { r: 0xFF, g: 0x88, b: 0x00, a: 255 }, + KeyType::Yellow => Rgba8 { r: 0xFF, g: 0xFF, b: 0x50, a: 255 }, + KeyType::Green => Rgba8 { r: 0x50, g: 0xFF, b: 0x50, a: 255 }, + KeyType::Blue => Rgba8 { r: 0x50, g: 0x50, b: 0xFF, a: 255 }, + KeyType::Cyan => Rgba8 { r: 0x00, g: 0xAA, b: 0xAA, a: 255 }, + KeyType::Purple => Rgba8 { r: 0xAA, g: 0x00, b: 0xAA, 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 } } + } +} + /// The player's key inventory: one boolean per key color. /// /// Each field is `true` when the player holds a key of that color. Use @@ -60,14 +82,14 @@ impl Keyring { /// Order: blue, green, cyan, red, purple, orange, yellow, white. pub fn colors(&self) -> [(bool, Rgba8); 8] { [ - (self.blue, Rgba8 { r: 0x00, g: 0x00, b: 0xAA, a: 255 }), - (self.green, Rgba8 { r: 0x00, g: 0xAA, b: 0x00, a: 255 }), - (self.cyan, Rgba8 { r: 0x00, g: 0xAA, b: 0xAA, a: 255 }), - (self.red, Rgba8 { r: 0xAA, g: 0x00, b: 0x00, a: 255 }), - (self.purple, Rgba8 { r: 0xAA, g: 0x00, b: 0xAA, a: 255 }), - (self.orange, Rgba8 { r: 0xFF, g: 0x88, b: 0x00, a: 255 }), - (self.yellow, Rgba8 { r: 0xFF, g: 0xFF, b: 0x55, a: 255 }), - (self.white, Rgba8 { r: 0xFF, g: 0xFF, b: 0xFF, a: 255 }), + (self.blue, KeyType::Blue.glyph().fg), + (self.green, KeyType::Green.glyph().fg), + (self.cyan, KeyType::Cyan.glyph().fg), + (self.red, KeyType::Red.glyph().fg), + (self.purple, KeyType::Purple.glyph().fg), + (self.orange, KeyType::Orange.glyph().fg), + (self.yellow, KeyType::Yellow.glyph().fg), + (self.white, KeyType::White.glyph().fg), ] } } @@ -75,6 +97,7 @@ impl Keyring { // Re-export ScrollLine so kiln-tui can pattern-match scroll content without // accessing the private `action` module directly. pub use crate::action::ScrollLine; +use crate::glyph::Glyph; /// An active scroll overlay opened by a scripted object via `scroll()`. /// @@ -166,8 +189,7 @@ impl GameState { board_transition: None, player_health: 5, player_gems: 0, - // Test starting keys: red, green, orange, white. - player_keys: Keyring { red: true, green: true, orange: true, white: true, ..Default::default() }, + player_keys: Keyring::default(), }; state.drain_errors(); state diff --git a/kiln-tui/src/editor.rs b/kiln-tui/src/editor.rs index 39367f9..42edb39 100644 --- a/kiln-tui/src/editor.rs +++ b/kiln-tui/src/editor.rs @@ -13,7 +13,7 @@ use crate::editor_menu::{MenuEntry, MenuLevel}; use crate::log::{LogWidget, log_preview_line}; use crate::render::{BoardWidget, board_to_screen, screen_to_board}; use crate::ui::Ui; -use crate::utils::rgba8_to_color; +use crate::utils::{glyph_to_span, rgba8_to_color}; use kiln_core::cp437::tile_to_char; use kiln_core::game::GameState; use kiln_core::glyph::Glyph; @@ -458,6 +458,14 @@ pub(crate) fn draw_editor(frame: &mut Frame, ed: &mut EditorState, ui: &mut Ui) Span::styled(label, label_style), ])); } + MenuEntry::Glyph { key, glyph, label, .. } => { + lines.push(Line::from(vec![ + Span::styled(format!("[{}] ", key), key_style), + glyph_to_span(glyph), + Span::raw(" "), + Span::styled(label, label_style), + ])); + } MenuEntry::Blank => lines.push(Line::from("")), // A horizontal rule spanning the sidebar's inner width. MenuEntry::Separator => lines.push(Line::from(Span::styled( @@ -514,7 +522,7 @@ fn draw_footer_lines<'a>( // The archetype currently being drawn (no UI to change it yet). Line::from(Span::styled(ed.current_archetype.name(), label_style)), Line::from(vec![ - Span::styled("[g] ", key_style), + Span::styled("[G] ", key_style), Span::styled("Glyph ", label_style), preview, ]), diff --git a/kiln-tui/src/editor_menu.rs b/kiln-tui/src/editor_menu.rs index 8b75d7c..2bfc084 100644 --- a/kiln-tui/src/editor_menu.rs +++ b/kiln-tui/src/editor_menu.rs @@ -2,6 +2,8 @@ use crate::editor::EditorState; use crate::menu::{MenuItem, MenuKey}; use crate::mode::PendingMode; use kiln_core::{Archetype, Builtin}; +use kiln_core::game::KeyType; +use kiln_core::glyph::Glyph; /// One level in the editor's sidebar menu tree. /// @@ -100,10 +102,21 @@ impl MenuLevel { ed.set_current(Archetype::Builtin(Builtin::Pusher, "pusher_west")) }), ], - MenuLevel::Items => vec![MenuEntry::item('t', "♦ Gem", |ed| { - // 't' for 'treasure' — 'g' conflicts with glyph picker - ed.set_current(Archetype::Builtin(Builtin::Gem, "gem")) - })], + MenuLevel::Items => vec![ + MenuEntry::item('t', "♦ Gem", |ed| { + // 't' for 'treasure' — 'g' conflicts with glyph picker + ed.set_current(Archetype::Builtin(Builtin::Gem, "gem")) + }), + MenuEntry::Separator, + MenuEntry::glyph('b', KeyType::Blue.glyph(), "Blue key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_blue"))), + MenuEntry::glyph('g', KeyType::Green.glyph(),"Green key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_green"))), + MenuEntry::glyph('c', KeyType::Cyan.glyph(),"Cyan key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_cyan"))), + MenuEntry::glyph('r', KeyType::Red.glyph(),"Red key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_red"))), + MenuEntry::glyph('p', KeyType::Purple.glyph(),"Purple key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_purple"))), + MenuEntry::glyph('o', KeyType::Orange.glyph(), "Orange key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_orange"))), + MenuEntry::glyph('y', KeyType::Yellow.glyph(), "Yellow key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_yellow"))), + MenuEntry::glyph('w', KeyType::White.glyph(), "White key", |ed| ed.set_current(Archetype::Builtin(Builtin::Key, "key_white"))), + ], } } @@ -113,6 +126,7 @@ impl MenuLevel { pub(crate) fn perform_key(self, ch: char, editor: &mut EditorState) { let action = self.entries().into_iter().find_map(|entry| match entry { MenuEntry::Item { key, action, .. } if key == ch => Some(action), + MenuEntry::Glyph { key, action, .. } if key == ch => Some(action), _ => None, }); if let Some(action) = action { @@ -134,6 +148,17 @@ pub(crate) enum MenuEntry { /// Run when the user presses `key`; mutates the editor session. action: Box, }, + /// An item labeled by a styled Glyph + Glyph { + /// The letter the user presses to activate this item. + key: char, + /// The glyph to show next to the label + glyph: Glyph, + /// Display label shown after the `[key]` in the sidebar. + label: String, + /// Run when the user presses `key`; mutates the editor session. + action: Box, + }, /// A blank spacer line. Part of the menu API; no current level uses one yet. #[allow(dead_code)] Blank, @@ -156,6 +181,20 @@ impl MenuEntry { } } + pub(crate) fn glyph( + key: char, + glyph: Glyph, + label: impl Into, + action: F, + ) -> Self { + MenuEntry::Glyph { + key, + glyph, + label: label.into(), + action: Box::new(action), + } + } + pub(crate) fn value String + 'static>(action: F) -> Self { MenuEntry::CurrentValue(Box::new(action)) } diff --git a/kiln-tui/src/input.rs b/kiln-tui/src/input.rs index c3c5243..f096cb0 100644 --- a/kiln-tui/src/input.rs +++ b/kiln-tui/src/input.rs @@ -131,7 +131,7 @@ pub(crate) fn handle_editor_input(event: Event, term_w: u16, ed: &mut EditorStat KeyCode::Right => ed.move_cursor(1, 0), KeyCode::Char('l') => ui.log.toggle(), // `g` opens the glyph picker to edit the current drawing glyph. - KeyCode::Char('g') => ed.open_glyph_picker(), + KeyCode::Char('G') => ed.open_glyph_picker(), // Space stamps the current thing; Tab toggles draw mode. KeyCode::Char(' ') => ed.place_current(), KeyCode::Tab => ed.toggle_draw_mode(), @@ -143,7 +143,7 @@ pub(crate) fn handle_editor_input(event: Event, term_w: u16, ed: &mut EditorStat }, Event::Mouse(m) => match m.kind { // Right-click moves the cursor to the clicked cell (if on the board). - MouseEventKind::Down(MouseButton::Right) => ed.set_cursor_at_screen(m.column, m.row), + MouseEventKind::Down(MouseButton::Left) => ed.set_cursor_at_screen(m.column, m.row), // Dragging the divider resizes the sidebar. MouseEventKind::Drag(_) => ed.resize_sidebar(term_w.saturating_sub(m.column), term_w), _ => {} diff --git a/kiln-tui/src/status.rs b/kiln-tui/src/status.rs index b2d0845..89bc641 100644 --- a/kiln-tui/src/status.rs +++ b/kiln-tui/src/status.rs @@ -86,7 +86,7 @@ impl Widget for StatusSidebarWidget { Line::from(vec![ Span::raw("Gems: "), Span::styled(gem_char, gem_style), - Span::raw(format!("{}", self.gems)), + Span::raw(format!("{} ", self.gems)), ]), Line::from(""), Line::from_iter(key_spans.into_iter()), diff --git a/kiln-tui/src/utils.rs b/kiln-tui/src/utils.rs index 56b19a7..7a7ccbe 100644 --- a/kiln-tui/src/utils.rs +++ b/kiln-tui/src/utils.rs @@ -1,6 +1,10 @@ use color::Rgba8; +use kiln_core::cp437::tile_to_char; +use kiln_core::glyph::Glyph; use ratatui::layout::Rect; use ratatui::prelude::Color; +use ratatui::style::Style; +use ratatui::text::Span; /// Converts a core [`Rgba8`] color into a ratatui truecolor [`Color`]. /// @@ -10,6 +14,17 @@ pub fn rgba8_to_color(c: Rgba8) -> Color { Color::Rgb(c.r, c.g, c.b) } +/// Converts a [`Glyph`] to a single-character styled [`Span`]. +/// +/// The tile index is mapped to a CP437 character; fg and bg are both applied. +pub fn glyph_to_span(glyph: Glyph) -> Span<'static> { + let ch = tile_to_char(glyph.tile).to_string(); + let style = Style::default() + .fg(rgba8_to_color(glyph.fg)) + .bg(rgba8_to_color(glyph.bg)); + Span::styled(ch, style) +} + /// Returns true if two `Rect`s share at least one cell. pub fn rects_overlap(a: Rect, b: Rect) -> bool { a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height