From f0bcc90480e4b54855fec95ae582206325cbee91 Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Wed, 3 Jun 2026 23:21:05 -0500 Subject: [PATCH] Timer --- Cargo.lock | 7 ++++ kiln-core/Cargo.toml | 1 + kiln-core/src/game.rs | 98 +++++++++++++++++++++++++++++++++++++++++++ kiln-tui/src/main.rs | 73 ++++++++++++++++++++------------ 4 files changed, 152 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 86bae4f..aea9df7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -609,6 +609,7 @@ dependencies = [ "color", "rhai", "serde", + "tinyrand", "toml", ] @@ -1520,6 +1521,12 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tinyrand" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ffaad2263779579369d45f65cad0647c860893d27e4543cdcc1e428d07da2c" + [[package]] name = "toml" version = "0.8.23" diff --git a/kiln-core/Cargo.toml b/kiln-core/Cargo.toml index 7ce24b1..e8e2bcf 100644 --- a/kiln-core/Cargo.toml +++ b/kiln-core/Cargo.toml @@ -7,4 +7,5 @@ edition = "2024" color = "0.3.3" rhai = "1" serde = { version = "1", features = ["derive"] } +tinyrand = "0.5" toml = { version = "0.8", features = ["preserve_order"] } diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 830feb8..91675e7 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -3,6 +3,8 @@ use color::Rgba8; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::hash::{Hash, Hasher}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tinyrand::{Rand, Seeded, StdRand}; /// The visual representation of a single board cell. /// @@ -397,14 +399,26 @@ pub struct GameState { pub board: Board, /// The in-game message log, oldest first (newest pushed at the end). pub log: Vec, + /// Elapsed real time not yet consumed by [`GameState::tick`], used to fire + /// once-per-second events regardless of the actual frame rate. + tick_accum: Duration, + /// The engine's random-number generator (currently used for tick colors). + rng: StdRand, } impl GameState { /// Creates a `GameState` from a pre-loaded [`Board`]. pub fn new(board: Board) -> Self { + // Seed the RNG from the wall clock so colors differ run-to-run. + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); Self { board, log: Vec::new(), + tick_accum: Duration::ZERO, + rng: StdRand::seed(seed), } } @@ -413,6 +427,29 @@ impl GameState { self.log.push(line); } + /// Advances real-time game state by `dt` (the elapsed time since the last + /// tick). Called once per frame by the front-end's game loop. + /// + /// For now this is a demo: it logs the word "tick" once per elapsed second, + /// each in a random bright color. A `while` loop (rather than `if`) keeps the + /// count correct even if a frame ran long enough to span multiple seconds. + pub fn tick(&mut self, dt: Duration) { + self.tick_accum += dt; + while self.tick_accum >= Duration::from_secs(1) { + self.tick_accum -= Duration::from_secs(1); + let color = self.random_color(); + self.log(LogLine::new().push("tick", Some(color), None)); + } + } + + /// Returns a random fully-saturated, full-value color by picking a random + /// hue — bright enough to stay readable against dark backgrounds. + fn random_color(&mut self) -> Rgba8 { + let hue = self.rng.next_lim_u32(360) as f32; + let (r, g, b) = hue_to_rgb(hue); + Rgba8 { r, g, b, a: 255 } + } + /// Attempts to move the player by `(dx, dy)` cells. /// /// The move is ignored if the target cell is out of bounds or its behavior @@ -430,3 +467,64 @@ impl GameState { } } } + +/// Converts a hue in degrees to an RGB triple at full saturation and value. +/// +/// Standard 6-segment HSV→RGB conversion (S = V = 1). Kept here rather than +/// shared with the front-end because kiln-core does not depend on any UI crate; +/// `kiln-tui` has its own copy for the terminal-status badge. +fn hue_to_rgb(hue: f32) -> (u8, u8, u8) { + let h = (hue % 360.0) / 60.0; + // `x` ramps the secondary channel up/down within each 60° segment. + let x = 1.0 - (h % 2.0 - 1.0).abs(); + let (r, g, b) = match h as u32 { + 0 => (1.0, x, 0.0), + 1 => (x, 1.0, 0.0), + 2 => (0.0, 1.0, x), + 3 => (0.0, x, 1.0), + 4 => (x, 0.0, 1.0), + _ => (1.0, 0.0, x), + }; + ((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal 1×1 empty board for exercising `GameState` logic. + fn empty_board() -> Board { + Board { + name: "test".into(), + width: 1, + height: 1, + cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty)], + player: Player { x: 0, y: 0 }, + objects: Vec::new(), + portals: Vec::new(), + font: None, + zoom: 1, + scripts: HashMap::new(), + board_script_name: None, + } + } + + #[test] + fn tick_logs_one_message_per_elapsed_second() { + let mut game = GameState::new(empty_board()); + assert_eq!(game.log.len(), 0); + + // Under a second of accumulated time logs nothing yet. + game.tick(Duration::from_millis(500)); + assert_eq!(game.log.len(), 0); + + // Crossing the one-second mark logs exactly one "tick". + game.tick(Duration::from_millis(600)); + assert_eq!(game.log.len(), 1); + assert_eq!(game.log[0].spans[0].text, "tick"); + + // A single long frame logs one message per whole second it spans. + game.tick(Duration::from_secs(3)); + assert_eq!(game.log.len(), 4); + } +} diff --git a/kiln-tui/src/main.rs b/kiln-tui/src/main.rs index 9f5517d..c8f288d 100644 --- a/kiln-tui/src/main.rs +++ b/kiln-tui/src/main.rs @@ -26,6 +26,7 @@ use ratatui::widgets::{Block, Paragraph, Wrap}; use render::{BoardWidget, logline_to_line}; use std::io; use std::process::ExitCode; +use std::time::{Duration, Instant}; use ratatui::symbols::merge::MergeStrategy; use term::TerminalCaps; @@ -107,34 +108,43 @@ fn main() -> ExitCode { ExitCode::SUCCESS } -/// Draw-then-handle-input loop. Returns once the user asks to quit. +/// The target frame interval: 30 frames per second. +const FRAME: Duration = Duration::from_nanos(1_000_000_000 / 30); + +/// Real-time draw / input / update loop at ~30 FPS. Returns once the user quits. +/// +/// Unlike a turn-based loop, this never blocks indefinitely: it waits for input +/// only until the next frame deadline (`event::poll`), then advances the game by +/// the real time elapsed since the last tick. `poll` returns the instant a key +/// arrives, so input stays responsive while the game keeps ticking on its own. fn run( terminal: &mut ratatui::DefaultTerminal, game: &mut GameState, ui: &mut Ui, ) -> std::io::Result<()> { + let mut last_tick = Instant::now(); loop { // Redraw every iteration; ratatui diffs against the previous frame so // only changed cells are actually written to the terminal. terminal.draw(|frame| draw(frame, game, ui))?; - match event::read()? { - Event::Key(key) => { + // Wait for input only up to the next frame deadline so the loop wakes to + // tick even with no keypresses. + let timeout = FRAME.saturating_sub(last_tick.elapsed()); + if event::poll(timeout)? { + match event::read()? { // Act on Press and Repeat so holding a key moves the player - // 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. - if key.kind == KeyEventKind::Release { - continue; - } - match key.code { + // 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(0, -1), KeyCode::Down => game.try_move(0, 1), KeyCode::Left => game.try_move(-1, 0), - // Right arrow moves right; 'l' is reserved for the log panel. + // Right arrow moves right; 'l' is the log panel. KeyCode::Right => game.try_move(1, 0), - // 'l' toggles the log panel; reset the scroll to the newest. + // 'l' toggles the log panel; reset scroll to newest. KeyCode::Char('l') => { ui.log_open = !ui.log_open; ui.scroll = 0; @@ -143,22 +153,31 @@ fn run( 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 regions 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)); - } + }, + // 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)); + } + _ => {} + }, _ => {} - }, - _ => {} + } + } + + // Advance the game by the real time elapsed since the last tick. Only + // resetting `last_tick` when we actually tick means time spent waiting on + // input (or skipped Release events) is preserved in the next `dt`. + if last_tick.elapsed() >= FRAME { + let dt = last_tick.elapsed(); + last_tick = Instant::now(); + game.tick(dt); } } }