mirror of
https://github.com/lucaspalomodevelop/binbreak.git
synced 2026-03-13 00:07:28 +00:00
Merge pull request #4 from epic-64/performance-adaptive-polling
performance: adaptive polling
This commit is contained in:
commit
d05c75000c
@ -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
BIN
docs/sc8.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
docs/sc9.png
Normal file
BIN
docs/sc9.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
129
src/app.rs
129
src/app.rs
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user