Merge pull request #4 from epic-64/performance-adaptive-polling

performance: adaptive polling
This commit is contained in:
William Raendchen 2025-11-19 18:25:47 +01:00 committed by GitHub
commit d05c75000c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 145 additions and 41 deletions

View File

@ -37,12 +37,13 @@ There is one file for linux and one for windows (.exe).
- press Enter to confirm choices - press Enter to confirm choices
- press Esc to exit a game mode or the game. CTRL+C also works to exit the game. - press Esc to exit a game mode or the game. CTRL+C also works to exit the game.
## Retro aesthetics ## Recommended terminals
For the best experience, I recommend using Cool Retro Terminal on Linux, or Windows Terminal in Retro mode. The game should run fine in any terminal. If you want retro CRT effects, here are some recommendations:
But the game should run fine in any terminal. - Windows: Windows Terminal (enable experimental "retro mode")
- Linux: Rio (with CRT shader), Cool Retro Term
## Build/Run from source ## Build/Run from source
You be inclined to not run binaries from the internet, and want to build from source instead. You may be inclined to not run binaries from the internet, and want to build from source instead.
- download the source code - download the source code
- make sure you have Rust and Cargo installed, see [rustup.rs](https://rustup.rs/) - make sure you have Rust and Cargo installed, see [rustup.rs](https://rustup.rs/)

BIN
docs/sc8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
docs/sc9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@ -7,10 +7,28 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::prelude::{Color, Modifier, Span, Style, Widget}; use ratatui::prelude::{Color, Modifier, Span, Style, Widget};
use ratatui::widgets::{List, ListItem, ListState}; use ratatui::widgets::{List, ListItem, ListState};
use std::cmp;
use std::collections::HashMap; use std::collections::HashMap;
use std::thread; use std::thread;
use std::time::Instant; use std::time::Instant;
use indoc::indoc; use indoc::indoc;
use std::sync::atomic::{AtomicUsize, Ordering};
static LAST_SELECTED_INDEX: AtomicUsize = AtomicUsize::new(4);
fn get_last_selected_index() -> usize {
LAST_SELECTED_INDEX.load(Ordering::Relaxed)
}
fn set_last_selected_index(index: usize) {
LAST_SELECTED_INDEX.store(index, Ordering::Relaxed);
}
#[derive(Copy, Clone, PartialEq, Debug)]
enum FpsMode {
RealTime, // 30 FPS with polling
Performance, // Block until input for minimal CPU
}
enum AppState { enum AppState {
Start(StartMenuState), Start(StartMenuState),
@ -24,6 +42,8 @@ fn handle_start_input(state: &mut StartMenuState, key: KeyEvent) -> Option<AppSt
KeyCode::Down => state.select_next(), KeyCode::Down => state.select_next(),
KeyCode::Enter => { KeyCode::Enter => {
let bits = state.selected_bits(); let bits = state.selected_bits();
// Store the current selection before entering the game
set_last_selected_index(state.selected_index());
return Some(AppState::Playing(BinaryNumbersGame::new(bits))); return Some(AppState::Playing(BinaryNumbersGame::new(bits)));
} }
KeyCode::Esc => return Some(AppState::Exit), KeyCode::Esc => return Some(AppState::Exit),
@ -106,6 +126,48 @@ fn render_start_screen(state: &mut StartMenuState, area: Rect, buf: &mut Buffer)
ratatui::widgets::StatefulWidget::render(list, list_area, buf, &mut state.list_state); ratatui::widgets::StatefulWidget::render(list, list_area, buf, &mut state.list_state);
} }
fn handle_crossterm_events(app_state: &mut AppState) -> color_eyre::Result<()> {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
match key.code {
// global exit via Ctrl+C
KeyCode::Char('c') | KeyCode::Char('C')
if key.modifiers == KeyModifiers::CONTROL =>
{
*app_state = AppState::Exit;
}
// state-specific input handling
_ => {
*app_state = match std::mem::replace(app_state, AppState::Exit) {
AppState::Start(mut menu) => {
handle_start_input(&mut menu, key)
.unwrap_or(AppState::Start(menu))
}
AppState::Playing(mut game) => {
game.handle_input(key);
AppState::Playing(game)
}
AppState::Exit => AppState::Exit,
}
}
}
}
}
Ok(())
}
/// Determine the appropriate FPS mode based on the current game state
fn get_fps_mode(game: &BinaryNumbersGame) -> FpsMode {
if game.is_active() {
FpsMode::RealTime // Timer running, needs continuous updates
} else if game.needs_render() {
FpsMode::RealTime // One frame after state transition
} else {
FpsMode::Performance // All other cases, block for minimal CPU
}
}
pub fn run_app(terminal: &mut ratatui::DefaultTerminal) -> color_eyre::Result<()> { pub fn run_app(terminal: &mut ratatui::DefaultTerminal) -> color_eyre::Result<()> {
let mut app_state = AppState::Start(StartMenuState::new()); let mut app_state = AppState::Start(StartMenuState::new());
let mut last_frame_time = Instant::now(); let mut last_frame_time = Instant::now();
@ -116,13 +178,7 @@ pub fn run_app(terminal: &mut ratatui::DefaultTerminal) -> color_eyre::Result<()
let dt = now - last_frame_time; let dt = now - last_frame_time;
last_frame_time = now; last_frame_time = now;
terminal.draw(|f| match &mut app_state { // Advance game BEFORE drawing so stats are updated
AppState::Start(menu) => render_start_screen(menu, f.area(), f.buffer_mut()),
AppState::Playing(game) => f.render_widget(&mut *game, f.area()),
AppState::Exit => {}
})?;
// Advance game if playing
if let AppState::Playing(game) = &mut app_state { if let AppState::Playing(game) = &mut app_state {
game.run(dt.as_secs_f64()); game.run(dt.as_secs_f64());
if game.is_exit_intended() { if game.is_exit_intended() {
@ -131,34 +187,33 @@ pub fn run_app(terminal: &mut ratatui::DefaultTerminal) -> color_eyre::Result<()
} }
} }
// handle input terminal.draw(|f| match &mut app_state {
let poll_timeout = std::cmp::min(dt, target_frame_duration); AppState::Start(menu) => render_start_screen(menu, f.area(), f.buffer_mut()),
if event::poll(poll_timeout)? { AppState::Playing(game) => f.render_widget(&mut *game, f.area()),
if let Event::Key(key) = event::read()? { AppState::Exit => {}
if key.kind == KeyEventKind::Press { })?;
match key.code {
// global exit via Ctrl+C
KeyCode::Char('c') | KeyCode::Char('C')
if key.modifiers == KeyModifiers::CONTROL =>
{
app_state = AppState::Exit;
}
// state-specific input handling // Clear needs_render flag after frame is rendered
_ => { // State transitions will set this flag again as needed, in performance mode
app_state = match app_state { if let AppState::Playing(game) = &mut app_state {
AppState::Start(mut menu) => handle_start_input(&mut menu, key) game.clear_needs_render();
.unwrap_or(AppState::Start(menu)), }
AppState::Playing(mut game) => {
game.handle_input(key); // handle input
AppState::Playing(game) if let AppState::Playing(game) = &app_state {
} if get_fps_mode(game) == FpsMode::RealTime {
AppState::Exit => AppState::Exit, let poll_timeout = cmp::min(dt, target_frame_duration);
} if event::poll(poll_timeout)? {
} handle_crossterm_events(&mut app_state)?;
}
} }
} else {
// performance mode: block thread until an input event occurs
handle_crossterm_events(&mut app_state)?;
} }
} else {
// For non-playing states (e.g., start menu), use performance mode
// to block until input and minimize CPU usage
handle_crossterm_events(&mut app_state)?;
} }
// cap frame rate // cap frame rate
@ -225,6 +280,10 @@ struct StartMenuState {
impl StartMenuState { impl StartMenuState {
fn new() -> Self { fn new() -> Self {
Self::with_selected(get_last_selected_index())
}
fn with_selected(selected_index: usize) -> Self {
let items = vec![ let items = vec![
("easy (4 bits)".to_string(), Bits::Four), ("easy (4 bits)".to_string(), Bits::Four),
("easy+16 (4 bits*16)".to_string(), Bits::FourShift4), ("easy+16 (4 bits*16)".to_string(), Bits::FourShift4),
@ -234,11 +293,13 @@ impl StartMenuState {
("master (12 bits)".to_string(), Bits::Twelve), ("master (12 bits)".to_string(), Bits::Twelve),
("insane (16 bits)".to_string(), Bits::Sixteen), ("insane (16 bits)".to_string(), Bits::Sixteen),
]; ];
Self { Self {
items, items,
list_state: ListState::default().with_selected(Some(4)), list_state: ListState::default().with_selected(Some(selected_index)),
} // default to normal (8 bits) }
} }
fn selected_index(&self) -> usize { fn selected_index(&self) -> usize {
self.list_state.selected().unwrap_or(0) self.list_state.selected().unwrap_or(0)
} }

View File

@ -129,7 +129,12 @@ impl WidgetRef for BinaryNumbersPuzzle {
.render(inner, buf); .render(inner, buf);
let binary_string = self.current_to_binary_string(); let binary_string = self.current_to_binary_string();
let scale_suffix = match self.bits { Bits::FourShift4 => Some(" x16"), Bits::FourShift8 => Some(" x256"), Bits::FourShift12 => Some(" x4096"), _ => None }; let scale_suffix = match self.bits {
Bits::FourShift4 => Some(" x16"),
Bits::FourShift8 => Some(" x256"),
Bits::FourShift12 => Some(" x4096"),
_ => None
};
let mut spans = vec![Span::raw(binary_string.clone())]; let mut spans = vec![Span::raw(binary_string.clone())];
if let Some(sfx) = scale_suffix { spans.push(Span::styled(sfx, Style::default().fg(Color::DarkGray))); } if let Some(sfx) = scale_suffix { spans.push(Span::styled(sfx, Style::default().fg(Color::DarkGray))); }
let total_width = spans.iter().map(|s| s.width()).sum::<usize>() as u16; let total_width = spans.iter().map(|s| s.width()).sum::<usize>() as u16;
@ -272,8 +277,10 @@ pub struct BinaryNumbersGame {
high_scores: HighScores, high_scores: HighScores,
prev_high_score_for_display: u32, prev_high_score_for_display: u32,
new_high_score_reached: bool, new_high_score_reached: bool,
needs_render: bool, // Flag to render one frame after state transition
} }
#[derive(Copy, Clone, PartialEq, Debug)] #[derive(Copy, Clone, PartialEq, Debug)]
enum GameState { Active, Result, PendingGameOver, GameOver } enum GameState { Active, Result, PendingGameOver, GameOver }
@ -295,7 +302,7 @@ impl BinaryNumbersGame {
pub fn new_with_max_lives(bits: Bits, max_lives: u32) -> Self { pub fn new_with_max_lives(bits: Bits, max_lives: u32) -> Self {
let hs = HighScores::load(); let hs = HighScores::load();
let starting_prev = hs.get(bits.high_score_key()); let starting_prev = hs.get(bits.high_score_key());
Self { let mut game = Self {
bits: bits.clone(), bits: bits.clone(),
puzzle: Self::init_puzzle(bits.clone(), 0), puzzle: Self::init_puzzle(bits.clone(), 0),
exit_intended: false, exit_intended: false,
@ -310,12 +317,31 @@ impl BinaryNumbersGame {
high_scores: hs, high_scores: hs,
prev_high_score_for_display: starting_prev, prev_high_score_for_display: starting_prev,
new_high_score_reached: false, new_high_score_reached: false,
} needs_render: true, // Need to render initial state
};
// Initialize stats snapshot immediately so stats display on first render
game.refresh_stats_snapshot();
game
} }
pub fn init_puzzle(bits: Bits, streak: u32) -> BinaryNumbersPuzzle { pub fn init_puzzle(bits: Bits, streak: u32) -> BinaryNumbersPuzzle {
BinaryNumbersPuzzle::new(bits, streak) BinaryNumbersPuzzle::new(bits, streak)
} }
/// Check if the game is in Active state (timer running)
pub fn is_active(&self) -> bool {
self.game_state == GameState::Active
}
/// Check if we need to render one frame (e.g., after state transition)
pub fn needs_render(&self) -> bool {
self.needs_render
}
/// Clear the needs_render flag after rendering
pub fn clear_needs_render(&mut self) {
self.needs_render = false;
}
} }
impl BinaryNumbersGame { impl BinaryNumbersGame {
@ -358,8 +384,10 @@ impl BinaryNumbersGame {
// set state after round resolution // set state after round resolution
if self.lives == 0 { if self.lives == 0 {
self.game_state = GameState::PendingGameOver; // defer summary until Enter self.game_state = GameState::PendingGameOver; // defer summary until Enter
self.needs_render = true; // Need to render result before blocking
} else { } else {
self.game_state = GameState::Result; self.game_state = GameState::Result;
self.needs_render = true; // Need to render result before blocking
} }
self.puzzle_resolved = true; self.puzzle_resolved = true;
} }
@ -367,6 +395,7 @@ impl BinaryNumbersGame {
pub fn handle_game_input(&mut self, input: KeyEvent) { pub fn handle_game_input(&mut self, input: KeyEvent) {
if input.code == KeyCode::Esc { self.exit_intended = true; return; } if input.code == KeyCode::Esc { self.exit_intended = true; return; }
if self.game_state == GameState::GameOver { self.handle_game_over_input(input); return; } if self.game_state == GameState::GameOver { self.handle_game_over_input(input); return; }
match self.puzzle.guess_result { match self.puzzle.guess_result {
None => self.handle_no_result_yet(input), None => self.handle_no_result_yet(input),
@ -513,6 +542,7 @@ pub struct BinaryNumbersPuzzle {
guess_result: Option<GuessResult>, guess_result: Option<GuessResult>,
last_points_awarded: u32, last_points_awarded: u32,
stats_snapshot: Option<StatsSnapshot>, stats_snapshot: Option<StatsSnapshot>,
skip_first_dt: bool, // Skip first dt to prevent timer jump when starting new puzzle
} }
impl BinaryNumbersPuzzle { impl BinaryNumbersPuzzle {
@ -556,6 +586,7 @@ impl BinaryNumbersPuzzle {
guess_result, guess_result,
last_points_awarded, last_points_awarded,
stats_snapshot: None, stats_snapshot: None,
skip_first_dt: true, // Skip first dt to prevent timer jump
} }
} }
@ -579,6 +610,12 @@ impl BinaryNumbersPuzzle {
return; return;
} }
// Skip first dt to prevent timer jump when starting new puzzle
if self.skip_first_dt {
self.skip_first_dt = false;
return;
}
self.time_left = (self.time_left - dt).max(0.0); self.time_left = (self.time_left - dt).max(0.0);
if self.time_left <= 0.0 { if self.time_left <= 0.0 {
@ -724,6 +761,11 @@ mod tests {
fn puzzle_timeout_sets_guess_result() { fn puzzle_timeout_sets_guess_result() {
let mut p = BinaryNumbersPuzzle::new(Bits::Four, 0); let mut p = BinaryNumbersPuzzle::new(Bits::Four, 0);
p.time_left = 0.5; p.time_left = 0.5;
// First run() skips dt due to skip_first_dt flag
// The reason for this is to prevent timer jump when starting a new puzzle
p.run(1.0);
assert_eq!(p.guess_result, None, "First run should skip dt");
// Second run() actually applies the dt and triggers timeout
p.run(1.0); // exceed remaining time p.run(1.0); // exceed remaining time
assert_eq!(p.guess_result, Some(GuessResult::Timeout)); assert_eq!(p.guess_result, Some(GuessResult::Timeout));
} }