diff --git a/docs/splash.gif b/docs/splash.gif new file mode 100644 index 0000000..3e1b947 Binary files /dev/null and b/docs/splash.gif differ diff --git a/src/app.rs b/src/app.rs index a62f54e..4753d43 100644 --- a/src/app.rs +++ b/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); +#[derive(Copy, Clone, PartialEq, Debug)] +pub enum NumberMode { + Unsigned, + Signed, +} + +impl NumberMode { + pub const fn label(&self) -> &'static str { + match self { + Self::Unsigned => "UNSIGNED", + Self::Signed => "SIGNED", + } + } +} -fn get_last_selected_index() -> usize { - LAST_SELECTED_INDEX.load(Ordering::Relaxed) +/// Persistent application preferences that survive across menu/game transitions +#[derive(Copy, Clone, Debug)] +struct AppPreferences { + last_selected_index: usize, + last_number_mode: NumberMode, } -fn set_last_selected_index(index: usize) { - LAST_SELECTED_INDEX.store(index, Ordering::Relaxed); +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(125, 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 { +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 = 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 = upper_labels .into_iter() .enumerate() .map(|(i, label)| { - let marker = if i == selected { '»' } else { ' ' }; - let padded = 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 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. - "#}; - - let colors = 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. - "#}; - - 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 default_color = Color::LightBlue; - AsciiCells::from(art, colors, &color_map, default_color) + "#} + .to_string(); + + // 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 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; + + // 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, + }; } } diff --git a/src/binary_numbers.rs b/src/binary_numbers.rs index d2d131c..a25d483 100644 --- a/src/binary_numbers.rs +++ b/src/binary_numbers.rs @@ -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, - selected_suggestion: Option, + suggestions: Vec, // Changed to i32 to support signed values + selected_suggestion: Option, time_total: f64, time_left: f64, guess_result: Option, @@ -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(); - - if bits.is_twos_complement() { - // For two's complement, generate unique raw bit patterns (0-15) - let mut raw_values: Vec = 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); + let num_bits = bits.to_int(); + + 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, + scores: HashMap, } 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::() && let Ok(score) = v.trim().parse::() { - 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: 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 { diff --git a/src/utils.rs b/src/utils.rs index 878f9e1..41399da 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,85 +1,179 @@ use ratatui::layout::Flex; use ratatui::prelude::*; -use std::collections::HashMap; - -pub struct AsciiCell { - pub ch: char, - pub x: u16, - pub y: u16, - pub color: Color, +use std::time::{Duration, Instant}; + +/// Type alias for the color function used in procedural animations +type ColorFn = Box Color>; + +/// Type alias for the character transformation function +type CharFn = Box 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, // (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, - default_color: Color, -) -> Vec { - let art_lines: Vec> = art.lines().map(|line| line.chars().collect()).collect(); - let color_lines: Vec> = - 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"); +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; + + 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, + } + } - let mut pixels = Vec::new(); + 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 + } - 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"); + pub fn with_pause_at_end(mut self, pause: Duration) -> Self { + self.pause_at_end = pause; + self + } - 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 }); + 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; } } - pixels -} + 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 struct AsciiCells { - pub cells: Vec, -} + pub fn toggle_pause(&mut self) { + if self.paused { + self.unpause(); + } else { + self.pause(); + } + } -impl AsciiCells { - pub fn from( - art: &str, - color_map_str: &str, - color_map: &HashMap, - default_color: Color, - ) -> Self { - Self { cells: parse_ascii_art(art, color_map_str, color_map, default_color) } + 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, -} + /// Set the highlight color for the animation + pub fn set_highlight_color(&mut self, color: Color) { + self.highlight_color = color; + } + + fn get_animation_progress_and_cycle(&self) -> (f32, usize) { + if self.paused { + return (self.paused_progress, self.paused_cycle); + } + + 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; -impl AsciiArtWidget { - pub const fn new(collection: AsciiCells) -> Self { - Self { collection } + 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); } -} -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); - - 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); + 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); + } } } }