mirror of
https://github.com/lucaspalomodevelop/binbreak.git
synced 2026-03-13 00:07:28 +00:00
Compare commits
5 Commits
714f9ec187
...
503f9eef0b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
503f9eef0b | ||
|
|
a67d852437 | ||
|
|
59496a70b6 | ||
|
|
5e078073a5 | ||
|
|
c22617f642 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
|||||||
/target
|
/target
|
||||||
/binbreak_highscores.txt
|
/binbreak_highscores.txt
|
||||||
/.idea
|
/.idea
|
||||||
|
/executables
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -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",
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
46
build.sh
Executable file
@ -0,0 +1,46 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Build script for binbreak game
|
||||||
|
# Builds for Linux and Windows, copies executables to executables/ directory
|
||||||
|
|
||||||
|
set -e # Exit on error
|
||||||
|
|
||||||
|
echo "🔨 Building binbreak for multiple platforms..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Create executables directory if it doesn't exist
|
||||||
|
EXEC_DIR="executables"
|
||||||
|
mkdir -p "$EXEC_DIR"
|
||||||
|
|
||||||
|
# Get version from Cargo.toml
|
||||||
|
VERSION=$(grep '^version' Cargo.toml | head -n1 | cut -d'"' -f2)
|
||||||
|
|
||||||
|
# Build for Linux (native)
|
||||||
|
echo "📦 Building for Linux (x86_64)..."
|
||||||
|
cargo build --release
|
||||||
|
LINUX_BIN="binbreak-v${VERSION}-linux-x86_64"
|
||||||
|
cp "target/release/binbreak" "$EXEC_DIR/$LINUX_BIN"
|
||||||
|
echo "✅ Linux build complete: $EXEC_DIR/$LINUX_BIN"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Build for Windows
|
||||||
|
echo "📦 Building for Windows (x86_64)..."
|
||||||
|
if ! rustup target list | grep -q "x86_64-pc-windows-gnu (installed)"; then
|
||||||
|
echo "⚠️ Installing Windows target (x86_64-pc-windows-gnu)..."
|
||||||
|
rustup target add x86_64-pc-windows-gnu
|
||||||
|
fi
|
||||||
|
|
||||||
|
cargo build --release --target x86_64-pc-windows-gnu
|
||||||
|
WINDOWS_BIN="binbreak-v${VERSION}-windows-x86_64.exe"
|
||||||
|
cp "target/x86_64-pc-windows-gnu/release/binbreak.exe" "$EXEC_DIR/$WINDOWS_BIN"
|
||||||
|
echo "✅ Windows build complete: $EXEC_DIR/$WINDOWS_BIN"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
echo "🎉 All builds complete!"
|
||||||
|
echo ""
|
||||||
|
echo "Executables:"
|
||||||
|
ls -lh "$EXEC_DIR" | tail -n +2
|
||||||
|
echo ""
|
||||||
|
echo "Location: $EXEC_DIR/"
|
||||||
|
|
||||||
BIN
docs/splash.gif
Normal file
BIN
docs/splash.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 920 KiB |
328
src/app.rs
328
src/app.rs
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
216
src/utils.rs
216
src/utils.rs
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user