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 Esc to exit a game mode or the game. CTRL+C also works to exit the game.
## Retro aesthetics
For the best experience, I recommend using Cool Retro Terminal on Linux, or Windows Terminal in Retro mode.
But the game should run fine in any terminal.
## Recommended terminals
The game should run fine in any terminal. If you want retro CRT effects, here are some recommendations:
- Windows: Windows Terminal (enable experimental "retro mode")
- Linux: Rio (with CRT shader), Cool Retro Term
## 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
- 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::prelude::{Color, Modifier, Span, Style, Widget};
use ratatui::widgets::{List, ListItem, ListState};
use std::cmp;
use std::collections::HashMap;
use std::thread;
use std::time::Instant;
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 {
Start(StartMenuState),
@ -24,6 +42,8 @@ fn handle_start_input(state: &mut StartMenuState, key: KeyEvent) -> Option<AppSt
KeyCode::Down => state.select_next(),
KeyCode::Enter => {
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)));
}
KeyCode::Esc => return Some(AppState::Exit),
@ -106,34 +126,7 @@ fn render_start_screen(state: &mut StartMenuState, area: Rect, buf: &mut Buffer)
ratatui::widgets::StatefulWidget::render(list, list_area, buf, &mut state.list_state);
}
pub fn run_app(terminal: &mut ratatui::DefaultTerminal) -> color_eyre::Result<()> {
let mut app_state = AppState::Start(StartMenuState::new());
let mut last_frame_time = Instant::now();
let target_frame_duration = std::time::Duration::from_millis(33); // ~30 FPS
while !matches!(app_state, AppState::Exit) {
let now = Instant::now();
let dt = now - last_frame_time;
last_frame_time = now;
terminal.draw(|f| match &mut app_state {
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 {
game.run(dt.as_secs_f64());
if game.is_exit_intended() {
app_state = AppState::Start(StartMenuState::new());
continue;
}
}
// handle input
let poll_timeout = std::cmp::min(dt, target_frame_duration);
if event::poll(poll_timeout)? {
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 {
@ -141,14 +134,16 @@ pub fn run_app(terminal: &mut ratatui::DefaultTerminal) -> color_eyre::Result<()
KeyCode::Char('c') | KeyCode::Char('C')
if key.modifiers == KeyModifiers::CONTROL =>
{
app_state = AppState::Exit;
*app_state = AppState::Exit;
}
// state-specific input handling
_ => {
app_state = match app_state {
AppState::Start(mut menu) => handle_start_input(&mut menu, key)
.unwrap_or(AppState::Start(menu)),
*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)
@ -159,6 +154,66 @@ pub fn run_app(terminal: &mut ratatui::DefaultTerminal) -> color_eyre::Result<()
}
}
}
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<()> {
let mut app_state = AppState::Start(StartMenuState::new());
let mut last_frame_time = Instant::now();
let target_frame_duration = std::time::Duration::from_millis(33); // ~30 FPS
while !matches!(app_state, AppState::Exit) {
let now = Instant::now();
let dt = now - last_frame_time;
last_frame_time = now;
// Advance game BEFORE drawing so stats are updated
if let AppState::Playing(game) = &mut app_state {
game.run(dt.as_secs_f64());
if game.is_exit_intended() {
app_state = AppState::Start(StartMenuState::new());
continue;
}
}
terminal.draw(|f| match &mut app_state {
AppState::Start(menu) => render_start_screen(menu, f.area(), f.buffer_mut()),
AppState::Playing(game) => f.render_widget(&mut *game, f.area()),
AppState::Exit => {}
})?;
// Clear needs_render flag after frame is rendered
// State transitions will set this flag again as needed, in performance mode
if let AppState::Playing(game) = &mut app_state {
game.clear_needs_render();
}
// handle input
if let AppState::Playing(game) = &app_state {
if get_fps_mode(game) == FpsMode::RealTime {
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
@ -225,6 +280,10 @@ struct StartMenuState {
impl StartMenuState {
fn new() -> Self {
Self::with_selected(get_last_selected_index())
}
fn with_selected(selected_index: usize) -> Self {
let items = vec![
("easy (4 bits)".to_string(), Bits::Four),
("easy+16 (4 bits*16)".to_string(), Bits::FourShift4),
@ -234,11 +293,13 @@ impl StartMenuState {
("master (12 bits)".to_string(), Bits::Twelve),
("insane (16 bits)".to_string(), Bits::Sixteen),
];
Self {
items,
list_state: ListState::default().with_selected(Some(4)),
} // default to normal (8 bits)
list_state: ListState::default().with_selected(Some(selected_index)),
}
}
fn selected_index(&self) -> usize {
self.list_state.selected().unwrap_or(0)
}

View File

@ -129,7 +129,12 @@ impl WidgetRef for BinaryNumbersPuzzle {
.render(inner, buf);
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())];
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;
@ -272,8 +277,10 @@ pub struct BinaryNumbersGame {
high_scores: HighScores,
prev_high_score_for_display: u32,
new_high_score_reached: bool,
needs_render: bool, // Flag to render one frame after state transition
}
#[derive(Copy, Clone, PartialEq, Debug)]
enum GameState { Active, Result, PendingGameOver, GameOver }
@ -295,7 +302,7 @@ impl BinaryNumbersGame {
pub fn new_with_max_lives(bits: Bits, max_lives: u32) -> Self {
let hs = HighScores::load();
let starting_prev = hs.get(bits.high_score_key());
Self {
let mut game = Self {
bits: bits.clone(),
puzzle: Self::init_puzzle(bits.clone(), 0),
exit_intended: false,
@ -310,12 +317,31 @@ impl BinaryNumbersGame {
high_scores: hs,
prev_high_score_for_display: starting_prev,
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 {
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 {
@ -358,8 +384,10 @@ impl BinaryNumbersGame {
// set state after round resolution
if self.lives == 0 {
self.game_state = GameState::PendingGameOver; // defer summary until Enter
self.needs_render = true; // Need to render result before blocking
} else {
self.game_state = GameState::Result;
self.needs_render = true; // Need to render result before blocking
}
self.puzzle_resolved = true;
}
@ -367,6 +395,7 @@ impl BinaryNumbersGame {
pub fn handle_game_input(&mut self, input: KeyEvent) {
if input.code == KeyCode::Esc { self.exit_intended = true; return; }
if self.game_state == GameState::GameOver { self.handle_game_over_input(input); return; }
match self.puzzle.guess_result {
None => self.handle_no_result_yet(input),
@ -513,6 +542,7 @@ pub struct BinaryNumbersPuzzle {
guess_result: Option<GuessResult>,
last_points_awarded: u32,
stats_snapshot: Option<StatsSnapshot>,
skip_first_dt: bool, // Skip first dt to prevent timer jump when starting new puzzle
}
impl BinaryNumbersPuzzle {
@ -556,6 +586,7 @@ impl BinaryNumbersPuzzle {
guess_result,
last_points_awarded,
stats_snapshot: None,
skip_first_dt: true, // Skip first dt to prevent timer jump
}
}
@ -579,6 +610,12 @@ impl BinaryNumbersPuzzle {
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);
if self.time_left <= 0.0 {
@ -724,6 +761,11 @@ mod tests {
fn puzzle_timeout_sets_guess_result() {
let mut p = BinaryNumbersPuzzle::new(Bits::Four, 0);
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
assert_eq!(p.guess_result, Some(GuessResult::Timeout));
}