status bar display
This commit is contained in:
+1
-1
@@ -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<ScrollLine> }` — an active scroll overlay opened by a script's `scroll(lines)` call. Lives on `GameState::active_scroll: Option<Scroll>`; 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<RefCell<Board>>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec<LogLine>`, `scripts: ScriptHost`, `pub speech_bubbles: Vec<SpeechBubble>`, `pub active_scroll: Option<Scroll>`. Front-ends reach the active board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>` (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<RefCell<Board>>` 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<RefCell<Board>>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec<LogLine>`, `scripts: ScriptHost`, `pub speech_bubbles: Vec<SpeechBubble>`, `pub active_scroll: Option<Scroll>`, `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>` / `board_mut() -> RefMut<Board>` (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<RefCell<Board>>` 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).
|
||||
|
||||
@@ -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<f64>,
|
||||
/// 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
|
||||
|
||||
+5
-2
@@ -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<Rect>` — 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.
|
||||
|
||||
@@ -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()),
|
||||
_ => {}
|
||||
|
||||
+32
-6
@@ -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
|
||||
|
||||
@@ -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<Span> = (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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user