status bar display

This commit is contained in:
2026-06-21 12:32:09 -05:00
parent a45c5a0a68
commit 9b53552f4a
7 changed files with 126 additions and 9 deletions
+1 -1
View File
@@ -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). - `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). - `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`. - `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: **`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). - `MOVE_COST: f64` — how long a move occupies an object before it can act again (0.25 s).
+10
View File
@@ -66,6 +66,14 @@ pub struct GameState {
/// by [`enter_board`](GameState::enter_board). Front-ends tick this down and /// 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`. /// may block input or show a visual effect while it is `Some(t)` where `t > 0`.
pub board_transition: Option<f64>, 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 { impl GameState {
@@ -92,6 +100,8 @@ impl GameState {
speech_bubbles: Vec::new(), speech_bubbles: Vec::new(),
active_scroll: None, active_scroll: None,
board_transition: None, board_transition: None,
player_health: 5,
player_gems: 0,
}; };
state.drain_errors(); state.drain_errors();
state state
+5 -2
View File
@@ -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. - `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_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`). - `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: **`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`). - `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: **`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`). - `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: **`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`. - `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. - `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. - `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: **`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)`. - `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. - `LogWidget<'a>``StatefulWidget` that renders all log lines newest-first in a bordered block with word-wrap.
+2
View File
@@ -94,6 +94,8 @@ pub(crate) fn handle_board_input(
KeyCode::Left => try_move_with_transition(game, ui, Direction::West), KeyCode::Left => try_move_with_transition(game, ui, Direction::West),
KeyCode::Right => try_move_with_transition(game, ui, Direction::East), KeyCode::Right => try_move_with_transition(game, ui, Direction::East),
KeyCode::Char('l') => ui.log.toggle(), 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::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()), KeyCode::PageDown if log_open => ui.log.scroll_by(5, game.log.len()),
_ => {} _ => {}
+32 -6
View File
@@ -18,6 +18,7 @@ mod overlay;
mod render; mod render;
mod scroll_overlay; mod scroll_overlay;
mod speech; mod speech;
mod status;
mod term; mod term;
mod transition; mod transition;
mod ui; mod ui;
@@ -37,6 +38,7 @@ use crate::menu::MenuWidget;
use crate::mode::{Mode, PendingMode}; use crate::mode::{Mode, PendingMode};
use crate::scroll_overlay::ScrollOverlayWidget; use crate::scroll_overlay::ScrollOverlayWidget;
use crate::speech::SpeechBubblesWidget; use crate::speech::SpeechBubblesWidget;
use crate::status::StatusSidebarWidget;
use crate::ui::Ui; use crate::ui::Ui;
use kiln_core::game::GameState; use kiln_core::game::GameState;
use kiln_core::log::LogLine; use kiln_core::log::LogLine;
@@ -330,12 +332,30 @@ fn draw_play(frame: &mut Frame, game: &GameState, ui: &mut Ui) {
format!(" {} ", board.name) 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 { 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] = let [board_area, log_area] =
Layout::vertical([Constraint::Min(3), Constraint::Length(ui.log.height)]) Layout::vertical([Constraint::Min(3), Constraint::Length(ui.log.height)])
.spacing(Spacing::Overlap(1)) .spacing(Spacing::Overlap(1))
.areas(frame.area()); .areas(content_area);
let block = Block::bordered() let block = Block::bordered()
.title(title) .title(title)
@@ -345,15 +365,21 @@ fn draw_play(frame: &mut Frame, game: &GameState, ui: &mut Ui) {
draw_board_area(frame, inner, game, ui); draw_board_area(frame, inner, game, ui);
frame.render_stateful_widget(LogWidget::new(&game.log), log_area, &mut ui.log); frame.render_stateful_widget(LogWidget::new(&game.log), log_area, &mut ui.log);
} else { } else {
// Panel closed: board fills the screen, latest message in the border. // Panel closed: board fills the content area, latest message in the border.
let mut block = Block::bordered().title(title); // 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()); block = block.title_bottom(log_preview_line(&game.log).left_aligned());
let inner = block.inner(frame.area()); let inner = block.inner(content_area);
frame.render_widget(block, frame.area()); frame.render_widget(block, content_area);
draw_board_area(frame, inner, game, ui); 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. /// 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 /// If `ui.pending_transition` is set, pre-renders both boards into a new
+72
View File
@@ -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);
}
}
+4
View File
@@ -44,6 +44,9 @@ pub(crate) struct Ui {
/// Set when `Esc` is pressed during a playtest, signalling the run loop to drop the /// Set when `Esc` is pressed during a playtest, signalling the run loop to drop the
/// playtest game and restore [`suspended_editor`](Ui::suspended_editor). /// playtest game and restore [`suspended_editor`](Ui::suspended_editor).
pub(crate) exit_playtest: bool, 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 { impl Default for Ui {
@@ -59,6 +62,7 @@ impl Default for Ui {
pending_mode: None, pending_mode: None,
suspended_editor: None, suspended_editor: None,
exit_playtest: false, exit_playtest: false,
show_status: true,
} }
} }
} }