feat: implement adaptive polling for FPS handling in game states

This commit is contained in:
epic-64 2025-11-19 00:00:00 +01:00
parent b21037a57c
commit 3e1933deb7
2 changed files with 107 additions and 28 deletions

View File

@ -1,4 +1,4 @@
use crate::binary_numbers::{BinaryNumbersGame, Bits};
use crate::binary_numbers::{BinaryNumbersGame, Bits, FpsMode};
use crate::main_screen_widget::MainScreenWidget;
use crate::utils::{AsciiArtWidget, AsciiCells};
use crossterm::event;
@ -7,6 +7,7 @@ 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;
@ -106,6 +107,37 @@ fn render_start_screen(state: &mut StartMenuState, area: Rect, buf: &mut Buffer)
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(())
}
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();
@ -122,6 +154,11 @@ pub fn run_app(terminal: &mut ratatui::DefaultTerminal) -> color_eyre::Result<()
AppState::Exit => {}
})?;
// Clear needs_render flag after frame is rendered
if let AppState::Playing(game) = &mut app_state {
game.clear_needs_render();
}
// Advance game if playing
if let AppState::Playing(game) = &mut app_state {
game.run(dt.as_secs_f64());
@ -132,33 +169,21 @@ pub fn run_app(terminal: &mut ratatui::DefaultTerminal) -> color_eyre::Result<()
}
// handle input
let poll_timeout = std::cmp::min(dt, target_frame_duration);
if let AppState::Playing(game) = &app_state {
let fps_mode = game.get_fps_mode();
if fps_mode == FpsMode::RealTime {
let poll_timeout = cmp::min(dt, target_frame_duration);
if event::poll(poll_timeout)? {
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 app_state {
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,
}
}
}
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

View File

@ -26,6 +26,7 @@ struct StatsSnapshot {
game_state: GameState,
prev_high_score: u32,
new_high_score: bool,
fps_mode: FpsMode,
}
impl WidgetRef for BinaryNumbersGame {
@ -76,12 +77,18 @@ impl WidgetRef for BinaryNumbersPuzzle {
high_label,
]);
let fps_label = match stats.fps_mode {
FpsMode::Performance => Span::styled("FPS: Perf ", Style::default().fg(Color::LightGreen)),
FpsMode::RealTime => Span::styled("FPS: RT ", Style::default().fg(Color::LightYellow)),
};
let line2 = Line::from(vec![
Span::styled(format!("Score: {} ", stats.score), Style::default().fg(Color::Green)),
Span::styled(format!("Streak: {} ", stats.streak), Style::default().fg(Color::Cyan)),
Span::styled(format!("Max: {} ", stats.max_streak), Style::default().fg(Color::Blue)),
Span::styled(format!("Rounds: {} ", stats.rounds), Style::default().fg(Color::Magenta)),
Span::styled(format!("Lives: {} ", stats.hearts), Style::default().fg(Color::Red)),
fps_label,
]);
let widest = line1.width().max(line2.width()) as u16;
@ -272,6 +279,14 @@ pub struct BinaryNumbersGame {
high_scores: HighScores,
prev_high_score_for_display: u32,
new_high_score_reached: bool,
pub fps_mode: FpsMode,
needs_render: bool, // Flag to render one frame after state transition
}
#[derive(Copy, Clone, PartialEq, Debug)]
pub enum FpsMode {
RealTime, // 30 FPS with polling
Performance, // Block until input for higher FPS
}
#[derive(Copy, Clone, PartialEq, Debug)]
@ -295,7 +310,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 +325,47 @@ impl BinaryNumbersGame {
high_scores: hs,
prev_high_score_for_display: starting_prev,
new_high_score_reached: false,
}
fps_mode: FpsMode::Performance,
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)
}
/// Get the appropriate FPS mode based on current game state
pub fn get_fps_mode(&self) -> FpsMode {
match self.game_state {
GameState::Active => {
FpsMode::RealTime // Timer running, needs continuous updates
}
GameState::Result | GameState::PendingGameOver => {
// Use RealTime mode for one frame after transition, then Performance
if self.needs_render {
FpsMode::RealTime
} else {
FpsMode::Performance
}
}
GameState::GameOver => {
FpsMode::Performance // Static final screen, can block until input
}
}
}
/// Mark that we need to render one frame (e.g., after state transition)
pub fn mark_needs_render(&mut self) {
self.needs_render = true;
}
/// Clear the needs_render flag after rendering
pub fn clear_needs_render(&mut self) {
self.needs_render = false;
}
}
impl BinaryNumbersGame {
@ -358,8 +408,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 +419,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),
@ -479,6 +532,7 @@ impl BinaryNumbersGame {
game_state: self.game_state,
prev_high_score: self.prev_high_score_for_display,
new_high_score: self.new_high_score_reached,
fps_mode: self.get_fps_mode(),
});
}
}