diff --git a/README.md b/README.md index 40a4ecc..ad09f07 100644 --- a/README.md +++ b/README.md @@ -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/) diff --git a/docs/sc8.png b/docs/sc8.png new file mode 100644 index 0000000..4a1a55c Binary files /dev/null and b/docs/sc8.png differ diff --git a/docs/sc9.png b/docs/sc9.png new file mode 100644 index 0000000..9995e8c Binary files /dev/null and b/docs/sc9.png differ diff --git a/src/app.rs b/src/app.rs index 1feba2b..cdee587 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 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,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); } +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<()> { let mut app_state = AppState::Start(StartMenuState::new()); 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; 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 + // 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() { @@ -131,34 +187,33 @@ 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; - } + 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 => {} + })?; - // 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, - } - } - } + // 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) } diff --git a/src/binary_numbers.rs b/src/binary_numbers.rs index 04acc5b..98857c4 100644 --- a/src/binary_numbers.rs +++ b/src/binary_numbers.rs @@ -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::() 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, last_points_awarded: u32, stats_snapshot: Option, + 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)); }