Added scroll()

This commit is contained in:
2026-06-10 01:42:33 -05:00
parent c2168b992d
commit f73c02437d
7 changed files with 375 additions and 43 deletions
+155 -32
View File
@@ -11,7 +11,7 @@ mod cp437;
mod render;
mod term;
use kiln_core::game::GameState;
use kiln_core::game::{GameState, ScrollLine};
use kiln_core::log::LogLine;
use kiln_core::map_file;
use kiln_core::Direction;
@@ -31,8 +31,19 @@ use std::process::ExitCode;
use std::time::{Duration, Instant};
use term::TerminalCaps;
/// View-only UI state for the log panel. This is presentation state, so it lives
/// in the front-end rather than on the engine's [`GameState`].
/// Animation state for the scroll overlay window.
enum ScrollAnimState {
/// Window is opening: progress goes from 0.0 → 1.0 over 0.5 s.
Opening(f32),
/// Window is fully open.
Open,
/// Window is closing: progress from 1.0 → 0.0 over 0.5 s.
/// When it reaches 0 the `choice` (if any) is dispatched and the overlay clears.
Closing { progress: f32, choice: Option<String> },
}
/// View-only UI state for the log panel and scroll overlay. This is presentation
/// state, so it lives in the front-end rather than on the engine's [`GameState`].
struct Ui {
/// Whether the bottom log panel is open.
log_open: bool,
@@ -40,6 +51,10 @@ struct Ui {
log_height: u16,
/// Vertical scroll offset of the log panel, in lines from the top (newest).
scroll: u16,
/// Vertical scroll offset within an active scroll overlay.
scroll_offset: u16,
/// Animation state for the scroll overlay; `None` when no overlay is active.
scroll_anim: Option<ScrollAnimState>,
}
impl Default for Ui {
@@ -48,10 +63,37 @@ impl Default for Ui {
log_open: false,
log_height: 10,
scroll: 0,
scroll_offset: 0,
scroll_anim: None,
}
}
}
/// Starts the scroll-close animation with the optional player choice.
/// Uses the current opening progress if the overlay was still animating in.
fn begin_close(ui: &mut Ui, choice: Option<String>) {
let progress = match &ui.scroll_anim {
Some(ScrollAnimState::Opening(p)) => p.min(1.0),
_ => 1.0,
};
ui.scroll_anim = Some(ScrollAnimState::Closing { progress, choice });
}
/// Returns the choice string for the given key character, or `None` if it doesn't
/// match any choice in the scroll. Choices are assigned letters a, b, c… in order.
fn resolve_choice(lines: &[ScrollLine], c: char) -> Option<String> {
let mut letter = b'a';
for line in lines {
if let ScrollLine::Choice { choice, .. } = line {
if c == letter as char {
return Some(choice.clone());
}
letter += 1;
}
}
None
}
/// Entry point: parse the map path, load the board, then run the play loop with
/// the terminal in raw/alternate-screen mode (restored on every exit path).
fn main() -> ExitCode {
@@ -142,36 +184,74 @@ fn run(
// continuously (the Kitty protocol emits Repeat events while a key
// is held). Ignore Release, which that protocol also emits and
// which would otherwise fire a second, spurious move per keypress.
Event::Key(key) if key.kind != KeyEventKind::Release => match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
KeyCode::Up => game.try_move(Direction::North),
KeyCode::Down => game.try_move(Direction::South),
KeyCode::Left => game.try_move(Direction::West),
// Right arrow moves right; 'l' is the log panel.
KeyCode::Right => game.try_move(Direction::East),
// 'l' toggles the log panel; reset scroll to newest.
KeyCode::Char('l') => {
ui.log_open = !ui.log_open;
ui.scroll = 0;
Event::Key(key) if key.kind != KeyEventKind::Release => {
// When a scroll overlay is active (and not already animating
// closed), intercept all keys for scroll navigation/choices.
let scroll_active = game.active_scroll.is_some()
&& !matches!(ui.scroll_anim, Some(ScrollAnimState::Closing { .. }));
if scroll_active {
let scroll = game.active_scroll.as_ref().unwrap();
let has_choices = scroll.lines.iter()
.any(|l| matches!(l, ScrollLine::Choice { .. }));
match key.code {
// Esc dismisses the scroll only when there are no choices;
// with choices present the player must select one.
KeyCode::Esc if !has_choices => begin_close(ui, None),
KeyCode::Up => ui.scroll_offset = ui.scroll_offset.saturating_sub(1),
KeyCode::Down => ui.scroll_offset = ui.scroll_offset.saturating_add(1),
KeyCode::Char(c) => {
if let Some(ch) = resolve_choice(&scroll.lines, c) {
begin_close(ui, Some(ch));
}
}
_ => {}
}
} else {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
KeyCode::Up => game.try_move(Direction::North),
KeyCode::Down => game.try_move(Direction::South),
KeyCode::Left => game.try_move(Direction::West),
// Right arrow moves right; 'l' is the log panel.
KeyCode::Right => game.try_move(Direction::East),
// 'l' toggles the log panel; reset scroll to newest.
KeyCode::Char('l') => {
ui.log_open = !ui.log_open;
ui.scroll = 0;
}
// PageUp/PageDown scroll the log when it's open.
KeyCode::PageUp if ui.log_open => scroll_log(ui, game, -5),
KeyCode::PageDown if ui.log_open => scroll_log(ui, game, 5),
_ => {}
}
}
// PageUp/PageDown scroll the log when it's open.
KeyCode::PageUp if ui.log_open => scroll_log(ui, game, -5),
KeyCode::PageDown if ui.log_open => scroll_log(ui, game, 5),
_ => {}
},
// Wheel scrolls the open log; dragging the divider resizes it.
Event::Mouse(m) if ui.log_open => match m.kind {
MouseEventKind::ScrollUp => scroll_log(ui, game, -1),
MouseEventKind::ScrollDown => scroll_log(ui, game, 1),
MouseEventKind::Drag(_) => {
// The divider sits at row `total - log_height`; dragging
// it up (smaller row) grows the panel. Keep both usable.
let total = terminal.size()?.height;
let target = total.saturating_sub(m.row);
ui.log_height = target.clamp(3, total.saturating_sub(3).max(3));
}
// Mouse: scroll overlay captures wheel events when active; otherwise
// wheel scrolls the log and drag resizes it.
Event::Mouse(m) => {
let scroll_active = game.active_scroll.is_some()
&& !matches!(ui.scroll_anim, Some(ScrollAnimState::Closing { .. }));
if scroll_active {
match m.kind {
MouseEventKind::ScrollUp => ui.scroll_offset = ui.scroll_offset.saturating_sub(1),
MouseEventKind::ScrollDown => ui.scroll_offset = ui.scroll_offset.saturating_add(1),
_ => {}
}
} else if ui.log_open {
match m.kind {
MouseEventKind::ScrollUp => scroll_log(ui, game, -1),
MouseEventKind::ScrollDown => scroll_log(ui, game, 1),
MouseEventKind::Drag(_) => {
// The divider sits at row `total - log_height`; dragging
// it up (smaller row) grows the panel. Keep both usable.
let total = terminal.size()?.height;
let target = total.saturating_sub(m.row);
ui.log_height = target.clamp(3, total.saturating_sub(3).max(3));
}
_ => {}
}
}
_ => {}
},
}
_ => {}
}
}
@@ -182,7 +262,35 @@ fn run(
if last_tick.elapsed() >= FRAME {
let dt = last_tick.elapsed();
last_tick = Instant::now();
game.tick(dt);
// Advance scroll animation regardless of game pause state.
let dt_s = dt.as_secs_f32();
match &mut ui.scroll_anim {
Some(ScrollAnimState::Opening(p)) => {
*p += dt_s / 0.5;
if *p >= 1.0 { ui.scroll_anim = Some(ScrollAnimState::Open); }
}
Some(ScrollAnimState::Closing { progress, choice }) => {
*progress -= dt_s / 0.5;
if *progress <= 0.0 {
// Animation done — dispatch choice and clear the scroll.
let ch = choice.take();
game.close_scroll(ch.as_deref());
ui.scroll_anim = None;
ui.scroll_offset = 0;
}
}
_ => {}
}
// Tick the game only when no scroll overlay is blocking.
if game.active_scroll.is_none() {
game.tick(dt);
// Detect a scroll just opened by the tick; start its open animation.
if game.active_scroll.is_some() && ui.scroll_anim.is_none() {
ui.scroll_anim = Some(ScrollAnimState::Opening(0.0));
}
}
}
}
}
@@ -197,6 +305,7 @@ fn scroll_log(ui: &mut Ui, game: &GameState, delta: i32) {
/// Render a single frame: the board in a bordered block, and — when open — a log
/// panel across the bottom. When the panel is closed the latest log message is
/// previewed in the board's bottom-left border after a `[l]og:` label.
/// A scroll overlay is drawn on top of everything when active.
fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) {
// Borrow the board for the duration of the frame (no script runs during draw).
let board = game.board();
@@ -239,6 +348,20 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) {
frame.render_widget(BoardWidget::new(board), inner);
render::draw_speech_bubbles(frame.buffer_mut(), inner, board, &game.speech_bubbles);
}
// Scroll overlay: drawn last so it sits above all other content.
if let Some(scroll) = &game.active_scroll {
let anim = match &ui.scroll_anim {
Some(ScrollAnimState::Opening(p)) => p.clamp(0.0, 1.0),
Some(ScrollAnimState::Open) => 1.0,
Some(ScrollAnimState::Closing { progress, .. }) => progress.clamp(0.0, 1.0),
// Fallback: overlay exists but animation not set yet — show fully open.
None => 1.0,
};
// Capture area before the mutable buffer borrow to satisfy the borrow checker.
let area = frame.area();
render::draw_scroll_overlay(frame.buffer_mut(), area, scroll, ui.scroll_offset, anim);
}
}
/// Builds the `[l]og: <latest message>` preview shown in the board's bottom-left