mirror of
https://github.com/lucaspalomodevelop/binbreak.git
synced 2026-03-13 00:07:28 +00:00
Compare commits
5 Commits
714f9ec187
...
503f9eef0b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
503f9eef0b | ||
|
|
a67d852437 | ||
|
|
59496a70b6 | ||
|
|
5e078073a5 | ||
|
|
c22617f642 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
/target
|
||||
/binbreak_highscores.txt
|
||||
/.idea
|
||||
/executables
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -40,7 +40,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "binbreak"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"color-eyre",
|
||||
"crossterm 0.29.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "binbreak"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
description = "A terminal based binary number guessing game"
|
||||
authors = ["William Raendchen <william@holonaut.io>"]
|
||||
license = "MIT"
|
||||
|
||||
@ -37,6 +37,7 @@ There is one file for linux and one for windows (.exe).
|
||||
|
||||
## Controls
|
||||
- use the arrow or vim keys for navigation
|
||||
- use left/right to toggle signed/unsigned mode
|
||||
- press Enter to confirm choices
|
||||
- press Esc or Q to exit a game mode or the game. CTRL+C also works to exit the game.
|
||||
|
||||
|
||||
46
build.sh
Executable file
46
build.sh
Executable file
@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Build script for binbreak game
|
||||
# Builds for Linux and Windows, copies executables to executables/ directory
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
echo "🔨 Building binbreak for multiple platforms..."
|
||||
echo ""
|
||||
|
||||
# Create executables directory if it doesn't exist
|
||||
EXEC_DIR="executables"
|
||||
mkdir -p "$EXEC_DIR"
|
||||
|
||||
# Get version from Cargo.toml
|
||||
VERSION=$(grep '^version' Cargo.toml | head -n1 | cut -d'"' -f2)
|
||||
|
||||
# Build for Linux (native)
|
||||
echo "📦 Building for Linux (x86_64)..."
|
||||
cargo build --release
|
||||
LINUX_BIN="binbreak-v${VERSION}-linux-x86_64"
|
||||
cp "target/release/binbreak" "$EXEC_DIR/$LINUX_BIN"
|
||||
echo "✅ Linux build complete: $EXEC_DIR/$LINUX_BIN"
|
||||
echo ""
|
||||
|
||||
# Build for Windows
|
||||
echo "📦 Building for Windows (x86_64)..."
|
||||
if ! rustup target list | grep -q "x86_64-pc-windows-gnu (installed)"; then
|
||||
echo "⚠️ Installing Windows target (x86_64-pc-windows-gnu)..."
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
fi
|
||||
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
WINDOWS_BIN="binbreak-v${VERSION}-windows-x86_64.exe"
|
||||
cp "target/x86_64-pc-windows-gnu/release/binbreak.exe" "$EXEC_DIR/$WINDOWS_BIN"
|
||||
echo "✅ Windows build complete: $EXEC_DIR/$WINDOWS_BIN"
|
||||
echo ""
|
||||
|
||||
# Print summary
|
||||
echo "🎉 All builds complete!"
|
||||
echo ""
|
||||
echo "Executables:"
|
||||
ls -lh "$EXEC_DIR" | tail -n +2
|
||||
echo ""
|
||||
echo "Location: $EXEC_DIR/"
|
||||
|
||||
BIN
docs/splash.gif
Normal file
BIN
docs/splash.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 920 KiB |
330
src/app.rs
330
src/app.rs
@ -1,28 +1,61 @@
|
||||
use crate::binary_numbers::{BinaryNumbersGame, Bits};
|
||||
use crate::keybinds;
|
||||
use crate::main_screen_widget::MainScreenWidget;
|
||||
use crate::utils::{AsciiArtWidget, AsciiCells};
|
||||
use crate::utils::ProceduralAnimationWidget;
|
||||
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::prelude::{Color, Modifier, Span, Style};
|
||||
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 std::time::{Duration, Instant};
|
||||
|
||||
static LAST_SELECTED_INDEX: AtomicUsize = AtomicUsize::new(4);
|
||||
|
||||
fn get_last_selected_index() -> usize {
|
||||
LAST_SELECTED_INDEX.load(Ordering::Relaxed)
|
||||
#[derive(Copy, Clone, PartialEq, Debug)]
|
||||
pub enum NumberMode {
|
||||
Unsigned,
|
||||
Signed,
|
||||
}
|
||||
|
||||
fn set_last_selected_index(index: usize) {
|
||||
LAST_SELECTED_INDEX.store(index, Ordering::Relaxed);
|
||||
impl NumberMode {
|
||||
pub const fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Unsigned => "UNSIGNED",
|
||||
Self::Signed => "SIGNED",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persistent application preferences that survive across menu/game transitions
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
struct AppPreferences {
|
||||
last_selected_index: usize,
|
||||
last_number_mode: NumberMode,
|
||||
}
|
||||
|
||||
impl Default for AppPreferences {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
last_selected_index: 4, // Default to "byte 8 bit"
|
||||
last_number_mode: NumberMode::Unsigned,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the color associated with a specific difficulty level / game mode
|
||||
pub fn get_mode_color(bits: &Bits) -> Color {
|
||||
// Color scheme: progression from easy (green/cyan) to hard (yellow/red)
|
||||
match bits {
|
||||
Bits::Four => Color::Rgb(100, 255, 100), // green
|
||||
Bits::FourShift4 => Color::Rgb(100, 255, 180), // cyan
|
||||
Bits::FourShift8 => Color::Rgb(100, 220, 255), // light blue
|
||||
Bits::FourShift12 => Color::Rgb(100, 180, 255), // blue
|
||||
Bits::Eight => Color::Rgb(150, 120, 255), // royal blue
|
||||
Bits::Twelve => Color::Rgb(200, 100, 255), // purple
|
||||
Bits::Sixteen => Color::Rgb(255, 80, 150), // pink
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Debug)]
|
||||
@ -32,40 +65,54 @@ enum FpsMode {
|
||||
}
|
||||
|
||||
enum AppState {
|
||||
Start(StartMenuState),
|
||||
Playing(BinaryNumbersGame),
|
||||
Start(StartMenuState, AppPreferences),
|
||||
Playing(BinaryNumbersGame, AppPreferences),
|
||||
Exit,
|
||||
}
|
||||
|
||||
fn handle_start_input(state: &mut StartMenuState, key: KeyEvent) -> Option<AppState> {
|
||||
fn handle_start_input(
|
||||
state: &mut StartMenuState,
|
||||
key: KeyEvent,
|
||||
prefs: AppPreferences,
|
||||
) -> Option<(AppState, AppPreferences)> {
|
||||
match key {
|
||||
x if keybinds::is_up(x) => state.select_previous(),
|
||||
x if keybinds::is_down(x) => state.select_next(),
|
||||
x if keybinds::is_left(x) | keybinds::is_right(x) => state.toggle_number_mode(),
|
||||
x if keybinds::is_select(x) => {
|
||||
let bits = state.selected_bits();
|
||||
// Store the current selection before entering the game
|
||||
set_last_selected_index(state.selected_index());
|
||||
return Some(AppState::Playing(BinaryNumbersGame::new(bits)));
|
||||
let number_mode = state.number_mode;
|
||||
// Update preferences with current selection
|
||||
let updated_prefs = AppPreferences {
|
||||
last_selected_index: state.selected_index(),
|
||||
last_number_mode: state.number_mode,
|
||||
};
|
||||
return Some((
|
||||
AppState::Playing(BinaryNumbersGame::new(bits, number_mode), updated_prefs),
|
||||
updated_prefs,
|
||||
));
|
||||
},
|
||||
x if keybinds::is_exit(x) => return Some(AppState::Exit),
|
||||
x if keybinds::is_exit(x) => return Some((AppState::Exit, prefs)),
|
||||
KeyEvent { code: KeyCode::Char('a' | 'A'), .. } => state.toggle_animation(),
|
||||
_ => {},
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn render_start_screen(state: &mut StartMenuState, area: Rect, buf: &mut Buffer) {
|
||||
// Build ASCII art to obtain real dimensions
|
||||
let cells = ascii_art_cells();
|
||||
let ascii_width = cells.get_width();
|
||||
let ascii_height = cells.get_height();
|
||||
let ascii_widget = AsciiArtWidget::new(cells);
|
||||
// Get animation dimensions
|
||||
let ascii_width = state.animation.get_width();
|
||||
let ascii_height = state.animation.get_height();
|
||||
|
||||
let selected = state.selected_index();
|
||||
let upper_labels: Vec<String> = state.items.iter().map(|(l, _)| l.to_uppercase()).collect();
|
||||
#[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
|
||||
// Calculate width for both columns: marker + space + label + spacing + mode
|
||||
let mode_label_width = 8; // "UNSIGNED" or "SIGNED " (8 chars for alignment)
|
||||
let column_spacing = 4; // spaces between difficulty and mode columns
|
||||
let list_width = 2 + max_len + column_spacing + mode_label_width; // marker + space + label + spacing + mode
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let list_height = upper_labels.len() as u16;
|
||||
|
||||
@ -90,28 +137,40 @@ fn render_start_screen(state: &mut StartMenuState, area: Rect, buf: &mut Buffer)
|
||||
list_height.min(area.height.saturating_sub(list_y - area.y)),
|
||||
);
|
||||
|
||||
// Render ASCII art
|
||||
ascii_widget.render(ascii_area, buf);
|
||||
// Get color for the selected menu item
|
||||
let selected_color = get_mode_color(&state.items[selected].1);
|
||||
|
||||
// Palette for menu flair
|
||||
let palette = [
|
||||
Color::LightGreen,
|
||||
Color::LightCyan,
|
||||
Color::LightBlue,
|
||||
Color::LightMagenta,
|
||||
Color::LightYellow,
|
||||
Color::LightRed,
|
||||
];
|
||||
// Update animation color to match selected menu item
|
||||
state.animation.set_highlight_color(selected_color);
|
||||
|
||||
// Render ASCII animation (handles paused state internally)
|
||||
state.animation.render_to_buffer(ascii_area, buf);
|
||||
|
||||
let items: Vec<ListItem> = upper_labels
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.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 is_selected = i == selected;
|
||||
let marker = if is_selected { '»' } else { ' ' };
|
||||
let padded_label = format!("{:<width$}", label, width = max_len as usize);
|
||||
|
||||
// Add number mode for selected item
|
||||
let mode_display = if is_selected {
|
||||
format!("{:>width$}", state.number_mode.label(), width = mode_label_width as usize)
|
||||
} else {
|
||||
" ".repeat(mode_label_width as usize)
|
||||
};
|
||||
|
||||
let line = format!("{marker} {padded_label} {mode_display}");
|
||||
|
||||
let item_color = get_mode_color(&state.items[i].1);
|
||||
let mut style = Style::default().fg(item_color).add_modifier(Modifier::BOLD);
|
||||
|
||||
// Make selected item extra prominent with background highlight
|
||||
if is_selected {
|
||||
style = style.bg(Color::Rgb(40, 40, 40));
|
||||
}
|
||||
|
||||
ListItem::new(Span::styled(line, style))
|
||||
})
|
||||
.collect();
|
||||
@ -133,12 +192,16 @@ fn handle_crossterm_events(app_state: &mut AppState) -> color_eyre::Result<()> {
|
||||
// state-specific input handling
|
||||
_ => {
|
||||
*app_state = match std::mem::replace(app_state, AppState::Exit) {
|
||||
AppState::Start(mut menu) => {
|
||||
handle_start_input(&mut menu, key).unwrap_or(AppState::Start(menu))
|
||||
AppState::Start(mut menu, prefs) => {
|
||||
if let Some((new_state, _)) = handle_start_input(&mut menu, key, prefs) {
|
||||
new_state
|
||||
} else {
|
||||
AppState::Start(menu, prefs)
|
||||
}
|
||||
},
|
||||
AppState::Playing(mut game) => {
|
||||
AppState::Playing(mut game, prefs) => {
|
||||
game.handle_input(key);
|
||||
AppState::Playing(game)
|
||||
AppState::Playing(game, prefs)
|
||||
},
|
||||
AppState::Exit => AppState::Exit,
|
||||
}
|
||||
@ -158,7 +221,8 @@ fn get_fps_mode(game: &BinaryNumbersGame) -> FpsMode {
|
||||
}
|
||||
|
||||
pub fn run_app(terminal: &mut ratatui::DefaultTerminal) -> color_eyre::Result<()> {
|
||||
let mut app_state = AppState::Start(StartMenuState::new());
|
||||
let prefs = AppPreferences::default();
|
||||
let mut app_state = AppState::Start(StartMenuState::new(prefs), prefs);
|
||||
let mut last_frame_time = Instant::now();
|
||||
let target_frame_duration = std::time::Duration::from_millis(33); // ~30 FPS
|
||||
|
||||
@ -168,22 +232,22 @@ pub fn run_app(terminal: &mut ratatui::DefaultTerminal) -> color_eyre::Result<()
|
||||
last_frame_time = now;
|
||||
|
||||
// Advance game BEFORE drawing so stats are updated
|
||||
if let AppState::Playing(game) = &mut app_state {
|
||||
if let AppState::Playing(game, prefs) = &mut app_state {
|
||||
game.run(dt.as_secs_f64());
|
||||
if game.is_exit_intended() {
|
||||
app_state = AppState::Start(StartMenuState::new());
|
||||
app_state = AppState::Start(StartMenuState::new(*prefs), *prefs);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
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::Start(menu, _) => render_start_screen(menu, f.area(), f.buffer_mut()),
|
||||
AppState::Playing(game, _) => f.render_widget(&mut *game, f.area()),
|
||||
AppState::Exit => {},
|
||||
})?;
|
||||
|
||||
// handle input
|
||||
if let AppState::Playing(game) = &app_state {
|
||||
if let AppState::Playing(game, _) = &app_state {
|
||||
if get_fps_mode(game) == FpsMode::RealTime {
|
||||
let poll_timeout = cmp::min(dt, target_frame_duration);
|
||||
if event::poll(poll_timeout)? {
|
||||
@ -193,10 +257,17 @@ pub fn run_app(terminal: &mut ratatui::DefaultTerminal) -> color_eyre::Result<()
|
||||
// performance mode: block thread until an input event occurs
|
||||
handle_crossterm_events(&mut app_state)?;
|
||||
}
|
||||
} else {
|
||||
// For non-playing states (e.g., start menu), use performance mode
|
||||
// to block until input and minimize CPU usage
|
||||
handle_crossterm_events(&mut app_state)?;
|
||||
} else if let AppState::Start(menu, _) = &app_state {
|
||||
// For start menu, use real-time mode only if animation is running
|
||||
if !menu.animation.is_paused() {
|
||||
let poll_timeout = cmp::min(dt, target_frame_duration);
|
||||
if event::poll(poll_timeout)? {
|
||||
handle_crossterm_events(&mut app_state)?;
|
||||
}
|
||||
} else {
|
||||
// Animation paused, use performance mode to save CPU
|
||||
handle_crossterm_events(&mut app_state)?;
|
||||
}
|
||||
}
|
||||
|
||||
// cap frame rate
|
||||
@ -208,72 +279,116 @@ pub fn run_app(terminal: &mut ratatui::DefaultTerminal) -> color_eyre::Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ascii_art_cells() -> AsciiCells {
|
||||
fn ascii_animation() -> ProceduralAnimationWidget {
|
||||
let art = indoc! {r#"
|
||||
,, ,, ,,
|
||||
*MM db *MM `7MM
|
||||
*MM db *MM [a: toggle animation] `7MM
|
||||
MM MM MM
|
||||
MM,dMMb.`7MM `7MMpMMMb. MM,dMMb.`7Mb,od8 .gP"Ya ,6"Yb. MM ,MP'
|
||||
MM `Mb MM MM MM MM `Mb MM' "',M' Yb 8) MM MM ;Y
|
||||
MM M8 MM MM MM MM M8 MM 8M"""""" ,pm9MM MM;Mm
|
||||
MM. ,M9 MM MM MM MM. ,M9 MM YM. , 8M MM MM `Mb.
|
||||
P^YbmdP'.JMML..JMML JMML.P^YbmdP'.JMML. `Mbmmd' `Moo9^Yo..JMML. YA.
|
||||
"#};
|
||||
"#}
|
||||
.to_string();
|
||||
|
||||
let colors = indoc! {r#"
|
||||
,, ,, ,,
|
||||
*MM db *MM `7MM
|
||||
MM MM MM
|
||||
MM,dMMb.`7MM `7MMpMMMb. MM,dMMb.`7Mb,od8 .gP"Ya ,6"Yb. MM ,MP'
|
||||
MM `Mb MM MM MM MM `Mb MM' "',M' Yb 8) MM MM ;Y
|
||||
MM M8 MM MM MM MM M8 MM 8M"""""" ,pm9MM MM;Mm
|
||||
MM. ,M9 MM MM MM MM. ,M9 MM YM. , 8M MM MM `Mb.
|
||||
P^YbmdP'.JMML..JMML JMML.P^YbmdP'.JMML. `Mbmmd' `Moo9^Yo..JMML. YA.
|
||||
"#};
|
||||
// Get dimensions for calculations
|
||||
let art_lines: Vec<&str> = art.lines().collect();
|
||||
let height = art_lines.len();
|
||||
let width = art_lines.iter().map(|line| line.len()).max().unwrap_or(0);
|
||||
|
||||
let color_map = HashMap::from([
|
||||
('M', Color::White),
|
||||
('b', Color::LightYellow),
|
||||
('d', Color::LightCyan),
|
||||
('Y', Color::LightGreen),
|
||||
('8', Color::LightMagenta),
|
||||
('*', Color::Magenta),
|
||||
('`', Color::Cyan),
|
||||
('6', Color::Green),
|
||||
('9', Color::Red),
|
||||
('(', Color::Blue),
|
||||
(')', Color::Blue),
|
||||
(' ', Color::Black),
|
||||
]);
|
||||
let strip_width = 8.0;
|
||||
let start_offset = -strip_width;
|
||||
let end_offset = (width + height) as f32 + strip_width;
|
||||
let total_range = end_offset - start_offset;
|
||||
|
||||
let default_color = Color::LightBlue;
|
||||
AsciiCells::from(art, colors, &color_map, default_color)
|
||||
// Color function that calculates colors on-the-fly based on animation progress
|
||||
let color_fn =
|
||||
move |x: usize, y: usize, progress: f32, _cycle: usize, highlight_color: Color| -> Color {
|
||||
let offset = start_offset + progress * total_range;
|
||||
let diag_pos = (x + y) as f32;
|
||||
let dist_from_strip = (diag_pos - offset).abs();
|
||||
|
||||
if dist_from_strip < strip_width {
|
||||
highlight_color
|
||||
} else {
|
||||
Color::DarkGray
|
||||
}
|
||||
};
|
||||
|
||||
// Character function that permanently replaces characters with '0' or '1' on first pass,
|
||||
// then reverses them back to original on second pass, creating an infinite loop
|
||||
let char_fn =
|
||||
move |x: usize, y: usize, progress: f32, cycle: usize, original_char: char| -> char {
|
||||
let offset = start_offset + progress * total_range;
|
||||
let diag_pos = (x + y) as f32;
|
||||
|
||||
// Hash function to determine if character is '0' or '1'
|
||||
let mut position_hash = x.wrapping_mul(2654435761);
|
||||
position_hash ^= y.wrapping_mul(2246822519);
|
||||
position_hash = position_hash.wrapping_mul(668265263);
|
||||
position_hash ^= position_hash >> 15;
|
||||
|
||||
let mut binary_hash = position_hash.wrapping_mul(1597334677);
|
||||
binary_hash ^= binary_hash >> 16;
|
||||
let binary_char = if (binary_hash & 1) == 0 { '0' } else { '1' };
|
||||
|
||||
// Even cycles (0, 2, 4...): transform original -> binary
|
||||
// Odd cycles (1, 3, 5...): transform binary -> original
|
||||
let is_forward_pass = cycle.is_multiple_of(2);
|
||||
|
||||
// Check if the strip has passed this character yet
|
||||
let has_strip_passed = diag_pos < offset;
|
||||
|
||||
if is_forward_pass {
|
||||
// Forward pass: if strip has passed, show binary; otherwise show original
|
||||
if has_strip_passed { binary_char } else { original_char }
|
||||
} else {
|
||||
// Reverse pass: if strip has passed, show original; otherwise show binary
|
||||
if has_strip_passed { original_char } else { binary_char }
|
||||
}
|
||||
};
|
||||
|
||||
ProceduralAnimationWidget::new(
|
||||
art,
|
||||
50, // 50 frames worth of timing
|
||||
Duration::from_millis(50),
|
||||
color_fn,
|
||||
)
|
||||
.with_char_fn(char_fn)
|
||||
.with_pause_at_end(Duration::from_secs(2))
|
||||
}
|
||||
|
||||
// Start menu state
|
||||
struct StartMenuState {
|
||||
items: Vec<(String, Bits)>,
|
||||
list_state: ListState,
|
||||
animation: ProceduralAnimationWidget,
|
||||
number_mode: NumberMode,
|
||||
}
|
||||
|
||||
impl StartMenuState {
|
||||
fn new() -> Self {
|
||||
Self::with_selected(get_last_selected_index())
|
||||
fn new(prefs: AppPreferences) -> Self {
|
||||
Self::with_preferences(prefs)
|
||||
}
|
||||
|
||||
fn with_selected(selected_index: usize) -> Self {
|
||||
fn with_preferences(prefs: AppPreferences) -> Self {
|
||||
let items = vec![
|
||||
("easy (4 bits)".to_string(), Bits::Four),
|
||||
("easy Two's complement (4 bits)".to_string(), Bits::FourTwosComplement),
|
||||
("easy+16 (4 bits*16)".to_string(), Bits::FourShift4),
|
||||
("easy+256 (4 bits*256)".to_string(), Bits::FourShift8),
|
||||
("easy+4096 (4 bits*4096)".to_string(), Bits::FourShift12),
|
||||
("normal (8 bits)".to_string(), Bits::Eight),
|
||||
("master (12 bits)".to_string(), Bits::Twelve),
|
||||
("insane (16 bits)".to_string(), Bits::Sixteen),
|
||||
("nibble_0 4 bit".to_string(), Bits::Four),
|
||||
("nibble_1 4 bit*16".to_string(), Bits::FourShift4),
|
||||
("nibble_2 4 bit*256".to_string(), Bits::FourShift8),
|
||||
("nibble_3 4 bit*4096".to_string(), Bits::FourShift12),
|
||||
("byte 8 bit".to_string(), Bits::Eight),
|
||||
("hexlet 12 bit".to_string(), Bits::Twelve),
|
||||
("word 16 bit".to_string(), Bits::Sixteen),
|
||||
];
|
||||
|
||||
Self { items, list_state: ListState::default().with_selected(Some(selected_index)) }
|
||||
Self {
|
||||
items,
|
||||
list_state: ListState::default().with_selected(Some(prefs.last_selected_index)),
|
||||
animation: ascii_animation(),
|
||||
number_mode: prefs.last_number_mode,
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_index(&self) -> usize {
|
||||
@ -283,9 +398,30 @@ impl StartMenuState {
|
||||
self.items[self.selected_index()].1.clone()
|
||||
}
|
||||
fn select_next(&mut self) {
|
||||
self.list_state.select_next();
|
||||
let current = self.selected_index();
|
||||
let next = if current + 1 >= self.items.len() {
|
||||
current // stay at last item
|
||||
} else {
|
||||
current + 1
|
||||
};
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
fn select_previous(&mut self) {
|
||||
self.list_state.select_previous();
|
||||
let current = self.selected_index();
|
||||
let prev = if current == 0 {
|
||||
0 // stay at first item
|
||||
} else {
|
||||
current - 1
|
||||
};
|
||||
self.list_state.select(Some(prev));
|
||||
}
|
||||
fn toggle_animation(&mut self) {
|
||||
self.animation.toggle_pause();
|
||||
}
|
||||
fn toggle_number_mode(&mut self) {
|
||||
self.number_mode = match self.number_mode {
|
||||
NumberMode::Unsigned => NumberMode::Signed,
|
||||
NumberMode::Signed => NumberMode::Unsigned,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
use crate::app::{NumberMode, get_mode_color};
|
||||
use crate::keybinds;
|
||||
use crate::main_screen_widget::{MainScreenWidget, WidgetRef};
|
||||
use crate::utils::{When, center};
|
||||
@ -24,6 +25,7 @@ struct StatsSnapshot {
|
||||
rounds: u32,
|
||||
lives: u32,
|
||||
bits: Bits,
|
||||
number_mode: NumberMode,
|
||||
hearts: String,
|
||||
game_state: GameState,
|
||||
prev_high_score: u32,
|
||||
@ -94,11 +96,10 @@ impl BinaryNumbersPuzzle {
|
||||
Span::styled(format!("Hi-Score: {} ", stats.prev_high_score), style)
|
||||
};
|
||||
|
||||
let mode_color = get_mode_color(&stats.bits);
|
||||
let mode_label = format!("{} {}", stats.bits.label(), stats.number_mode.label());
|
||||
let line1 = Line::from(vec![
|
||||
Span::styled(
|
||||
format!("Mode: {} ", stats.bits.label()),
|
||||
Style::default().fg(Color::Yellow),
|
||||
),
|
||||
Span::styled(format!("Mode: {} ", mode_label), Style::default().fg(mode_color)),
|
||||
high_label,
|
||||
]);
|
||||
|
||||
@ -190,13 +191,7 @@ impl BinaryNumbersPuzzle {
|
||||
|
||||
Block::bordered().border_type(border_type).fg(border_color).render(area, buf);
|
||||
|
||||
let suggestion_str = if self.bits.is_twos_complement() {
|
||||
// Convert raw bit pattern to signed value for display
|
||||
let signed_val = self.bits.raw_to_signed(*suggestion);
|
||||
format!("{signed_val}")
|
||||
} else {
|
||||
format!("{suggestion}")
|
||||
};
|
||||
let suggestion_str = format!("{suggestion}");
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
Paragraph::new(suggestion_str.to_string())
|
||||
@ -374,6 +369,7 @@ fn render_game_over(
|
||||
pub struct BinaryNumbersGame {
|
||||
puzzle: BinaryNumbersPuzzle,
|
||||
bits: Bits,
|
||||
number_mode: NumberMode,
|
||||
exit_intended: bool,
|
||||
score: u32,
|
||||
streak: u32,
|
||||
@ -418,15 +414,17 @@ impl MainScreenWidget for BinaryNumbersGame {
|
||||
}
|
||||
|
||||
impl BinaryNumbersGame {
|
||||
pub fn new(bits: Bits) -> Self {
|
||||
Self::new_with_max_lives(bits, 3)
|
||||
pub fn new(bits: Bits, number_mode: NumberMode) -> Self {
|
||||
Self::new_with_max_lives(bits, number_mode, 3)
|
||||
}
|
||||
pub fn new_with_max_lives(bits: Bits, max_lives: u32) -> Self {
|
||||
pub fn new_with_max_lives(bits: Bits, number_mode: NumberMode, max_lives: u32) -> Self {
|
||||
let hs = HighScores::load();
|
||||
let starting_prev = hs.get(bits.high_score_key());
|
||||
let high_score_key = Self::compute_high_score_key(&bits, number_mode);
|
||||
let starting_prev = hs.get(&high_score_key);
|
||||
let mut game = Self {
|
||||
bits: bits.clone(),
|
||||
puzzle: Self::init_puzzle(bits, 0),
|
||||
number_mode,
|
||||
puzzle: Self::init_puzzle(bits, number_mode, 0),
|
||||
exit_intended: false,
|
||||
score: 0,
|
||||
streak: 0,
|
||||
@ -445,8 +443,17 @@ impl BinaryNumbersGame {
|
||||
game
|
||||
}
|
||||
|
||||
pub fn init_puzzle(bits: Bits, streak: u32) -> BinaryNumbersPuzzle {
|
||||
BinaryNumbersPuzzle::new(bits, streak)
|
||||
pub fn init_puzzle(bits: Bits, number_mode: NumberMode, streak: u32) -> BinaryNumbersPuzzle {
|
||||
BinaryNumbersPuzzle::new(bits, number_mode, streak)
|
||||
}
|
||||
|
||||
fn compute_high_score_key(bits: &Bits, number_mode: NumberMode) -> String {
|
||||
let bits_key = bits.high_score_key();
|
||||
let mode_suffix = match number_mode {
|
||||
NumberMode::Unsigned => "u",
|
||||
NumberMode::Signed => "s",
|
||||
};
|
||||
format!("{}{}", bits_key, mode_suffix)
|
||||
}
|
||||
|
||||
pub fn is_active(&self) -> bool {
|
||||
@ -489,13 +496,13 @@ impl BinaryNumbersGame {
|
||||
},
|
||||
}
|
||||
// high score update
|
||||
let bits_key = self.bits.high_score_key();
|
||||
let prev = self.high_scores.get(bits_key);
|
||||
let bits_key = Self::compute_high_score_key(&self.bits, self.number_mode);
|
||||
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;
|
||||
}
|
||||
self.high_scores.update(bits_key, self.score);
|
||||
self.high_scores.update(&bits_key, self.score);
|
||||
self.new_high_score_reached = true;
|
||||
let _ = self.high_scores.save();
|
||||
}
|
||||
@ -544,9 +551,10 @@ impl BinaryNumbersGame {
|
||||
self.lives = self.max_lives.min(3);
|
||||
self.game_state = GameState::Active;
|
||||
self.max_streak = 0;
|
||||
self.prev_high_score_for_display = self.high_scores.get(self.bits.high_score_key());
|
||||
let high_score_key = Self::compute_high_score_key(&self.bits, self.number_mode);
|
||||
self.prev_high_score_for_display = self.high_scores.get(&high_score_key);
|
||||
self.new_high_score_reached = false;
|
||||
self.puzzle = Self::init_puzzle(self.bits.clone(), 0);
|
||||
self.puzzle = Self::init_puzzle(self.bits.clone(), self.number_mode, 0);
|
||||
self.puzzle_resolved = false;
|
||||
self.refresh_stats_snapshot();
|
||||
}
|
||||
@ -609,7 +617,8 @@ impl BinaryNumbersGame {
|
||||
},
|
||||
GameState::Result => {
|
||||
// start next puzzle
|
||||
self.puzzle = Self::init_puzzle(self.bits.clone(), self.streak);
|
||||
self.puzzle =
|
||||
Self::init_puzzle(self.bits.clone(), self.number_mode, self.streak);
|
||||
self.puzzle_resolved = false;
|
||||
self.game_state = GameState::Active;
|
||||
},
|
||||
@ -630,6 +639,7 @@ impl BinaryNumbersGame {
|
||||
rounds: self.rounds,
|
||||
lives: self.lives,
|
||||
bits: self.bits.clone(),
|
||||
number_mode: self.number_mode,
|
||||
hearts: self.lives_hearts(),
|
||||
game_state: self.game_state,
|
||||
prev_high_score: self.prev_high_score_for_display,
|
||||
@ -648,7 +658,6 @@ enum GuessResult {
|
||||
#[derive(Clone)]
|
||||
pub enum Bits {
|
||||
Four,
|
||||
FourTwosComplement,
|
||||
FourShift4,
|
||||
FourShift8,
|
||||
FourShift12,
|
||||
@ -660,11 +669,7 @@ pub enum Bits {
|
||||
impl Bits {
|
||||
pub const fn to_int(&self) -> u32 {
|
||||
match self {
|
||||
Self::Four
|
||||
| Self::FourShift4
|
||||
| Self::FourShift8
|
||||
| Self::FourShift12
|
||||
| Self::FourTwosComplement => 4,
|
||||
Self::Four | Self::FourShift4 | Self::FourShift8 | Self::FourShift12 => 4,
|
||||
Self::Eight => 8,
|
||||
Self::Twelve => 12,
|
||||
Self::Sixteen => 16,
|
||||
@ -673,7 +678,6 @@ impl Bits {
|
||||
pub const fn scale_factor(&self) -> u32 {
|
||||
match self {
|
||||
Self::Four => 1,
|
||||
Self::FourTwosComplement => 1,
|
||||
Self::FourShift4 => 16,
|
||||
Self::FourShift8 => 256,
|
||||
Self::FourShift12 => 4096,
|
||||
@ -685,7 +689,6 @@ impl Bits {
|
||||
pub const fn high_score_key(&self) -> u32 {
|
||||
match self {
|
||||
Self::Four => 4,
|
||||
Self::FourTwosComplement => 42, // separate key for two's complement
|
||||
Self::FourShift4 => 44,
|
||||
Self::FourShift8 => 48,
|
||||
Self::FourShift12 => 412,
|
||||
@ -699,11 +702,7 @@ impl Bits {
|
||||
}
|
||||
pub const fn suggestion_count(&self) -> usize {
|
||||
match self {
|
||||
Self::Four
|
||||
| Self::FourShift4
|
||||
| Self::FourShift8
|
||||
| Self::FourShift12
|
||||
| Self::FourTwosComplement => 3,
|
||||
Self::Four | Self::FourShift4 | Self::FourShift8 | Self::FourShift12 => 3,
|
||||
Self::Eight => 4,
|
||||
Self::Twelve => 5,
|
||||
Self::Sixteen => 6,
|
||||
@ -711,39 +710,26 @@ impl Bits {
|
||||
}
|
||||
pub const fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Four => "4 bits",
|
||||
Self::FourTwosComplement => "4 bits (Two's complement)",
|
||||
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",
|
||||
Self::Four => "4 bit",
|
||||
Self::FourShift4 => "4 bit*16",
|
||||
Self::FourShift8 => "4 bit*256",
|
||||
Self::FourShift12 => "4 bit*4096",
|
||||
Self::Eight => "8 bit",
|
||||
Self::Twelve => "12 bit",
|
||||
Self::Sixteen => "16 bit",
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert raw bit pattern to signed value for two's complement mode
|
||||
pub const fn raw_to_signed(&self, raw: u32) -> i32 {
|
||||
match self {
|
||||
Self::FourTwosComplement => {
|
||||
// 4-bit two's complement: range -8 to +7
|
||||
if raw >= 8 { (raw as i32) - 16 } else { raw as i32 }
|
||||
},
|
||||
_ => raw as i32, // other modes use unsigned
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn is_twos_complement(&self) -> bool {
|
||||
matches!(self, Self::FourTwosComplement)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BinaryNumbersPuzzle {
|
||||
bits: Bits,
|
||||
current_number: u32, // scaled value used for suggestions matching
|
||||
#[allow(dead_code)]
|
||||
number_mode: NumberMode,
|
||||
#[allow(dead_code)]
|
||||
current_number: u32, // scaled value used for suggestions matching
|
||||
raw_current_number: u32, // raw bit value (unscaled) for display
|
||||
suggestions: Vec<u32>,
|
||||
selected_suggestion: Option<u32>,
|
||||
suggestions: Vec<i32>, // Changed to i32 to support signed values
|
||||
selected_suggestion: Option<i32>,
|
||||
time_total: f64,
|
||||
time_left: f64,
|
||||
guess_result: Option<GuessResult>,
|
||||
@ -753,62 +739,81 @@ pub struct BinaryNumbersPuzzle {
|
||||
}
|
||||
|
||||
impl BinaryNumbersPuzzle {
|
||||
pub fn new(bits: Bits, streak: u32) -> Self {
|
||||
pub fn new(bits: Bits, number_mode: NumberMode, streak: u32) -> Self {
|
||||
let mut rng = rand::rng();
|
||||
|
||||
let mut suggestions = Vec::new();
|
||||
let scale = bits.scale_factor();
|
||||
let num_bits = bits.to_int();
|
||||
|
||||
if bits.is_twos_complement() {
|
||||
// For two's complement, generate unique raw bit patterns (0-15)
|
||||
let mut raw_values: Vec<u32> = Vec::new();
|
||||
while raw_values.len() < bits.suggestion_count() {
|
||||
let raw = rng.random_range(0..u32::pow(2, bits.to_int()));
|
||||
if !raw_values.contains(&raw) {
|
||||
raw_values.push(raw);
|
||||
match number_mode {
|
||||
NumberMode::Unsigned => {
|
||||
while suggestions.len() < bits.suggestion_count() {
|
||||
let raw = rng.random_range(0..u32::pow(2, num_bits));
|
||||
let num = (raw * scale) as i32;
|
||||
if !suggestions.contains(&num) {
|
||||
suggestions.push(num);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Store raw bit patterns directly
|
||||
suggestions = raw_values;
|
||||
} else {
|
||||
// For unsigned modes
|
||||
while suggestions.len() < bits.suggestion_count() {
|
||||
let raw = rng.random_range(0..u32::pow(2, bits.to_int()));
|
||||
let num = raw * scale;
|
||||
if !suggestions.contains(&num) {
|
||||
suggestions.push(num);
|
||||
},
|
||||
NumberMode::Signed => {
|
||||
// For signed mode, use two's complement representation
|
||||
// Range is from -(2^(n-1)) to 2^(n-1)-1
|
||||
while suggestions.len() < bits.suggestion_count() {
|
||||
let raw = rng.random_range(0..u32::pow(2, num_bits));
|
||||
// Convert raw bits to signed value using two's complement
|
||||
let signed_val = if raw >= (1 << (num_bits - 1)) {
|
||||
// Negative number: raw - 2^n
|
||||
(raw as i32) - (1 << num_bits)
|
||||
} else {
|
||||
// Positive number
|
||||
raw as i32
|
||||
};
|
||||
let num = signed_val * (scale as i32);
|
||||
if !suggestions.contains(&num) {
|
||||
suggestions.push(num);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
let current_number = suggestions[0]; // scaled value or raw for twos complement
|
||||
let raw_current_number = if bits.is_twos_complement() {
|
||||
current_number // for two's complement, it's already the raw bit pattern
|
||||
} else {
|
||||
current_number / scale // back-calculate raw bits
|
||||
};
|
||||
// Shuffle suggestions
|
||||
suggestions.shuffle(&mut rng);
|
||||
|
||||
// Base time by bits + difficulty scaling (shorter as streak increases)
|
||||
let base_time = match bits {
|
||||
Bits::Four
|
||||
| Bits::FourShift4
|
||||
| Bits::FourShift8
|
||||
| Bits::FourShift12
|
||||
| Bits::FourTwosComplement => 8.0,
|
||||
Bits::Eight => 12.0,
|
||||
Bits::Twelve => 16.0,
|
||||
Bits::Sixteen => 20.0,
|
||||
// Pick first suggestion as the current number
|
||||
let current_number_signed = suggestions[0];
|
||||
|
||||
// Calculate raw_current_number based on mode
|
||||
let raw_current_number = match number_mode {
|
||||
NumberMode::Unsigned => {
|
||||
let current_number = current_number_signed.unsigned_abs();
|
||||
current_number / scale
|
||||
},
|
||||
NumberMode::Signed => {
|
||||
// For signed mode, we need to preserve the two's complement representation
|
||||
// First, get the unscaled signed value
|
||||
let unscaled_signed = current_number_signed / (scale as i32);
|
||||
|
||||
// Convert to unsigned bits using two's complement masking
|
||||
// For n-bit number, mask is (2^n - 1)
|
||||
let mask = (1u32 << num_bits) - 1;
|
||||
(unscaled_signed as u32) & mask
|
||||
},
|
||||
};
|
||||
let penalty = f64::from(streak) * 0.5; // 0.5s less per streak
|
||||
let time_total = (base_time - penalty).max(5.0);
|
||||
|
||||
let current_number = current_number_signed.unsigned_abs();
|
||||
|
||||
// Calculate time based on difficulty
|
||||
let time_total = 10.0 - (streak.min(8) as f64 * 0.5);
|
||||
let time_left = time_total;
|
||||
|
||||
let selected_suggestion = Some(suggestions[0]);
|
||||
let guess_result = None;
|
||||
let last_points_awarded = 0;
|
||||
|
||||
Self {
|
||||
bits,
|
||||
number_mode,
|
||||
current_number,
|
||||
raw_current_number,
|
||||
suggestions,
|
||||
@ -818,15 +823,16 @@ impl BinaryNumbersPuzzle {
|
||||
guess_result,
|
||||
last_points_awarded,
|
||||
stats_snapshot: None,
|
||||
skip_first_dt: true, // Skip first dt to prevent timer jump
|
||||
skip_first_dt: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn suggestions(&self) -> &[u32] {
|
||||
pub fn suggestions(&self) -> &[i32] {
|
||||
&self.suggestions
|
||||
}
|
||||
pub const fn is_correct_guess(&self, guess: u32) -> bool {
|
||||
guess == self.current_number
|
||||
|
||||
pub fn is_correct_guess(&self, guess: i32) -> bool {
|
||||
guess == self.suggestions[0]
|
||||
}
|
||||
|
||||
pub fn current_to_binary_string(&self) -> String {
|
||||
@ -841,19 +847,14 @@ impl BinaryNumbersPuzzle {
|
||||
}
|
||||
|
||||
pub fn run(&mut self, dt: f64) {
|
||||
if self.guess_result.is_some() {
|
||||
// If a guess has been made, we don't need to run the game logic anymore.
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip first dt to prevent timer jump when starting new puzzle
|
||||
if self.skip_first_dt {
|
||||
self.skip_first_dt = false;
|
||||
return;
|
||||
}
|
||||
|
||||
self.time_left = (self.time_left - dt).max(0.0);
|
||||
|
||||
if self.guess_result.is_some() {
|
||||
return;
|
||||
}
|
||||
self.time_left -= dt;
|
||||
if self.time_left <= 0.0 {
|
||||
self.guess_result = Some(GuessResult::Timeout);
|
||||
}
|
||||
@ -894,7 +895,7 @@ fn render_ascii_gauge(area: Rect, buf: &mut Buffer, ratio: f64, color: Color) {
|
||||
}
|
||||
|
||||
struct HighScores {
|
||||
scores: HashMap<u32, u32>,
|
||||
scores: HashMap<String, u32>,
|
||||
}
|
||||
|
||||
impl HighScores {
|
||||
@ -911,10 +912,9 @@ impl HighScores {
|
||||
if file.read_to_string(&mut contents).is_ok() {
|
||||
for line in contents.lines() {
|
||||
if let Some((k, v)) = line.split_once('=')
|
||||
&& let Ok(bits) = k.trim().parse::<u32>()
|
||||
&& let Ok(score) = v.trim().parse::<u32>()
|
||||
{
|
||||
hs.scores.insert(bits, score);
|
||||
hs.scores.insert(k.trim().to_string(), score);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -924,7 +924,10 @@ impl HighScores {
|
||||
|
||||
fn save(&self) -> std::io::Result<()> {
|
||||
let mut data = String::new();
|
||||
for key in [4u32, 42u32, 44u32, 48u32, 412u32, 8u32, 12u32, 16u32] {
|
||||
for key in [
|
||||
"4u", "4s", "44u", "44s", "48u", "48s", "412u", "412s", "8u", "8s", "12u", "12s",
|
||||
"16u", "16s",
|
||||
] {
|
||||
let val = self.get(key);
|
||||
let _ = writeln!(data, "{key}={val}");
|
||||
}
|
||||
@ -932,12 +935,12 @@ impl HighScores {
|
||||
file.write_all(data.as_bytes())
|
||||
}
|
||||
|
||||
fn get(&self, bits: u32) -> u32 {
|
||||
*self.scores.get(&bits).unwrap_or(&0)
|
||||
fn get(&self, bits: &str) -> u32 {
|
||||
*self.scores.get(bits).unwrap_or(&0)
|
||||
}
|
||||
|
||||
fn update(&mut self, bits: u32, score: u32) {
|
||||
self.scores.insert(bits, score);
|
||||
fn update(&mut self, bits: &str, score: u32) {
|
||||
self.scores.insert(bits.to_string(), score);
|
||||
}
|
||||
}
|
||||
|
||||
@ -951,7 +954,8 @@ mod tests {
|
||||
static HS_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn with_high_score_file<F: FnOnce()>(f: F) {
|
||||
let _guard = HS_LOCK.lock().unwrap();
|
||||
#[allow(clippy::expect_used)]
|
||||
let _guard = HS_LOCK.lock().expect("Failed to lock high score mutex");
|
||||
let original = fs::read_to_string(HighScores::FILE).ok();
|
||||
f();
|
||||
// restore
|
||||
@ -984,7 +988,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn puzzle_generation_unique_and_scaled() {
|
||||
let p = BinaryNumbersPuzzle::new(Bits::FourShift4.clone(), 0);
|
||||
let p = BinaryNumbersPuzzle::new(Bits::FourShift4.clone(), NumberMode::Unsigned, 0);
|
||||
let scale = Bits::FourShift4.scale_factor();
|
||||
assert_eq!(p.suggestions().len(), Bits::FourShift4.suggestion_count());
|
||||
// uniqueness
|
||||
@ -995,26 +999,81 @@ mod tests {
|
||||
}
|
||||
// scaling property
|
||||
for &s in p.suggestions() {
|
||||
assert_eq!(s % scale, 0);
|
||||
assert_eq!(s.unsigned_abs() % scale, 0);
|
||||
}
|
||||
// current number must be one of suggestions and raw_current_number * scale == current_number
|
||||
assert!(p.suggestions().contains(&p.current_number));
|
||||
assert!(p.suggestions().contains(&(p.current_number as i32)));
|
||||
assert_eq!(p.raw_current_number * scale, p.current_number);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_string_formatting_groups_every_four_bits() {
|
||||
let mut p = BinaryNumbersPuzzle::new(Bits::Eight, 0);
|
||||
let mut p = BinaryNumbersPuzzle::new(Bits::Eight, NumberMode::Unsigned, 0);
|
||||
p.raw_current_number = 0xAB; // 171 = 10101011
|
||||
assert_eq!(p.current_to_binary_string(), "1010 1011");
|
||||
let mut p4 = BinaryNumbersPuzzle::new(Bits::Four, 0);
|
||||
let mut p4 = BinaryNumbersPuzzle::new(Bits::Four, NumberMode::Unsigned, 0);
|
||||
p4.raw_current_number = 0b0101;
|
||||
assert_eq!(p4.current_to_binary_string(), "0101");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_mode_negative_numbers_show_sign_bit() {
|
||||
// Test 4-bit signed mode with a negative number
|
||||
let mut p = BinaryNumbersPuzzle::new(Bits::Four, NumberMode::Signed, 0);
|
||||
// In 4-bit two's complement, -8 is represented as 1000
|
||||
p.raw_current_number = 0b1000; // -8 in 4-bit two's complement
|
||||
assert_eq!(p.current_to_binary_string(), "1000", "4-bit: -8 should be 1000");
|
||||
|
||||
// In 4-bit two's complement, -1 is represented as 1111
|
||||
p.raw_current_number = 0b1111; // -1 in 4-bit two's complement
|
||||
assert_eq!(p.current_to_binary_string(), "1111", "4-bit: -1 should be 1111");
|
||||
|
||||
// Test 8-bit signed mode with a negative number
|
||||
let mut p8 = BinaryNumbersPuzzle::new(Bits::Eight, NumberMode::Signed, 0);
|
||||
// In 8-bit two's complement, -128 is represented as 10000000
|
||||
p8.raw_current_number = 0b10000000; // -128 in 8-bit two's complement
|
||||
assert_eq!(p8.current_to_binary_string(), "1000 0000", "8-bit: -128 should be 1000 0000");
|
||||
|
||||
// In 8-bit two's complement, -1 is represented as 11111111
|
||||
p8.raw_current_number = 0b11111111; // -1 in 8-bit two's complement
|
||||
assert_eq!(p8.current_to_binary_string(), "1111 1111", "8-bit: -1 should be 1111 1111");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_mode_puzzle_generates_correct_raw_bits_for_negative() {
|
||||
// Generate many puzzles and check that when we have a negative number,
|
||||
// the raw_current_number has the sign bit set correctly
|
||||
for _ in 0..20 {
|
||||
let p = BinaryNumbersPuzzle::new(Bits::Four, NumberMode::Signed, 0);
|
||||
let current_signed = p.suggestions[0];
|
||||
|
||||
if current_signed < 0 {
|
||||
// For negative numbers in 4-bit two's complement, the MSB (bit 3) should be 1
|
||||
// which means raw_current_number should be >= 8 (0b1000)
|
||||
assert!(
|
||||
p.raw_current_number >= 8,
|
||||
"Negative number {} should have raw bits >= 8 (sign bit set), but got {}. Binary: {}",
|
||||
current_signed,
|
||||
p.raw_current_number,
|
||||
p.current_to_binary_string()
|
||||
);
|
||||
} else {
|
||||
// For positive numbers (including 0), MSB should be 0
|
||||
// which means raw_current_number should be < 8
|
||||
assert!(
|
||||
p.raw_current_number < 8,
|
||||
"Positive number {} should have raw bits < 8 (sign bit clear), but got {}. Binary: {}",
|
||||
current_signed,
|
||||
p.raw_current_number,
|
||||
p.current_to_binary_string()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_timeout_sets_guess_result() {
|
||||
let mut p = BinaryNumbersPuzzle::new(Bits::Four, 0);
|
||||
let mut p = BinaryNumbersPuzzle::new(Bits::Four, NumberMode::Unsigned, 0);
|
||||
p.time_left = 0.5;
|
||||
// First run() skips dt due to skip_first_dt flag
|
||||
// The reason for this is to prevent timer jump when starting a new puzzle
|
||||
@ -1028,9 +1087,9 @@ mod tests {
|
||||
#[test]
|
||||
fn finalize_round_correct_increments_score_streak_and_sets_result_state() {
|
||||
with_high_score_file(|| {
|
||||
let mut g = BinaryNumbersGame::new(Bits::Four);
|
||||
let mut g = BinaryNumbersGame::new(Bits::Four, NumberMode::Unsigned);
|
||||
// ensure deterministic: mark puzzle correct
|
||||
let answer = g.puzzle.current_number;
|
||||
let answer = g.puzzle.current_number as i32;
|
||||
g.puzzle.guess_result = Some(GuessResult::Correct);
|
||||
g.finalize_round();
|
||||
assert_eq!(g.streak, 1);
|
||||
@ -1045,7 +1104,7 @@ mod tests {
|
||||
#[test]
|
||||
fn life_awarded_every_five_streak() {
|
||||
with_high_score_file(|| {
|
||||
let mut g = BinaryNumbersGame::new_with_max_lives(Bits::Four, 3);
|
||||
let mut g = BinaryNumbersGame::new_with_max_lives(Bits::Four, NumberMode::Unsigned, 3);
|
||||
g.lives = 2; // below max
|
||||
g.streak = 4; // about to become 5
|
||||
g.puzzle.guess_result = Some(GuessResult::Correct);
|
||||
@ -1058,7 +1117,7 @@ mod tests {
|
||||
#[test]
|
||||
fn incorrect_guess_resets_streak_and_loses_life() {
|
||||
with_high_score_file(|| {
|
||||
let mut g = BinaryNumbersGame::new(Bits::Four);
|
||||
let mut g = BinaryNumbersGame::new(Bits::Four, NumberMode::Unsigned);
|
||||
g.streak = 3;
|
||||
let lives_before = g.lives;
|
||||
g.puzzle.guess_result = Some(GuessResult::Incorrect);
|
||||
@ -1071,7 +1130,7 @@ mod tests {
|
||||
#[test]
|
||||
fn pending_game_over_when_life_reaches_zero() {
|
||||
with_high_score_file(|| {
|
||||
let mut g = BinaryNumbersGame::new(Bits::Four);
|
||||
let mut g = BinaryNumbersGame::new(Bits::Four, NumberMode::Unsigned);
|
||||
g.lives = 1;
|
||||
g.puzzle.guess_result = Some(GuessResult::Incorrect);
|
||||
g.finalize_round();
|
||||
@ -1083,28 +1142,29 @@ mod tests {
|
||||
#[test]
|
||||
fn high_score_updates_and_flag_set() {
|
||||
with_high_score_file(|| {
|
||||
let mut g = BinaryNumbersGame::new(Bits::Four);
|
||||
let mut g = BinaryNumbersGame::new(Bits::Four, NumberMode::Unsigned);
|
||||
// Force previous high score low
|
||||
g.high_scores.update(g.bits.high_score_key(), 5);
|
||||
let key = BinaryNumbersGame::compute_high_score_key(&g.bits, g.number_mode);
|
||||
g.high_scores.update(&key, 5);
|
||||
g.prev_high_score_for_display = 5;
|
||||
g.puzzle.guess_result = Some(GuessResult::Correct);
|
||||
g.finalize_round();
|
||||
assert!(g.new_high_score_reached);
|
||||
assert!(g.high_scores.get(g.bits.high_score_key()) >= 10);
|
||||
assert!(g.high_scores.get(&key) >= 10);
|
||||
assert_eq!(g.prev_high_score_for_display, 5); // previous stored
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hearts_representation_matches_lives() {
|
||||
let mut g = BinaryNumbersGame::new_with_max_lives(Bits::Four, 3);
|
||||
let mut g = BinaryNumbersGame::new_with_max_lives(Bits::Four, NumberMode::Unsigned, 3);
|
||||
g.lives = 2;
|
||||
assert_eq!(g.lives_hearts(), "♥♥·");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_input_navigation_changes_selected_suggestion() {
|
||||
let mut g = BinaryNumbersGame::new(Bits::Four);
|
||||
let mut g = BinaryNumbersGame::new(Bits::Four, NumberMode::Unsigned);
|
||||
let initial = g.puzzle.selected_suggestion;
|
||||
// Simulate Right key
|
||||
let right_event = KeyEvent {
|
||||
|
||||
216
src/utils.rs
216
src/utils.rs
@ -1,85 +1,179 @@
|
||||
use ratatui::layout::Flex;
|
||||
use ratatui::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub struct AsciiCell {
|
||||
pub ch: char,
|
||||
pub x: u16,
|
||||
pub y: u16,
|
||||
pub color: Color,
|
||||
/// Type alias for the color function used in procedural animations
|
||||
type ColorFn = Box<dyn Fn(usize, usize, f32, usize, Color) -> Color>;
|
||||
|
||||
/// Type alias for the character transformation function
|
||||
type CharFn = Box<dyn Fn(usize, usize, f32, usize, char) -> char>;
|
||||
|
||||
/// A procedural animation widget that calculates colors on-the-fly
|
||||
/// This is much more memory efficient than storing multiple frames
|
||||
pub struct ProceduralAnimationWidget {
|
||||
art: String,
|
||||
width: u16,
|
||||
height: u16,
|
||||
num_frames: usize,
|
||||
frame_duration: Duration,
|
||||
pause_at_end: Duration,
|
||||
start_time: Instant,
|
||||
paused: bool,
|
||||
paused_progress: f32,
|
||||
paused_cycle: usize,
|
||||
highlight_color: Color, // The color for the animated strip
|
||||
color_fn: ColorFn, // (x, y, progress, cycle, highlight_color) -> Color
|
||||
char_fn: Option<CharFn>, // (x, y, progress, cycle, original_char) -> char
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
pub fn parse_ascii_art(
|
||||
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();
|
||||
impl ProceduralAnimationWidget {
|
||||
pub fn new(
|
||||
art: String,
|
||||
num_frames: usize,
|
||||
frame_duration: Duration,
|
||||
color_fn: impl Fn(usize, usize, f32, usize, Color) -> Color + 'static,
|
||||
) -> Self {
|
||||
let art_lines: Vec<&str> = art.lines().collect();
|
||||
let height = art_lines.len() as u16;
|
||||
let width = art_lines.iter().map(|line| line.len()).max().unwrap_or(0) as u16;
|
||||
|
||||
assert_eq!(art_lines.len(), color_lines.len(), "Art and color string must have same height");
|
||||
|
||||
let mut pixels = Vec::new();
|
||||
|
||||
for (y, (art_row, color_row)) in art_lines.iter().zip(color_lines.iter()).enumerate() {
|
||||
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).copied().unwrap_or(default_color);
|
||||
pixels.push(AsciiCell { ch, x: x as u16, y: y as u16, color });
|
||||
Self {
|
||||
art,
|
||||
width,
|
||||
height,
|
||||
num_frames,
|
||||
frame_duration,
|
||||
pause_at_end: Duration::ZERO,
|
||||
start_time: Instant::now(),
|
||||
paused: false,
|
||||
paused_progress: 0.0,
|
||||
paused_cycle: 0,
|
||||
highlight_color: Color::LightGreen, // Default color
|
||||
color_fn: Box::new(color_fn),
|
||||
char_fn: None,
|
||||
}
|
||||
}
|
||||
|
||||
pixels
|
||||
}
|
||||
|
||||
pub struct AsciiCells {
|
||||
pub cells: Vec<AsciiCell>,
|
||||
}
|
||||
|
||||
impl AsciiCells {
|
||||
pub fn from(
|
||||
art: &str,
|
||||
color_map_str: &str,
|
||||
color_map: &HashMap<char, Color>,
|
||||
default_color: Color,
|
||||
pub fn with_char_fn(
|
||||
mut self,
|
||||
char_fn: impl Fn(usize, usize, f32, usize, char) -> char + 'static,
|
||||
) -> Self {
|
||||
Self { cells: parse_ascii_art(art, color_map_str, color_map, default_color) }
|
||||
self.char_fn = Some(Box::new(char_fn));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_pause_at_end(mut self, pause: Duration) -> Self {
|
||||
self.pause_at_end = pause;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn pause(&mut self) {
|
||||
if !self.paused {
|
||||
let (progress, cycle) = self.get_animation_progress_and_cycle();
|
||||
self.paused_progress = progress;
|
||||
self.paused_cycle = cycle;
|
||||
self.paused = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unpause(&mut self) {
|
||||
if self.paused {
|
||||
// Adjust start_time so that the animation continues from paused_progress
|
||||
let animation_duration = self.frame_duration * self.num_frames as u32;
|
||||
let total_cycle_duration = animation_duration + self.pause_at_end;
|
||||
let elapsed_at_pause = Duration::from_millis(
|
||||
(self.paused_cycle as f32 * total_cycle_duration.as_millis() as f32
|
||||
+ self.paused_progress * animation_duration.as_millis() as f32)
|
||||
as u64,
|
||||
);
|
||||
self.start_time = Instant::now() - elapsed_at_pause;
|
||||
self.paused = false;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_pause(&mut self) {
|
||||
if self.paused {
|
||||
self.unpause();
|
||||
} else {
|
||||
self.pause();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_paused(&self) -> bool {
|
||||
self.paused
|
||||
}
|
||||
|
||||
pub fn get_width(&self) -> u16 {
|
||||
self.cells.iter().map(|cell| cell.x).max().unwrap_or(0) + 1
|
||||
self.width
|
||||
}
|
||||
|
||||
pub fn get_height(&self) -> u16 {
|
||||
self.cells.iter().map(|cell| cell.y).max().unwrap_or(0) + 1
|
||||
self.height
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AsciiArtWidget {
|
||||
collection: AsciiCells,
|
||||
}
|
||||
|
||||
impl AsciiArtWidget {
|
||||
pub const fn new(collection: AsciiCells) -> Self {
|
||||
Self { collection }
|
||||
/// Set the highlight color for the animation
|
||||
pub fn set_highlight_color(&mut self, color: Color) {
|
||||
self.highlight_color = color;
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for AsciiArtWidget {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
for pixel in self.collection.cells {
|
||||
let position = Position::new(pixel.x + area.x, pixel.y + area.y);
|
||||
fn get_animation_progress_and_cycle(&self) -> (f32, usize) {
|
||||
if self.paused {
|
||||
return (self.paused_progress, self.paused_cycle);
|
||||
}
|
||||
|
||||
if area.contains(position) {
|
||||
#[allow(clippy::expect_used)]
|
||||
buf.cell_mut(position)
|
||||
.expect("Failed to get cell at position")
|
||||
.set_char(pixel.ch)
|
||||
.set_fg(pixel.color);
|
||||
let elapsed = self.start_time.elapsed();
|
||||
let animation_duration = self.frame_duration * self.num_frames as u32;
|
||||
let total_cycle_duration = animation_duration + self.pause_at_end;
|
||||
|
||||
let cycle = (elapsed.as_millis() / total_cycle_duration.as_millis()) as usize;
|
||||
let cycle_time = elapsed.as_millis() % total_cycle_duration.as_millis();
|
||||
|
||||
// If we're in the pause period, return 1.0 (end of animation)
|
||||
if cycle_time >= animation_duration.as_millis() {
|
||||
return (1.0, cycle);
|
||||
}
|
||||
|
||||
// Otherwise calculate progress through animation
|
||||
let progress = cycle_time as f32 / animation_duration.as_millis() as f32;
|
||||
(progress, cycle)
|
||||
}
|
||||
|
||||
pub fn render_to_buffer(&self, area: Rect, buf: &mut Buffer) {
|
||||
let (progress, cycle) = self.get_animation_progress_and_cycle();
|
||||
self.render_to_buffer_at_progress(area, buf, progress, cycle);
|
||||
}
|
||||
|
||||
pub fn render_to_buffer_at_progress(
|
||||
&self,
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
progress: f32,
|
||||
cycle: usize,
|
||||
) {
|
||||
for (y, line) in self.art.lines().enumerate() {
|
||||
for (x, ch) in line.chars().enumerate() {
|
||||
if ch == ' ' {
|
||||
continue; // Skip spaces
|
||||
}
|
||||
|
||||
let color = (self.color_fn)(x, y, progress, cycle, self.highlight_color);
|
||||
|
||||
// Apply character transformation if char_fn is provided
|
||||
let display_char = if let Some(ref char_fn) = self.char_fn {
|
||||
char_fn(x, y, progress, cycle, ch)
|
||||
} else {
|
||||
ch
|
||||
};
|
||||
|
||||
let position = Position::new(x as u16 + area.x, y as u16 + area.y);
|
||||
|
||||
if area.contains(position) {
|
||||
#[allow(clippy::expect_used)]
|
||||
buf.cell_mut(position)
|
||||
.expect("Failed to get cell at position")
|
||||
.set_char(display_char)
|
||||
.set_fg(color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user