binbreak/src/app.rs

316 lines
11 KiB
Rust

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, Style, Widget};
use ratatui::widgets::{List, ListItem, ListState};
use std::cmp;
use std::collections::HashMap;
use std::thread;
use std::time::Instant;
use indoc::indoc;
use std::sync::atomic::{AtomicUsize, Ordering};
static LAST_SELECTED_INDEX: AtomicUsize = AtomicUsize::new(4);
fn get_last_selected_index() -> usize {
LAST_SELECTED_INDEX.load(Ordering::Relaxed)
}
fn set_last_selected_index(index: usize) {
LAST_SELECTED_INDEX.store(index, Ordering::Relaxed);
}
#[derive(Copy, Clone, PartialEq, Debug)]
enum FpsMode {
RealTime, // 30 FPS with polling
Performance, // Block until input for minimal CPU
}
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();
// Store the current selection before entering the game
set_last_selected_index(state.selected_index());
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 handle_crossterm_events(app_state: &mut AppState) -> color_eyre::Result<()> {
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 std::mem::replace(app_state, AppState::Exit) {
AppState::Start(mut menu) => {
handle_start_input(&mut menu, key)
.unwrap_or(AppState::Start(menu))
}
AppState::Playing(mut game) => {
game.handle_input(key);
AppState::Playing(game)
}
AppState::Exit => AppState::Exit,
}
}
}
}
}
Ok(())
}
/// Determine the appropriate FPS mode based on the current game state
fn get_fps_mode(game: &BinaryNumbersGame) -> FpsMode {
if game.is_active() {
FpsMode::RealTime // Timer running, needs continuous updates
} else if game.needs_render() {
FpsMode::RealTime // One frame after state transition
} else {
FpsMode::Performance // All other cases, block for minimal CPU
}
}
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;
// Advance game BEFORE drawing so stats are updated
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;
}
}
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 => {}
})?;
// Clear needs_render flag after frame is rendered
// State transitions will set this flag again as needed, in performance mode
if let AppState::Playing(game) = &mut app_state {
game.clear_needs_render();
}
// handle input
if let AppState::Playing(game) = &app_state {
if get_fps_mode(game) == FpsMode::RealTime {
let poll_timeout = cmp::min(dt, target_frame_duration);
if event::poll(poll_timeout)? {
handle_crossterm_events(&mut app_state)?;
}
} else {
// performance mode: block thread until an input event occurs
handle_crossterm_events(&mut app_state)?;
}
} else {
// For non-playing states (e.g., start menu), use performance mode
// to block until input and minimize CPU usage
handle_crossterm_events(&mut app_state)?;
}
// 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 = indoc! {r#"
,, ,, ,,
*MM db *MM `7MM
MM MM MM
MM,dMMb.`7MM `7MMpMMMb. MM,dMMb.`7Mb,od8 .gP"Ya ,6"Yb. MM ,MP'
MM `Mb MM MM MM MM `Mb MM' "',M' Yb 8) MM MM ;Y
MM M8 MM MM MM MM M8 MM 8M"""""" ,pm9MM MM;Mm
MM. ,M9 MM MM MM MM. ,M9 MM YM. , 8M MM MM `Mb.
P^YbmdP'.JMML..JMML JMML.P^YbmdP'.JMML. `Mbmmd' `Moo9^Yo..JMML. YA.
"#};
let colors = indoc! {r#"
,, ,, ,,
*MM db *MM `7MM
MM 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 {
Self::with_selected(get_last_selected_index())
}
fn with_selected(selected_index: usize) -> 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(selected_index)),
}
}
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();
}
}