Timer
This commit is contained in:
Generated
+7
@@ -609,6 +609,7 @@ dependencies = [
|
|||||||
"color",
|
"color",
|
||||||
"rhai",
|
"rhai",
|
||||||
"serde",
|
"serde",
|
||||||
|
"tinyrand",
|
||||||
"toml",
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1520,6 +1521,12 @@ dependencies = [
|
|||||||
"crunchy",
|
"crunchy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinyrand"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "87ffaad2263779579369d45f65cad0647c860893d27e4543cdcc1e428d07da2c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.8.23"
|
version = "0.8.23"
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ edition = "2024"
|
|||||||
color = "0.3.3"
|
color = "0.3.3"
|
||||||
rhai = "1"
|
rhai = "1"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
tinyrand = "0.5"
|
||||||
toml = { version = "0.8", features = ["preserve_order"] }
|
toml = { version = "0.8", features = ["preserve_order"] }
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ use color::Rgba8;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::hash::{Hash, Hasher};
|
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.
|
/// The visual representation of a single board cell.
|
||||||
///
|
///
|
||||||
@@ -397,14 +399,26 @@ pub struct GameState {
|
|||||||
pub board: Board,
|
pub board: Board,
|
||||||
/// The in-game message log, oldest first (newest pushed at the end).
|
/// The in-game message log, oldest first (newest pushed at the end).
|
||||||
pub log: Vec<LogLine>,
|
pub log: Vec<LogLine>,
|
||||||
|
/// 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 {
|
impl GameState {
|
||||||
/// Creates a `GameState` from a pre-loaded [`Board`].
|
/// Creates a `GameState` from a pre-loaded [`Board`].
|
||||||
pub fn new(board: Board) -> Self {
|
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 {
|
Self {
|
||||||
board,
|
board,
|
||||||
log: Vec::new(),
|
log: Vec::new(),
|
||||||
|
tick_accum: Duration::ZERO,
|
||||||
|
rng: StdRand::seed(seed),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,6 +427,29 @@ impl GameState {
|
|||||||
self.log.push(line);
|
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.
|
/// Attempts to move the player by `(dx, dy)` cells.
|
||||||
///
|
///
|
||||||
/// The move is ignored if the target cell is out of bounds or its behavior
|
/// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+46
-27
@@ -26,6 +26,7 @@ use ratatui::widgets::{Block, Paragraph, Wrap};
|
|||||||
use render::{BoardWidget, logline_to_line};
|
use render::{BoardWidget, logline_to_line};
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
use ratatui::symbols::merge::MergeStrategy;
|
use ratatui::symbols::merge::MergeStrategy;
|
||||||
use term::TerminalCaps;
|
use term::TerminalCaps;
|
||||||
|
|
||||||
@@ -107,34 +108,43 @@ fn main() -> ExitCode {
|
|||||||
ExitCode::SUCCESS
|
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(
|
fn run(
|
||||||
terminal: &mut ratatui::DefaultTerminal,
|
terminal: &mut ratatui::DefaultTerminal,
|
||||||
game: &mut GameState,
|
game: &mut GameState,
|
||||||
ui: &mut Ui,
|
ui: &mut Ui,
|
||||||
) -> std::io::Result<()> {
|
) -> std::io::Result<()> {
|
||||||
|
let mut last_tick = Instant::now();
|
||||||
loop {
|
loop {
|
||||||
// Redraw every iteration; ratatui diffs against the previous frame so
|
// Redraw every iteration; ratatui diffs against the previous frame so
|
||||||
// only changed cells are actually written to the terminal.
|
// only changed cells are actually written to the terminal.
|
||||||
terminal.draw(|frame| draw(frame, game, ui))?;
|
terminal.draw(|frame| draw(frame, game, ui))?;
|
||||||
|
|
||||||
match event::read()? {
|
// Wait for input only up to the next frame deadline so the loop wakes to
|
||||||
Event::Key(key) => {
|
// 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
|
// Act on Press and Repeat so holding a key moves the player
|
||||||
// continuously (the Kitty protocol emits Repeat events while a
|
// continuously (the Kitty protocol emits Repeat events while a key
|
||||||
// key is held). Ignore Release, which that protocol also emits
|
// is held). Ignore Release, which that protocol also emits and
|
||||||
// and which would otherwise fire a second, spurious move.
|
// which would otherwise fire a second, spurious move per keypress.
|
||||||
if key.kind == KeyEventKind::Release {
|
Event::Key(key) if key.kind != KeyEventKind::Release => match key.code {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
|
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
|
||||||
KeyCode::Up => game.try_move(0, -1),
|
KeyCode::Up => game.try_move(0, -1),
|
||||||
KeyCode::Down => game.try_move(0, 1),
|
KeyCode::Down => game.try_move(0, 1),
|
||||||
KeyCode::Left => game.try_move(-1, 0),
|
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),
|
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') => {
|
KeyCode::Char('l') => {
|
||||||
ui.log_open = !ui.log_open;
|
ui.log_open = !ui.log_open;
|
||||||
ui.scroll = 0;
|
ui.scroll = 0;
|
||||||
@@ -143,22 +153,31 @@ fn run(
|
|||||||
KeyCode::PageUp if ui.log_open => scroll_log(ui, game, -5),
|
KeyCode::PageUp if ui.log_open => scroll_log(ui, game, -5),
|
||||||
KeyCode::PageDown 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.
|
||||||
// Wheel scrolls the open log; dragging the divider resizes it.
|
Event::Mouse(m) if ui.log_open => match m.kind {
|
||||||
Event::Mouse(m) if ui.log_open => match m.kind {
|
MouseEventKind::ScrollUp => scroll_log(ui, game, -1),
|
||||||
MouseEventKind::ScrollUp => scroll_log(ui, game, -1),
|
MouseEventKind::ScrollDown => scroll_log(ui, game, 1),
|
||||||
MouseEventKind::ScrollDown => scroll_log(ui, game, 1),
|
MouseEventKind::Drag(_) => {
|
||||||
MouseEventKind::Drag(_) => {
|
// The divider sits at row `total - log_height`; dragging
|
||||||
// The divider sits at row `total - log_height`; dragging it up
|
// it up (smaller row) grows the panel. Keep both usable.
|
||||||
// (smaller row) grows the panel. Keep both regions usable.
|
let total = terminal.size()?.height;
|
||||||
let total = terminal.size()?.height;
|
let target = total.saturating_sub(m.row);
|
||||||
let target = total.saturating_sub(m.row);
|
ui.log_height = target.clamp(3, total.saturating_sub(3).max(3));
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user