diff --git a/src/app.rs b/src/app.rs index 1feba2b..3c631cf 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 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, - } - } - } + 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)? { + 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 diff --git a/src/binary_numbers.rs b/src/binary_numbers.rs index 04acc5b..4d5977c 100644 --- a/src/binary_numbers.rs +++ b/src/binary_numbers.rs @@ -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(), }); } }