cleanup: remove obsolete code, configure rustfmt and clippy (#7)

* chore: remove unnecessary needs_render checks from game logic

* chore: remove needs_render flag and related logic from game state

* chore: add rustfmt configuration file for consistent formatting

* chore: clean up imports and formatting in app.rs, binary_numbers.rs, main_screen_widget.rs, rustfmt.toml, and utils.rs

* chore: remove outdated comment from binary_numbers.rs

* chore: remove outdated comments from binary_numbers.rs

* chore: extract game over rendering logic into a separate function

* run clippy fix

* chore: add Clippy configuration files for linting thresholds

* run clippy fix

* chore: allow clippy warnings in selected places. check fix later

* docs: add command docs for linting and formatting

* docs: add command docs for linting and formatting

* split BinaryNumbersPuzzle.render_ref into several sub functions for rendering different areas.

* chore: adjust clippy lint levels to allow certain patterns

* chore: add CI configuration with testing, clippy, and formatting jobs

* cargo fmt

* chore: simplify clippy and formatting commands in CI configuration

* chore: consolidate cargo caching in CI configuration

* chore: replace cargo caching with rust-cache action in CI configuration

* chore: use is_multiple_of for streak check in score calculation

* chore: simplify game over check in render logic
This commit is contained in:
William Raendchen 2025-11-22 15:24:40 +01:00 committed by GitHub
parent 9a19822aaf
commit de05e0c91c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 618 additions and 229 deletions

70
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,70 @@
name: CI
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
env:
CARGO_TERM_COLOR: always
jobs:
test:
name: Test
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
rust: [stable]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ matrix.rust }}
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
- name: Run tests
run: cargo test --verbose
clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
- name: Run clippy
run: cargo clippy
fmt:
name: Format
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
- name: Check formatting
run: cargo fmt --check

View File

@ -19,3 +19,32 @@ ratatui = "0.29.0"
indoc = "2.0.7"
color-eyre = "0.6.3"
rand = "0.9.1"
[lints.rust]
unsafe_code = "forbid"
unused_must_use = "warn"
unused_imports = "warn"
dead_code = "warn"
[lints.clippy]
# Lint groups - enable comprehensive checking
pedantic = { level = "allow", priority = -1 }
nursery = { level = "allow", priority = -1 }
correctness = { level = "deny", priority = -1 }
all = { level = "warn", priority = -1 }
# Allow certain common patterns
match_same_arms = "allow"
# Complexity warnings (thresholds in clippy.toml)
cognitive_complexity = "warn"
too_many_arguments = "warn"
too_many_lines = "warn"
type_complexity = "warn"
struct_excessive_bools = "warn"
fn_params_excessive_bools = "warn"
# Style preferences - encourage safer code
enum_glob_use = "warn"
unwrap_used = "warn"
expect_used = "warn"

View File

@ -1,3 +1,6 @@
[![CI](https://github.com/epic-64/binbreak/workflows/CI/badge.svg)](https://github.com/epic-64/binbreak/actions)
[![Built With Ratatui](https://ratatui.rs/built-with-ratatui/badge.svg)](https://ratatui.rs/)
https://github.com/user-attachments/assets/4413fe8d-9a3f-4c00-9c1a-b9ca01a946fc
Guess the correct number (from binary to decimal) before time runs out!
@ -55,12 +58,25 @@ You may be inclined to not run binaries from the internet, and want to build fro
cargo run --release
```
# Contributing
All pull requests are automatically checked by GitHub Actions CI, which runs tests,
clippy, and formatting checks on Linux, Windows, and macOS.
## Test
```bash
cargo test
```
## License
MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
## Lint
```bash
cargo clippy
```
[![Built With Ratatui](https://ratatui.rs/built-with-ratatui/badge.svg)](https://ratatui.rs/)
## Format
```bash
cargo fmt
```
## License
MIT license ([LICENSE](LICENSE) or http://opensource.org/licenses/MIT)

31
clippy.toml Normal file
View File

@ -0,0 +1,31 @@
# ============================================================================
# Clippy Threshold Configuration
# ============================================================================
# This file configures clippy threshold values that cannot be set in Cargo.toml
# Lint levels (warn/deny/allow) are configured in Cargo.toml [lints.clippy]
# https://doc.rust-lang.org/clippy/lint_configuration.html
# ============================================================================
# Complexity thresholds
cognitive-complexity-threshold = 30 # default: 25
too-many-arguments-threshold = 8 # default: 7
too-many-lines-threshold = 80 # default: 100
type-complexity-threshold = 250 # default: 500
# Boolean usage limits
max-struct-bools = 4 # default: 3
max-fn-params-bools = 3 # default: 3
# Variable naming
single-char-binding-names-threshold = 4 # default: 4
allowed-idents-below-min-chars = [
"x", "y", "dx", "dy", "dt", # coordinates and delta time
"id", "ui", "io", # common abbreviations
"hs", # high scores (project-specific)
]
# Other thresholds
vec-box-size-threshold = 4096 # default: 4096
max-trait-bounds = 3 # default: 3

65
rustfmt.toml Normal file
View File

@ -0,0 +1,65 @@
# Rustfmt configuration for binbreak project
# Edition
edition = "2024"
# Maximum width of each line
max_width = 100
# Maximum width of the args of a function call before falling back to vertical formatting
fn_call_width = 80
# Maximum width of the args of a function-like attributes before falling back to vertical formatting
attr_fn_like_width = 80
# Maximum width in the body of a struct lit before falling back to vertical formatting
struct_lit_width = 90
# Maximum width in the body of a struct variant before falling back to vertical formatting
struct_variant_width = 90
# Maximum width of an array literal before falling back to vertical formatting
array_width = 90
# Maximum width of a chain to fit on a single line
chain_width = 100
# Maximum line length for single line if-else expressions
single_line_if_else_max_width = 60
# How to indent in files
hard_tabs = false
# Number of spaces per tab
tab_spaces = 4
# Remove nested parens
remove_nested_parens = true
# Reorder imports
reorder_imports = true
# Reorder modules
reorder_modules = true
# Use field init shorthand if possible
use_field_init_shorthand = true
# Use try shorthand
use_try_shorthand = true
# Force explicit types in let statements
force_explicit_abi = true
# Newline style
newline_style = "Unix"
# Merge derives
merge_derives = true
# Use small heuristics (Off, Max, or Default)
# Max preserves more single-line expressions
use_small_heuristics = "Max"
# Match block trailing comma
match_block_trailing_comma = true

View File

@ -4,16 +4,16 @@ use crate::main_screen_widget::MainScreenWidget;
use crate::utils::{AsciiArtWidget, AsciiCells};
use crossterm::event;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use indoc::indoc;
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::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
use std::time::Instant;
use indoc::indoc;
use std::sync::atomic::{AtomicUsize, Ordering};
static LAST_SELECTED_INDEX: AtomicUsize = AtomicUsize::new(4);
@ -46,9 +46,9 @@ fn handle_start_input(state: &mut StartMenuState, key: KeyEvent) -> Option<AppSt
// Store the current selection before entering the game
set_last_selected_index(state.selected_index());
return Some(AppState::Playing(BinaryNumbersGame::new(bits)));
}
},
x if keybinds::is_exit(x) => return Some(AppState::Exit),
_ => {}
_ => {},
}
None
}
@ -62,13 +62,11 @@ fn render_start_screen(state: &mut StartMenuState, area: Rect, buf: &mut Buffer)
let selected = state.selected_index();
let upper_labels: Vec<String> = state.items.iter().map(|(l, _)| l.to_uppercase()).collect();
let max_len = upper_labels
.iter()
.map(|s| s.len() as u16)
.max()
.unwrap_or(0);
#[allow(clippy::cast_possible_truncation)]
let max_len = upper_labels.iter().map(|s| s.len() as u16).max().unwrap_or(0);
let list_width = 2 + max_len; // marker + space + label
#[allow(clippy::cast_possible_truncation)]
let list_height = upper_labels.len() as u16;
// Vertical spacing between ASCII art and list
@ -83,12 +81,8 @@ fn render_start_screen(state: &mut StartMenuState, area: Rect, buf: &mut Buffer)
let list_y = ascii_y + ascii_height + spacing;
// Define rects (clamp to area)
let ascii_area = Rect::new(
ascii_x,
ascii_y,
ascii_width.min(area.width),
ascii_height.min(area.height),
);
let ascii_area =
Rect::new(ascii_x, ascii_y, ascii_width.min(area.width), ascii_height.min(area.height));
let list_area = Rect::new(
list_x,
list_y,
@ -115,10 +109,9 @@ fn render_start_screen(state: &mut StartMenuState, area: Rect, buf: &mut Buffer)
.map(|(i, label)| {
let marker = if i == selected { '»' } else { ' ' };
let padded = format!("{:<width$}", label, width = max_len as usize);
let line = format!("{} {}", marker, padded);
let style = Style::default()
.fg(palette[i % palette.len()])
.add_modifier(Modifier::BOLD);
let line = format!("{marker} {padded}");
let style =
Style::default().fg(palette[i % palette.len()]).add_modifier(Modifier::BOLD);
ListItem::new(Span::styled(line, style))
})
.collect();
@ -133,26 +126,23 @@ fn handle_crossterm_events(app_state: &mut AppState) -> color_eyre::Result<()> {
{
match key.code {
// global exit via Ctrl+C
KeyCode::Char('c' | 'C')
if key.modifiers == KeyModifiers::CONTROL =>
{
KeyCode::Char('c' | '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))
}
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(())
@ -162,8 +152,6 @@ fn handle_crossterm_events(app_state: &mut AppState) -> color_eyre::Result<()> {
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
}
@ -191,15 +179,9 @@ pub fn run_app(terminal: &mut ratatui::DefaultTerminal) -> color_eyre::Result<()
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 => {}
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 {
@ -265,12 +247,7 @@ fn ascii_art_cells() -> AsciiCells {
]);
let default_color = Color::LightBlue;
AsciiCells::from(
art.to_string(),
colors.to_string(),
&color_map,
default_color,
)
AsciiCells::from(art, colors, &color_map, default_color)
}
// Start menu state
@ -295,10 +272,7 @@ impl StartMenuState {
("insane (16 bits)".to_string(), Bits::Sixteen),
];
Self {
items,
list_state: ListState::default().with_selected(Some(selected_index)),
}
Self { items, list_state: ListState::default().with_selected(Some(selected_index)) }
}
fn selected_index(&self) -> usize {

View File

@ -1,9 +1,9 @@
use crate::keybinds;
use crate::main_screen_widget::{MainScreenWidget, WidgetRef};
use crate::utils::{center, When};
use crate::utils::{When, center};
use crossterm::event::{KeyCode, KeyEvent};
use rand::prelude::SliceRandom;
use rand::Rng;
use rand::prelude::SliceRandom;
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Direction, Flex, Layout, Rect};
use ratatui::prelude::Alignment::Center;
@ -13,7 +13,8 @@ use ratatui::text::Span;
use ratatui::widgets::BorderType::Double;
use ratatui::widgets::{Block, BorderType, Paragraph};
use std::collections::HashMap;
use std::fs::{File};
use std::fmt::Write as _;
use std::fs::File;
use std::io::{Read, Write};
struct StatsSnapshot {
@ -42,9 +43,8 @@ impl WidgetRef for BinaryNumbersGame {
impl WidgetRef for BinaryNumbersPuzzle {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let [middle] = Layout::horizontal([Constraint::Percentage(100)])
.flex(Flex::Center)
.areas(area);
let [middle] =
Layout::horizontal([Constraint::Percentage(100)]).flex(Flex::Center).areas(area);
let [stats_area, current_number_area, suggestions_area, progress_bar_area, result_area] =
Layout::vertical([
@ -58,10 +58,32 @@ impl WidgetRef for BinaryNumbersPuzzle {
.horizontal_margin(0)
.areas(middle);
Block::bordered()
.title_alignment(Center)
.dark_gray()
.render(stats_area, buf);
self.render_stats_area(stats_area, buf);
if let Some(stats) = &self.stats_snapshot
&& stats.game_state == GameState::GameOver
{
render_game_over(
stats,
current_number_area,
suggestions_area,
progress_bar_area,
result_area,
buf,
);
return;
}
self.render_current_number(current_number_area, buf);
self.render_suggestions(suggestions_area, buf);
self.render_status_and_timer(progress_bar_area, buf);
self.render_instructions(result_area, buf);
}
}
impl BinaryNumbersPuzzle {
fn render_stats_area(&self, area: Rect, buf: &mut Buffer) {
Block::bordered().title_alignment(Center).dark_gray().render(area, buf);
if let Some(stats) = &self.stats_snapshot {
let high_label = if stats.new_high_score {
@ -73,56 +95,44 @@ impl WidgetRef for BinaryNumbersPuzzle {
};
let line1 = Line::from(vec![
Span::styled(format!("Mode: {} ", stats.bits.label()), Style::default().fg(Color::Yellow)),
Span::styled(
format!("Mode: {} ", stats.bits.label()),
Style::default().fg(Color::Yellow),
),
high_label,
]);
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!("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)),
]);
#[allow(clippy::cast_possible_truncation)]
let widest = line1.width().max(line2.width()) as u16;
Paragraph::new(vec![line1, line2])
.alignment(Center)
.render(center(stats_area, Constraint::Length(widest)), buf);
// If game over, render game over block occupying the remaining area and return early
if stats.game_state == GameState::GameOver {
let combined_rect = Rect { x: current_number_area.x, y: current_number_area.y, width: current_number_area.width, height: current_number_area.height + suggestions_area.height + progress_bar_area.height + result_area.height };
let block = Block::bordered()
.title("Game Over")
.title_alignment(Center)
.border_type(Double)
.title_style(Style::default().fg(Color::Red));
block.render(combined_rect, buf);
let mut lines = vec![
Line::from(Span::styled(format!("Final Score: {}", stats.score), Style::default().fg(Color::Green))),
Line::from(Span::styled(format!("Previous High: {}", stats.prev_high_score), Style::default().fg(Color::Yellow))),
Line::from(Span::styled(format!("Rounds Played: {}", stats.rounds), Style::default().fg(Color::Magenta))),
Line::from(Span::styled(format!("Max Streak: {}", stats.max_streak), Style::default().fg(Color::Cyan))),
];
if stats.new_high_score {
lines.insert(1, Line::from(Span::styled("NEW HIGH SCORE!", Style::default().fg(Color::LightGreen).bold())));
}
if stats.lives == 0 {
lines.push(Line::from(Span::styled("You lost all your lives.", Style::default().fg(Color::Red))));
}
lines.push(Line::from(Span::styled("Press Enter to restart or Esc to exit", Style::default().fg(Color::Yellow))));
Paragraph::new(lines)
.alignment(Center)
.render(center(combined_rect, Constraint::Length(48)), buf);
return;
.render(center(area, Constraint::Length(widest)), buf);
}
}
// Existing puzzle rendering now uses updated area references
let [inner] = Layout::horizontal([Constraint::Percentage(100)])
.flex(Flex::Center)
.areas(current_number_area);
fn render_current_number(&self, area: Rect, buf: &mut Buffer) {
let [inner] =
Layout::horizontal([Constraint::Percentage(100)]).flex(Flex::Center).areas(area);
Block::bordered()
.border_type(Double)
@ -134,26 +144,38 @@ impl WidgetRef for BinaryNumbersPuzzle {
Bits::FourShift4 => Some(" x16"),
Bits::FourShift8 => Some(" x256"),
Bits::FourShift12 => Some(" x4096"),
_ => None
_ => 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;
let mut spans = vec![Span::raw(binary_string)];
if let Some(sfx) = scale_suffix {
spans.push(Span::styled(sfx, Style::default().fg(Color::DarkGray)));
}
#[allow(clippy::cast_possible_truncation)]
let total_width = spans.iter().map(ratatui::prelude::Span::width).sum::<usize>() as u16;
let lines: Vec<Line> = vec![Line::from(spans)];
Paragraph::new(lines).alignment(Center).render(center(inner, Constraint::Length(total_width)), buf);
Paragraph::new(lines)
.alignment(Center)
.render(center(inner, Constraint::Length(total_width)), buf);
}
fn render_suggestions(&self, area: Rect, buf: &mut Buffer) {
let suggestions = self.suggestions();
let suggestions_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints(vec![Constraint::Min(6); suggestions.len()])
.split(suggestions_area);
.split(area);
for (i, suggestion) in suggestions.iter().enumerate() {
let item_is_selected = self.selected_suggestion == Some(*suggestion);
let show_correct_number = self.guess_result.is_some();
let is_correct_number = self.is_correct_guess(*suggestion);
let area = suggestions_layout[i];
let border_type = if item_is_selected { BorderType::Double } else { BorderType::Plain };
let border_type = if item_is_selected {
BorderType::Double
} else {
BorderType::Plain
};
let border_color = if item_is_selected {
match self.guess_result {
@ -169,19 +191,33 @@ impl WidgetRef for BinaryNumbersPuzzle {
Block::bordered().border_type(border_type).fg(border_color).render(area, buf);
let suggestion_str = format!("{suggestion}");
#[allow(clippy::cast_possible_truncation)]
Paragraph::new(suggestion_str.to_string())
.white()
.when(show_correct_number && is_correct_number, |p| p.light_green().underlined())
.alignment(Center)
.render(center(area, Constraint::Length(suggestion_str.len() as u16)), buf);
}
}
fn render_status_and_timer(&self, area: Rect, buf: &mut Buffer) {
let [left, right] = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.areas(progress_bar_area);
.areas(area);
Block::bordered().dark_gray().title("Status").title_alignment(Center).title_style(Style::default().white()).render(left, buf);
self.render_status(left, buf);
self.render_timer(right, buf);
}
fn render_status(&self, area: Rect, buf: &mut Buffer) {
Block::bordered()
.dark_gray()
.title("Status")
.title_alignment(Center)
.title_style(Style::default().white())
.render(area, buf);
if let Some(result) = &self.guess_result {
let (icon, line1_text, color) = match result {
@ -197,16 +233,19 @@ impl WidgetRef for BinaryNumbersPuzzle {
};
let text = vec![
Line::from(format!("{} {}", icon, line1_text).fg(color)),
Line::from(format!("{icon} {line1_text}").fg(color)),
Line::from(gained_line.fg(color)),
];
let widest = text.iter().map(|l| l.width()).max().unwrap_or(0) as u16;
#[allow(clippy::cast_possible_truncation)]
let widest = text.iter().map(Line::width).max().unwrap_or(0) as u16;
Paragraph::new(text)
.alignment(Center)
.style(Style::default().fg(color))
.render(center(left, Constraint::Length(widest)), buf);
.render(center(area, Constraint::Length(widest)), buf);
}
}
fn render_timer(&self, area: Rect, buf: &mut Buffer) {
let ratio = self.time_left / self.time_total;
let gauge_color = if ratio > 0.6 {
Color::Green
@ -216,20 +255,16 @@ impl WidgetRef for BinaryNumbersPuzzle {
Color::Red
};
// Replace previous split layout: keep everything inside a single bordered block and remove percent label
let time_block = Block::bordered()
.dark_gray()
.title("Time Remaining")
.title_style(Style::default().white())
.title_alignment(Center);
let inner_time = time_block.inner(right);
time_block.render(right, buf);
let inner_time = time_block.inner(area);
time_block.render(area, buf);
// Vertical layout inside the time block interior: gauge line + text line (2 lines total)
let [gauge_line, time_line] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1)
]).areas(inner_time);
let [gauge_line, time_line] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner_time);
render_ascii_gauge(gauge_line, buf, ratio, gauge_color);
@ -239,19 +274,25 @@ impl WidgetRef for BinaryNumbersPuzzle {
)))
.alignment(Center)
.render(time_line, buf);
}
Block::bordered().dark_gray().render(result_area, buf);
fn render_instructions(&self, area: Rect, buf: &mut Buffer) {
Block::bordered().dark_gray().render(area, buf);
let instruction_spans: Vec<Span> = [
hotkey_span("Left Right", "select "),
hotkey_span("Enter", "confirm "),
hotkey_span("S", "skip "),
hotkey_span("Esc", "exit"),
].iter().flatten().cloned().collect();
]
.iter()
.flatten()
.cloned()
.collect();
Paragraph::new(vec![Line::from(instruction_spans)])
.alignment(Center)
.render(center(result_area, Constraint::Length(65)), buf);
.render(center(area, Constraint::Length(65)), buf);
}
}
@ -259,10 +300,71 @@ fn hotkey_span<'a>(key: &'a str, description: &str) -> Vec<Span<'a>> {
vec![
Span::styled("<", Style::default().fg(Color::White)),
Span::styled(key, Style::default().fg(Color::LightCyan)),
Span::styled(format!("> {}", description), Style::default().fg(Color::White)),
Span::styled(format!("> {description}"), Style::default().fg(Color::White)),
]
}
fn render_game_over(
stats: &StatsSnapshot,
current_number_area: Rect,
suggestions_area: Rect,
progress_bar_area: Rect,
result_area: Rect,
buf: &mut Buffer,
) {
let combined_rect = Rect {
x: current_number_area.x,
y: current_number_area.y,
width: current_number_area.width,
height: current_number_area.height
+ suggestions_area.height
+ progress_bar_area.height
+ result_area.height,
};
Block::bordered().border_style(Style::default().fg(Color::DarkGray)).render(combined_rect, buf);
let mut lines = vec![
Line::from(Span::styled(
format!("Final Score: {}", stats.score),
Style::default().fg(Color::Green),
)),
Line::from(Span::styled(
format!("Previous High: {}", stats.prev_high_score),
Style::default().fg(Color::Yellow),
)),
Line::from(Span::styled(
format!("Rounds Played: {}", stats.rounds),
Style::default().fg(Color::Magenta),
)),
Line::from(Span::styled(
format!("Max Streak: {}", stats.max_streak),
Style::default().fg(Color::Cyan),
)),
];
if stats.new_high_score {
lines.insert(
1,
Line::from(Span::styled(
"NEW HIGH SCORE!",
Style::default().fg(Color::LightGreen).bold(),
)),
);
}
if stats.lives == 0 {
lines.push(Line::from(Span::styled(
"You lost all your lives.",
Style::default().fg(Color::Red),
)));
}
lines.push(Line::from(Span::styled(
"Press Enter to restart or Esc to exit",
Style::default().fg(Color::Yellow),
)));
Paragraph::new(lines)
.alignment(Center)
.render(center(combined_rect, Constraint::Length(48)), buf);
}
pub struct BinaryNumbersGame {
puzzle: BinaryNumbersPuzzle,
bits: Bits,
@ -278,34 +380,47 @@ 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 }
enum GameState {
Active,
Result,
PendingGameOver,
GameOver,
}
impl MainScreenWidget for BinaryNumbersGame {
fn run(&mut self, dt: f64) {
self.refresh_stats_snapshot();
if self.game_state == GameState::GameOver { return; }
if self.game_state == GameState::GameOver {
return;
}
self.puzzle.run(dt);
if self.puzzle.guess_result.is_some() && !self.puzzle_resolved { self.finalize_round(); }
if self.puzzle.guess_result.is_some() && !self.puzzle_resolved {
self.finalize_round();
}
self.refresh_stats_snapshot();
}
fn handle_input(&mut self, input: KeyEvent) { self.handle_game_input(input); }
fn is_exit_intended(&self) -> bool { self.exit_intended }
fn handle_input(&mut self, input: KeyEvent) {
self.handle_game_input(input);
}
fn is_exit_intended(&self) -> bool {
self.exit_intended
}
}
impl BinaryNumbersGame {
pub fn new(bits: Bits) -> Self { Self::new_with_max_lives(bits, 3) }
pub fn new(bits: Bits) -> Self {
Self::new_with_max_lives(bits, 3)
}
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());
let mut game = Self {
bits: bits.clone(),
puzzle: Self::init_puzzle(bits.clone(), 0),
puzzle: Self::init_puzzle(bits, 0),
exit_intended: false,
score: 0,
streak: 0,
@ -318,7 +433,6 @@ 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();
@ -329,20 +443,9 @@ impl BinaryNumbersGame {
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 {
@ -351,7 +454,7 @@ impl BinaryNumbersGame {
let full = "".repeat(full_count);
let empty_count = self.max_lives.saturating_sub(self.lives) as usize;
let empty = "·".repeat(empty_count);
format!("{}{}", full, empty)
format!("{full}{empty}")
}
fn finalize_round(&mut self) {
@ -360,24 +463,32 @@ impl BinaryNumbersGame {
match result {
GuessResult::Correct => {
self.streak += 1;
if self.streak > self.max_streak { self.max_streak = self.streak; }
if self.streak > self.max_streak {
self.max_streak = self.streak;
}
let streak_bonus = (self.streak - 1) * 2;
let points = 10 + streak_bonus;
self.score += points;
self.puzzle.last_points_awarded = points;
if self.streak % 5 == 0 && self.lives < self.max_lives { self.lives += 1; }
if self.streak.is_multiple_of(5) && self.lives < self.max_lives {
self.lives += 1;
}
},
GuessResult::Incorrect | GuessResult::Timeout => {
self.streak = 0;
self.puzzle.last_points_awarded = 0;
if self.lives > 0 { self.lives -= 1; }
if self.lives > 0 {
self.lives -= 1;
}
},
}
// high score update
let bits_key = self.bits.high_score_key();
let prev = self.high_scores.get(bits_key);
if self.score > prev {
if !self.new_high_score_reached { self.prev_high_score_for_display = prev; }
if !self.new_high_score_reached {
self.prev_high_score_for_display = prev;
}
self.high_scores.update(bits_key, self.score);
self.new_high_score_reached = true;
let _ = self.high_scores.save();
@ -385,19 +496,23 @@ 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;
}
}
pub fn handle_game_input(&mut self, input: KeyEvent) {
if keybinds::is_exit(input) { self.exit_intended = true; return; }
if keybinds::is_exit(input) {
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 {
None => self.handle_no_result_yet(input),
Some(_) => self.handle_result_available(input),
@ -406,9 +521,13 @@ impl BinaryNumbersGame {
fn handle_game_over_input(&mut self, key: KeyEvent) {
match key {
x if keybinds::is_select(x) => { self.reset_game_state(); }
x if keybinds::is_exit(x) => { self.exit_intended = true; }
_ => {}
x if keybinds::is_select(x) => {
self.reset_game_state();
},
x if keybinds::is_exit(x) => {
self.exit_intended = true;
},
_ => {},
}
}
@ -440,7 +559,7 @@ impl BinaryNumbersGame {
// if no suggestion is selected, select the first one
self.puzzle.selected_suggestion = Some(self.puzzle.suggestions[0]);
}
}
},
x if keybinds::is_left(x) => {
// select the previous suggestion
if let Some(selected) = self.puzzle.selected_suggestion {
@ -454,7 +573,7 @@ impl BinaryNumbersGame {
self.puzzle.selected_suggestion = Some(self.puzzle.suggestions[prev_index]);
}
}
}
},
x if keybinds::is_select(x) => {
if let Some(selected) = self.puzzle.selected_suggestion {
if self.puzzle.is_correct_guess(selected) {
@ -464,13 +583,13 @@ impl BinaryNumbersGame {
}
self.finalize_round();
}
}
},
KeyEvent { code: KeyCode::Char('s' | 'S'), .. } => {
// Skip puzzle counts as timeout
self.puzzle.guess_result = Some(GuessResult::Timeout);
self.finalize_round();
}
_ => {}
},
_ => {},
}
}
@ -481,19 +600,19 @@ impl BinaryNumbersGame {
GameState::PendingGameOver => {
// reveal summary
self.game_state = GameState::GameOver;
}
},
GameState::Result => {
// start next puzzle
self.puzzle = Self::init_puzzle(self.bits.clone(), self.streak);
self.puzzle_resolved = false;
self.game_state = GameState::Active;
},
GameState::GameOver => { /* handled elsewhere */ },
GameState::Active => { /* shouldn't be here */ },
}
GameState::GameOver => { /* handled elsewhere */ }
GameState::Active => { /* shouldn't be here */ }
}
}
},
x if keybinds::is_exit(x) => self.exit_intended = true,
_ => {}
_ => {},
}
}
@ -521,15 +640,69 @@ enum GuessResult {
}
#[derive(Clone)]
pub enum Bits { Four, FourShift4, FourShift8, FourShift12, Eight, Twelve, Sixteen, }
pub enum Bits {
Four,
FourShift4,
FourShift8,
FourShift12,
Eight,
Twelve,
Sixteen,
}
impl Bits {
pub fn to_int(&self) -> u32 { match self { Bits::Four | Bits::FourShift4 | Bits::FourShift8 | Bits::FourShift12 => 4, Bits::Eight => 8, Bits::Twelve => 12, Bits::Sixteen => 16 } }
pub fn scale_factor(&self) -> u32 { match self { Bits::Four => 1, Bits::FourShift4 => 16, Bits::FourShift8 => 256, Bits::FourShift12 => 4096, Bits::Eight => 1, Bits::Twelve => 1, Bits::Sixteen => 1 } }
pub fn high_score_key(&self) -> u32 { match self { Bits::Four => 4, Bits::FourShift4 => 44, Bits::FourShift8 => 48, Bits::FourShift12 => 412, Bits::Eight => 8, Bits::Twelve => 12, Bits::Sixteen => 16 } }
pub fn upper_bound(&self) -> u32 { (u32::pow(2, self.to_int()) - 1) * self.scale_factor() }
pub fn suggestion_count(&self) -> usize { match self { Bits::Four | Bits::FourShift4 | Bits::FourShift8 | Bits::FourShift12 => 3, Bits::Eight => 4, Bits::Twelve => 5, Bits::Sixteen => 6 } }
pub fn label(&self) -> &'static str { match self { Bits::Four => "4 bits", Bits::FourShift4 => "4 bits*16", Bits::FourShift8 => "4 bits*256", Bits::FourShift12 => "4 bits*4096", Bits::Eight => "8 bits", Bits::Twelve => "12 bits", Bits::Sixteen => "16 bits" } }
pub const fn to_int(&self) -> u32 {
match self {
Self::Four | Self::FourShift4 | Self::FourShift8 | Self::FourShift12 => 4,
Self::Eight => 8,
Self::Twelve => 12,
Self::Sixteen => 16,
}
}
pub const fn scale_factor(&self) -> u32 {
match self {
Self::Four => 1,
Self::FourShift4 => 16,
Self::FourShift8 => 256,
Self::FourShift12 => 4096,
Self::Eight => 1,
Self::Twelve => 1,
Self::Sixteen => 1,
}
}
pub const fn high_score_key(&self) -> u32 {
match self {
Self::Four => 4,
Self::FourShift4 => 44,
Self::FourShift8 => 48,
Self::FourShift12 => 412,
Self::Eight => 8,
Self::Twelve => 12,
Self::Sixteen => 16,
}
}
pub const fn upper_bound(&self) -> u32 {
(u32::pow(2, self.to_int()) - 1) * self.scale_factor()
}
pub const fn suggestion_count(&self) -> usize {
match self {
Self::Four | Self::FourShift4 | Self::FourShift8 | Self::FourShift12 => 3,
Self::Eight => 4,
Self::Twelve => 5,
Self::Sixteen => 6,
}
}
pub const fn label(&self) -> &'static str {
match self {
Self::Four => "4 bits",
Self::FourShift4 => "4 bits*16",
Self::FourShift8 => "4 bits*256",
Self::FourShift12 => "4 bits*4096",
Self::Eight => "8 bits",
Self::Twelve => "12 bits",
Self::Sixteen => "16 bits",
}
}
}
pub struct BinaryNumbersPuzzle {
@ -553,9 +726,11 @@ impl BinaryNumbersPuzzle {
let mut suggestions = Vec::new();
let scale = bits.scale_factor();
while suggestions.len() < bits.suggestion_count() {
let raw = rng.random_range(0..=u32::pow(2, bits.to_int()) - 1);
let raw = rng.random_range(0..u32::pow(2, bits.to_int()));
let num = raw * scale;
if !suggestions.contains(&num) { suggestions.push(num); }
if !suggestions.contains(&num) {
suggestions.push(num);
}
}
let current_number = suggestions[0]; // scaled value
@ -569,7 +744,7 @@ impl BinaryNumbersPuzzle {
Bits::Twelve => 16.0,
Bits::Sixteen => 20.0,
};
let penalty = (streak as f64) * 0.5; // 0.5s less per streak
let penalty = f64::from(streak) * 0.5; // 0.5s less per streak
let time_total = (base_time - penalty).max(5.0);
let time_left = time_total;
let selected_suggestion = Some(suggestions[0]);
@ -591,8 +766,12 @@ impl BinaryNumbersPuzzle {
}
}
pub fn suggestions(&self) -> &[u32] { &self.suggestions }
pub fn is_correct_guess(&self, guess: u32) -> bool { guess == self.current_number }
pub fn suggestions(&self) -> &[u32] {
&self.suggestions
}
pub const fn is_correct_guess(&self, guess: u32) -> bool {
guess == self.current_number
}
pub fn current_to_binary_string(&self) -> String {
let width = self.bits.to_int() as usize;
@ -633,8 +812,15 @@ impl Widget for &mut BinaryNumbersGame {
// Simple ASCII gauge renderer to avoid variable glyph heights from Unicode block elements
fn render_ascii_gauge(area: Rect, buf: &mut Buffer, ratio: f64, color: Color) {
let fill_width = ((area.width as f64) * ratio.clamp(0.0, 1.0)).round().min(area.width as f64) as u16;
if area.height == 0 { return; }
#[allow(clippy::cast_sign_loss)]
#[allow(clippy::cast_possible_truncation)]
let fill_width =
(f64::from(area.width) * ratio.clamp(0.0, 1.0)).round().min(f64::from(area.width)) as u16;
if area.height == 0 {
return;
}
for x in 0..area.width {
let filled = x < fill_width;
let symbol = if filled { "=" } else { " " };
@ -651,12 +837,16 @@ fn render_ascii_gauge(area: Rect, buf: &mut Buffer, ratio: f64, color: Color) {
}
}
struct HighScores { scores: HashMap<u32, u32>, }
struct HighScores {
scores: HashMap<u32, u32>,
}
impl HighScores {
const FILE: &'static str = "binbreak_highscores.txt";
fn empty() -> Self { Self { scores: HashMap::new() } }
fn empty() -> Self {
Self { scores: HashMap::new() }
}
fn load() -> Self {
let mut hs = Self::empty();
@ -664,7 +854,7 @@ impl HighScores {
let mut contents = String::new();
if file.read_to_string(&mut contents).is_ok() {
for line in contents.lines() {
if let Some((k,v)) = line.split_once('=')
if let Some((k, v)) = line.split_once('=')
&& let Ok(bits) = k.trim().parse::<u32>()
&& let Ok(score) = v.trim().parse::<u32>()
{
@ -678,9 +868,9 @@ impl HighScores {
fn save(&self) -> std::io::Result<()> {
let mut data = String::new();
for key in [4u32,44u32,48u32,412u32,8u32,12u32,16u32] {
for key in [4u32, 44u32, 48u32, 412u32, 8u32, 12u32, 16u32] {
let val = self.get(key);
data.push_str(&format!("{}={}\n", key, val));
let _ = writeln!(data, "{key}={val}");
}
let mut file = File::create(Self::FILE)?;
file.write_all(data.as_bytes())
@ -698,9 +888,9 @@ impl HighScores {
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyModifiers, KeyEventKind, KeyEventState};
use std::sync::Mutex;
use crossterm::event::{KeyEventKind, KeyEventState, KeyModifiers};
use std::fs;
use std::sync::Mutex;
static HS_LOCK: Mutex<()> = Mutex::new(());
@ -710,8 +900,12 @@ mod tests {
f();
// restore
match original {
Some(data) => { let _ = fs::write(HighScores::FILE, data); }
None => { let _ = fs::remove_file(HighScores::FILE); }
Some(data) => {
let _ = fs::write(HighScores::FILE, data);
},
None => {
let _ = fs::remove_file(HighScores::FILE);
},
}
}
@ -739,10 +933,14 @@ mod tests {
assert_eq!(p.suggestions().len(), Bits::FourShift4.suggestion_count());
// uniqueness
let mut sorted = p.suggestions().to_vec();
sorted.sort();
for pair in sorted.windows(2) { assert_ne!(pair[0], pair[1]); }
sorted.sort_unstable();
for pair in sorted.windows(2) {
assert_ne!(pair[0], pair[1]);
}
// scaling property
for &s in p.suggestions() { assert_eq!(s % scale, 0); }
for &s in p.suggestions() {
assert_eq!(s % scale, 0);
}
// current number must be one of suggestions and raw_current_number * scale == current_number
assert!(p.suggestions().contains(&p.current_number));
assert_eq!(p.raw_current_number * scale, p.current_number);
@ -853,11 +1051,21 @@ mod tests {
let mut g = BinaryNumbersGame::new(Bits::Four);
let initial = g.puzzle.selected_suggestion;
// Simulate Right key
let right_event = KeyEvent { code: KeyCode::Right, modifiers: KeyModifiers::empty(), kind: KeyEventKind::Press, state: KeyEventState::NONE };
let right_event = KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::empty(),
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
g.handle_game_input(right_event);
assert_ne!(g.puzzle.selected_suggestion, initial);
// Simulate Left key should cycle back
let left_event = KeyEvent { code: KeyCode::Left, modifiers: KeyModifiers::empty(), kind: KeyEventKind::Press, state: KeyEventState::NONE };
let left_event = KeyEvent {
code: KeyCode::Left,
modifiers: KeyModifiers::empty(),
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
g.handle_game_input(left_event);
assert!(g.puzzle.selected_suggestion.is_some());
}

View File

@ -1,25 +1,25 @@
use crossterm::event::{KeyCode, KeyEvent};
pub(crate) fn is_up(key: KeyEvent) -> bool {
pub const fn is_up(key: KeyEvent) -> bool {
matches!(key.code, KeyCode::Up | KeyCode::Char('k'))
}
pub(crate) fn is_down(key: KeyEvent) -> bool {
pub const fn is_down(key: KeyEvent) -> bool {
matches!(key.code, KeyCode::Down | KeyCode::Char('j'))
}
pub(crate) fn is_left(key: KeyEvent) -> bool {
pub const fn is_left(key: KeyEvent) -> bool {
matches!(key.code, KeyCode::Left | KeyCode::Char('h'))
}
pub(crate) fn is_right(key: KeyEvent) -> bool {
pub const fn is_right(key: KeyEvent) -> bool {
matches!(key.code, KeyCode::Right | KeyCode::Char('l'))
}
pub(crate) fn is_select(key: KeyEvent) -> bool {
pub const fn is_select(key: KeyEvent) -> bool {
matches!(key.code, KeyCode::Enter)
}
pub(crate) fn is_exit(key: KeyEvent) -> bool {
pub const fn is_exit(key: KeyEvent) -> bool {
matches!(key.code, KeyCode::Esc | KeyCode::Char('q' | 'Q'))
}

View File

@ -9,14 +9,16 @@ pub struct AsciiCell {
pub color: Color,
}
#[allow(clippy::cast_possible_truncation)]
pub fn parse_ascii_art(
art: String,
color_map_str: String,
art: &str,
color_map_str: &str,
color_map: &HashMap<char, Color>,
default_color: Color,
) -> Vec<AsciiCell> {
let art_lines: Vec<Vec<char>> = art.lines().map(|line| line.chars().collect()).collect();
let color_lines: Vec<Vec<char>> = color_map_str.lines().map(|line| line.chars().collect()).collect();
let color_lines: Vec<Vec<char>> =
color_map_str.lines().map(|line| line.chars().collect()).collect();
assert_eq!(art_lines.len(), color_lines.len(), "Art and color string must have same height");
@ -26,13 +28,8 @@ pub fn parse_ascii_art(
assert_eq!(art_row.len(), color_row.len(), "Mismatched line lengths");
for (x, (&ch, &color_ch)) in art_row.iter().zip(color_row.iter()).enumerate() {
let color = color_map.get(&color_ch).cloned().unwrap_or(default_color);
pixels.push(AsciiCell {
ch,
x: x as u16,
y: y as u16,
color,
});
let color = color_map.get(&color_ch).copied().unwrap_or(default_color);
pixels.push(AsciiCell { ch, x: x as u16, y: y as u16, color });
}
}
@ -45,8 +42,8 @@ pub struct AsciiCells {
impl AsciiCells {
pub fn from(
art: String,
color_map_str: String,
art: &str,
color_map_str: &str,
color_map: &HashMap<char, Color>,
default_color: Color,
) -> Self {
@ -67,7 +64,7 @@ pub struct AsciiArtWidget {
}
impl AsciiArtWidget {
pub fn new(collection: AsciiCells) -> Self {
pub const fn new(collection: AsciiCells) -> Self {
Self { collection }
}
}
@ -78,6 +75,7 @@ impl Widget for AsciiArtWidget {
let position = Position::new(pixel.x + area.x, pixel.y + area.y);
if area.contains(position) {
#[allow(clippy::expect_used)]
buf.cell_mut(position)
.expect("Failed to get cell at position")
.set_char(pixel.ch)
@ -100,15 +98,13 @@ pub fn vertically_center(area: Rect) -> Rect {
}
pub trait When {
fn when(self, condition: bool, action: impl FnOnce(Self) -> Self) -> Self where Self: Sized;
fn when(self, condition: bool, action: impl FnOnce(Self) -> Self) -> Self
where
Self: Sized;
}
impl<T> When for T {
fn when(self, condition: bool, action: impl FnOnce(T) -> T) -> Self {
if condition {
action(self)
} else {
self
}
if condition { action(self) } else { self }
}
}