diff --git a/kiln-core/CLAUDE.md b/kiln-core/CLAUDE.md index 03ba462..61a7363 100644 --- a/kiln-core/CLAUDE.md +++ b/kiln-core/CLAUDE.md @@ -61,7 +61,7 @@ The core types that were formerly monolithic in `game.rs` are now split into foc - `SpeechBubble { object_id, text, remaining }` — an active speech bubble created by a script's `say(s)` call. `remaining` counts down in `GameState::tick`; when it reaches zero the bubble is removed. At most one bubble per object (a new `say()` replaces the old one). - `SAY_DURATION: f64` — how long a bubble lives (3.0 seconds). - `Scroll { source: ObjectId, lines: Vec }` — an active scroll overlay opened by a script's `scroll(lines)` call. Lives on `GameState::active_scroll: Option`; front-ends pause ticks while it's `Some` and close it via `close_scroll(choice)`. `ScrollLine` is re-exported from `action.rs` through `game.rs` so front-ends import both from `kiln_core::game`. -- `GameState` — owns `world: World` (all boards as `Rc>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec`, `scripts: ScriptHost`, `pub speech_bubbles: Vec`, `pub active_scroll: Option`. Front-ends reach the active board through `board() -> Ref` / `board_mut() -> RefMut` (both look up `world.boards[current_board_name]`). `current_board_name() -> &str` returns the active key. **`from_world(world: World) -> Self`** is the primary constructor: clones the start board's `Rc>` for the `ScriptHost`, compiles scripts from `world.scripts`. `new(board)` and `with_scripts(board, scripts)` are `#[cfg(test)]`-only sugar that build a minimal single-board `World`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(-1)` on any solid object walked into. `run_init()` runs object `init()` hooks once at startup. `tick(dt)` advances cooldowns, expires speech bubbles, and runs `tick(dt)` hooks every frame. Both end by calling `resolve()`, which drains the board queue and applies each `Action` (`Log` → `log`, `SetTile` → source glyph, `Move(dir)` → `step_object`, `Say` → `speech_bubbles`, `Scroll` → `active_scroll`). Resolution is two-phase: mutate the board collecting `(bumped, bumper)` pairs, drop the borrow, then fire `run_bump` for each pair. `drain_errors()` moves script errors into `log`. `close_scroll(choice)` clears `active_scroll` and optionally fires `run_send(source, choice, None)` to dispatch the player's selection back to the object. +- `GameState` — owns `world: World` (all boards as `Rc>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec`, `scripts: ScriptHost`, `pub speech_bubbles: Vec`, `pub active_scroll: Option`, `pub player_health: u32` (starts 5) and `pub player_gems: u32` (starts 0) — game-global player stats that persist across board transitions (display-only for now, no runtime mutation path). Front-ends reach the active board through `board() -> Ref` / `board_mut() -> RefMut` (both look up `world.boards[current_board_name]`). `current_board_name() -> &str` returns the active key. **`from_world(world: World) -> Self`** is the primary constructor: clones the start board's `Rc>` for the `ScriptHost`, compiles scripts from `world.scripts`. `new(board)` and `with_scripts(board, scripts)` are `#[cfg(test)]`-only sugar that build a minimal single-board `World`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(-1)` on any solid object walked into. `run_init()` runs object `init()` hooks once at startup. `tick(dt)` advances cooldowns, expires speech bubbles, and runs `tick(dt)` hooks every frame. Both end by calling `resolve()`, which drains the board queue and applies each `Action` (`Log` → `log`, `SetTile` → source glyph, `Move(dir)` → `step_object`, `Say` → `speech_bubbles`, `Scroll` → `active_scroll`). Resolution is two-phase: mutate the board collecting `(bumped, bumper)` pairs, drop the borrow, then fire `run_bump` for each pair. `drain_errors()` moves script errors into `log`. `close_scroll(choice)` clears `active_scroll` and optionally fires `run_send(source, choice, None)` to dispatch the player's selection back to the object. **`kiln-core/src/action.rs`** — the `Action` enum and its Rhai-facing conversion: - `MOVE_COST: f64` — how long a move occupies an object before it can act again (0.25 s). diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 6ef5df3..17ba2e1 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -66,6 +66,14 @@ pub struct GameState { /// by [`enter_board`](GameState::enter_board). Front-ends tick this down and /// may block input or show a visual effect while it is `Some(t)` where `t > 0`. pub board_transition: Option, + /// The player's current health. Game-global (not per-board), so it persists + /// across board transitions. Players start with `5`. There is no runtime + /// mutation path yet — front-ends only display it. + pub player_health: u32, + /// The number of gems the player has collected. Game-global like + /// [`player_health`](GameState::player_health); starts at `0` and is + /// display-only for now. + pub player_gems: u32, } impl GameState { @@ -92,6 +100,8 @@ impl GameState { speech_bubbles: Vec::new(), active_scroll: None, board_transition: None, + player_health: 5, + player_gems: 0, }; state.drain_errors(); state diff --git a/kiln-tui/CLAUDE.md b/kiln-tui/CLAUDE.md index 2520fbf..1005dfd 100644 --- a/kiln-tui/CLAUDE.md +++ b/kiln-tui/CLAUDE.md @@ -15,7 +15,7 @@ Renders the board as text: each `Glyph.tile` index is reinterpreted as a charact - `run` is a ~30 FPS real-time loop: `event::poll`s only until the next frame deadline (so input stays responsive) and otherwise wakes to advance by the real elapsed `dt` via `Ui::tick(dt, mode)`. Acts on `Press`/`Repeat` and ignores `Release` (the Kitty protocol emits these; acting on them double-fires moves). Input is routed by `current_input_mode`: `Menu`→`handle_menu_input`; otherwise (unless `Ignore`) it matches on `Mode` — `Play` → `handle_board_input`/`handle_scroll_input`, `Edit` → `handle_editor_input`/`handle_dialog_input`. After ticking it calls `apply_pending_mode` then `apply_playtest_transition`, and exits once `should_quit` is set and no close animation is running. - `apply_pending_mode(mode, ui)` — applies a `PendingMode` a menu action requested: reloads the world from `world_path` and swaps `*mode` to `Mode::Edit(EditorState::new(world))` (enter editor) or a fresh `Mode::Play` (play world, re-running `init()`); a reload failure is logged to the play game and leaves the mode unchanged. - `apply_playtest_transition(mode, ui)` — enters/leaves a **playtest** (the editor's `[p] Play board`). Enter: takes the `GameState` the editor parked in `EditorState::pending_playtest` (built from a `World::deep_clone`, so isolated) and `std::mem::replace`s `*mode` with `Mode::Play(game)`, stashing the old `Mode::Edit` into `Ui::suspended_editor`. Exit: when `Ui::exit_playtest` is set (the playtest pressed `Esc`), drops the playtest game and restores the suspended editor verbatim. Reuses `Mode::Play`, so playtest draw/tick/input are the normal play paths; `suspended_editor.is_some()` is the "is playtest" flag (drives the `(playtest)` title cue and the `Esc` branch in `handle_board_input`). -- `draw(frame, mode, ui)` — renders the active mode (`draw_play` or `editor::draw_editor`), then the overlay layer in precedence **animation > menu > (Play: scroll / Edit: dialog)**: an `Overlay`-layer animation via `AnimWidget`, else `MenuWidget`, else the play scroll overlay (`ScrollOverlayWidget`) or — via `kiln_ui::render_overlay` — the editor's glyph picker (when open) or dialog (both `CursorOverlay`s). `draw_play` wraps the board in a bordered `Block` (board-name title; latest log previewed in the bottom border when the panel is closed). `draw_board_area` initializes a pending portal transition (now that the inner area is known) and renders either the board-layer wipe animation or the live board + speech bubbles. +- `draw(frame, mode, ui)` — renders the active mode (`draw_play` or `editor::draw_editor`), then the overlay layer in precedence **animation > menu > (Play: scroll / Edit: dialog)**: an `Overlay`-layer animation via `AnimWidget`, else `MenuWidget`, else the play scroll overlay (`ScrollOverlayWidget`) or — via `kiln_ui::render_overlay` — the editor's glyph picker (when open) or dialog (both `CursorOverlay`s). `draw_play` first carves a fixed-width (`STATUS_SIDEBAR_WIDTH = 14`) status sidebar (`StatusSidebarWidget`) off the left edge when `ui.show_status` (default on, toggled by `i`), then wraps the board in a bordered `Block` (board-name title; latest log previewed in the bottom border when the panel is closed) over the remaining `content_area`. The sidebar is play-only (the editor never calls `draw_play`); it also shows during a playtest. `draw_board_area` initializes a pending portal transition (now that the inner area is known) and renders either the board-layer wipe animation or the live board + speech bubbles. **`kiln-tui/src/mode.rs`** — top-level application mode: - `Mode { Play(GameState), Edit(EditorState) }` — exactly one is live at a time (play and edit are mutually exclusive: entering the editor drops the running game; returning to play rebuilds it fresh from the world file). `PendingMode { EnterEditor, PlayWorld }` — a deferred Play↔Edit switch requested by a menu closure (which only has `&mut Ui`) and applied by the run loop, mirroring the `should_quit` pattern. A **playtest** also uses `Mode::Play` but is launched from the editor's sidebar (not a `PendingMode`): the parked editor lives in `Ui::suspended_editor` so `Esc` restores it (see `apply_playtest_transition`). @@ -33,7 +33,7 @@ Renders the board as text: each `Glyph.tile` index is reinterpreted as a charact **`kiln-tui/src/input.rs`** — input mode + dispatch: - `InputMode { Board, Scroll, Menu, Editor, Dialog, CodeEditor, GlyphDialog, Ignore, Frozen }`. `current_input_mode(mode, ui)` — derives the mode: an active animation declares its own (`input_mode_during`); a pending transition → `Ignore`; an open `ui.menu` → `Menu`; else from `Mode` (`Edit` → `GlyphDialog` when the glyph picker is open, else `Dialog` when a dialog is open, else `CodeEditor` when a script editor is open, else `Editor`; `Play` → `Scroll` when a scroll is active else `Board`). -- `handle_board_input` (arrows move the player via `try_move_with_transition`, `l` toggles log, `Esc` opens the `pause_menu_items()` overlay — or, during a playtest, sets `ui.exit_playtest` to return to the editor, PageUp/Down + mouse scroll/resize the log), `handle_editor_input` (arrows move the cursor, `l` toggles log, `g` opens the glyph picker, `Esc` → `menu_escape`, other letters → `menu_key`, right-click moves the cursor, drag resizes the sidebar), `handle_scroll_input` (scroll nav + choice letters via `resolve_choice`), `handle_menu_input` (clone the matched item's action `Rc` to drop the `ui.menu` borrow, then call it). `try_move_with_transition` captures both board `Rc`s into `ui.pending_transition` when a move crosses a portal. +- `handle_board_input` (arrows move the player via `try_move_with_transition`, `l` toggles log, `i` toggles the status sidebar (`ui.show_status`), `Esc` opens the `pause_menu_items()` overlay — or, during a playtest, sets `ui.exit_playtest` to return to the editor, PageUp/Down + mouse scroll/resize the log), `handle_editor_input` (arrows move the cursor, `l` toggles log, `g` opens the glyph picker, `Esc` → `menu_escape`, other letters → `menu_key`, right-click moves the cursor, drag resizes the sidebar), `handle_scroll_input` (scroll nav + choice letters via `resolve_choice`), `handle_menu_input` (clone the matched item's action `Rc` to drop the `ui.menu` borrow, then call it). `try_move_with_transition` captures both board `Rc`s into `ui.pending_transition` when a move crosses a portal. **`kiln-tui/src/animation.rs`** — the animation abstraction: - `Animation` trait — `update(dt) -> bool` (done?), `draw(area, buf)`, `layer()`, `input_mode_during()` (default `Ignore`), `input_mode_after()`. `AnimationLayer { Board, Overlay }` (board-area replacement vs. full-screen overlay). `AnimWidget<'a>(&'a dyn Animation)` — adapts an animation to a ratatui `Widget` for `frame.render_widget`. @@ -60,6 +60,9 @@ Renders the board as text: each `Glyph.tile` index is reinterpreted as a charact - `place_bubble(area, obj_sx, obj_sy, lines, placed) -> Option` — sizes and places one bubble, appending its rect to `placed` on success. - `board_screen_pos(area, board, bx, by) -> Option<(u16, u16)>` — converts a board cell coordinate to terminal screen coordinates, returning `None` if off-screen. Used by bubble placement. +**`kiln-tui/src/status.rs`** — play-mode status sidebar widget: +- `StatusSidebarWidget` — a `ratatui::widgets::Widget` built with `new(health, gems)` that draws the player's stats in a bordered `Block` titled `Status`: a `Health:` label above a row of `MAX_HEARTS` (5) `♥` glyphs (first `health` bright red, rest dark red) and a `Gems: ♦ N` line (gem glyph blue). Purely presentational — reads the values handed in, never mutates game state. Drawn by `draw_play` when `ui.show_status` is set; `i` toggles it. + **`kiln-tui/src/log.rs`** — log panel widget: - `LogState { open, height, scroll }` — panel visibility, height in rows, and scroll offset. `toggle()`, `scroll_by(delta, log_len)`, `resize(target, total)`. - `LogWidget<'a>` — `StatefulWidget` that renders all log lines newest-first in a bordered block with word-wrap. diff --git a/kiln-tui/src/input.rs b/kiln-tui/src/input.rs index 46c6816..c3c5243 100644 --- a/kiln-tui/src/input.rs +++ b/kiln-tui/src/input.rs @@ -94,6 +94,8 @@ pub(crate) fn handle_board_input( KeyCode::Left => try_move_with_transition(game, ui, Direction::West), KeyCode::Right => try_move_with_transition(game, ui, Direction::East), KeyCode::Char('l') => ui.log.toggle(), + // Toggle the status sidebar (play-only: this handler is InputMode::Board). + KeyCode::Tab => ui.show_status = !ui.show_status, KeyCode::PageUp if log_open => ui.log.scroll_by(-5, game.log.len()), KeyCode::PageDown if log_open => ui.log.scroll_by(5, game.log.len()), _ => {} diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index a1f7a94..2574e66 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -18,6 +18,7 @@ mod overlay; mod render; mod scroll_overlay; mod speech; +mod status; mod term; mod transition; mod ui; @@ -37,6 +38,7 @@ use crate::menu::MenuWidget; use crate::mode::{Mode, PendingMode}; use crate::scroll_overlay::ScrollOverlayWidget; use crate::speech::SpeechBubblesWidget; +use crate::status::StatusSidebarWidget; use crate::ui::Ui; use kiln_core::game::GameState; use kiln_core::log::LogLine; @@ -330,12 +332,30 @@ fn draw_play(frame: &mut Frame, game: &GameState, ui: &mut Ui) { format!(" {} ", board.name) }; + // Carve a fixed-width status sidebar off the left edge when enabled; the + // board/log layout below operates on whatever area is left (`content_area`). + let content_area = if ui.show_status { + // Overlap by one column so the sidebar's right border and the board's + // left border share a cell (both blocks merge their borders). + let [sidebar_area, rest] = + Layout::horizontal([Constraint::Length(STATUS_SIDEBAR_WIDTH), Constraint::Min(0)]) + .spacing(Spacing::Overlap(1)) + .areas(frame.area()); + frame.render_widget( + StatusSidebarWidget::new(game.player_health - 2, game.player_gems), + sidebar_area, + ); + rest + } else { + frame.area() + }; + if ui.log.open { - // Split the screen: board on top, log panel of `height` at the bottom. + // Split the content area: board on top, log panel of `height` at the bottom. let [board_area, log_area] = Layout::vertical([Constraint::Min(3), Constraint::Length(ui.log.height)]) .spacing(Spacing::Overlap(1)) - .areas(frame.area()); + .areas(content_area); let block = Block::bordered() .title(title) @@ -345,15 +365,21 @@ fn draw_play(frame: &mut Frame, game: &GameState, ui: &mut Ui) { draw_board_area(frame, inner, game, ui); frame.render_stateful_widget(LogWidget::new(&game.log), log_area, &mut ui.log); } else { - // Panel closed: board fills the screen, latest message in the border. - let mut block = Block::bordered().title(title); + // Panel closed: board fills the content area, latest message in the border. + // Merge borders so a joined edge with the status sidebar renders cleanly. + let mut block = Block::bordered() + .title(title) + .merge_borders(MergeStrategy::Exact); block = block.title_bottom(log_preview_line(&game.log).left_aligned()); - let inner = block.inner(frame.area()); - frame.render_widget(block, frame.area()); + let inner = block.inner(content_area); + frame.render_widget(block, content_area); draw_board_area(frame, inner, game, ui); } } +/// Width (in terminal columns, borders included) of the play-mode status sidebar. +const STATUS_SIDEBAR_WIDTH: u16 = 18; + /// Renders the inner board area: either the active board-layer animation or the live board. /// /// If `ui.pending_transition` is set, pre-renders both boards into a new diff --git a/kiln-tui/src/status.rs b/kiln-tui/src/status.rs new file mode 100644 index 0000000..3fcd432 --- /dev/null +++ b/kiln-tui/src/status.rs @@ -0,0 +1,72 @@ +//! Play-mode status sidebar widget for kiln-tui. +//! +//! [`StatusSidebarWidget`] draws the player's persistent stats (health + gems) +//! in a bordered block on the left of the screen during play. It is purely +//! presentational: it reads `health`/`gems` values handed in at construction and +//! never mutates game state. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::symbols::merge::MergeStrategy; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph, Widget}; + +/// How many hearts the health row always shows (the player's max health). +const MAX_HEARTS: u32 = 5; +/// A filled heart: bright red. +const HEART_FULL: Color = Color::Rgb(255, 0, 0); +/// A depleted heart: dark red. +const HEART_EMPTY: Color = Color::Rgb(90, 0, 0); +/// The gem glyph color: blue. +const GEM: Color = Color::Rgb(80, 80, 255); + +/// A ratatui widget that renders the play-mode status sidebar. +/// +/// Shows a `Health:` label above a row of [`MAX_HEARTS`] heart glyphs (the first +/// `health` filled bright red, the rest dark red) and a `Gems: ♦ N` line. +pub struct StatusSidebarWidget { + /// The player's current health, clamped to [`MAX_HEARTS`] when drawn. + health: u32, + /// The number of gems collected, shown as a count. + gems: u32, +} + +impl StatusSidebarWidget { + /// Builds a sidebar widget for the given player stats. + pub fn new(health: u32, gems: u32) -> Self { + Self { health, gems } + } +} + +impl Widget for StatusSidebarWidget { + fn render(self, area: Rect, buf: &mut Buffer) { + // One bright-red span per filled heart, dark-red for the rest, so a + // partly-depleted bar reads at a glance. + let filled = self.health.min(MAX_HEARTS); + let hearts: Vec = (0..MAX_HEARTS) + .map(|i| { + let color = if i < filled { HEART_FULL } else { HEART_EMPTY }; + Span::styled("♥", Style::default().fg(color)) + }) + .collect(); + + let lines = vec![ + Line::from("Health:"), + Line::from(hearts), + Line::from(""), + Line::from(vec![ + Span::raw("Gems: "), + Span::styled("♦", Style::default().fg(GEM)), + Span::raw(format!(" {}", self.gems)), + ]), + ]; + + // A bordered block; `merge_borders` joins its right edge with the board + // block's left edge where the layout overlaps them by one column. + let block = Block::bordered() + .title(" Status (tab) ") + .merge_borders(MergeStrategy::Exact); + Paragraph::new(lines).block(block).render(area, buf); + } +} diff --git a/kiln-tui/src/ui.rs b/kiln-tui/src/ui.rs index 2a4f7d6..cb9ce46 100644 --- a/kiln-tui/src/ui.rs +++ b/kiln-tui/src/ui.rs @@ -44,6 +44,9 @@ pub(crate) struct Ui { /// Set when `Esc` is pressed during a playtest, signalling the run loop to drop the /// playtest game and restore [`suspended_editor`](Ui::suspended_editor). pub(crate) exit_playtest: bool, + /// Whether the play-mode status sidebar (player health + gems) is visible. + /// Toggled with `i` while playing; defaults to `true`. Has no effect in the editor. + pub(crate) show_status: bool, } impl Default for Ui { @@ -59,6 +62,7 @@ impl Default for Ui { pending_mode: None, suspended_editor: None, exit_playtest: false, + show_status: true, } } }