mirror of
https://github.com/lucaspalomodevelop/binbreak.git
synced 2026-03-13 08:09:43 +00:00
864 lines
34 KiB
Rust
864 lines
34 KiB
Rust
use crate::main_screen_widget::{MainScreenWidget, WidgetRef};
|
|
use crate::utils::{center, When};
|
|
use crossterm::event::{KeyCode, KeyEvent};
|
|
use rand::prelude::SliceRandom;
|
|
use rand::Rng;
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::{Constraint, Direction, Flex, Layout, Rect};
|
|
use ratatui::prelude::Alignment::Center;
|
|
use ratatui::prelude::{Color, Line, Style, Stylize, Widget};
|
|
use ratatui::style::Modifier;
|
|
use ratatui::text::Span;
|
|
use ratatui::widgets::BorderType::Double;
|
|
use ratatui::widgets::{Block, BorderType, Paragraph};
|
|
use std::collections::HashMap;
|
|
use std::fs::{File};
|
|
use std::io::{Read, Write};
|
|
|
|
struct StatsSnapshot {
|
|
score: u32,
|
|
streak: u32,
|
|
max_streak: u32,
|
|
rounds: u32,
|
|
lives: u32,
|
|
bits: Bits,
|
|
hearts: String,
|
|
game_state: GameState,
|
|
prev_high_score: u32,
|
|
new_high_score: bool,
|
|
}
|
|
|
|
impl WidgetRef for BinaryNumbersGame {
|
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
|
let [game_column] = Layout::horizontal([Constraint::Length(65)])
|
|
.flex(Flex::Center)
|
|
.horizontal_margin(1)
|
|
.areas(area);
|
|
|
|
self.puzzle.render_ref(game_column, buf);
|
|
}
|
|
}
|
|
|
|
impl WidgetRef for BinaryNumbersPuzzle {
|
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
|
let [middle] = Layout::horizontal([Constraint::Percentage(100)])
|
|
.flex(Flex::Center)
|
|
.areas(area);
|
|
|
|
let [stats_area, current_number_area, suggestions_area, progress_bar_area, result_area] =
|
|
Layout::vertical([
|
|
Constraint::Length(4),
|
|
Constraint::Length(5),
|
|
Constraint::Length(3),
|
|
Constraint::Length(4),
|
|
Constraint::Length(5),
|
|
])
|
|
.flex(Flex::Center)
|
|
.horizontal_margin(0)
|
|
.areas(middle);
|
|
|
|
Block::bordered()
|
|
.title_alignment(Center)
|
|
.dark_gray()
|
|
.render(stats_area, buf);
|
|
|
|
if let Some(stats) = &self.stats_snapshot {
|
|
let high_label = if stats.new_high_score {
|
|
let style = Style::default().fg(Color::LightGreen).add_modifier(Modifier::BOLD);
|
|
Span::styled(format!("Hi-Score: {}* ", stats.score), style)
|
|
} else {
|
|
let style = Style::default().fg(Color::DarkGray);
|
|
Span::styled(format!("Hi-Score: {} ", stats.prev_high_score), style)
|
|
};
|
|
|
|
let line1 = Line::from(vec![
|
|
Span::styled(format!("Mode: {} ", stats.bits.label()), Style::default().fg(Color::Yellow)),
|
|
high_label,
|
|
]);
|
|
|
|
let line2 = Line::from(vec![
|
|
Span::styled(format!("Score: {} ", stats.score), Style::default().fg(Color::Green)),
|
|
Span::styled(format!("Streak: {} ", stats.streak), Style::default().fg(Color::Cyan)),
|
|
Span::styled(format!("Max: {} ", stats.max_streak), Style::default().fg(Color::Blue)),
|
|
Span::styled(format!("Rounds: {} ", stats.rounds), Style::default().fg(Color::Magenta)),
|
|
Span::styled(format!("Lives: {} ", stats.hearts), Style::default().fg(Color::Red)),
|
|
]);
|
|
|
|
let widest = line1.width().max(line2.width()) as u16;
|
|
Paragraph::new(vec![line1, line2])
|
|
.alignment(Center)
|
|
.render(center(stats_area, Constraint::Length(widest)), buf);
|
|
|
|
// If game over, render game over block occupying the remaining area and return early
|
|
if stats.game_state == GameState::GameOver {
|
|
let combined_rect = Rect { x: current_number_area.x, y: current_number_area.y, width: current_number_area.width, height: current_number_area.height + suggestions_area.height + progress_bar_area.height + result_area.height };
|
|
let block = Block::bordered()
|
|
.title("Game Over")
|
|
.title_alignment(Center)
|
|
.border_type(Double)
|
|
.title_style(Style::default().fg(Color::Red));
|
|
block.render(combined_rect, buf);
|
|
let mut lines = vec![
|
|
Line::from(Span::styled(format!("Final Score: {}", stats.score), Style::default().fg(Color::Green))),
|
|
Line::from(Span::styled(format!("Previous High: {}", stats.prev_high_score), Style::default().fg(Color::Yellow))),
|
|
Line::from(Span::styled(format!("Rounds Played: {}", stats.rounds), Style::default().fg(Color::Magenta))),
|
|
Line::from(Span::styled(format!("Max Streak: {}", stats.max_streak), Style::default().fg(Color::Cyan))),
|
|
];
|
|
if stats.new_high_score {
|
|
lines.insert(1, Line::from(Span::styled("NEW HIGH SCORE!", Style::default().fg(Color::LightGreen).bold())));
|
|
}
|
|
if stats.lives == 0 {
|
|
lines.push(Line::from(Span::styled("You lost all your lives.", Style::default().fg(Color::Red))));
|
|
}
|
|
lines.push(Line::from(Span::styled("Press Enter to restart or Esc to exit", Style::default().fg(Color::Yellow))));
|
|
Paragraph::new(lines)
|
|
.alignment(Center)
|
|
.render(center(combined_rect, Constraint::Length(48)), buf);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Existing puzzle rendering now uses updated area references
|
|
let [inner] = Layout::horizontal([Constraint::Percentage(100)])
|
|
.flex(Flex::Center)
|
|
.areas(current_number_area);
|
|
|
|
Block::bordered()
|
|
.border_type(Double)
|
|
.border_style(Style::default().dark_gray())
|
|
.render(inner, buf);
|
|
|
|
let binary_string = self.current_to_binary_string();
|
|
let scale_suffix = match self.bits {
|
|
Bits::FourShift4 => Some(" x16"),
|
|
Bits::FourShift8 => Some(" x256"),
|
|
Bits::FourShift12 => Some(" x4096"),
|
|
_ => None
|
|
};
|
|
let mut spans = vec![Span::raw(binary_string.clone())];
|
|
if let Some(sfx) = scale_suffix { spans.push(Span::styled(sfx, Style::default().fg(Color::DarkGray))); }
|
|
let total_width = spans.iter().map(|s| s.width()).sum::<usize>() as u16;
|
|
let lines: Vec<Line> = vec![Line::from(spans)];
|
|
Paragraph::new(lines).alignment(Center).render(center(inner, Constraint::Length(total_width)), buf);
|
|
|
|
let suggestions = self.suggestions();
|
|
let suggestions_layout = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints(vec![Constraint::Min(6); suggestions.len()])
|
|
.split(suggestions_area);
|
|
for (i, suggestion) in suggestions.iter().enumerate() {
|
|
let item_is_selected = self.selected_suggestion == Some(*suggestion);
|
|
let show_correct_number = self.guess_result.is_some();
|
|
let is_correct_number = self.is_correct_guess(*suggestion);
|
|
let area = suggestions_layout[i];
|
|
|
|
let border_type = if item_is_selected { BorderType::Double } else { BorderType::Plain };
|
|
|
|
let border_color = if item_is_selected {
|
|
match self.guess_result {
|
|
Some(GuessResult::Correct) => Color::Green,
|
|
Some(GuessResult::Incorrect) => Color::Red,
|
|
Some(GuessResult::Timeout) => Color::Yellow,
|
|
None => Color::LightCyan,
|
|
}
|
|
} else {
|
|
Color::DarkGray
|
|
};
|
|
|
|
Block::bordered().border_type(border_type).fg(border_color).render(area, buf);
|
|
|
|
let suggestion_str = format!("{suggestion}");
|
|
Paragraph::new(suggestion_str.to_string())
|
|
.white()
|
|
.when(show_correct_number && is_correct_number, |p| p.light_green().underlined())
|
|
.alignment(Center)
|
|
.render(center(area, Constraint::Length(suggestion_str.len() as u16)), buf);
|
|
}
|
|
|
|
let [left, right] = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
|
.areas(progress_bar_area);
|
|
|
|
Block::bordered().dark_gray().title("Status").title_alignment(Center).title_style(Style::default().white()).render(left, buf);
|
|
|
|
if let Some(result) = &self.guess_result {
|
|
let (icon, line1_text, color) = match result {
|
|
GuessResult::Correct => (":)", "success", Color::Green),
|
|
GuessResult::Incorrect => (":(", "incorrect", Color::Red),
|
|
GuessResult::Timeout => (":(", "time's up", Color::Yellow),
|
|
};
|
|
|
|
let gained_line = match result {
|
|
GuessResult::Correct => format!("gained {} points", self.last_points_awarded),
|
|
GuessResult::Incorrect => "lost a life".to_string(),
|
|
GuessResult::Timeout => "timeout".to_string(),
|
|
};
|
|
|
|
let text = vec![
|
|
Line::from(format!("{} {}", icon, line1_text).fg(color)),
|
|
Line::from(gained_line.fg(color)),
|
|
];
|
|
let widest = text.iter().map(|l| l.width()).max().unwrap_or(0) as u16;
|
|
Paragraph::new(text)
|
|
.alignment(Center)
|
|
.style(Style::default().fg(color))
|
|
.render(center(left, Constraint::Length(widest)), buf);
|
|
}
|
|
|
|
let ratio = self.time_left / self.time_total;
|
|
let gauge_color = if ratio > 0.6 {
|
|
Color::Green
|
|
} else if ratio > 0.3 {
|
|
Color::Yellow
|
|
} else {
|
|
Color::Red
|
|
};
|
|
|
|
// Replace previous split layout: keep everything inside a single bordered block and remove percent label
|
|
let time_block = Block::bordered()
|
|
.dark_gray()
|
|
.title("Time Remaining")
|
|
.title_style(Style::default().white())
|
|
.title_alignment(Center);
|
|
let inner_time = time_block.inner(right);
|
|
time_block.render(right, buf);
|
|
|
|
// Vertical layout inside the time block interior: gauge line + text line (2 lines total)
|
|
let [gauge_line, time_line] = Layout::vertical([
|
|
Constraint::Length(1),
|
|
Constraint::Length(1)
|
|
]).areas(inner_time);
|
|
|
|
render_ascii_gauge(gauge_line, buf, ratio, gauge_color);
|
|
|
|
Paragraph::new(Line::from(Span::styled(
|
|
format!("{:.2} seconds left", self.time_left),
|
|
Style::default().fg(gauge_color),
|
|
)))
|
|
.alignment(Center)
|
|
.render(time_line, buf);
|
|
|
|
Block::bordered().dark_gray().render(result_area, buf);
|
|
|
|
let instruction_spans: Vec<Span> = [
|
|
hotkey_span("Left Right", "select "),
|
|
hotkey_span("Enter", "confirm "),
|
|
hotkey_span("S", "skip "),
|
|
hotkey_span("Esc", "exit"),
|
|
].iter().flatten().cloned().collect();
|
|
|
|
Paragraph::new(vec![Line::from(instruction_spans)])
|
|
.alignment(Center)
|
|
.render(center(result_area, Constraint::Length(65)), buf);
|
|
}
|
|
}
|
|
|
|
fn hotkey_span<'a>(key: &'a str, description: &str) -> Vec<Span<'a>> {
|
|
vec![
|
|
Span::styled("<", Style::default().fg(Color::White)),
|
|
Span::styled(key, Style::default().fg(Color::LightCyan)),
|
|
Span::styled(format!("> {}", description), Style::default().fg(Color::White)),
|
|
]
|
|
}
|
|
|
|
pub struct BinaryNumbersGame {
|
|
puzzle: BinaryNumbersPuzzle,
|
|
bits: Bits,
|
|
exit_intended: bool,
|
|
score: u32,
|
|
streak: u32,
|
|
rounds: u32,
|
|
puzzle_resolved: bool,
|
|
lives: u32,
|
|
max_lives: u32,
|
|
game_state: GameState,
|
|
max_streak: u32,
|
|
high_scores: HighScores,
|
|
prev_high_score_for_display: u32,
|
|
new_high_score_reached: bool,
|
|
needs_render: bool, // Flag to render one frame after state transition
|
|
}
|
|
|
|
|
|
#[derive(Copy, Clone, PartialEq, Debug)]
|
|
enum GameState { Active, Result, PendingGameOver, GameOver }
|
|
|
|
impl MainScreenWidget for BinaryNumbersGame {
|
|
fn run(&mut self, dt: f64) {
|
|
self.refresh_stats_snapshot();
|
|
if self.game_state == GameState::GameOver { return; }
|
|
self.puzzle.run(dt);
|
|
if self.puzzle.guess_result.is_some() && !self.puzzle_resolved { self.finalize_round(); }
|
|
self.refresh_stats_snapshot();
|
|
}
|
|
|
|
fn handle_input(&mut self, input: KeyEvent) { self.handle_game_input(input); }
|
|
fn is_exit_intended(&self) -> bool { self.exit_intended }
|
|
}
|
|
|
|
impl BinaryNumbersGame {
|
|
pub fn new(bits: Bits) -> Self { Self::new_with_max_lives(bits, 3) }
|
|
pub fn new_with_max_lives(bits: Bits, max_lives: u32) -> Self {
|
|
let hs = HighScores::load();
|
|
let starting_prev = hs.get(bits.high_score_key());
|
|
let mut game = Self {
|
|
bits: bits.clone(),
|
|
puzzle: Self::init_puzzle(bits.clone(), 0),
|
|
exit_intended: false,
|
|
score: 0,
|
|
streak: 0,
|
|
rounds: 0,
|
|
puzzle_resolved: false,
|
|
lives: max_lives.min(3),
|
|
max_lives,
|
|
game_state: GameState::Active,
|
|
max_streak: 0,
|
|
high_scores: hs,
|
|
prev_high_score_for_display: starting_prev,
|
|
new_high_score_reached: false,
|
|
needs_render: true, // Need to render initial state
|
|
};
|
|
// Initialize stats snapshot immediately so stats display on first render
|
|
game.refresh_stats_snapshot();
|
|
game
|
|
}
|
|
|
|
pub fn init_puzzle(bits: Bits, streak: u32) -> BinaryNumbersPuzzle {
|
|
BinaryNumbersPuzzle::new(bits, streak)
|
|
}
|
|
|
|
/// Check if the game is in Active state (timer running)
|
|
pub fn is_active(&self) -> bool {
|
|
self.game_state == GameState::Active
|
|
}
|
|
|
|
/// Check if we need to render one frame (e.g., after state transition)
|
|
pub fn needs_render(&self) -> bool {
|
|
self.needs_render
|
|
}
|
|
|
|
/// Clear the needs_render flag after rendering
|
|
pub fn clear_needs_render(&mut self) {
|
|
self.needs_render = false;
|
|
}
|
|
}
|
|
|
|
impl BinaryNumbersGame {
|
|
pub fn lives_hearts(&self) -> String {
|
|
let full_count = self.lives.min(self.max_lives) as usize;
|
|
let full = "♥".repeat(full_count);
|
|
let empty_count = self.max_lives.saturating_sub(self.lives) as usize;
|
|
let empty = "·".repeat(empty_count);
|
|
format!("{}{}", full, empty)
|
|
}
|
|
|
|
fn finalize_round(&mut self) {
|
|
if let Some(result) = self.puzzle.guess_result {
|
|
self.rounds += 1;
|
|
match result {
|
|
GuessResult::Correct => {
|
|
self.streak += 1;
|
|
if self.streak > self.max_streak { self.max_streak = self.streak; }
|
|
let streak_bonus = (self.streak - 1) * 2;
|
|
let points = 10 + streak_bonus;
|
|
self.score += points;
|
|
self.puzzle.last_points_awarded = points;
|
|
if self.streak % 5 == 0 && self.lives < self.max_lives { self.lives += 1; }
|
|
}
|
|
GuessResult::Incorrect | GuessResult::Timeout => {
|
|
self.streak = 0;
|
|
self.puzzle.last_points_awarded = 0;
|
|
if self.lives > 0 { self.lives -= 1; }
|
|
}
|
|
}
|
|
// high score update
|
|
let bits_key = self.bits.high_score_key();
|
|
let prev = self.high_scores.get(bits_key);
|
|
if self.score > prev {
|
|
if !self.new_high_score_reached { self.prev_high_score_for_display = prev; }
|
|
self.high_scores.update(bits_key, self.score);
|
|
self.new_high_score_reached = true;
|
|
let _ = self.high_scores.save();
|
|
}
|
|
// set state after round resolution
|
|
if self.lives == 0 {
|
|
self.game_state = GameState::PendingGameOver; // defer summary until Enter
|
|
self.needs_render = true; // Need to render result before blocking
|
|
} else {
|
|
self.game_state = GameState::Result;
|
|
self.needs_render = true; // Need to render result before blocking
|
|
}
|
|
self.puzzle_resolved = true;
|
|
}
|
|
}
|
|
|
|
pub fn handle_game_input(&mut self, input: KeyEvent) {
|
|
if input.code == KeyCode::Esc { self.exit_intended = true; return; }
|
|
|
|
if self.game_state == GameState::GameOver { self.handle_game_over_input(input); return; }
|
|
match self.puzzle.guess_result {
|
|
None => self.handle_no_result_yet(input),
|
|
Some(_) => self.handle_result_available(input),
|
|
}
|
|
}
|
|
|
|
fn handle_game_over_input(&mut self, input: KeyEvent) {
|
|
match input.code {
|
|
KeyCode::Enter => { self.reset_game_state(); }
|
|
KeyCode::Esc => { self.exit_intended = true; }
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn reset_game_state(&mut self) {
|
|
self.score = 0;
|
|
self.streak = 0;
|
|
self.rounds = 0;
|
|
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());
|
|
self.new_high_score_reached = false;
|
|
self.puzzle = Self::init_puzzle(self.bits.clone(), 0);
|
|
self.puzzle_resolved = false;
|
|
self.refresh_stats_snapshot();
|
|
}
|
|
|
|
fn handle_no_result_yet(&mut self, input: KeyEvent) {
|
|
match input.code {
|
|
KeyCode::Right => {
|
|
// select the next suggestion
|
|
if let Some(selected) = self.puzzle.selected_suggestion {
|
|
let current_index = self.puzzle.suggestions.iter().position(|&x| x == selected);
|
|
if let Some(index) = current_index {
|
|
let next_index = (index + 1) % self.puzzle.suggestions.len();
|
|
self.puzzle.selected_suggestion = Some(self.puzzle.suggestions[next_index]);
|
|
}
|
|
} else {
|
|
// if no suggestion is selected, select the first one
|
|
self.puzzle.selected_suggestion = Some(self.puzzle.suggestions[0]);
|
|
}
|
|
}
|
|
KeyCode::Left => {
|
|
// select the previous suggestion
|
|
if let Some(selected) = self.puzzle.selected_suggestion {
|
|
let current_index = self.puzzle.suggestions.iter().position(|&x| x == selected);
|
|
if let Some(index) = current_index {
|
|
let prev_index = if index == 0 {
|
|
self.puzzle.suggestions.len() - 1
|
|
} else {
|
|
index - 1
|
|
};
|
|
self.puzzle.selected_suggestion = Some(self.puzzle.suggestions[prev_index]);
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Enter => {
|
|
if let Some(selected) = self.puzzle.selected_suggestion {
|
|
if self.puzzle.is_correct_guess(selected) {
|
|
self.puzzle.guess_result = Some(GuessResult::Correct);
|
|
} else {
|
|
self.puzzle.guess_result = Some(GuessResult::Incorrect);
|
|
}
|
|
self.finalize_round();
|
|
}
|
|
}
|
|
KeyCode::Char('s') | KeyCode::Char('S') => {
|
|
// Skip puzzle counts as timeout
|
|
self.puzzle.guess_result = Some(GuessResult::Timeout);
|
|
self.finalize_round();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn handle_result_available(&mut self, input: KeyEvent) {
|
|
match input.code {
|
|
KeyCode::Enter => {
|
|
match self.game_state {
|
|
GameState::PendingGameOver => {
|
|
// reveal summary
|
|
self.game_state = GameState::GameOver;
|
|
}
|
|
GameState::Result => {
|
|
// start next puzzle
|
|
self.puzzle = Self::init_puzzle(self.bits.clone(), self.streak);
|
|
self.puzzle_resolved = false;
|
|
self.game_state = GameState::Active;
|
|
}
|
|
GameState::GameOver => { /* handled elsewhere */ }
|
|
GameState::Active => { /* shouldn't be here */ }
|
|
}
|
|
}
|
|
KeyCode::Esc => self.exit_intended = true,
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn refresh_stats_snapshot(&mut self) {
|
|
self.puzzle.stats_snapshot = Some(StatsSnapshot {
|
|
score: self.score,
|
|
streak: self.streak,
|
|
max_streak: self.max_streak,
|
|
rounds: self.rounds,
|
|
lives: self.lives,
|
|
bits: self.bits.clone(),
|
|
hearts: self.lives_hearts(),
|
|
game_state: self.game_state,
|
|
prev_high_score: self.prev_high_score_for_display,
|
|
new_high_score: self.new_high_score_reached,
|
|
});
|
|
}
|
|
}
|
|
|
|
#[derive(PartialEq, Copy, Clone, Debug)]
|
|
enum GuessResult {
|
|
Correct,
|
|
Incorrect,
|
|
Timeout,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub enum Bits { Four, FourShift4, FourShift8, FourShift12, Eight, Twelve, Sixteen, }
|
|
|
|
impl Bits {
|
|
pub fn to_int(&self) -> u32 { match self { Bits::Four | Bits::FourShift4 | Bits::FourShift8 | Bits::FourShift12 => 4, Bits::Eight => 8, Bits::Twelve => 12, Bits::Sixteen => 16 } }
|
|
pub fn scale_factor(&self) -> u32 { match self { Bits::Four => 1, Bits::FourShift4 => 16, Bits::FourShift8 => 256, Bits::FourShift12 => 4096, Bits::Eight => 1, Bits::Twelve => 1, Bits::Sixteen => 1 } }
|
|
pub fn high_score_key(&self) -> u32 { match self { Bits::Four => 4, Bits::FourShift4 => 44, Bits::FourShift8 => 48, Bits::FourShift12 => 412, Bits::Eight => 8, Bits::Twelve => 12, Bits::Sixteen => 16 } }
|
|
pub fn upper_bound(&self) -> u32 { (u32::pow(2, self.to_int()) - 1) * self.scale_factor() }
|
|
pub fn suggestion_count(&self) -> usize { match self { Bits::Four | Bits::FourShift4 | Bits::FourShift8 | Bits::FourShift12 => 3, Bits::Eight => 4, Bits::Twelve => 5, Bits::Sixteen => 6 } }
|
|
pub fn label(&self) -> &'static str { match self { Bits::Four => "4 bits", Bits::FourShift4 => "4 bits*16", Bits::FourShift8 => "4 bits*256", Bits::FourShift12 => "4 bits*4096", Bits::Eight => "8 bits", Bits::Twelve => "12 bits", Bits::Sixteen => "16 bits" } }
|
|
}
|
|
|
|
pub struct BinaryNumbersPuzzle {
|
|
bits: Bits,
|
|
current_number: u32, // scaled value used for suggestions matching
|
|
raw_current_number: u32, // raw bit value (unscaled) for display
|
|
suggestions: Vec<u32>,
|
|
selected_suggestion: Option<u32>,
|
|
time_total: f64,
|
|
time_left: f64,
|
|
guess_result: Option<GuessResult>,
|
|
last_points_awarded: u32,
|
|
stats_snapshot: Option<StatsSnapshot>,
|
|
skip_first_dt: bool, // Skip first dt to prevent timer jump when starting new puzzle
|
|
}
|
|
|
|
impl BinaryNumbersPuzzle {
|
|
pub fn new(bits: Bits, streak: u32) -> Self {
|
|
let mut rng = rand::rng();
|
|
|
|
let mut suggestions = Vec::new();
|
|
let scale = bits.scale_factor();
|
|
while suggestions.len() < bits.suggestion_count() {
|
|
let raw = rng.random_range(0..=u32::pow(2, bits.to_int()) - 1);
|
|
let num = raw * scale;
|
|
if !suggestions.contains(&num) { suggestions.push(num); }
|
|
}
|
|
|
|
let current_number = suggestions[0]; // scaled value
|
|
let raw_current_number = current_number / scale; // back-calculate raw bits
|
|
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 => 8.0,
|
|
Bits::Eight => 12.0,
|
|
Bits::Twelve => 16.0,
|
|
Bits::Sixteen => 20.0,
|
|
};
|
|
let penalty = (streak as f64) * 0.5; // 0.5s less per streak
|
|
let time_total = (base_time - penalty).max(5.0);
|
|
let time_left = time_total;
|
|
let selected_suggestion = Some(suggestions[0]);
|
|
let guess_result = None;
|
|
let last_points_awarded = 0;
|
|
|
|
Self {
|
|
bits,
|
|
current_number,
|
|
raw_current_number,
|
|
suggestions,
|
|
time_total,
|
|
time_left,
|
|
selected_suggestion,
|
|
guess_result,
|
|
last_points_awarded,
|
|
stats_snapshot: None,
|
|
skip_first_dt: true, // Skip first dt to prevent timer jump
|
|
}
|
|
}
|
|
|
|
pub fn suggestions(&self) -> &[u32] { &self.suggestions }
|
|
pub fn is_correct_guess(&self, guess: u32) -> bool { guess == self.current_number }
|
|
|
|
pub fn current_to_binary_string(&self) -> String {
|
|
let width = self.bits.to_int() as usize;
|
|
let raw = format!("{:0width$b}", self.raw_current_number, width = width);
|
|
raw.chars()
|
|
.collect::<Vec<_>>()
|
|
.chunks(4)
|
|
.map(|chunk| chunk.iter().collect::<String>())
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
}
|
|
|
|
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.time_left <= 0.0 {
|
|
self.guess_result = Some(GuessResult::Timeout);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Widget for &mut BinaryNumbersGame {
|
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
|
self.render_ref(area, buf);
|
|
}
|
|
}
|
|
|
|
// Simple ASCII gauge renderer to avoid variable glyph heights from Unicode block elements
|
|
fn render_ascii_gauge(area: Rect, buf: &mut Buffer, ratio: f64, color: Color) {
|
|
let fill_width = ((area.width as f64) * ratio.clamp(0.0, 1.0)).round().min(area.width as f64) as u16;
|
|
if area.height == 0 { return; }
|
|
for x in 0..area.width {
|
|
let filled = x < fill_width;
|
|
let symbol = if filled { "=" } else { " " };
|
|
let style = if filled {
|
|
Style::default().fg(color)
|
|
} else {
|
|
Style::default().fg(Color::DarkGray)
|
|
};
|
|
|
|
if let Some(cell) = buf.cell_mut((area.x + x, area.y)) {
|
|
cell.set_symbol(symbol);
|
|
cell.set_style(style);
|
|
}
|
|
}
|
|
}
|
|
|
|
struct HighScores { scores: HashMap<u32, u32>, }
|
|
|
|
impl HighScores {
|
|
const FILE: &'static str = "binbreak_highscores.txt";
|
|
|
|
fn empty() -> Self { Self { scores: HashMap::new() } }
|
|
|
|
fn load() -> Self {
|
|
let mut hs = Self::empty();
|
|
if let Ok(mut file) = File::open(Self::FILE) {
|
|
let mut contents = String::new();
|
|
if file.read_to_string(&mut contents).is_ok() {
|
|
for line in contents.lines() {
|
|
if let Some((k,v)) = line.split_once('=')
|
|
&& let Ok(bits) = k.trim().parse::<u32>()
|
|
&& let Ok(score) = v.trim().parse::<u32>()
|
|
{
|
|
hs.scores.insert(bits, score);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
hs
|
|
}
|
|
|
|
fn save(&self) -> std::io::Result<()> {
|
|
let mut data = String::new();
|
|
for key in [4u32,44u32,48u32,412u32,8u32,12u32,16u32] {
|
|
let val = self.get(key);
|
|
data.push_str(&format!("{}={}\n", key, val));
|
|
}
|
|
let mut file = File::create(Self::FILE)?;
|
|
file.write_all(data.as_bytes())
|
|
}
|
|
|
|
fn get(&self, bits: u32) -> u32 {
|
|
*self.scores.get(&bits).unwrap_or(&0)
|
|
}
|
|
|
|
fn update(&mut self, bits: u32, score: u32) {
|
|
self.scores.insert(bits, score);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crossterm::event::{KeyModifiers, KeyEventKind, KeyEventState};
|
|
use std::sync::Mutex;
|
|
use std::fs;
|
|
|
|
static HS_LOCK: Mutex<()> = Mutex::new(());
|
|
|
|
fn with_high_score_file<F: FnOnce()>(f: F) {
|
|
let _guard = HS_LOCK.lock().unwrap();
|
|
let original = fs::read_to_string(HighScores::FILE).ok();
|
|
f();
|
|
// restore
|
|
match original {
|
|
Some(data) => { let _ = fs::write(HighScores::FILE, data); }
|
|
None => { let _ = fs::remove_file(HighScores::FILE); }
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn bits_properties() {
|
|
assert_eq!(Bits::Four.to_int(), 4);
|
|
assert_eq!(Bits::Four.upper_bound(), 15);
|
|
assert_eq!(Bits::Four.suggestion_count(), 3);
|
|
|
|
assert_eq!(Bits::FourShift4.scale_factor(), 16);
|
|
assert_eq!(Bits::FourShift4.upper_bound(), 240);
|
|
assert_eq!(Bits::FourShift4.suggestion_count(), 3);
|
|
|
|
assert_eq!(Bits::FourShift8.scale_factor(), 256);
|
|
assert_eq!(Bits::FourShift12.high_score_key(), 412);
|
|
assert_eq!(Bits::Eight.upper_bound(), 255);
|
|
|
|
assert_eq!(Bits::Sixteen.suggestion_count(), 6);
|
|
}
|
|
|
|
#[test]
|
|
fn puzzle_generation_unique_and_scaled() {
|
|
let p = BinaryNumbersPuzzle::new(Bits::FourShift4.clone(), 0);
|
|
let scale = Bits::FourShift4.scale_factor();
|
|
assert_eq!(p.suggestions().len(), Bits::FourShift4.suggestion_count());
|
|
// uniqueness
|
|
let mut sorted = p.suggestions().to_vec();
|
|
sorted.sort();
|
|
for pair in sorted.windows(2) { assert_ne!(pair[0], pair[1]); }
|
|
// scaling property
|
|
for &s in p.suggestions() { assert_eq!(s % scale, 0); }
|
|
// current number must be one of suggestions and raw_current_number * scale == current_number
|
|
assert!(p.suggestions().contains(&p.current_number));
|
|
assert_eq!(p.raw_current_number * scale, p.current_number);
|
|
}
|
|
|
|
#[test]
|
|
fn binary_string_formatting_groups_every_four_bits() {
|
|
let mut p = BinaryNumbersPuzzle::new(Bits::Eight, 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);
|
|
p4.raw_current_number = 0b0101;
|
|
assert_eq!(p4.current_to_binary_string(), "0101");
|
|
}
|
|
|
|
#[test]
|
|
fn puzzle_timeout_sets_guess_result() {
|
|
let mut p = BinaryNumbersPuzzle::new(Bits::Four, 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
|
|
p.run(1.0);
|
|
assert_eq!(p.guess_result, None, "First run should skip dt");
|
|
// Second run() actually applies the dt and triggers timeout
|
|
p.run(1.0); // exceed remaining time
|
|
assert_eq!(p.guess_result, Some(GuessResult::Timeout));
|
|
}
|
|
|
|
#[test]
|
|
fn finalize_round_correct_increments_score_streak_and_sets_result_state() {
|
|
with_high_score_file(|| {
|
|
let mut g = BinaryNumbersGame::new(Bits::Four);
|
|
// ensure deterministic: mark puzzle correct
|
|
let answer = g.puzzle.current_number;
|
|
g.puzzle.guess_result = Some(GuessResult::Correct);
|
|
g.finalize_round();
|
|
assert_eq!(g.streak, 1);
|
|
assert_eq!(g.score, 10); // base points
|
|
assert_eq!(g.puzzle.last_points_awarded, 10);
|
|
assert_eq!(g.game_state, GameState::Result);
|
|
assert!(g.puzzle_resolved);
|
|
assert!(g.puzzle.is_correct_guess(answer));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn life_awarded_every_five_streak() {
|
|
with_high_score_file(|| {
|
|
let mut g = BinaryNumbersGame::new_with_max_lives(Bits::Four, 3);
|
|
g.lives = 2; // below max
|
|
g.streak = 4; // about to become 5
|
|
g.puzzle.guess_result = Some(GuessResult::Correct);
|
|
g.finalize_round();
|
|
assert_eq!(g.streak, 5);
|
|
assert_eq!(g.lives, 3); // gained life
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn incorrect_guess_resets_streak_and_loses_life() {
|
|
with_high_score_file(|| {
|
|
let mut g = BinaryNumbersGame::new(Bits::Four);
|
|
g.streak = 3;
|
|
let lives_before = g.lives;
|
|
g.puzzle.guess_result = Some(GuessResult::Incorrect);
|
|
g.finalize_round();
|
|
assert_eq!(g.streak, 0);
|
|
assert_eq!(g.lives, lives_before - 1);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn pending_game_over_when_life_reaches_zero() {
|
|
with_high_score_file(|| {
|
|
let mut g = BinaryNumbersGame::new(Bits::Four);
|
|
g.lives = 1;
|
|
g.puzzle.guess_result = Some(GuessResult::Incorrect);
|
|
g.finalize_round();
|
|
assert_eq!(g.lives, 0);
|
|
assert_eq!(g.game_state, GameState::PendingGameOver);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn high_score_updates_and_flag_set() {
|
|
with_high_score_file(|| {
|
|
let mut g = BinaryNumbersGame::new(Bits::Four);
|
|
// Force previous high score low
|
|
g.high_scores.update(g.bits.high_score_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_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);
|
|
g.lives = 2;
|
|
assert_eq!(g.lives_hearts(), "♥♥·");
|
|
}
|
|
|
|
#[test]
|
|
fn handle_input_navigation_changes_selected_suggestion() {
|
|
let mut g = BinaryNumbersGame::new(Bits::Four);
|
|
let initial = g.puzzle.selected_suggestion;
|
|
// Simulate Right key
|
|
let right_event = KeyEvent { code: KeyCode::Right, modifiers: KeyModifiers::empty(), kind: KeyEventKind::Press, state: KeyEventState::NONE };
|
|
g.handle_game_input(right_event);
|
|
assert_ne!(g.puzzle.selected_suggestion, initial);
|
|
// Simulate Left key should cycle back
|
|
let left_event = KeyEvent { code: KeyCode::Left, modifiers: KeyModifiers::empty(), kind: KeyEventKind::Press, state: KeyEventState::NONE };
|
|
g.handle_game_input(left_event);
|
|
assert!(g.puzzle.selected_suggestion.is_some());
|
|
}
|
|
}
|