mirror of
https://github.com/lucaspalomodevelop/binbreak.git
synced 2026-03-13 00:07:28 +00:00
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:
parent
9a19822aaf
commit
de05e0c91c
70
.github/workflows/ci.yml
vendored
Normal file
70
.github/workflows/ci.yml
vendored
Normal 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
|
||||
|
||||
29
Cargo.toml
29
Cargo.toml
@ -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"
|
||||
|
||||
22
README.md
22
README.md
@ -1,3 +1,6 @@
|
||||
[](https://github.com/epic-64/binbreak/actions)
|
||||
[](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
|
||||
```
|
||||
|
||||
[](https://ratatui.rs/)
|
||||
## Format
|
||||
```bash
|
||||
cargo fmt
|
||||
```
|
||||
|
||||
## License
|
||||
MIT license ([LICENSE](LICENSE) or http://opensource.org/licenses/MIT)
|
||||
|
||||
31
clippy.toml
Normal file
31
clippy.toml
Normal 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
65
rustfmt.toml
Normal 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
|
||||
68
src/app.rs
68
src/app.rs
@ -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 {
|
||||
|
||||
@ -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();
|
||||
@ -680,7 +870,7 @@ impl HighScores {
|
||||
let mut data = String::new();
|
||||
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());
|
||||
}
|
||||
|
||||
@ -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'))
|
||||
}
|
||||
|
||||
34
src/utils.rs
34
src/utils.rs
@ -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 }
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user