Compare commits

...

5 Commits

Author SHA1 Message Date
epic-64
503f9eef0b feat: add build script for cross-platform executables and update version to 0.3.0 2025-12-01 00:00:00 +01:00
epic-64
a67d852437 docs: add toggle for signed/unsigned mode in controls section 2025-12-01 00:00:00 +01:00
epic-64
59496a70b6 update color for bits::eight 2025-12-01 00:00:00 +01:00
William Raendchen
5e078073a5
feat: animate splash screen (#8)
* improved splash screen
* improved menu
* added signed mode (two's complement) for all game modes
2025-12-01 21:54:26 +01:00
William Raendchen
c22617f642
Merge pull request #9 from lucaspalomodevelop/main
Add 4-bit Two’s Complement mode
2025-12-01 21:21:44 +01:00
9 changed files with 636 additions and 298 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
/target /target
/binbreak_highscores.txt /binbreak_highscores.txt
/.idea /.idea
/executables

2
Cargo.lock generated
View File

@ -40,7 +40,7 @@ dependencies = [
[[package]] [[package]]
name = "binbreak" name = "binbreak"
version = "0.1.0" version = "0.3.0"
dependencies = [ dependencies = [
"color-eyre", "color-eyre",
"crossterm 0.29.0", "crossterm 0.29.0",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "binbreak" name = "binbreak"
version = "0.1.0" version = "0.3.0"
description = "A terminal based binary number guessing game" description = "A terminal based binary number guessing game"
authors = ["William Raendchen <william@holonaut.io>"] authors = ["William Raendchen <william@holonaut.io>"]
license = "MIT" license = "MIT"

View File

@ -37,6 +37,7 @@ There is one file for linux and one for windows (.exe).
## Controls ## Controls
- use the arrow or vim keys for navigation - use the arrow or vim keys for navigation
- use left/right to toggle signed/unsigned mode
- press Enter to confirm choices - 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. - 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 920 KiB

View File

@ -1,28 +1,61 @@
use crate::binary_numbers::{BinaryNumbersGame, Bits}; use crate::binary_numbers::{BinaryNumbersGame, Bits};
use crate::keybinds; use crate::keybinds;
use crate::main_screen_widget::MainScreenWidget; use crate::main_screen_widget::MainScreenWidget;
use crate::utils::{AsciiArtWidget, AsciiCells}; use crate::utils::ProceduralAnimationWidget;
use crossterm::event; use crossterm::event;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use indoc::indoc; use indoc::indoc;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::prelude::{Color, Modifier, Span, Style, Widget}; use ratatui::prelude::{Color, Modifier, Span, Style};
use ratatui::widgets::{List, ListItem, ListState}; use ratatui::widgets::{List, ListItem, ListState};
use std::cmp; use std::cmp;
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread; use std::thread;
use std::time::Instant; use std::time::{Duration, Instant};
static LAST_SELECTED_INDEX: AtomicUsize = AtomicUsize::new(4); #[derive(Copy, Clone, PartialEq, Debug)]
pub enum NumberMode {
fn get_last_selected_index() -> usize { Unsigned,
LAST_SELECTED_INDEX.load(Ordering::Relaxed) Signed,
} }
fn set_last_selected_index(index: usize) { impl NumberMode {
LAST_SELECTED_INDEX.store(index, Ordering::Relaxed); 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)] #[derive(Copy, Clone, PartialEq, Debug)]
@ -32,40 +65,54 @@ enum FpsMode {
} }
enum AppState { enum AppState {
Start(StartMenuState), Start(StartMenuState, AppPreferences),
Playing(BinaryNumbersGame), Playing(BinaryNumbersGame, AppPreferences),
Exit, 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 { match key {
x if keybinds::is_up(x) => state.select_previous(), x if keybinds::is_up(x) => state.select_previous(),
x if keybinds::is_down(x) => state.select_next(), 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) => { x if keybinds::is_select(x) => {
let bits = state.selected_bits(); let bits = state.selected_bits();
// Store the current selection before entering the game let number_mode = state.number_mode;
set_last_selected_index(state.selected_index()); // Update preferences with current selection
return Some(AppState::Playing(BinaryNumbersGame::new(bits))); 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 None
} }
fn render_start_screen(state: &mut StartMenuState, area: Rect, buf: &mut Buffer) { fn render_start_screen(state: &mut StartMenuState, area: Rect, buf: &mut Buffer) {
// Build ASCII art to obtain real dimensions // Get animation dimensions
let cells = ascii_art_cells(); let ascii_width = state.animation.get_width();
let ascii_width = cells.get_width(); let ascii_height = state.animation.get_height();
let ascii_height = cells.get_height();
let ascii_widget = AsciiArtWidget::new(cells);
let selected = state.selected_index(); let selected = state.selected_index();
let upper_labels: Vec<String> = state.items.iter().map(|(l, _)| l.to_uppercase()).collect(); let upper_labels: Vec<String> = state.items.iter().map(|(l, _)| l.to_uppercase()).collect();
#[allow(clippy::cast_possible_truncation)] #[allow(clippy::cast_possible_truncation)]
let max_len = upper_labels.iter().map(|s| s.len() as u16).max().unwrap_or(0); 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)] #[allow(clippy::cast_possible_truncation)]
let list_height = upper_labels.len() as u16; 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)), list_height.min(area.height.saturating_sub(list_y - area.y)),
); );
// Render ASCII art // Get color for the selected menu item
ascii_widget.render(ascii_area, buf); let selected_color = get_mode_color(&state.items[selected].1);
// Palette for menu flair // Update animation color to match selected menu item
let palette = [ state.animation.set_highlight_color(selected_color);
Color::LightGreen,
Color::LightCyan, // Render ASCII animation (handles paused state internally)
Color::LightBlue, state.animation.render_to_buffer(ascii_area, buf);
Color::LightMagenta,
Color::LightYellow,
Color::LightRed,
];
let items: Vec<ListItem> = upper_labels let items: Vec<ListItem> = upper_labels
.into_iter() .into_iter()
.enumerate() .enumerate()
.map(|(i, label)| { .map(|(i, label)| {
let marker = if i == selected { '»' } else { ' ' }; let is_selected = i == selected;
let padded = format!("{:<width$}", label, width = max_len as usize); let marker = if is_selected { '»' } else { ' ' };
let line = format!("{marker} {padded}"); let padded_label = format!("{:<width$}", label, width = max_len as usize);
let style =
Style::default().fg(palette[i % palette.len()]).add_modifier(Modifier::BOLD); // 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)) ListItem::new(Span::styled(line, style))
}) })
.collect(); .collect();
@ -133,12 +192,16 @@ fn handle_crossterm_events(app_state: &mut AppState) -> color_eyre::Result<()> {
// state-specific input handling // state-specific input handling
_ => { _ => {
*app_state = match std::mem::replace(app_state, AppState::Exit) { *app_state = match std::mem::replace(app_state, AppState::Exit) {
AppState::Start(mut menu) => { AppState::Start(mut menu, prefs) => {
handle_start_input(&mut menu, key).unwrap_or(AppState::Start(menu)) 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); game.handle_input(key);
AppState::Playing(game) AppState::Playing(game, prefs)
}, },
AppState::Exit => AppState::Exit, 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<()> { 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 mut last_frame_time = Instant::now();
let target_frame_duration = std::time::Duration::from_millis(33); // ~30 FPS 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; last_frame_time = now;
// Advance game BEFORE drawing so stats are updated // 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()); game.run(dt.as_secs_f64());
if game.is_exit_intended() { if game.is_exit_intended() {
app_state = AppState::Start(StartMenuState::new()); app_state = AppState::Start(StartMenuState::new(*prefs), *prefs);
continue; continue;
} }
} }
terminal.draw(|f| match &mut app_state { terminal.draw(|f| match &mut app_state {
AppState::Start(menu) => render_start_screen(menu, f.area(), f.buffer_mut()), AppState::Start(menu, _) => render_start_screen(menu, f.area(), f.buffer_mut()),
AppState::Playing(game) => f.render_widget(&mut *game, f.area()), AppState::Playing(game, _) => f.render_widget(&mut *game, f.area()),
AppState::Exit => {}, AppState::Exit => {},
})?; })?;
// handle input // handle input
if let AppState::Playing(game) = &app_state { if let AppState::Playing(game, _) = &app_state {
if get_fps_mode(game) == FpsMode::RealTime { if get_fps_mode(game) == FpsMode::RealTime {
let poll_timeout = cmp::min(dt, target_frame_duration); let poll_timeout = cmp::min(dt, target_frame_duration);
if event::poll(poll_timeout)? { if event::poll(poll_timeout)? {
@ -193,11 +257,18 @@ pub fn run_app(terminal: &mut ratatui::DefaultTerminal) -> color_eyre::Result<()
// performance mode: block thread until an input event occurs // performance mode: block thread until an input event occurs
handle_crossterm_events(&mut app_state)?; handle_crossterm_events(&mut app_state)?;
} }
} else { } else if let AppState::Start(menu, _) = &app_state {
// For non-playing states (e.g., start menu), use performance mode // For start menu, use real-time mode only if animation is running
// to block until input and minimize CPU usage 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)?; handle_crossterm_events(&mut app_state)?;
} }
} else {
// Animation paused, use performance mode to save CPU
handle_crossterm_events(&mut app_state)?;
}
}
// cap frame rate // cap frame rate
let frame_duration = last_frame_time.elapsed(); let frame_duration = last_frame_time.elapsed();
@ -208,72 +279,116 @@ pub fn run_app(terminal: &mut ratatui::DefaultTerminal) -> color_eyre::Result<()
Ok(()) Ok(())
} }
fn ascii_art_cells() -> AsciiCells { fn ascii_animation() -> ProceduralAnimationWidget {
let art = indoc! {r#" let art = indoc! {r#"
,, ,, ,, ,, ,, ,,
*MM db *MM `7MM *MM db *MM [a: toggle animation] `7MM
MM MM MM MM MM MM
MM,dMMb.`7MM `7MMpMMMb. MM,dMMb.`7Mb,od8 .gP"Ya ,6"Yb. MM ,MP' 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 `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 M8 MM MM MM MM M8 MM 8M"""""" ,pm9MM MM;Mm
MM. ,M9 MM MM MM MM. ,M9 MM YM. , 8M MM MM `Mb. 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. P^YbmdP'.JMML..JMML JMML.P^YbmdP'.JMML. `Mbmmd' `Moo9^Yo..JMML. YA.
"#}; "#}
.to_string();
let colors = indoc! {r#" // Get dimensions for calculations
,, ,, ,, let art_lines: Vec<&str> = art.lines().collect();
*MM db *MM `7MM let height = art_lines.len();
MM MM MM let width = art_lines.iter().map(|line| line.len()).max().unwrap_or(0);
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.
"#};
let color_map = HashMap::from([ let strip_width = 8.0;
('M', Color::White), let start_offset = -strip_width;
('b', Color::LightYellow), let end_offset = (width + height) as f32 + strip_width;
('d', Color::LightCyan), let total_range = end_offset - start_offset;
('Y', Color::LightGreen),
('8', Color::LightMagenta),
('*', Color::Magenta),
('`', Color::Cyan),
('6', Color::Green),
('9', Color::Red),
('(', Color::Blue),
(')', Color::Blue),
(' ', Color::Black),
]);
let default_color = Color::LightBlue; // Color function that calculates colors on-the-fly based on animation progress
AsciiCells::from(art, colors, &color_map, default_color) 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 // Start menu state
struct StartMenuState { struct StartMenuState {
items: Vec<(String, Bits)>, items: Vec<(String, Bits)>,
list_state: ListState, list_state: ListState,
animation: ProceduralAnimationWidget,
number_mode: NumberMode,
} }
impl StartMenuState { impl StartMenuState {
fn new() -> Self { fn new(prefs: AppPreferences) -> Self {
Self::with_selected(get_last_selected_index()) Self::with_preferences(prefs)
} }
fn with_selected(selected_index: usize) -> Self { fn with_preferences(prefs: AppPreferences) -> Self {
let items = vec![ let items = vec![
("easy (4 bits)".to_string(), Bits::Four), ("nibble_0 4 bit".to_string(), Bits::Four),
("easy Two's complement (4 bits)".to_string(), Bits::FourTwosComplement), ("nibble_1 4 bit*16".to_string(), Bits::FourShift4),
("easy+16 (4 bits*16)".to_string(), Bits::FourShift4), ("nibble_2 4 bit*256".to_string(), Bits::FourShift8),
("easy+256 (4 bits*256)".to_string(), Bits::FourShift8), ("nibble_3 4 bit*4096".to_string(), Bits::FourShift12),
("easy+4096 (4 bits*4096)".to_string(), Bits::FourShift12), ("byte 8 bit".to_string(), Bits::Eight),
("normal (8 bits)".to_string(), Bits::Eight), ("hexlet 12 bit".to_string(), Bits::Twelve),
("master (12 bits)".to_string(), Bits::Twelve), ("word 16 bit".to_string(), Bits::Sixteen),
("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(prefs.last_selected_index)),
animation: ascii_animation(),
number_mode: prefs.last_number_mode,
}
} }
fn selected_index(&self) -> usize { fn selected_index(&self) -> usize {
@ -283,9 +398,30 @@ impl StartMenuState {
self.items[self.selected_index()].1.clone() self.items[self.selected_index()].1.clone()
} }
fn select_next(&mut self) { 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) { 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,
};
} }
} }

View File

@ -1,3 +1,4 @@
use crate::app::{NumberMode, get_mode_color};
use crate::keybinds; use crate::keybinds;
use crate::main_screen_widget::{MainScreenWidget, WidgetRef}; use crate::main_screen_widget::{MainScreenWidget, WidgetRef};
use crate::utils::{When, center}; use crate::utils::{When, center};
@ -24,6 +25,7 @@ struct StatsSnapshot {
rounds: u32, rounds: u32,
lives: u32, lives: u32,
bits: Bits, bits: Bits,
number_mode: NumberMode,
hearts: String, hearts: String,
game_state: GameState, game_state: GameState,
prev_high_score: u32, prev_high_score: u32,
@ -94,11 +96,10 @@ impl BinaryNumbersPuzzle {
Span::styled(format!("Hi-Score: {} ", stats.prev_high_score), style) 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![ let line1 = Line::from(vec![
Span::styled( Span::styled(format!("Mode: {} ", mode_label), Style::default().fg(mode_color)),
format!("Mode: {} ", stats.bits.label()),
Style::default().fg(Color::Yellow),
),
high_label, high_label,
]); ]);
@ -190,13 +191,7 @@ impl BinaryNumbersPuzzle {
Block::bordered().border_type(border_type).fg(border_color).render(area, buf); Block::bordered().border_type(border_type).fg(border_color).render(area, buf);
let suggestion_str = if self.bits.is_twos_complement() { let suggestion_str = format!("{suggestion}");
// Convert raw bit pattern to signed value for display
let signed_val = self.bits.raw_to_signed(*suggestion);
format!("{signed_val}")
} else {
format!("{suggestion}")
};
#[allow(clippy::cast_possible_truncation)] #[allow(clippy::cast_possible_truncation)]
Paragraph::new(suggestion_str.to_string()) Paragraph::new(suggestion_str.to_string())
@ -374,6 +369,7 @@ fn render_game_over(
pub struct BinaryNumbersGame { pub struct BinaryNumbersGame {
puzzle: BinaryNumbersPuzzle, puzzle: BinaryNumbersPuzzle,
bits: Bits, bits: Bits,
number_mode: NumberMode,
exit_intended: bool, exit_intended: bool,
score: u32, score: u32,
streak: u32, streak: u32,
@ -418,15 +414,17 @@ impl MainScreenWidget for BinaryNumbersGame {
} }
impl BinaryNumbersGame { impl BinaryNumbersGame {
pub fn new(bits: Bits) -> Self { pub fn new(bits: Bits, number_mode: NumberMode) -> Self {
Self::new_with_max_lives(bits, 3) 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 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 { let mut game = Self {
bits: bits.clone(), bits: bits.clone(),
puzzle: Self::init_puzzle(bits, 0), number_mode,
puzzle: Self::init_puzzle(bits, number_mode, 0),
exit_intended: false, exit_intended: false,
score: 0, score: 0,
streak: 0, streak: 0,
@ -445,8 +443,17 @@ impl BinaryNumbersGame {
game game
} }
pub fn init_puzzle(bits: Bits, streak: u32) -> BinaryNumbersPuzzle { pub fn init_puzzle(bits: Bits, number_mode: NumberMode, streak: u32) -> BinaryNumbersPuzzle {
BinaryNumbersPuzzle::new(bits, streak) 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 { pub fn is_active(&self) -> bool {
@ -489,13 +496,13 @@ impl BinaryNumbersGame {
}, },
} }
// high score update // high score update
let bits_key = self.bits.high_score_key(); let bits_key = Self::compute_high_score_key(&self.bits, self.number_mode);
let prev = self.high_scores.get(bits_key); let prev = self.high_scores.get(&bits_key);
if self.score > prev { if self.score > prev {
if !self.new_high_score_reached { if !self.new_high_score_reached {
self.prev_high_score_for_display = prev; 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; self.new_high_score_reached = true;
let _ = self.high_scores.save(); let _ = self.high_scores.save();
} }
@ -544,9 +551,10 @@ impl BinaryNumbersGame {
self.lives = self.max_lives.min(3); self.lives = self.max_lives.min(3);
self.game_state = GameState::Active; self.game_state = GameState::Active;
self.max_streak = 0; 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.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.puzzle_resolved = false;
self.refresh_stats_snapshot(); self.refresh_stats_snapshot();
} }
@ -609,7 +617,8 @@ impl BinaryNumbersGame {
}, },
GameState::Result => { GameState::Result => {
// start next puzzle // 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.puzzle_resolved = false;
self.game_state = GameState::Active; self.game_state = GameState::Active;
}, },
@ -630,6 +639,7 @@ impl BinaryNumbersGame {
rounds: self.rounds, rounds: self.rounds,
lives: self.lives, lives: self.lives,
bits: self.bits.clone(), bits: self.bits.clone(),
number_mode: self.number_mode,
hearts: self.lives_hearts(), hearts: self.lives_hearts(),
game_state: self.game_state, game_state: self.game_state,
prev_high_score: self.prev_high_score_for_display, prev_high_score: self.prev_high_score_for_display,
@ -648,7 +658,6 @@ enum GuessResult {
#[derive(Clone)] #[derive(Clone)]
pub enum Bits { pub enum Bits {
Four, Four,
FourTwosComplement,
FourShift4, FourShift4,
FourShift8, FourShift8,
FourShift12, FourShift12,
@ -660,11 +669,7 @@ pub enum Bits {
impl Bits { impl Bits {
pub const fn to_int(&self) -> u32 { pub const fn to_int(&self) -> u32 {
match self { match self {
Self::Four Self::Four | Self::FourShift4 | Self::FourShift8 | Self::FourShift12 => 4,
| Self::FourShift4
| Self::FourShift8
| Self::FourShift12
| Self::FourTwosComplement => 4,
Self::Eight => 8, Self::Eight => 8,
Self::Twelve => 12, Self::Twelve => 12,
Self::Sixteen => 16, Self::Sixteen => 16,
@ -673,7 +678,6 @@ impl Bits {
pub const fn scale_factor(&self) -> u32 { pub const fn scale_factor(&self) -> u32 {
match self { match self {
Self::Four => 1, Self::Four => 1,
Self::FourTwosComplement => 1,
Self::FourShift4 => 16, Self::FourShift4 => 16,
Self::FourShift8 => 256, Self::FourShift8 => 256,
Self::FourShift12 => 4096, Self::FourShift12 => 4096,
@ -685,7 +689,6 @@ impl Bits {
pub const fn high_score_key(&self) -> u32 { pub const fn high_score_key(&self) -> u32 {
match self { match self {
Self::Four => 4, Self::Four => 4,
Self::FourTwosComplement => 42, // separate key for two's complement
Self::FourShift4 => 44, Self::FourShift4 => 44,
Self::FourShift8 => 48, Self::FourShift8 => 48,
Self::FourShift12 => 412, Self::FourShift12 => 412,
@ -699,11 +702,7 @@ impl Bits {
} }
pub const fn suggestion_count(&self) -> usize { pub const fn suggestion_count(&self) -> usize {
match self { match self {
Self::Four Self::Four | Self::FourShift4 | Self::FourShift8 | Self::FourShift12 => 3,
| Self::FourShift4
| Self::FourShift8
| Self::FourShift12
| Self::FourTwosComplement => 3,
Self::Eight => 4, Self::Eight => 4,
Self::Twelve => 5, Self::Twelve => 5,
Self::Sixteen => 6, Self::Sixteen => 6,
@ -711,39 +710,26 @@ impl Bits {
} }
pub const fn label(&self) -> &'static str { pub const fn label(&self) -> &'static str {
match self { match self {
Self::Four => "4 bits", Self::Four => "4 bit",
Self::FourTwosComplement => "4 bits (Two's complement)", Self::FourShift4 => "4 bit*16",
Self::FourShift4 => "4 bits*16", Self::FourShift8 => "4 bit*256",
Self::FourShift8 => "4 bits*256", Self::FourShift12 => "4 bit*4096",
Self::FourShift12 => "4 bits*4096", Self::Eight => "8 bit",
Self::Eight => "8 bits", Self::Twelve => "12 bit",
Self::Twelve => "12 bits", Self::Sixteen => "16 bit",
Self::Sixteen => "16 bits",
} }
} }
/// 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 { pub struct BinaryNumbersPuzzle {
bits: Bits, bits: Bits,
#[allow(dead_code)]
number_mode: NumberMode,
#[allow(dead_code)]
current_number: u32, // scaled value used for suggestions matching current_number: u32, // scaled value used for suggestions matching
raw_current_number: u32, // raw bit value (unscaled) for display raw_current_number: u32, // raw bit value (unscaled) for display
suggestions: Vec<u32>, suggestions: Vec<i32>, // Changed to i32 to support signed values
selected_suggestion: Option<u32>, selected_suggestion: Option<i32>,
time_total: f64, time_total: f64,
time_left: f64, time_left: f64,
guess_result: Option<GuessResult>, guess_result: Option<GuessResult>,
@ -753,62 +739,81 @@ pub struct BinaryNumbersPuzzle {
} }
impl 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 rng = rand::rng();
let mut suggestions = Vec::new(); let mut suggestions = Vec::new();
let scale = bits.scale_factor(); let scale = bits.scale_factor();
let num_bits = bits.to_int();
if bits.is_twos_complement() { match number_mode {
// For two's complement, generate unique raw bit patterns (0-15) NumberMode::Unsigned => {
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);
}
}
// Store raw bit patterns directly
suggestions = raw_values;
} else {
// For unsigned modes
while suggestions.len() < bits.suggestion_count() { while suggestions.len() < bits.suggestion_count() {
let raw = rng.random_range(0..u32::pow(2, bits.to_int())); let raw = rng.random_range(0..u32::pow(2, num_bits));
let num = raw * scale; let num = (raw * scale) as i32;
if !suggestions.contains(&num) { if !suggestions.contains(&num) {
suggestions.push(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 // Shuffle suggestions
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
};
suggestions.shuffle(&mut rng); suggestions.shuffle(&mut rng);
// Base time by bits + difficulty scaling (shorter as streak increases) // Pick first suggestion as the current number
let base_time = match bits { let current_number_signed = suggestions[0];
Bits::Four
| Bits::FourShift4 // Calculate raw_current_number based on mode
| Bits::FourShift8 let raw_current_number = match number_mode {
| Bits::FourShift12 NumberMode::Unsigned => {
| Bits::FourTwosComplement => 8.0, let current_number = current_number_signed.unsigned_abs();
Bits::Eight => 12.0, current_number / scale
Bits::Twelve => 16.0, },
Bits::Sixteen => 20.0, 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 time_left = time_total;
let selected_suggestion = Some(suggestions[0]); let selected_suggestion = Some(suggestions[0]);
let guess_result = None; let guess_result = None;
let last_points_awarded = 0; let last_points_awarded = 0;
Self { Self {
bits, bits,
number_mode,
current_number, current_number,
raw_current_number, raw_current_number,
suggestions, suggestions,
@ -818,15 +823,16 @@ impl BinaryNumbersPuzzle {
guess_result, guess_result,
last_points_awarded, last_points_awarded,
stats_snapshot: None, stats_snapshot: None,
skip_first_dt: true, // Skip first dt to prevent timer jump skip_first_dt: true,
} }
} }
pub fn suggestions(&self) -> &[u32] { pub fn suggestions(&self) -> &[i32] {
&self.suggestions &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 { pub fn current_to_binary_string(&self) -> String {
@ -841,19 +847,14 @@ impl BinaryNumbersPuzzle {
} }
pub fn run(&mut self, dt: f64) { 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 { if self.skip_first_dt {
self.skip_first_dt = false; self.skip_first_dt = false;
return; return;
} }
if self.guess_result.is_some() {
self.time_left = (self.time_left - dt).max(0.0); return;
}
self.time_left -= dt;
if self.time_left <= 0.0 { if self.time_left <= 0.0 {
self.guess_result = Some(GuessResult::Timeout); 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 { struct HighScores {
scores: HashMap<u32, u32>, scores: HashMap<String, u32>,
} }
impl HighScores { impl HighScores {
@ -911,10 +912,9 @@ impl HighScores {
if file.read_to_string(&mut contents).is_ok() { if file.read_to_string(&mut contents).is_ok() {
for line in contents.lines() { for line in contents.lines() {
if let Some((k, v)) = line.split_once('=') if let Some((k, v)) = line.split_once('=')
&& let Ok(bits) = k.trim().parse::<u32>()
&& let Ok(score) = v.trim().parse::<u32>() && 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<()> { fn save(&self) -> std::io::Result<()> {
let mut data = String::new(); 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 val = self.get(key);
let _ = writeln!(data, "{key}={val}"); let _ = writeln!(data, "{key}={val}");
} }
@ -932,12 +935,12 @@ impl HighScores {
file.write_all(data.as_bytes()) file.write_all(data.as_bytes())
} }
fn get(&self, bits: u32) -> u32 { fn get(&self, bits: &str) -> u32 {
*self.scores.get(&bits).unwrap_or(&0) *self.scores.get(bits).unwrap_or(&0)
} }
fn update(&mut self, bits: u32, score: u32) { fn update(&mut self, bits: &str, score: u32) {
self.scores.insert(bits, score); self.scores.insert(bits.to_string(), score);
} }
} }
@ -951,7 +954,8 @@ mod tests {
static HS_LOCK: Mutex<()> = Mutex::new(()); static HS_LOCK: Mutex<()> = Mutex::new(());
fn with_high_score_file<F: FnOnce()>(f: F) { 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(); let original = fs::read_to_string(HighScores::FILE).ok();
f(); f();
// restore // restore
@ -984,7 +988,7 @@ mod tests {
#[test] #[test]
fn puzzle_generation_unique_and_scaled() { 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(); let scale = Bits::FourShift4.scale_factor();
assert_eq!(p.suggestions().len(), Bits::FourShift4.suggestion_count()); assert_eq!(p.suggestions().len(), Bits::FourShift4.suggestion_count());
// uniqueness // uniqueness
@ -995,26 +999,81 @@ mod tests {
} }
// scaling property // scaling property
for &s in p.suggestions() { 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 // 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); assert_eq!(p.raw_current_number * scale, p.current_number);
} }
#[test] #[test]
fn binary_string_formatting_groups_every_four_bits() { 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 p.raw_current_number = 0xAB; // 171 = 10101011
assert_eq!(p.current_to_binary_string(), "1010 1011"); 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; p4.raw_current_number = 0b0101;
assert_eq!(p4.current_to_binary_string(), "0101"); 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] #[test]
fn puzzle_timeout_sets_guess_result() { fn puzzle_timeout_sets_guess_result() {
let mut p = BinaryNumbersPuzzle::new(Bits::Four, 0); let mut p = BinaryNumbersPuzzle::new(Bits::Four, NumberMode::Unsigned, 0);
p.time_left = 0.5; p.time_left = 0.5;
// First run() skips dt due to skip_first_dt flag // First run() skips dt due to skip_first_dt flag
// The reason for this is to prevent timer jump when starting a new puzzle // The reason for this is to prevent timer jump when starting a new puzzle
@ -1028,9 +1087,9 @@ mod tests {
#[test] #[test]
fn finalize_round_correct_increments_score_streak_and_sets_result_state() { fn finalize_round_correct_increments_score_streak_and_sets_result_state() {
with_high_score_file(|| { 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 // 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.puzzle.guess_result = Some(GuessResult::Correct);
g.finalize_round(); g.finalize_round();
assert_eq!(g.streak, 1); assert_eq!(g.streak, 1);
@ -1045,7 +1104,7 @@ mod tests {
#[test] #[test]
fn life_awarded_every_five_streak() { fn life_awarded_every_five_streak() {
with_high_score_file(|| { 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.lives = 2; // below max
g.streak = 4; // about to become 5 g.streak = 4; // about to become 5
g.puzzle.guess_result = Some(GuessResult::Correct); g.puzzle.guess_result = Some(GuessResult::Correct);
@ -1058,7 +1117,7 @@ mod tests {
#[test] #[test]
fn incorrect_guess_resets_streak_and_loses_life() { fn incorrect_guess_resets_streak_and_loses_life() {
with_high_score_file(|| { with_high_score_file(|| {
let mut g = BinaryNumbersGame::new(Bits::Four); let mut g = BinaryNumbersGame::new(Bits::Four, NumberMode::Unsigned);
g.streak = 3; g.streak = 3;
let lives_before = g.lives; let lives_before = g.lives;
g.puzzle.guess_result = Some(GuessResult::Incorrect); g.puzzle.guess_result = Some(GuessResult::Incorrect);
@ -1071,7 +1130,7 @@ mod tests {
#[test] #[test]
fn pending_game_over_when_life_reaches_zero() { fn pending_game_over_when_life_reaches_zero() {
with_high_score_file(|| { with_high_score_file(|| {
let mut g = BinaryNumbersGame::new(Bits::Four); let mut g = BinaryNumbersGame::new(Bits::Four, NumberMode::Unsigned);
g.lives = 1; g.lives = 1;
g.puzzle.guess_result = Some(GuessResult::Incorrect); g.puzzle.guess_result = Some(GuessResult::Incorrect);
g.finalize_round(); g.finalize_round();
@ -1083,28 +1142,29 @@ mod tests {
#[test] #[test]
fn high_score_updates_and_flag_set() { fn high_score_updates_and_flag_set() {
with_high_score_file(|| { 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 // 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.prev_high_score_for_display = 5;
g.puzzle.guess_result = Some(GuessResult::Correct); g.puzzle.guess_result = Some(GuessResult::Correct);
g.finalize_round(); g.finalize_round();
assert!(g.new_high_score_reached); 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 assert_eq!(g.prev_high_score_for_display, 5); // previous stored
}); });
} }
#[test] #[test]
fn hearts_representation_matches_lives() { 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; g.lives = 2;
assert_eq!(g.lives_hearts(), "♥♥·"); assert_eq!(g.lives_hearts(), "♥♥·");
} }
#[test] #[test]
fn handle_input_navigation_changes_selected_suggestion() { 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; let initial = g.puzzle.selected_suggestion;
// Simulate Right key // Simulate Right key
let right_event = KeyEvent { let right_event = KeyEvent {

View File

@ -1,85 +1,179 @@
use ratatui::layout::Flex; use ratatui::layout::Flex;
use ratatui::prelude::*; use ratatui::prelude::*;
use std::collections::HashMap; use std::time::{Duration, Instant};
pub struct AsciiCell { /// Type alias for the color function used in procedural animations
pub ch: char, type ColorFn = Box<dyn Fn(usize, usize, f32, usize, Color) -> Color>;
pub x: u16,
pub y: u16, /// Type alias for the character transformation function
pub color: Color, 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)] impl ProceduralAnimationWidget {
pub fn parse_ascii_art( pub fn new(
art: &str, art: String,
color_map_str: &str, num_frames: usize,
color_map: &HashMap<char, Color>, frame_duration: Duration,
default_color: Color, color_fn: impl Fn(usize, usize, f32, usize, Color) -> Color + 'static,
) -> 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();
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 });
}
}
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,
) -> Self { ) -> Self {
Self { cells: parse_ascii_art(art, color_map_str, color_map, default_color) } 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;
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,
}
}
pub fn with_char_fn(
mut self,
char_fn: impl Fn(usize, usize, f32, usize, char) -> char + 'static,
) -> Self {
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 { 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 { pub fn get_height(&self) -> u16 {
self.cells.iter().map(|cell| cell.y).max().unwrap_or(0) + 1 self.height
} }
}
pub struct AsciiArtWidget { /// Set the highlight color for the animation
collection: AsciiCells, pub fn set_highlight_color(&mut self, color: Color) {
} self.highlight_color = color;
impl AsciiArtWidget {
pub const fn new(collection: AsciiCells) -> Self {
Self { collection }
} }
}
impl Widget for AsciiArtWidget { fn get_animation_progress_and_cycle(&self) -> (f32, usize) {
fn render(self, area: Rect, buf: &mut Buffer) { if self.paused {
for pixel in self.collection.cells { return (self.paused_progress, self.paused_cycle);
let position = Position::new(pixel.x + area.x, pixel.y + area.y); }
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) { if area.contains(position) {
#[allow(clippy::expect_used)] #[allow(clippy::expect_used)]
buf.cell_mut(position) buf.cell_mut(position)
.expect("Failed to get cell at position") .expect("Failed to get cell at position")
.set_char(pixel.ch) .set_char(display_char)
.set_fg(pixel.color); .set_fg(color);
}
} }
} }
} }