This commit is contained in:
epic-64 2025-11-08 00:00:00 +01:00
parent e2fd52d6b3
commit 6c7f3e0729
5 changed files with 257 additions and 275 deletions

253
src/app.rs Normal file
View File

@ -0,0 +1,253 @@
use crate::binary_numbers::{BinaryNumbersGame, Bits};
use crate::main_screen_widget::MainScreenWidget;
use crate::utils::{AsciiArtWidget, AsciiCells};
use crossterm::event;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::{Color, Modifier, Span, StatefulWidget, Style, Widget};
use ratatui::widgets::{List, ListItem, ListState};
use std::collections::HashMap;
use std::thread;
use std::time::Instant;
enum AppState {
Start(StartMenuState),
Playing(BinaryNumbersGame),
Exit,
}
fn handle_start_input(state: &mut StartMenuState, key: KeyEvent) -> Option<AppState> {
match key.code {
KeyCode::Up => state.select_previous(),
KeyCode::Down => state.select_next(),
KeyCode::Enter => {
let bits = state.selected_bits();
return Some(AppState::Playing(BinaryNumbersGame::new(bits)));
}
KeyCode::Esc => return Some(AppState::Exit),
_ => {}
}
None
}
fn render_start_screen(state: &mut StartMenuState, area: Rect, buf: &mut Buffer) {
// Build ASCII art to obtain real dimensions
let cells = ascii_art_cells();
let ascii_width = cells.get_width();
let ascii_height = cells.get_height();
let ascii_widget = AsciiArtWidget::new(cells);
let selected = state.selected_index();
let upper_labels: Vec<String> = state.items.iter().map(|(l, _)| l.to_uppercase()).collect();
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
let list_height = upper_labels.len() as u16;
// Vertical spacing between ASCII art and list
let spacing: u16 = 3;
let total_height = ascii_height + spacing + list_height;
// Center vertically & horizontally
let start_y = area.y + area.height.saturating_sub(total_height) / 2;
let ascii_x = area.x + area.width.saturating_sub(ascii_width) / 2;
let list_x = area.x + area.width.saturating_sub(list_width) / 2;
let ascii_y = start_y;
let list_y = ascii_y + ascii_height + spacing;
// Define rects (clamp to area)
let ascii_area = Rect::new(
ascii_x,
ascii_y,
ascii_width.min(area.width),
ascii_height.min(area.height),
);
let list_area = Rect::new(
list_x,
list_y,
list_width.min(area.width),
list_height.min(area.height.saturating_sub(list_y - area.y)),
);
// Render ASCII art
ascii_widget.render(ascii_area, buf);
// Palette for menu flair
let palette = [
Color::LightGreen,
Color::LightCyan,
Color::LightBlue,
Color::LightMagenta,
Color::LightYellow,
Color::LightRed,
];
let items: Vec<ListItem> = upper_labels
.into_iter()
.enumerate()
.map(|(i, label)| {
let marker = if i == selected { '»' } else { ' ' };
let padded = format!("{:<width$}", label, width = max_len as usize);
let line = format!("{} {}", marker, padded);
let style = Style::default()
.fg(palette[i % palette.len()])
.add_modifier(Modifier::BOLD);
ListItem::new(Span::styled(line, style))
})
.collect();
let list = List::new(items);
ratatui::widgets::StatefulWidget::render(list, list_area, buf, &mut state.list_state);
}
pub fn run_app(terminal: &mut ratatui::DefaultTerminal) -> color_eyre::Result<()> {
let mut app_state = AppState::Start(StartMenuState::new());
let mut last_frame_time = Instant::now();
let target_frame_duration = std::time::Duration::from_millis(33); // ~30 FPS
while !matches!(app_state, AppState::Exit) {
let now = Instant::now();
let dt = now - last_frame_time;
last_frame_time = now;
terminal.draw(|f| match &mut app_state {
AppState::Start(menu) => render_start_screen(menu, f.area(), f.buffer_mut()),
AppState::Playing(game) => f.render_widget(&mut *game, f.area()),
AppState::Exit => {}
})?;
// Advance game if playing
if let AppState::Playing(game) = &mut app_state {
game.run(dt.as_secs_f64());
if game.is_exit_intended() {
app_state = AppState::Start(StartMenuState::new());
continue;
}
}
// handle input
let poll_timeout = std::cmp::min(dt, target_frame_duration);
if event::poll(poll_timeout)? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
match key.code {
// global exit via Ctrl+C
KeyCode::Char('c') | KeyCode::Char('C')
if key.modifiers == KeyModifiers::CONTROL =>
{
app_state = AppState::Exit;
}
// state-specific input handling
_ => {
app_state = match app_state {
AppState::Start(mut menu) => handle_start_input(&mut menu, key)
.unwrap_or(AppState::Start(menu)),
AppState::Playing(mut game) => {
game.handle_game_input(key);
AppState::Playing(game)
}
AppState::Exit => AppState::Exit,
}
}
}
}
}
}
// cap frame rate
let frame_duration = last_frame_time.elapsed();
if frame_duration < target_frame_duration {
thread::sleep(target_frame_duration - frame_duration);
}
}
Ok(())
}
fn ascii_art_cells() -> AsciiCells {
let art = r#"
,, ,, ,,
*MM db *MM `7MM
MM MM MM
MM,dMMb.`7MM `7MMpMMMb. MM,dMMb.`7Mb,od8 .gP"Ya ,6"Yb. MM ,MP'
MM `Mb MM MM MM MM `Mb MM' "',M' Yb 8) MM MM ;Y
MM M8 MM MM MM MM M8 MM 8M"""""" ,pm9MM MM;Mm
MM. ,M9 MM MM MM MM. ,M9 MM YM. , 8M MM MM `Mb.
P^YbmdP'.JMML..JMML JMML.P^YbmdP'.JMML. `Mbmmd' `Moo9^Yo..JMML. YA.
"#;
let colors = r#"
,, ,, ,,
*MM db *MM `7MM
MM MM MM
MM,dMMb.`7MM `7MMpMMMb. MM,dMMb.`7Mb,od8 .gP"Ya ,6"Yb. MM ,MP'
MM `Mb MM MM MM MM `Mb MM' "',M' Yb 8) MM MM ;Y
MM M8 MM MM MM MM M8 MM 8M"""""" ,pm9MM MM;Mm
MM. ,M9 MM MM MM MM. ,M9 MM YM. , 8M MM MM `Mb.
P^YbmdP'.JMML..JMML JMML.P^YbmdP'.JMML. `Mbmmd' `Moo9^Yo..JMML. YA.
"#;
let color_map = HashMap::from([
('M', Color::White),
('b', Color::LightYellow),
('d', Color::LightCyan),
('Y', Color::LightGreen),
('8', Color::LightMagenta),
('*', Color::Magenta),
('`', Color::Cyan),
('6', Color::Green),
('9', Color::Red),
('(', Color::Blue),
(')', Color::Blue),
(' ', Color::Black),
]);
let default_color = Color::LightBlue;
AsciiCells::from(
art.to_string(),
colors.to_string(),
&color_map,
default_color,
)
}
// Start menu state
struct StartMenuState {
items: Vec<(String, Bits)>,
list_state: ListState,
}
impl StartMenuState {
fn new() -> Self {
let items = vec![
("easy (4 bits)".to_string(), Bits::Four),
("easy+16 (4 bits*16)".to_string(), Bits::FourShift4),
("easy+256 (4 bits*256)".to_string(), Bits::FourShift8),
("easy+4096 (4 bits*4096)".to_string(), Bits::FourShift12),
("normal (8 bits)".to_string(), Bits::Eight),
("master (12 bits)".to_string(), Bits::Twelve),
("insane (16 bits)".to_string(), Bits::Sixteen),
];
Self {
items,
list_state: ListState::default().with_selected(Some(4)),
} // default to normal (8 bits)
}
fn selected_index(&self) -> usize {
self.list_state.selected().unwrap_or(0)
}
fn selected_bits(&self) -> Bits {
self.items[self.selected_index()].1.clone()
}
fn select_next(&mut self) {
self.list_state.select_next();
}
fn select_previous(&mut self) {
self.list_state.select_previous();
}
}

View File

@ -657,6 +657,3 @@ impl HighScores {
self.scores.insert(bits, score);
}
}
// NEW: public helper for external modules (e.g., start screen) to read current high score for a bits mode
pub fn get_high_score(bits: Bits) -> u32 { HighScores::load().get(bits.high_score_key()) }

View File

@ -1,235 +1,12 @@
mod app;
mod binary_numbers;
mod utils;
mod main_screen_widget;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use binary_numbers::{BinaryNumbersGame, Bits};
use crate::main_screen_widget::MainScreenWidget;
use utils::{AsciiArtWidget, AsciiCells};
use ratatui::prelude::*;
use ratatui::widgets::{List, ListItem, ListState};
use std::collections::HashMap;
use std::thread;
use std::time::Instant;
mod utils;
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
let mut terminal = ratatui::init();
let result = run_app(&mut terminal);
let result = app::run_app(&mut terminal);
ratatui::restore();
result
}
// Start menu state
struct StartMenuState {
items: Vec<(String, Bits)>,
list_state: ListState,
}
impl StartMenuState {
fn new() -> Self {
let items = vec![
("easy (4 bits)".to_string(), Bits::Four),
("easy+16 (4 bits*16)".to_string(), Bits::FourShift4),
("easy+256 (4 bits*256)".to_string(), Bits::FourShift8),
("easy+4096 (4 bits*4096)".to_string(), Bits::FourShift12),
("normal (8 bits)".to_string(), Bits::Eight),
("master (12 bits)".to_string(), Bits::Twelve),
("insane (16 bits)".to_string(), Bits::Sixteen),
];
Self { items, list_state: ListState::default().with_selected(Some(4)) } // default to normal (8 bits)
}
fn selected_index(&self) -> usize {
self.list_state.selected().unwrap_or(0)
}
fn selected_bits(&self) -> Bits {
self.items[self.selected_index()].1.clone()
}
fn select_next(&mut self) {
self.list_state.select_next();
}
fn select_previous(&mut self) {
self.list_state.select_previous();
}
}
enum AppState {
Start(StartMenuState),
Playing(BinaryNumbersGame),
Exit,
}
fn handle_start_input(state: &mut StartMenuState, key: KeyEvent) -> Option<AppState> {
match key.code {
KeyCode::Up => state.select_previous(),
KeyCode::Down => state.select_next(),
KeyCode::Enter => {
let bits = state.selected_bits();
return Some(AppState::Playing(BinaryNumbersGame::new(bits)));
}
KeyCode::Esc => return Some(AppState::Exit),
_ => {}
}
None
}
fn render_start_screen(state: &mut StartMenuState, area: Rect, buf: &mut Buffer) {
// Build ASCII art to obtain real dimensions
let cells = ascii_art_cells();
let ascii_width = cells.get_width();
let ascii_height = cells.get_height();
let ascii_widget = AsciiArtWidget::new(cells);
let selected = state.selected_index();
let upper_labels: Vec<String> = state.items.iter().map(|(l, _)| l.to_uppercase()).collect();
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
let list_height = upper_labels.len() as u16;
// Vertical spacing between ASCII art and list
let spacing: u16 = 3;
let total_height = ascii_height + spacing + list_height;
// Center vertically & horizontally
let start_y = area.y + area.height.saturating_sub(total_height) / 2;
let ascii_x = area.x + area.width.saturating_sub(ascii_width) / 2;
let list_x = area.x + area.width.saturating_sub(list_width) / 2;
let ascii_y = start_y;
let list_y = ascii_y + ascii_height + spacing;
// Define rects (clamp to area)
let ascii_area = Rect::new(ascii_x, ascii_y, ascii_width.min(area.width), ascii_height.min(area.height));
let list_area = Rect::new(list_x, list_y, list_width.min(area.width), list_height.min(area.height.saturating_sub(list_y - area.y)));
// Render ASCII art
ascii_widget.render(ascii_area, buf);
// Palette for menu flair
let palette = [
Color::LightGreen,
Color::LightCyan,
Color::LightBlue,
Color::LightMagenta,
Color::LightYellow,
Color::LightRed,
];
let items: Vec<ListItem> = upper_labels
.into_iter()
.enumerate()
.map(|(i, label)| {
let marker = if i == selected { '»' } else { ' ' };
let padded = format!("{:<width$}", label, width = max_len as usize);
let line = format!("{} {}", marker, padded);
let style = Style::default().fg(palette[i % palette.len()]).add_modifier(Modifier::BOLD);
ListItem::new(Span::styled(line, style))
})
.collect();
let list = List::new(items);
ratatui::widgets::StatefulWidget::render(list, list_area, buf, &mut state.list_state);
}
fn run_app(terminal: &mut ratatui::DefaultTerminal) -> color_eyre::Result<()> {
let mut app_state = AppState::Start(StartMenuState::new());
let mut last_frame_time = Instant::now();
let target_frame_duration = std::time::Duration::from_millis(33); // ~30 FPS
while !matches!(app_state, AppState::Exit) {
let now = Instant::now();
let dt = now - last_frame_time;
last_frame_time = now;
terminal.draw(|f| match &mut app_state {
AppState::Start(menu) => render_start_screen(menu, f.area(), f.buffer_mut()),
AppState::Playing(game) => f.render_widget(&mut *game, f.area()),
AppState::Exit => {}
})?;
// Advance game if playing
if let AppState::Playing(game) = &mut app_state {
game.run(dt.as_secs_f64());
if game.is_exit_intended() {
app_state = AppState::Start(StartMenuState::new());
continue;
}
}
// handle input
let poll_timeout = std::cmp::min(dt, target_frame_duration);
if event::poll(poll_timeout)? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
match key.code {
// global exit via Ctrl+C
KeyCode::Char('c') | KeyCode::Char('C') if key.modifiers == KeyModifiers::CONTROL => {
app_state = AppState::Exit;
}
// state-specific input handling
_ => app_state = match app_state {
AppState::Start(mut menu) => {
handle_start_input(&mut menu, key).unwrap_or(AppState::Start(menu))
}
AppState::Playing(mut game) => {
game.handle_game_input(key);
AppState::Playing(game)
}
AppState::Exit => AppState::Exit,
}
}
}
}
}
// cap frame rate
let frame_duration = last_frame_time.elapsed();
if frame_duration < target_frame_duration {
thread::sleep(target_frame_duration - frame_duration);
}
}
Ok(())
}
fn ascii_art_cells() -> AsciiCells {
let art = r#"
,, ,, ,,
*MM db *MM `7MM
MM MM MM
MM,dMMb.`7MM `7MMpMMMb. MM,dMMb.`7Mb,od8 .gP"Ya ,6"Yb. MM ,MP'
MM `Mb MM MM MM MM `Mb MM' "',M' Yb 8) MM MM ;Y
MM M8 MM MM MM MM M8 MM 8M"""""" ,pm9MM MM;Mm
MM. ,M9 MM MM MM MM. ,M9 MM YM. , 8M MM MM `Mb.
P^YbmdP'.JMML..JMML JMML.P^YbmdP'.JMML. `Mbmmd' `Moo9^Yo..JMML. YA.
"#;
let colors = r#"
,, ,, ,,
*MM db *MM `7MM
MM MM MM
MM,dMMb.`7MM `7MMpMMMb. MM,dMMb.`7Mb,od8 .gP"Ya ,6"Yb. MM ,MP'
MM `Mb MM MM MM MM `Mb MM' "',M' Yb 8) MM MM ;Y
MM M8 MM MM MM MM M8 MM 8M"""""" ,pm9MM MM;Mm
MM. ,M9 MM MM MM MM. ,M9 MM YM. , 8M MM MM `Mb.
P^YbmdP'.JMML..JMML JMML.P^YbmdP'.JMML. `Mbmmd' `Moo9^Yo..JMML. YA.
"#;
let color_map = HashMap::from([
('M', Color::White),
('b', Color::LightYellow),
('d', Color::LightCyan),
('Y', Color::LightGreen),
('8', Color::LightMagenta),
('*', Color::Magenta),
('`', Color::Cyan),
('6', Color::Green),
('9', Color::Red),
('(', Color::Blue),
(')', Color::Blue),
(' ', Color::Black),
]);
let default_color = Color::LightBlue;
AsciiCells::from(art.to_string(), colors.to_string(), &color_map, default_color)
}

View File

@ -8,15 +8,6 @@ pub trait WidgetRef {
pub trait MainScreenWidget: WidgetRef {
fn run(&mut self, dt: f64) -> ();
fn handle_input(&mut self, input: KeyEvent) -> ();
fn handle_input(&mut self, input: KeyEvent);
fn is_exit_intended(&self) -> bool;
fn get_name(&self) -> String {
let type_name = std::any::type_name::<Self>();
type_name.split("::").last().unwrap_or("Unknown").to_string()
}
fn get_overview(&self) -> String {
format!("You are here: {}. The overview is not implemented.", self.get_name())
}
}

View File

@ -2,18 +2,6 @@ use ratatui::layout::Flex;
use ratatui::prelude::*;
use std::collections::HashMap;
pub trait ToDuration {
/// Convert a number to a [`std::time::Duration`].
fn milliseconds(&self) -> std::time::Duration;
}
impl ToDuration for u64 {
/// Convert a number to a [`std::time::Duration`].
fn milliseconds(&self) -> std::time::Duration {
std::time::Duration::from_millis(*self)
}
}
pub struct AsciiCell {
pub ch: char,
pub x: u16,
@ -56,10 +44,6 @@ pub struct AsciiCells {
}
impl AsciiCells {
pub fn new(cells: Vec<AsciiCell>) -> Self {
Self { cells }
}
pub fn from(
art: String,
color_map_str: String,
@ -76,15 +60,6 @@ impl AsciiCells {
pub fn get_height(&self) -> u16 {
self.cells.iter().map(|cell| cell.y).max().unwrap_or(0) + 1
}
pub fn get_centered_area(&self, area: Rect) -> Rect {
let width = self.get_width();
let height = self.get_height();
let x_offset = (area.width.saturating_sub(width)) / 2;
let y_offset = (area.height.saturating_sub(height)) / 2;
Rect::new(area.x + x_offset, area.y + y_offset, width, height)
}
}
pub struct AsciiArtWidget {
@ -112,17 +87,6 @@ impl Widget for AsciiArtWidget {
}
}
fn buffer_to_string(buf: &Buffer) -> String {
(0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn center(area: Rect, horizontal: Constraint) -> Rect {
let [area] = Layout::horizontal([horizontal]).flex(Flex::Center).areas(area);
let area = vertically_center(area);