From 131cc053cd16e1f6eca9baf64f5e5708db9b7cc5 Mon Sep 17 00:00:00 2001 From: hunglongtrangithub Date: Fri, 12 Dec 2025 16:40:13 -0500 Subject: [PATCH 1/2] feat(app): have game mode for Mazest --- Cargo.lock | 27 + Cargo.toml | 1 + src/app/game.rs | 100 +++ src/app/mod.rs | 1073 ++++++++++----------------- src/app/{ => visualize}/history.rs | 0 src/app/visualize/mod.rs | 402 ++++++++++ src/app/{ => visualize}/renderer.rs | 129 ++-- src/main.rs | 10 +- src/maze/grid.rs | 9 + src/maze/mod.rs | 5 + 10 files changed, 1000 insertions(+), 756 deletions(-) create mode 100644 src/app/game.rs rename src/app/{ => visualize}/history.rs (100%) create mode 100644 src/app/visualize/mod.rs rename src/app/{ => visualize}/renderer.rs (89%) diff --git a/Cargo.lock b/Cargo.lock index c0080e4..c8935dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,6 +104,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "errno" version = "0.3.14" @@ -126,6 +132,15 @@ dependencies = [ "wasi 0.14.7+wasi-0.2.4", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -181,6 +196,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "unicode-truncate", "unicode-width", ] @@ -577,6 +593,17 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-truncate" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fbf03860ff438702f3910ca5f28f8dac63c1c11e7efb5012b8b175493606330" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "unicode-width" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 4669843..01c5e71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ rand = "0.9.2" crossterm = "0.29.0" rand_set = "0.1.2" unicode-width = "0.2.2" +unicode-truncate = "2.0.0" tracing = "0.1.41" tracing-subscriber = "0.3.20" tracing-appender = "0.2.3" diff --git a/src/app/game.rs b/src/app/game.rs new file mode 100644 index 0000000..efa137f --- /dev/null +++ b/src/app/game.rs @@ -0,0 +1,100 @@ +use crate::{ + app, + generators::{Generator, generate_maze}, + maze::Maze, +}; +use crossterm::{ + ExecutableCommand, QueueableCommand, cursor, + event::KeyCode, + queue, + style::{self, Attribute, Color, Stylize}, +}; +use std::io::{Stdout, Write}; + +struct GameState { + maze: Maze, +} + +impl GameState { + fn new(width: u8, height: u8, generator: Generator) -> Self { + let mut maze = Maze::new(width, height, None); + generate_maze(&mut maze, generator, None); + GameState { maze } + } + + fn render_initial_maze(&self, stdout: &mut Stdout) -> std::io::Result<()> { + let grid = self.maze.grid(); + stdout.queue(cursor::MoveTo(0, 0))?; + for y in 0..grid.height() { + for x in 0..grid.width() { + stdout.queue(style::Print(grid[(x, y)]))?; + } + stdout.queue(style::Print("\r\n"))?; + } + stdout.flush()?; + Ok(()) + } +} + +pub fn run(stdout: &mut Stdout) -> std::io::Result<()> { + // Ask user for maze dimensions + let (width, height) = match app::ask_maze_dimensions(stdout)? { + Some(dims) => dims, + None => { + return Ok(()); + } + }; + + // Ask user for maze generation algorithm + let generator = match app::select_from_menu( + stdout, + "Select maze generation algorithm (use arrow keys and Enter, or Esc to exit):", + &app::GENERATORS, + )? { + Some(generator) => { + stdout.execute(style::PrintStyledContent( + format!("Selected generator: {}\r\n", generator) + .with(Color::Green) + .attribute(Attribute::Bold), + ))?; + generator + } + None => { + return Ok(()); + } + }; + + queue!( + stdout, + style::PrintStyledContent( + "Move your Pacman through the maze using arrow keys to its destination before time's over!\r\n" + .with(Color::Yellow) + .attribute(Attribute::Bold) + ), + style::PrintStyledContent( + "Controls:\r\n" + .with(Color::Yellow) + .attribute(Attribute::Bold) + ), + style::PrintStyledContent( + " ←/→/↑/↓: Step up/down/left/right to control Pacman\r\n".with(Color::Cyan) + ), + style::PrintStyledContent(" Esc: Exit game\r\n\r\n".with(Color::Cyan)), + )?; + + let game_state = GameState::new(width, height, generator); + game_state.render_initial_maze(stdout)?; + app::log_terminal( + stdout, + game_state.maze.grid().height(), + Some( + "Game loop not implemented. Press Enter to exit the game." + .with(Color::Magenta) + .attribute(Attribute::Bold), + ), + )?; + + // Wait for Enter key to start the game + app::wait_for_keypress(KeyCode::Enter)?; + Ok(()) +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 6af4a3c..f97ba29 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,744 +1,483 @@ -mod history; -mod renderer; +mod game; +mod visualize; +use crossterm::{QueueableCommand, event::Event}; use std::{ + fmt::Display, io::{Stdout, Write}, - sync::{ - Arc, - atomic::AtomicBool, - mpsc::{Receiver, Sender}, - }, - time::Duration, }; +use unicode_truncate::UnicodeTruncateStr; +use unicode_width::UnicodeWidthStr; use crossterm::{ - ExecutableCommand, QueueableCommand, cursor, + ExecutableCommand, cursor, event::{self, KeyCode}, queue, style::{self, Attribute, Color, Stylize}, terminal::{self, ClearType}, }; -use rand::Rng; -use crate::{ - app::renderer::{Renderer, RendererStatus}, - generators::{Generator, generate_maze}, - maze::{Maze, cell::GridCell, grid::GridEvent}, - solvers::{Solver, solve_maze}, +use crate::{generators::Generator, maze::cell::GridCell, solvers::Solver}; + +/// Available maze generators +const GENERATORS: [Generator; 4] = [ + Generator::RecurBacktrack, + Generator::Kruskal, + Generator::Prim, + Generator::RecurDiv, +]; +/// Available maze solvers +const SOLVERS: [Solver; 4] = [Solver::Dfs, Solver::Bfs, Solver::Dijkstra, Solver::AStar]; +/// All combinations of maze generators and solvers +const COMBOS: [(Generator, Solver); 16] = { + let mut combos = [(Generator::RecurBacktrack, Solver::Dfs); 16]; + let mut index = 0; + let mut gen_index = 0; + while gen_index < GENERATORS.len() { + let mut sol_index = 0; + while sol_index < SOLVERS.len() { + combos[index] = (GENERATORS[gen_index], SOLVERS[sol_index]); + index += 1; + sol_index += 1; + } + gen_index += 1; + } + combos }; -enum UserInputEvent { - KeyPress(event::KeyEvent), - Resize, -} - -#[derive(Debug)] -enum UserActionEvent { - /// Pause the animation - Pause, - /// Resume the animation - Resume, - /// Step forward in history or to the future when paused - Forward, - /// Step backward in history when paused - Backward, - /// Terminal resize - Resize, - /// Increase animation speed - SpeedUp, - /// Decrease animation speed - SlowDown, - /// Cancel rendering - Cancel, -} - -pub struct App { - /// Timeout for receiving input events, a.k.a. how often to check for render done/cancel flags - input_recv_timeout: Duration, - /// Timeout for polling input events in the input thread, a.k.a. - /// how often to check for render done/cancel flags - user_input_event_poll_timeout: Duration, - /// maximum number of grid events to keep for history browsing when paused or grid state - /// recovery - max_history_grid_events: usize, -} - -impl Default for App { - fn default() -> Self { - Self { - // render_refresh_rate: Duration::from_micros(20), - input_recv_timeout: Duration::from_millis(100), - user_input_event_poll_timeout: Duration::from_millis(100), - max_history_grid_events: 100, - } +/// Number of rows reserved at the bottom of the terminal for logging or status messages +const NUM_STATUS_ROWS: u16 = 1; + +/// Present a menu of options to the user and let them select one using up/down arrow keys +/// Returns None if user cancels input with Esc +/// Returns Some(T) if user selects an option and presses Enter, where T is the option type +fn select_from_menu( + stdout: &mut Stdout, + prompt: &str, + options: &[T], +) -> std::io::Result> { + if options.is_empty() { + return Ok(None); } -} -impl App { - /// Maximum number of grid events to buffer in the channel between compute and render threads - const MAX_EVENTS_IN_CHANNEL_BUFFER: usize = 1000; - /// Available maze generators - const GENERATORS: [Generator; 4] = [ - Generator::RecurBacktrack, - Generator::Kruskal, - Generator::Prim, - Generator::RecurDiv, - ]; - /// Available maze solvers - const SOLVERS: [Solver; 4] = [Solver::Dfs, Solver::Bfs, Solver::Dijkstra, Solver::AStar]; + // Save cursor position so we can restore / redraw + queue!(stdout, cursor::Hide, cursor::SavePosition)?; - /// Set a panic hook to restore terminal state on panic - /// This ensures that the terminal is not left in raw mode or alternate screen on panic - /// even if the panic occurs in a different thread - fn set_panic_hook() { - let hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |panic_info| { - let _ = App::restore_terminal(&mut std::io::stdout()); // ignore any errors as we are already failing - hook(panic_info); - std::process::exit(1); // exit immediately after restoring terminal - })); - } + let mut selected = 0; - /// Setup terminal in raw mode and enter alternate screen - /// Also sets a panic hook to restore terminal on panic - pub fn setup_terminal(stdout: &mut Stdout) -> std::io::Result<()> { - terminal::enable_raw_mode()?; - App::set_panic_hook(); - crossterm::queue!( + let selected_option = loop { + // Re-render prompt line + queue!( stdout, - terminal::EnterAlternateScreen, - terminal::Clear(ClearType::All), - cursor::Hide, - cursor::MoveTo(0, 0) + cursor::RestorePosition, + terminal::Clear(ClearType::FromCursorDown) )?; - stdout.flush()?; - Ok(()) - } - - /// Restore terminal to original state - /// Leave alternate screen and disable raw mode - pub fn restore_terminal(stdout: &mut Stdout) -> std::io::Result<()> { - queue!(stdout, terminal::LeaveAlternateScreen, cursor::Show)?; - stdout.flush()?; - terminal::disable_raw_mode()?; - Ok(()) - } - - /// Main application loop - pub fn run(&self, stdout: &mut Stdout) -> std::io::Result<()> { - // Ask user for maze dimensions - let (width, height) = match App::ask_maze_dimensions(stdout)? { - Some(dims) => dims, - None => { - return Ok(()); - } - }; - // Ask user for maze generation algorithm - let mut generator = match App::select_from_menu( - stdout, - "Select maze generation algorithm (use arrow keys and Enter, or Esc to exit):", - &App::GENERATORS, - )? { - Some(generator) => { - stdout.execute(style::PrintStyledContent( - format!("Selected generator: {}\r\n", generator) - .with(Color::Green) - .attribute(Attribute::Bold), - ))?; - generator - } - None => { - return Ok(()); - } - }; + // Print prompt + stdout.queue(style::PrintStyledContent(prompt.with(Color::Yellow)))?; - // Ask user for maze solving algorithm - let mut solver = match App::select_from_menu( - stdout, - "Select maze solving algorithm (use arrow keys and Enter, or Esc to exit):", - &App::SOLVERS, - )? { - Some(solver) => { - stdout.execute(style::PrintStyledContent( - format!("Selected solver: {}\r\n", solver) - .with(Color::Green) - .attribute(Attribute::Bold), - ))?; - solver + // Print options + for (i, option) in options.iter().enumerate() { + if i == selected { + stdout.queue(style::SetAttribute(Attribute::Reverse))?; } - None => { - return Ok(()); + stdout.queue(style::Print(format!("\r\n{}", option)))?; + if i == selected { + stdout.queue(style::SetAttribute(Attribute::NoReverse))?; } - }; - - queue!( - stdout, - style::PrintStyledContent( - "Controls:\r\n" - .with(Color::Yellow) - .attribute(Attribute::Bold) - ), - style::PrintStyledContent(" Enter: Pause/Resume animation\r\n".with(Color::Cyan)), - style::PrintStyledContent( - " ←/→: Step backward/forward when paused\r\n".with(Color::Cyan) - ), - style::PrintStyledContent(" ↑/↓: Speed up/slow down animation\r\n".with(Color::Cyan)), - style::PrintStyledContent(" Esc: Exit\r\n\r\n".with(Color::Cyan)), - )?; + } + stdout.queue(style::Print("\r\n"))?; stdout.flush()?; - // Ask if user wants to loop generation and solving - let loop_animation = match App::select_from_menu( - stdout, - "Loop maze generation and solving? Will randomize generator & solver combination. (use arrow keys and Enter, or Esc to exit):", - &["Yes", "No"], - )? { - Some(choice) => choice == "Yes", - None => { - return Ok(()); - } - }; - // Flag to indicate rendering is done. Set to true by the render thread when it finishes. - let render_done = Arc::new(AtomicBool::new(false)); - // Flag to indicate rendering should be cancelled. Set to true by the main thread on Esc key event. - let render_cancel = Arc::new(AtomicBool::new(false)); - - let (user_input_event_tx, user_input_event_rx) = - std::sync::mpsc::channel::(); - let user_input_event_poll_timeout = self.user_input_event_poll_timeout; - let render_done_for_input = render_done.clone(); - let render_cancel_for_input = render_cancel.clone(); - // Spawn a thread to listen for user input - let input_thread_handle = std::thread::spawn(move || -> std::io::Result<()> { - App::listen_to_user_input( - user_input_event_tx, - user_input_event_poll_timeout, - &render_done_for_input, - &render_cancel_for_input, - ) - }); - - let (grid_event_tx, grid_event_rx) = - std::sync::mpsc::sync_channel::(App::MAX_EVENTS_IN_CHANNEL_BUFFER); - let (user_action_event_tx, user_action_event_rx) = - std::sync::mpsc::channel::(); - - // Spawn a thread to listen for grid updates and render the maze - let max_history_grid_events = self.max_history_grid_events; - let render_cancel_for_render = render_cancel.clone(); - let render_done_for_render = render_done.clone(); - let render_thread_handle = std::thread::spawn(move || { - Renderer::new(max_history_grid_events, Some((width, height))).render( - grid_event_rx, - user_action_event_rx, - &render_cancel_for_render, - &render_done_for_render, - ) - }); - - // Spawn a thread to generate maze and solve it - let combos = App::GENERATORS - .iter() - .flat_map(|&generator| App::SOLVERS.iter().map(move |&solver| (generator, solver))) - .collect::>(); - let render_cancel_for_compute = render_cancel.clone(); - let compute_thread_handle = std::thread::spawn(move || -> bool { - if !loop_animation { - return App::compute(width, height, grid_event_tx, generator, solver); + // Wait for key event + if let Event::Key(event::KeyEvent { code, kind, .. }) = event::read()? { + if kind != event::KeyEventKind::Press { + // Only handle key press events + continue; } - // Looping mode: randomly select generator and solver each iteration - let mut rng = rand::rng(); - loop { - let goal_reached = - App::compute(width, height, grid_event_tx.clone(), generator, solver); - // Check if rendering was cancelled - if render_cancel_for_compute.load(std::sync::atomic::Ordering::Relaxed) { - tracing::info!("Compute thread detected render cancel, exiting loop"); - return goal_reached; + match code { + KeyCode::Up => { + selected = match selected { + 0 => options.len() - 1, + _ => selected - 1, + }; + } + KeyCode::Down => { + selected = if selected >= options.len() - 1 { + 0 + } else { + selected + 1 + }; + } + KeyCode::Enter => { + break Some(options[selected]); + } + KeyCode::Esc => { + // User cancelled input + break None; } - // Randomly select new generator and solver combination for next iteration - (generator, solver) = combos[rng.random_range(0..combos.len())]; + _ => {} } - }); - - // Main thread loop to listen for user input events during rendering - self.app_loop( - user_input_event_rx, - user_action_event_tx, - render_done, - render_cancel, - ); - - // Wait for input thread to finish - let _ = input_thread_handle.join(); - - // Wait for compute thread to finish - let goal_reached = compute_thread_handle - .join() - .expect("Compute thread panicked"); - - // Wait for render thread to finish - let completed = render_thread_handle - .join() - .expect("Render thread panicked")?; - - if let RendererStatus::Cancelled = completed { - tracing::info!("Rendering was cancelled by user."); - return Ok(()); } + }; + // Cleanup + queue!( + stdout, + cursor::RestorePosition, + terminal::Clear(ClearType::FromCursorDown), + cursor::Show + )?; + stdout.flush()?; + + Ok(selected_option) +} - let msg = if goal_reached { - "Path found! " +/// Calculate max maze size based on terminal size and cell size +/// Ensures the size is odd and at least 3 +fn get_max_maze_size(term_size: u16, cell_size: u16) -> u8 { + // Get default grid dimension based on terminal size. Make sure they are odd and at least 3. + let max_grid_size = { + let n = term_size / cell_size; + if n.is_multiple_of(2) && n > 0 { + n - 1 } else { - "No path found. " - }; - stdout.execute(style::PrintStyledContent( - msg.with(Color::Green).attribute(Attribute::Bold), - ))?; + n + } + .max(3) + }; - stdout.execute(style::PrintStyledContent( - "Press Esc to exit...\r" - .with(Color::Blue) - .attribute(Attribute::Bold), - ))?; - // Wait for user to press Esc - App::wait_for_esc()?; - Ok(()) - } + // Default maze dimensions are half the grid dimensions, capped at u8::MAX + (max_grid_size / 2).min(u8::MAX as u16) as u8 +} - /// App loop after starting input and render threads - fn app_loop( - &self, - user_input_event_rx: Receiver, - user_action_event_tx: Sender, - render_done: Arc, - render_cancel: Arc, - ) { - tracing::info!("Started main app loop"); - // Flag to indicate if the animation is currently paused - let mut is_paused = false; - loop { - // Check if render is done - if render_done.load(std::sync::atomic::Ordering::Relaxed) { - // Drop the receiver to signal input thread to exit - drop(user_input_event_rx); - break; +/// Ask user for maze dimensions (width and height between 1 and 255) +/// Returns None if user cancels input with Esc +/// Returns Some((width, height)) if user inputs valid dimensions +fn ask_maze_dimensions(stdout: &mut Stdout) -> std::io::Result> { + stdout.execute(style::PrintStyledContent( + "Enter maze dimensions (width and height between 1 and 255), or press Esc to exit. \ +Maximum acceptable values are based on current terminal size.\r\n" + .with(Color::Blue), + ))?; + + // Validation closure based on default sizes + let validate = |s: &str, is_width| { + let max_size = if let Ok((term_width, term_height)) = terminal::size() { + if is_width { + get_max_maze_size(term_width, GridCell::CELL_WIDTH) + } else { + // Reserve rows for logs + get_max_maze_size(term_height.saturating_sub(NUM_STATUS_ROWS), 1) } + } else { + // Fallback to max size if terminal size cannot be determined + u8::MAX + }; - let event = match user_input_event_rx.recv_timeout(self.input_recv_timeout) { - Err(e) => { - match e { - std::sync::mpsc::RecvTimeoutError::Timeout => { - // Skip to next iteration to check render_done again - continue; - } - std::sync::mpsc::RecvTimeoutError::Disconnected => { - // Input thread has exited, break the loop - break; - } - } - } - Ok(event) => match event { - UserInputEvent::KeyPress(key_event) => { - match key_event.code { - // Exit on Esc key - KeyCode::Esc => { - tracing::debug!("[app loop] Esc key pressed, notifying renderer"); - // Error only happens if user_input_event_rx is dropped, which - // means Renderer::render has exited already - user_action_event_tx.send(UserActionEvent::Cancel).ok(); - render_cancel.store(true, std::sync::atomic::Ordering::Relaxed); - break; - } - KeyCode::Enter => { - // Toggle pause/resume on Enter key - let event = if is_paused { - UserActionEvent::Resume - } else { - UserActionEvent::Pause - }; - // Toggle pause state - is_paused = !is_paused; - Some(event) - } - KeyCode::Left if is_paused => { - // Step backward when paused - Some(UserActionEvent::Backward) - } - KeyCode::Right if is_paused => { - // Step forward when paused - Some(UserActionEvent::Forward) - } - KeyCode::Up => { - // Speed up animation - Some(UserActionEvent::SpeedUp) - } - KeyCode::Down => { - // Slow down animation - Some(UserActionEvent::SlowDown) - } - _ => None, // Ignore other keys - } - } - UserInputEvent::Resize => Some(UserActionEvent::Resize), - }, - }; - - // Send the user action event to the render thread - if let Some(event) = event - && user_action_event_tx.send(event).is_err() - { - // Render thread has exited - break; - } + if s.trim().is_empty() { + return Ok(max_size); } - // The user_input_event_rx and user_action_event_tx are dropped here - tracing::info!("Exiting main app loop"); - } - /// Listen for user input events (key presses and resize) - /// This function runs in a separate thread, and is the only place where user input is read - fn listen_to_user_input( - user_input_event_tx: Sender, - event_poll_timeout: Duration, - render_done: &AtomicBool, - render_cancel: &AtomicBool, - ) -> std::io::Result<()> { - loop { - // Check if render is done or canceled - if render_done.load(std::sync::atomic::Ordering::Relaxed) - || render_cancel.load(std::sync::atomic::Ordering::Relaxed) - { - return Ok(()); - } + let error_msg = format!("Please enter a valid number between 1 and {}.", max_size); + s.parse::() + .map_err(|_| error_msg.clone()) + .and_then(|n| match n { + 1..=255 if n <= max_size => Ok(n), + _ => Err(error_msg), + }) + }; + + let validate_width = |s: &str| validate(s, true); + let validate_height = |s: &str| validate(s, false); + + let width = match prompt_with_validation(stdout, "Width: ", validate_width)? { + Some(w) => w, + None => return Ok(None), + }; + stdout.execute(style::PrintStyledContent( + format!("Width set to {}\r\n", width) + .with(Color::Green) + .attribute(Attribute::Bold), + ))?; + + let height = match prompt_with_validation(stdout, "Height: ", validate_height)? { + Some(h) => h, + None => return Ok(None), + }; + stdout.execute(style::PrintStyledContent( + format!("Height set to {}\r\n", height) + .with(Color::Green) + .attribute(Attribute::Bold), + ))?; + + Ok(Some((width, height))) +} - // Poll for events with a timeout - if !event::poll(event_poll_timeout)? { - // No event available, continue loop to check flags again - continue; - } +/// Get user input with real-time validation and feedback +/// Returns None if user cancels input with Esc +/// Returns Some(T) if user inputs a valid input and presses Enter, where T is the validated type +fn prompt_with_validation( + stdout: &mut Stdout, + prompt: &str, + validate: F, +) -> std::io::Result> +where + F: Fn(&str) -> Result, +{ + // Save cursor position so we can restore / redraw + queue!(stdout, cursor::Hide, cursor::SavePosition)?; + stdout.flush()?; + + let mut input = String::new(); + + let number_option = loop { + // Re-render + queue!( + stdout, + cursor::RestorePosition, + terminal::Clear(ClearType::FromCursorDown) + )?; - // Read the next event - // We only care about key presses events for now - let input_event = match event::read()? { - event::Event::Key(key_event) if key_event.kind == event::KeyEventKind::Press => { - UserInputEvent::KeyPress(key_event) - } - event::Event::Resize(_, _) => UserInputEvent::Resize, - _ => continue, // Ignore other events - }; - - // Should exit input thread on Esc key - let should_exit = matches!( - input_event, - UserInputEvent::KeyPress(event::KeyEvent { - code: KeyCode::Esc, - .. - }) - ); - - // Send the input event to the main thread - if user_input_event_tx.send(input_event).is_err() { - // Receiver has been dropped, exit the thread - return Ok(()); - } + // Print prompt + stdout.queue(style::PrintStyledContent( + prompt.with(Color::Cyan).attribute(Attribute::Bold), + ))?; - if should_exit { - tracing::debug!("[input loop] Esc key pressed, exiting"); - return Ok(()); + // Decide color based on validity + let validation_result = validate(input.trim()); + match validation_result { + Ok(_) => { + stdout.queue(style::SetForegroundColor(Color::Green))?; } - } - } - - /// Generate and solve the maze - /// Returns whether the goal was reached - fn compute( - width: u8, - height: u8, - grid_event_tx: std::sync::mpsc::SyncSender, - generator: Generator, - solver: Solver, - ) -> bool { - let mut maze = Maze::new(width, height, Some(grid_event_tx)); - // Generate the maze using the selected algorithm - generate_maze(&mut maze, generator, None); - - // Solve the maze using the selected algorithm - solve_maze(&mut maze, solver) - // Maze is dropped here, as well as the grid_event_tx sender - } - - /// Wait for the user to press the Esc key - /// This function blocks until Esc is pressed - fn wait_for_esc() -> std::io::Result<()> { - loop { - let event::Event::Key(event::KeyEvent { code, kind, .. }) = event::read()? else { - continue; - }; - if code == KeyCode::Esc && kind == event::KeyEventKind::Press { - break; + Err(_) => { + stdout.queue(style::SetForegroundColor(Color::Red))?; } } - Ok(()) - } - - /// Get user input with real-time validation and feedback - /// Returns None if user cancels input with Esc - /// Returns Some(T) if user inputs a valid input and presses Enter, where T is the validated type - fn prompt_with_validation( - stdout: &mut Stdout, - prompt: &str, - validate: F, - ) -> std::io::Result> - where - F: Fn(&str) -> Result, - { - // Save cursor position so we can restore / redraw - queue!(stdout, cursor::Hide, cursor::SavePosition)?; - stdout.flush()?; - let mut input = String::new(); + queue!(stdout, style::Print(&input), style::ResetColor)?; - let number_option = loop { - // Re-render - queue!( - stdout, - cursor::RestorePosition, - terminal::Clear(ClearType::FromCursorDown) - )?; + stdout.queue(style::Print(" \r\n"))?; - // Print prompt + // Error message line (if any) + if let Err(msg) = validation_result { stdout.queue(style::PrintStyledContent( - prompt.with(Color::Cyan).attribute(Attribute::Bold), + msg.with(Color::DarkGrey).attribute(Attribute::Dim), ))?; + } - // Decide color based on validity - let validation_result = validate(input.trim()); - match validation_result { - Ok(_) => { - stdout.queue(style::SetForegroundColor(Color::Green))?; - } - Err(_) => { - stdout.queue(style::SetForegroundColor(Color::Red))?; - } - } - - queue!(stdout, style::Print(&input), style::ResetColor)?; - - stdout.queue(style::Print(" \r\n"))?; - - // Error message line (if any) - if let Err(msg) = validation_result { - stdout.queue(style::PrintStyledContent( - msg.with(Color::DarkGrey).attribute(Attribute::Dim), - ))?; - } + stdout.flush()?; - stdout.flush()?; - - // Wait for key event - if let event::Event::Key(event::KeyEvent { code, kind, .. }) = event::read()? { - match code { - KeyCode::Enter => { - match validate(&input) { - Ok(n) => break Some(n), // valid number, exit loop - Err(_) => continue, // invalid, re-render - } - // otherwise, stay in loop - } - KeyCode::Char(c) if kind == event::KeyEventKind::Press => { - if !c.is_whitespace() && !c.is_control() { - input.push(c); - } - } - KeyCode::Backspace => { - input.pop(); + // Wait for key event + if let Event::Key(event::KeyEvent { code, kind, .. }) = event::read()? { + match code { + KeyCode::Enter => { + match validate(&input) { + Ok(n) => break Some(n), // valid number, exit loop + Err(_) => continue, // invalid, re-render } - KeyCode::Esc => { - // User cancelled input - break None; + // otherwise, stay in loop + } + KeyCode::Char(c) if kind == event::KeyEventKind::Press => { + if !c.is_whitespace() && !c.is_control() { + input.push(c); } - _ => {} } + KeyCode::Backspace => { + input.pop(); + } + KeyCode::Esc => { + // User cancelled input + break None; + } + _ => {} } - }; - // Cleanup - queue!( - stdout, - cursor::RestorePosition, - terminal::Clear(ClearType::FromCursorDown), - cursor::Show - )?; - stdout.flush()?; + } + }; + // Cleanup + queue!( + stdout, + cursor::RestorePosition, + terminal::Clear(ClearType::FromCursorDown), + cursor::Show + )?; + stdout.flush()?; + + Ok(number_option) +} - Ok(number_option) +/// Wait for a specific key press event from the user +fn wait_for_keypress(key: KeyCode) -> std::io::Result<()> { + loop { + if let Event::Key(event::KeyEvent { code, kind, .. }) = event::read()? + && kind == event::KeyEventKind::Press + && code == key + { + break; + } } + Ok(()) +} - /// Calculate max maze size based on terminal size and cell size - /// Ensures the size is odd and at least 3 - fn get_max_maze_size(term_size: u16, cell_size: u16) -> u8 { - // Get default grid dimension based on terminal size. Make sure they are odd and at least 3. - let odd_and_min_3 = |n: u16| { - if n.is_multiple_of(2) && n > 0 { - n - 1 - } else { - n +/// Print a line of message to the reserved area without disrupting the grid display +/// The cursor position is saved and restored after logging +/// Returns `Err` if there was an I/O error +/// If msg is None, clears all messages from the reserved rows +fn log_terminal( + stdout: &mut impl Write, + grid_height: u16, + msg: Option>>, +) -> std::io::Result<()> { + let term_width = terminal::size()?.0 as usize; + queue!( + stdout, + // Save cursor position first + cursor::SavePosition, + // Move cursor to the log line (below the grid) + cursor::MoveTo(0, grid_height), + // Clear previous log line + terminal::Clear(ClearType::CurrentLine), + )?; + if let Some(msg) = msg { + let content = msg.content().as_ref(); + if content.width() > term_width { + // Truncate message to fit terminal width. Reserve 1 char for '~' + let (truncated, printed_width) = content.unicode_truncate(term_width.saturating_sub(1)); + stdout.queue(style::PrintStyledContent(style::StyledContent::new( + *msg.style(), + truncated, + )))?; + // If the remaining space is enough for a '~' + if term_width - printed_width >= 1 { + stdout.queue(style::PrintStyledContent("~".stylize()))?; } - .max(3) - }; - let max_grid_size = odd_and_min_3(term_size / cell_size); - - // Default maze dimensions are half the grid dimensions, capped at u8::MAX - (max_grid_size / 2).min(u8::MAX as u16) as u8 + } else { + // Just print the whole thing if it fits the terminal width + stdout.queue(style::PrintStyledContent(msg))?; + } } + // Go back to previous cursor position + stdout.queue(cursor::RestorePosition)?; + stdout.flush()?; + Ok(()) +} - /// Ask user for maze dimensions (width and height between 1 and 255) - /// Returns None if user cancels input with Esc - /// Returns Some((width, height)) if user inputs valid dimensions - fn ask_maze_dimensions(stdout: &mut Stdout) -> std::io::Result> { - stdout.execute(style::PrintStyledContent( - "Enter maze dimensions (width and height between 1 and 255), or press Esc to exit. \ -Maximum acceptable values are based on current terminal size.\r\n" - .with(Color::Blue), - ))?; - - // Validation closure based on default sizes - let validate = |s: &str, is_width| { - let max_size = if let Ok((term_width, term_height)) = terminal::size() { - if is_width { - App::get_max_maze_size(term_width, GridCell::CELL_WIDTH) - } else { - // Reserve rows for logs - App::get_max_maze_size(term_height.saturating_sub(Renderer::NUM_LOG_ROWS), 1) - } - } else { - // Fallback to max size if terminal size cannot be determined - u8::MAX - }; - - if s.trim().is_empty() { - return Ok(max_size); - } - - let error_msg = format!("Please enter a valid number between 1 and {}.", max_size); - s.parse::() - .map_err(|_| error_msg.clone()) - .and_then(|n| match n { - 1..=255 if n <= max_size => Ok(n), - _ => Err(error_msg), - }) - }; - - let validate_width = |s: &str| validate(s, true); - let validate_height = |s: &str| validate(s, false); - - let width = match App::prompt_with_validation(stdout, "Width: ", validate_width)? { - Some(w) => w, - None => return Ok(None), - }; - stdout.execute(style::PrintStyledContent( - format!("Width set to {}\r\n", width) - .with(Color::Green) - .attribute(Attribute::Bold), - ))?; - - let height = match App::prompt_with_validation(stdout, "Height: ", validate_height)? { - Some(h) => h, - None => return Ok(None), - }; - stdout.execute(style::PrintStyledContent( - format!("Height set to {}\r\n", height) - .with(Color::Green) - .attribute(Attribute::Bold), - ))?; +#[derive(Copy, Clone)] +enum AppMode { + Visualize, + Game, +} - Ok(Some((width, height))) +impl std::fmt::Display for AppMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AppMode::Visualize => write!(f, "Visualization Mode"), + AppMode::Game => write!(f, "Game Mode"), + } } +} - /// Present a menu of options to the user and let them select one using arrow keys - /// Returns None if user cancels input with Esc - /// Returns Some(T) if user selects an option and presses Enter, where T is the option type - fn select_from_menu( - stdout: &mut Stdout, - prompt: &str, - options: &[T], - ) -> std::io::Result> { - if options.is_empty() { - return Ok(None); - } +pub struct App { + stdout: Stdout, +} - // Save cursor position so we can restore / redraw - queue!(stdout, cursor::Hide, cursor::SavePosition)?; +impl Default for App { + fn default() -> Self { + Self { + stdout: std::io::stdout(), + } + } +} - let mut selected = 0; +impl App { + /// Set a panic hook to restore terminal state on panic + /// This ensures that the terminal is not left in raw mode or alternate screen on panic + /// even if the panic occurs in a different thread + fn set_panic_hook() { + let hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + let _ = App::restore_terminal(&mut std::io::stdout()); // ignore any errors as we are already failing + hook(panic_info); + std::process::exit(1); // exit immediately after restoring terminal + })); + } - let selected_option = loop { - // Re-render prompt line - queue!( - stdout, - cursor::RestorePosition, - terminal::Clear(ClearType::FromCursorDown) - )?; + /// Restore terminal to original state + /// Leave alternate screen and disable raw mode + fn restore_terminal(stdout: &mut Stdout) -> std::io::Result<()> { + queue!(stdout, terminal::LeaveAlternateScreen, cursor::Show)?; + stdout.flush()?; + terminal::disable_raw_mode()?; + Ok(()) + } - // Print prompt - stdout.queue(style::PrintStyledContent(prompt.with(Color::Yellow)))?; + /// Setup terminal in raw mode and enter alternate screen + /// Also sets a panic hook to restore terminal on panic + fn setup_terminal(&mut self) -> std::io::Result<()> { + terminal::enable_raw_mode()?; + App::set_panic_hook(); + crossterm::queue!( + self.stdout, + terminal::EnterAlternateScreen, + terminal::Clear(ClearType::All), + cursor::Hide, + cursor::MoveTo(0, 0) + )?; + self.stdout.flush()?; + Ok(()) + } - // Print options - for (i, option) in options.iter().enumerate() { - if i == selected { - stdout.queue(style::SetAttribute(Attribute::Reverse))?; - } - stdout.queue(style::Print(format!("\r\n{}", option)))?; - if i == selected { - stdout.queue(style::SetAttribute(Attribute::NoReverse))?; - } - } - stdout.queue(style::Print("\r\n"))?; + /// Entry point to run the application. + /// Sets up the terminal, runs the app logic, and restores the terminal state on exit. + pub fn run(&mut self) -> std::io::Result<()> { + self.setup_terminal()?; + self.app()?; + App::restore_terminal(&mut self.stdout)?; + Ok(()) + } - stdout.flush()?; + /// Main application logic + fn app(&mut self) -> std::io::Result<()> { + self.stdout.execute(style::PrintStyledContent( + "Welcome to Mazest!\r\n" + .with(Color::Yellow) + .attribute(Attribute::Bold), + ))?; - // Wait for key event - if let event::Event::Key(event::KeyEvent { code, kind, .. }) = event::read()? { - if kind != event::KeyEventKind::Press { - // Only handle key press events - continue; - } - match code { - KeyCode::Up => { - selected = match selected { - 0 => options.len() - 1, - _ => selected - 1, - }; - } - KeyCode::Down => { - selected = if selected >= options.len() - 1 { - 0 - } else { - selected + 1 - }; - } - KeyCode::Enter => { - break Some(options[selected]); - } - KeyCode::Esc => { - // User cancelled input - break None; - } - _ => {} - } - } + let mode = match select_from_menu( + &mut self.stdout, + "Select app mode (use arrow keys and Enter, or Esc to exit):", + &[AppMode::Visualize, AppMode::Game], + )? { + Some(m) => m, + None => return Ok(()), }; - // Cleanup + queue!( - stdout, - cursor::RestorePosition, - terminal::Clear(ClearType::FromCursorDown), - cursor::Show + self.stdout, + terminal::Clear(ClearType::All), + cursor::MoveTo(0, 0) )?; - stdout.flush()?; - - Ok(selected_option) + match mode { + AppMode::Visualize => { + queue!( + self.stdout, + style::SetAttribute(Attribute::Reverse), + style::PrintStyledContent("Visualization Mode\r\n".with(Color::Yellow)), + style::SetAttribute(Attribute::NoReverse), + )?; + visualize::run(&mut self.stdout)?; + } + AppMode::Game => { + queue!( + self.stdout, + style::SetAttribute(Attribute::Reverse), + style::PrintStyledContent("Game Mode\r\n".with(Color::Yellow)), + style::SetAttribute(Attribute::NoReverse), + )?; + game::run(&mut self.stdout)?; + } + } + Ok(()) } } diff --git a/src/app/history.rs b/src/app/visualize/history.rs similarity index 100% rename from src/app/history.rs rename to src/app/visualize/history.rs diff --git a/src/app/visualize/mod.rs b/src/app/visualize/mod.rs new file mode 100644 index 0000000..436fa7c --- /dev/null +++ b/src/app/visualize/mod.rs @@ -0,0 +1,402 @@ +mod history; +mod renderer; + +use std::{ + io::{Stdout, Write}, + sync::{ + Arc, + atomic::AtomicBool, + mpsc::{Receiver, Sender}, + }, + time::Duration, +}; + +use crossterm::{ + ExecutableCommand, + event::{self, KeyCode}, + queue, + style::{self, Attribute, Color, Stylize}, +}; +use rand::Rng; + +use crate::{ + app::{ + self, + visualize::renderer::{Renderer, RendererStatus}, + }, + generators::{Generator, generate_maze}, + maze::{Maze, grid::GridEvent}, + solvers::{Solver, solve_maze}, +}; + +enum UserInputEvent { + KeyPress(event::KeyEvent), + Resize, +} + +#[derive(Debug)] +pub enum UserActionEvent { + /// Pause the animation + Pause, + /// Resume the animation + Resume, + /// Step forward in history or to the future when paused + Forward, + /// Step backward in history when paused + Backward, + /// Terminal resize + Resize, + /// Increase animation speed + SpeedUp, + /// Decrease animation speed + SlowDown, + /// Cancel rendering + Cancel, +} + +/// Maximum number of grid events to buffer in the channel between compute and render threads +const MAX_EVENTS_IN_CHANNEL_BUFFER: usize = 1000; +/// Timeout for receiving input events, a.k.a. how often to check for render done/cancel flags +const INPUT_RECV_TIMEOUT: Duration = Duration::from_millis(100); +/// Timeout for polling input events in the input thread, a.k.a. +/// how often to check for render done/cancel flags +const USER_INPUT_EVENT_POLL_TIMEOUT: Duration = Duration::from_millis(100); +/// Maximum number of grid events to keep for history browsing when paused or grid state +/// recovery +const MAX_HISTORY_GRID_EVENTS: usize = 100; + +/// Entry point of the visualizer app +pub fn run(stdout: &mut Stdout) -> std::io::Result<()> { + // Ask user for maze dimensions + let (width, height) = match app::ask_maze_dimensions(stdout)? { + Some(dims) => dims, + None => { + return Ok(()); + } + }; + + // Ask user for maze generation algorithm + let mut generator = match app::select_from_menu( + stdout, + "Select maze generation algorithm (use arrow keys and Enter, or Esc to exit):", + &app::GENERATORS, + )? { + Some(generator) => { + stdout.execute(style::PrintStyledContent( + format!("Selected generator: {}\r\n", generator) + .with(Color::Green) + .attribute(Attribute::Bold), + ))?; + generator + } + None => { + return Ok(()); + } + }; + + // Ask user for maze solving algorithm + let mut solver = match app::select_from_menu( + stdout, + "Select maze solving algorithm (use arrow keys and Enter, or Esc to exit):", + &app::SOLVERS, + )? { + Some(solver) => { + stdout.execute(style::PrintStyledContent( + format!("Selected solver: {}\r\n", solver) + .with(Color::Green) + .attribute(Attribute::Bold), + ))?; + solver + } + None => { + return Ok(()); + } + }; + + queue!( + stdout, + style::PrintStyledContent( + "Controls:\r\n" + .with(Color::Yellow) + .attribute(Attribute::Bold) + ), + style::PrintStyledContent(" Enter: Pause/Resume animation\r\n".with(Color::Cyan)), + style::PrintStyledContent(" ←/→: Step backward/forward when paused\r\n".with(Color::Cyan)), + style::PrintStyledContent(" ↑/↓: Speed up/slow down animation\r\n".with(Color::Cyan)), + style::PrintStyledContent(" Esc: Exit\r\n\r\n".with(Color::Cyan)), + )?; + + stdout.flush()?; + // Ask if user wants to loop generation and solving + let loop_animation = match app::select_from_menu( + stdout, + "Loop maze generation and solving? Will randomize generator & solver combination. (use arrow keys and Enter, or Esc to exit):", + &["Yes", "No"], + )? { + Some(choice) => choice == "Yes", + None => { + return Ok(()); + } + }; + + // Flag to indicate rendering is done. Set to true by the render thread when it finishes. + let render_done = Arc::new(AtomicBool::new(false)); + // Flag to indicate rendering should be cancelled. Set to true by the main thread on Esc key event. + let render_cancel = Arc::new(AtomicBool::new(false)); + + let (user_input_event_tx, user_input_event_rx) = std::sync::mpsc::channel::(); + let render_done_for_input = render_done.clone(); + let render_cancel_for_input = render_cancel.clone(); + // Spawn a thread to listen for user input + let input_thread_handle = std::thread::spawn(move || -> std::io::Result<()> { + listen_to_user_input( + user_input_event_tx, + USER_INPUT_EVENT_POLL_TIMEOUT, + &render_done_for_input, + &render_cancel_for_input, + ) + }); + + let (grid_event_tx, grid_event_rx) = + std::sync::mpsc::sync_channel::(MAX_EVENTS_IN_CHANNEL_BUFFER); + let (user_action_event_tx, user_action_event_rx) = + std::sync::mpsc::channel::(); + + // Spawn a thread to listen for grid updates and render the maze + let render_cancel_for_render = render_cancel.clone(); + let render_done_for_render = render_done.clone(); + let render_thread_handle = std::thread::spawn(move || { + Renderer::new(MAX_HISTORY_GRID_EVENTS, Some((width, height))).render( + grid_event_rx, + user_action_event_rx, + &render_cancel_for_render, + &render_done_for_render, + ) + }); + + // Spawn a thread to generate maze and solve it + let render_cancel_for_compute = render_cancel.clone(); + let compute_thread_handle = std::thread::spawn(move || -> bool { + if !loop_animation { + return compute(width, height, grid_event_tx, generator, solver); + } + // Looping mode: randomly select generator and solver each iteration + let mut rng = rand::rng(); + loop { + let goal_reached = compute(width, height, grid_event_tx.clone(), generator, solver); + // Check if rendering was cancelled + if render_cancel_for_compute.load(std::sync::atomic::Ordering::Relaxed) { + tracing::info!("Compute thread detected render cancel, exiting loop"); + return goal_reached; + } + // Randomly select new generator and solver combination for next iteration + (generator, solver) = app::COMBOS[rng.random_range(0..app::COMBOS.len())]; + } + }); + + // Main thread loop to listen for user input events during rendering + app_loop( + user_input_event_rx, + user_action_event_tx, + render_done, + render_cancel, + ); + + // Wait for input thread to finish + let _ = input_thread_handle.join(); + + // Wait for compute thread to finish + let goal_reached = compute_thread_handle + .join() + .expect("Compute thread panicked"); + + // Wait for render thread to finish + let completed = render_thread_handle + .join() + .expect("Render thread panicked")?; + + if let RendererStatus::Cancelled = completed { + tracing::info!("Rendering was cancelled by user."); + return Ok(()); + } + + let msg = if goal_reached { + "Path found! " + } else { + "No path found. " + }; + stdout.execute(style::PrintStyledContent( + msg.with(Color::Green).attribute(Attribute::Bold), + ))?; + + stdout.execute(style::PrintStyledContent( + "Press Esc to exit...\r" + .with(Color::Blue) + .attribute(Attribute::Bold), + ))?; + + // Wait for user to press Esc + app::wait_for_keypress(KeyCode::Esc)?; + Ok(()) +} + +/// App loop after starting input and render threads +fn app_loop( + user_input_event_rx: Receiver, + user_action_event_tx: Sender, + render_done: Arc, + render_cancel: Arc, +) { + tracing::info!("Started main app loop"); + // Flag to indicate if the animation is currently paused + let mut is_paused = false; + loop { + // Check if render is done + if render_done.load(std::sync::atomic::Ordering::Relaxed) { + // Drop the receiver to signal input thread to exit + drop(user_input_event_rx); + break; + } + + let event = match user_input_event_rx.recv_timeout(INPUT_RECV_TIMEOUT) { + Err(e) => { + match e { + std::sync::mpsc::RecvTimeoutError::Timeout => { + // Skip to next iteration to check render_done again + continue; + } + std::sync::mpsc::RecvTimeoutError::Disconnected => { + // Input thread has exited, break the loop + break; + } + } + } + Ok(event) => match event { + UserInputEvent::KeyPress(key_event) => { + match key_event.code { + // Exit on Esc key + KeyCode::Esc => { + tracing::debug!("[app loop] Esc key pressed, notifying renderer"); + // Error only happens if user_input_event_rx is dropped, which + // means Renderer::render has exited already + user_action_event_tx.send(UserActionEvent::Cancel).ok(); + render_cancel.store(true, std::sync::atomic::Ordering::Relaxed); + break; + } + KeyCode::Enter => { + // Toggle pause/resume on Enter key + let event = if is_paused { + UserActionEvent::Resume + } else { + UserActionEvent::Pause + }; + // Toggle pause state + is_paused = !is_paused; + Some(event) + } + KeyCode::Left if is_paused => { + // Step backward when paused + Some(UserActionEvent::Backward) + } + KeyCode::Right if is_paused => { + // Step forward when paused + Some(UserActionEvent::Forward) + } + KeyCode::Up => { + // Speed up animation + Some(UserActionEvent::SpeedUp) + } + KeyCode::Down => { + // Slow down animation + Some(UserActionEvent::SlowDown) + } + _ => None, // Ignore other keys + } + } + UserInputEvent::Resize => Some(UserActionEvent::Resize), + }, + }; + + // Send the user action event to the render thread + if let Some(event) = event + && user_action_event_tx.send(event).is_err() + { + // Render thread has exited + break; + } + } + // The user_input_event_rx and user_action_event_tx are dropped here + tracing::info!("Exiting main app loop"); +} + +/// Listen for user input events (key presses and resize) +/// This function runs in a separate thread, and is the only place where user input is read +fn listen_to_user_input( + user_input_event_tx: Sender, + event_poll_timeout: Duration, + render_done: &AtomicBool, + render_cancel: &AtomicBool, +) -> std::io::Result<()> { + loop { + // Check if render is done or canceled + if render_done.load(std::sync::atomic::Ordering::Relaxed) + || render_cancel.load(std::sync::atomic::Ordering::Relaxed) + { + return Ok(()); + } + + // Poll for events with a timeout + if !event::poll(event_poll_timeout)? { + // No event available, continue loop to check flags again + continue; + } + + // Read the next event + // We only care about key presses events for now + let input_event = match event::read()? { + event::Event::Key(key_event) if key_event.kind == event::KeyEventKind::Press => { + UserInputEvent::KeyPress(key_event) + } + event::Event::Resize(_, _) => UserInputEvent::Resize, + _ => continue, // Ignore other events + }; + + // Should exit input thread on Esc key + let should_exit = matches!( + input_event, + UserInputEvent::KeyPress(event::KeyEvent { + code: KeyCode::Esc, + .. + }) + ); + + // Send the input event to the main thread + if user_input_event_tx.send(input_event).is_err() { + // Receiver has been dropped, exit the thread + return Ok(()); + } + + if should_exit { + tracing::debug!("[input loop] Esc key pressed, exiting"); + return Ok(()); + } + } +} + +/// Generate and solve the maze +/// Returns whether the goal was reached +fn compute( + width: u8, + height: u8, + grid_event_tx: std::sync::mpsc::SyncSender, + generator: Generator, + solver: Solver, +) -> bool { + let mut maze = Maze::new(width, height, Some(grid_event_tx)); + // Generate the maze using the selected algorithm + generate_maze(&mut maze, generator, None); + + // Solve the maze using the selected algorithm + solve_maze(&mut maze, solver) + // Maze is dropped here, as well as the grid_event_tx sender +} diff --git a/src/app/renderer.rs b/src/app/visualize/renderer.rs similarity index 89% rename from src/app/renderer.rs rename to src/app/visualize/renderer.rs index d19681c..4debc25 100644 --- a/src/app/renderer.rs +++ b/src/app/visualize/renderer.rs @@ -1,5 +1,6 @@ use std::{ collections::HashMap, + fmt::Display, io::{StdoutLock, Write}, sync::{atomic::AtomicBool, mpsc::Receiver}, time::Duration, @@ -7,13 +8,12 @@ use std::{ use crossterm::{ QueueableCommand, cursor, queue, - style::{self, Attribute, Color, Stylize}, + style::{self, Attribute, Color, StyledContent, Stylize}, terminal::{self, ClearType}, }; -use unicode_width::UnicodeWidthStr; use crate::{ - app::{UserActionEvent, history::GridEventHistory}, + app::{self, visualize::UserActionEvent, visualize::history::GridEventHistory}, maze::{cell::GridCell, grid::GridEvent}, }; @@ -181,6 +181,7 @@ pub enum RendererStatus { Cancelled, } +/// Renderer to manage terminal rendering of grid events from the [`crate::app::visualize`] module. pub struct Renderer<'a> { /// Standard output handle to write to the terminal. /// Locked for exclusive access during rendering. @@ -195,9 +196,6 @@ pub struct Renderer<'a> { } impl<'a> Renderer<'a> { - /// Number of log rows reserved at the bottom of the terminal - pub const NUM_LOG_ROWS: u16 = 1; - /// Create a new Renderer instance /// `max_history_grid_events` specifies the maximum number of grid events to keep in history /// `maze_dims` is an optional tuple of (width, height) to calibrate the render refresh time scale @@ -294,7 +292,7 @@ impl<'a> Renderer<'a> { Some((width, height)) => { let (term_width, term_height) = terminal::size()?; if term_width < width * GridCell::CELL_WIDTH - || term_height.saturating_sub(Self::NUM_LOG_ROWS) < height + || term_height.saturating_sub(app::NUM_STATUS_ROWS) < height { tracing::info!("Terminal size too small for grid display, pausing rendering"); let msg = format!( @@ -338,7 +336,7 @@ impl<'a> Renderer<'a> { // Check terminal size again let (new_term_width, new_term_height) = terminal::size()?; if new_term_width >= width * GridCell::CELL_WIDTH - && new_term_height.saturating_sub(Self::NUM_LOG_ROWS) + && new_term_height.saturating_sub(app::NUM_STATUS_ROWS) >= height { // Terminal resized sufficiently, recover display @@ -438,48 +436,27 @@ impl<'a> Renderer<'a> { Ok(width) } - /// Log a message to the terminal below the grid without disrupting the grid display - /// The cursor position is saved and restored after logging - /// Returns `Err` if there was an I/O error - /// If msg is None, clears the log line + fn clear_terminal_log(&mut self) -> std::io::Result<()> { + let grid_height = match self.grid_state.dims() { + // Move cursor right below the grid + Some((_, height)) => height, + // Default to top line if grid dimensions not yet set + None => 0, + }; + app::log_terminal(&mut self.stdout, grid_height, None::>) + } + fn log_to_terminal( &mut self, - msg: Option>, + msg: style::StyledContent>, ) -> std::io::Result<()> { - queue!( - self.stdout, - // Save cursor position first - cursor::SavePosition, - // Move cursor to the log line (below the grid) - cursor::MoveTo( - 0, - match self.grid_state.dims() { - Some((_, height)) => height, // Move cursor right below the grid - None => 0, // Default to top line if grid dimensions not yet set - } - ), - // Clear previous log line - terminal::Clear(ClearType::CurrentLine), - // Print the log message - style::PrintStyledContent(match msg { - Some(msg) => { - let width = terminal::size()?.0 as usize; - let content = msg.content(); - if content.width() > width { - // Truncate message to fit terminal width - let truncated = format!("{}...", &content[..width.saturating_sub(3)]); - style::StyledContent::new(*msg.style(), truncated) - } else { - msg - } - } - None => "".to_string().with(Color::Reset), - }), - // Go back to previous cursor position - cursor::RestorePosition, - )?; - self.stdout.flush()?; - Ok(()) + let grid_height = match self.grid_state.dims() { + // Move cursor right below the grid + Some((_, height)) => height, + // Default to top line if grid dimensions not yet set + None => 0, + }; + app::log_terminal(&mut self.stdout, grid_height, Some(msg)) } /// Handle a single user action event in the paused state @@ -495,7 +472,7 @@ impl<'a> Renderer<'a> { match event { UserActionEvent::Resume => { // Clear any log messages - self.log_to_terminal(None)?; + self.clear_terminal_log()?; tracing::debug!("Resuming rendering from pause"); // Step forward in the history and exit pause loop while let Some(event) = self.history.history_forward() { @@ -521,7 +498,7 @@ impl<'a> Renderer<'a> { { return Ok(RendererStatus::Cancelled); } - self.log_to_terminal(Some(event.to_string().with(Color::Green)))?; + self.log_to_terminal(event.with(Color::Green))?; } else { tracing::debug!("Attempting to step into the future"); match grid_event_rx.try_recv() { @@ -532,27 +509,24 @@ impl<'a> Renderer<'a> { { return Ok(RendererStatus::Cancelled); } - self.log_to_terminal(Some(event.to_string().with(Color::Green)))?; + self.log_to_terminal(event.with(Color::Green))?; } Err(std::sync::mpsc::TryRecvError::Empty) => { // No action event, continue tracing::debug!("No future event available at the moment"); - self.log_to_terminal(Some( - "No future event available at the moment" - .to_string() - .with(Color::Yellow), - ))?; + self.log_to_terminal( + "No future event available at the moment".with(Color::Yellow), + )?; } Err(std::sync::mpsc::TryRecvError::Disconnected) => { // Channel disconnected, ignore and continue // This loop will just wait for Resume to exit the pause state, // And the main render loop will eventually exit when it detects the disconnection tracing::debug!("Grid event channel disconnected"); - self.log_to_terminal(Some( + self.log_to_terminal( "Grid event channel disconnected. Resume to exit the rendering" - .to_string() .with(Color::Red), - ))?; + )?; } }; } @@ -566,11 +540,10 @@ impl<'a> Renderer<'a> { if self.history.history_backward().is_none() { // Cannot go back further tracing::debug!("Already at the oldest event, cannot go backward"); - self.log_to_terminal(Some( + self.log_to_terminal( "Already at the oldest event, cannot go backward" - .to_string() .with(Color::Yellow), - ))?; + )?; } else { // Recover the grid state up to right before this initial event tracing::debug!( @@ -578,18 +551,16 @@ impl<'a> Renderer<'a> { ); if !self.recover_from_history()? { // Recovery skipped due to missing initial event - self.log_to_terminal(Some( + self.log_to_terminal( "No initial event found in history during revert, cannot go backward" - .to_string() .with(Color::Yellow), - ))?; + )?; self.history.history_forward(); // Move back to current position } else { - self.log_to_terminal(Some( + self.log_to_terminal( "Reverted to state before initial event" - .to_string() .with(Color::Yellow), - ))?; + )?; } } } @@ -597,11 +568,9 @@ impl<'a> Renderer<'a> { // Move backward in history if self.history.history_backward().is_none() { // Cannot go back further - self.log_to_terminal(Some( - "Already at the oldest event after revert" - .to_string() - .with(Color::Yellow), - ))?; + self.log_to_terminal( + "Already at the oldest event after revert".with(Color::Yellow), + )?; } else { // Only render the revert event when not at the oldest event let revert_event = GridEvent::Update { @@ -621,9 +590,7 @@ impl<'a> Renderer<'a> { )? { return Ok(RendererStatus::Cancelled); } - self.log_to_terminal(Some( - revert_event.to_string().with(Color::Yellow), - ))?; + self.log_to_terminal(revert_event.with(Color::Yellow))?; } } }; @@ -636,11 +603,11 @@ impl<'a> Renderer<'a> { "Increased rendering speed, new refresh time: {:?}", self.render_refresh_time_scale.current() ); - self.log_to_terminal(Some( + self.log_to_terminal( self.render_refresh_time_scale .make_scale_bar(self.get_width()?) .stylize(), - ))?; + )?; } UserActionEvent::Resize => { // Check terminal size against current grid dimensions @@ -656,15 +623,15 @@ impl<'a> Renderer<'a> { "Decreased rendering speed, new refresh time: {:?}", self.render_refresh_time_scale.current() ); - self.log_to_terminal(Some( + self.log_to_terminal( self.render_refresh_time_scale .make_scale_bar(self.get_width()?) .stylize(), - ))?; + )?; } UserActionEvent::Cancel => { // Clear any log messages - self.log_to_terminal(None)?; + self.clear_terminal_log()?; tracing::info!("Rendering cancelled by user"); // Exit rendering loop return Ok(RendererStatus::Cancelled); @@ -773,7 +740,7 @@ impl<'a> Renderer<'a> { Err(_e) => { // Compute thread has finished sending events, exit render loop // Clear logs first - self.log_to_terminal(None)?; + self.clear_terminal_log()?; break; } Ok(event) => { diff --git a/src/main.rs b/src/main.rs index c782293..2d89155 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,12 +15,6 @@ fn main() -> std::io::Result<()> { tracing::info!("Logging initialized."); guard }; - - // Set up terminal and run the application - let mut stdout = std::io::stdout(); - App::setup_terminal(&mut stdout)?; - let app = App::default(); - let res = app.run(&mut stdout); - App::restore_terminal(&mut stdout)?; - res + let mut app = App::default(); + app.run() } diff --git a/src/maze/grid.rs b/src/maze/grid.rs index cd10f56..786cf2b 100644 --- a/src/maze/grid.rs +++ b/src/maze/grid.rs @@ -1,4 +1,5 @@ use super::cell::GridCell; +use crossterm::style::{ContentStyle, StyledContent, Stylize}; pub struct Grid { data: Box<[GridCell]>, @@ -21,6 +22,14 @@ pub enum GridEvent { }, } +impl Stylize for GridEvent { + type Styled = StyledContent; + + fn stylize(self) -> Self::Styled { + StyledContent::new(ContentStyle::default(), format!("{:?}", self)) + } +} + impl std::fmt::Display for GridEvent { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let content = match self { diff --git a/src/maze/mod.rs b/src/maze/mod.rs index f798ecc..19f46cd 100644 --- a/src/maze/mod.rs +++ b/src/maze/mod.rs @@ -247,6 +247,11 @@ impl Maze { }); }); } + + /// Expose the internal grid for read-only access. + pub fn grid(&self) -> &Grid { + &self.grid + } } impl std::ops::Index<(u8, u8)> for Maze { From 1080b2d820926271faff42749ffb552f51084442 Mon Sep 17 00:00:00 2001 From: Hung Tran Date: Mon, 15 Dec 2025 01:59:33 -0500 Subject: [PATCH 2/2] feat(app): implement end-to-end game logic --- src/app/game.rs | 625 ++++++++++++++++++++++++++++-- src/app/mod.rs | 14 +- src/app/visualize/mod.rs | 19 +- src/app/visualize/renderer.rs | 5 + src/generators/kruskal.rs | 4 +- src/generators/mod.rs | 17 + src/generators/prim.rs | 13 +- src/generators/recur_backtrack.rs | 7 +- src/maze/grid.rs | 3 +- src/maze/mod.rs | 20 +- 10 files changed, 670 insertions(+), 57 deletions(-) diff --git a/src/app/game.rs b/src/app/game.rs index efa137f..7797631 100644 --- a/src/app/game.rs +++ b/src/app/game.rs @@ -1,42 +1,606 @@ use crate::{ app, generators::{Generator, generate_maze}, - maze::Maze, + maze::{Maze, Orientation, cell::GridCell}, }; use crossterm::{ - ExecutableCommand, QueueableCommand, cursor, - event::KeyCode, + ExecutableCommand, cursor, + event::{self, Event, KeyCode}, queue, - style::{self, Attribute, Color, Stylize}, + style::{self, Attribute, Color, StyledContent, Stylize}, }; -use std::io::{Stdout, Write}; +use std::{ + io::{Stdout, Write}, + sync::{ + Arc, + atomic::AtomicBool, + mpsc::{Receiver, Sender}, + }, + time::{Duration, Instant}, +}; + +#[derive(Debug)] +enum UserInputEvent { + KeyPress(event::KeyEvent), + Resize, +} + +#[derive(Debug, Copy, Clone)] +enum Direction { + Up, + Down, + Left, + Right, +} + +enum UiEvent { + GridInit { width: u16, height: u16 }, + GridUpdate { coord: (u16, u16), new: GridCell }, + LogMessage(Option>), +} + +#[derive(Debug, PartialEq)] +enum GameRunResult { + /// Goal is reached before timer runs out + GoalReached, + /// Timer runs out before goal is reached + Timeout, + /// Game is canceled by user + Canceled, +} struct GameState { maze: Maze, + current: (u8, u8), + goal: (u8, u8), + ui_event_tx: Sender, } +/// Timeout for polling input events in the input thread, a.k.a. +/// how often to check for done/cancel flags for the game +const USER_INPUT_EVENT_POLL_TIMEOUT: Duration = Duration::from_millis(100); +/// Timeout for receiving input events, a.k.a. how often to check for render done/cancel flags +const INPUT_RECV_TIMEOUT: Duration = Duration::from_millis(100); +/// Max time for one run +const GAME_RUN_DURATION: Duration = Duration::from_secs(60); +/// Tick duration for the game timer +const GAME_TIMER_TICK_DURATION: Duration = Duration::from_secs(1); + impl GameState { - fn new(width: u8, height: u8, generator: Generator) -> Self { + /// Set up the initial game state with: + /// * Maze generation algorithm. + /// * Maze width & height. + /// * Start & goal positions. Either randomized (with `random_start_goal = true`) or top left + /// for start cell and bottom right for goal cell. + /// + /// Panics if either width or height is 0. + /// Return the initialized [`GameState`]. + fn initialize( + width: u8, + height: u8, + generator: Generator, + random_start_goal: bool, + ui_event_tx: Sender, + ) -> Self { + // Get the initial maze let mut maze = Maze::new(width, height, None); + // Carve the maze with the generator algorithm generate_maze(&mut maze, generator, None); - GameState { maze } + + let start = if random_start_goal { + (rand::random_range(0..width), rand::random_range(0..height)) + } else { + (0, 0) + }; + // Use GridCell::START to represent Pacman + maze.set(start, GridCell::START); + + let goal = if random_start_goal { + (rand::random_range(0..width), rand::random_range(0..height)) + } else { + (width - 1, height - 1) + }; + maze.set(goal, GridCell::GOAL); + + GameState { + maze, + goal, + current: start, + ui_event_tx, + } + } + + /// Attempt to move the player in the specified direction. + /// Marks the previous cell as visited and updates current position if move is valid. + /// Returns the new position if the move is successful, None otherwise. + fn move_player(&mut self, direction: Direction) -> Option<(u8, u8)> { + // Calculate new position + determine orientation for wall checking + path orientation to set if no wall + let (new_pos, check_pos, wall_orientation, path_orientation) = match direction { + Direction::Left => { + let new_x = self.current.0.checked_sub(1)?; + let new_pos = (new_x, self.current.1); + // Moving left: vertical wall to the right of new_pos + ( + new_pos, + new_pos, + Orientation::Vertical, + Orientation::Horizontal, + ) + } + Direction::Right => { + let new_x = self.current.0.checked_add(1)?; + if new_x >= self.maze.width() { + return None; + } + let new_pos = (new_x, self.current.1); + // Moving right: vertical wall to the right of current + ( + new_pos, + self.current, + Orientation::Vertical, + Orientation::Horizontal, + ) + } + Direction::Up => { + let new_y = self.current.1.checked_sub(1)?; + let new_pos = (self.current.0, new_y); + // Moving up: horizontal wall below new_pos + ( + new_pos, + new_pos, + Orientation::Horizontal, + Orientation::Vertical, + ) + } + Direction::Down => { + let new_y = self.current.1.checked_add(1)?; + if new_y >= self.maze.height() { + return None; + } + let new_pos = (self.current.0, new_y); + // Moving down: horizontal wall below current + ( + new_pos, + self.current, + Orientation::Horizontal, + Orientation::Vertical, + ) + } + }; + + // Check for walls; disallow movement if a wall exists + if self.maze.is_wall_cell_after(check_pos, wall_orientation) { + return None; + } + + // Ensure the corridor cell between the two cells is a path for rendering + let path_coord = self.maze.set_path_cell_after(check_pos, path_orientation); + if self + .ui_event_tx + .send(UiEvent::GridUpdate { + coord: path_coord, + new: self.maze.grid()[path_coord], + }) + .is_err() + { + // Receiver has been dropped, cannot send UI event + return None; + } + + if self + .ui_event_tx + .send(UiEvent::GridUpdate { + coord: self.maze.set(self.current, GridCell::VISITED), + new: *self.maze.cell_at(self.current), + }) + .is_err() + { + // Receiver has been dropped, cannot send UI event + return None; + }; + + // Move to new position + self.current = new_pos; + if self + .ui_event_tx + .send(UiEvent::GridUpdate { + coord: self.maze.set(self.current, GridCell::START), + new: *self.maze.cell_at(self.current), + }) + .is_err() + { + // Receiver has been dropped, cannot send UI event + return None; + }; + Some(self.current) + } +} + +/// Render the current maze to stdout +fn render_ui_events(ui_event_rx: Receiver) -> std::io::Result<()> { + // Get a new stdout handle + let mut stdout = std::io::stdout(); + // Store grid dimensions once received + let mut grid_dims = None; + loop { + // Block and render any new grid events as they come from the sender + match ui_event_rx.recv() { + Err(_) => { + // All senders are dropped. Break from the loop + tracing::debug!("[render] UI event channel closed, exiting render thread"); + break; + } + Ok(event) => match event { + UiEvent::GridInit { width, height } => { + grid_dims = Some((width, height)); + } + UiEvent::GridUpdate { coord, new } => { + match grid_dims { + Some(grid_dims) => { + if coord.0 >= grid_dims.0 || coord.1 >= grid_dims.1 { + // Out of bounds, skip rendering + continue; + } + } + // Grid dimensions not yet received, cannot render + None => continue, + } + // Move the cursor to the specified coordinate and print the + // new cell using the grid dimensions + queue!( + stdout, + cursor::MoveTo(coord.0 * GridCell::CELL_WIDTH, coord.1), + style::Print(new) + )?; + stdout.flush()?; + } + UiEvent::LogMessage(msg) => { + // Log message to terminal below the maze + app::log_terminal( + &mut stdout, + // Use grid height if available, otherwise 0 + grid_dims.unwrap_or((0, 0)).1, + msg, + )?; + } + }, + } + } + Ok(()) +} + +/// Spawn a run of the game, including user interaction, timer, and maze rendering +fn start_game( + stdout: &mut Stdout, + width: u8, + height: u8, + generator: Generator, + random_start_goal: bool, +) -> std::io::Result { + let (ui_event_tx, ui_event_rx) = std::sync::mpsc::channel::(); + + // Spawn render thread to render grid events from the game state + // Initial maze will be rendered to the terminal + let render_thread_handle = + std::thread::spawn(move || -> std::io::Result<()> { render_ui_events(ui_event_rx) }); + + // Initialize game state and render initial maze + let game_state = GameState::initialize( + width, + height, + generator, + random_start_goal, + ui_event_tx.clone(), + ); + // Send grid dimensions to render thread + if ui_event_tx + .send(UiEvent::GridInit { + width: game_state.maze.grid().width(), + height: game_state.maze.grid().height(), + }) + .is_err() + { + // Just return if render thread has exited + return Ok(GameRunResult::Canceled); + } + // Send initial grid cells to render thread + for y in 0..game_state.maze.grid().height() { + for x in 0..game_state.maze.grid().width() { + if ui_event_tx + .send(UiEvent::GridUpdate { + coord: (x, y), + new: game_state.maze.grid()[(x, y)], + }) + .is_err() + { + // Just return if render thread has exited + return Ok(GameRunResult::Canceled); + } + } + } + + // Flag to let other threads stop. Enabled by the main thread only. + let should_stop = Arc::new(AtomicBool::new(false)); + + let (user_input_event_tx, user_input_event_rx) = std::sync::mpsc::channel::(); + + // Spawn user input thread + let should_stop_for_input = should_stop.clone(); + let input_thread_handle = std::thread::spawn(move || -> std::io::Result<()> { + listen_to_user_input( + user_input_event_tx, + USER_INPUT_EVENT_POLL_TIMEOUT, + &should_stop_for_input, + ) + }); + + // Spawn a thread to start the timer + let start_time = Instant::now(); + let should_stop_for_timer = should_stop.clone(); + let timer_thread_handle = std::thread::spawn(move || -> std::io::Result<()> { + start_timer( + start_time, + GAME_RUN_DURATION, + GAME_TIMER_TICK_DURATION, + &should_stop_for_timer, + ui_event_tx, + ) + }); + + let grid_height = game_state.maze.grid().height(); + + // Start game loop in main thread + let game_result = game_loop( + game_state, + user_input_event_rx, + INPUT_RECV_TIMEOUT, + &should_stop, + &timer_thread_handle, + )?; + tracing::debug!("[game] Game loop exited with result: {:?}", game_result); + + // At this point, all UI event senders are dropped, so the render thread will exit + // Wait for render and input threads to finish + render_thread_handle + .join() + .expect("Render thread paniched")?; + input_thread_handle.join().expect("Input thread panicked")?; + + app::log_terminal(stdout, grid_height, None::>)?; + match game_result { + GameRunResult::GoalReached => { + app::log_terminal( + stdout, + grid_height, + Some( + "Congratulations! You reached the goal! Press Enter to continue., or Esc to exit." + .with(Color::Green) + .attribute(Attribute::Bold), + ), + )?; + } + GameRunResult::Timeout => { + app::log_terminal( + stdout, + grid_height, + Some( + "Time's up! You failed to reach the goal. Press Enter to continue. or Esc to exit." + .with(Color::Red) + .attribute(Attribute::Bold), + ), + )?; + } + GameRunResult::Canceled => { + // Just return immediately + return Ok(game_result); + } } - fn render_initial_maze(&self, stdout: &mut Stdout) -> std::io::Result<()> { - let grid = self.maze.grid(); - stdout.queue(cursor::MoveTo(0, 0))?; - for y in 0..grid.height() { - for x in 0..grid.width() { - stdout.queue(style::Print(grid[(x, y)]))?; + loop { + if let Event::Key(event::KeyEvent { code, kind, .. }) = event::read()? + && kind == event::KeyEventKind::Press + { + match code { + KeyCode::Enter => { + break; + } + KeyCode::Esc => { + return Ok(GameRunResult::Canceled); + } + _ => {} + } + break; + } + } + Ok(game_result) +} + +/// Main game loop, running in the main thread +/// Polls for user input events and updates game state accordingly +/// Exits when either the goal is reached, time runs out, or user cancels +fn game_loop( + mut game_state: GameState, + user_input_event_rx: Receiver, + input_recv_timeout: Duration, + should_stop: &AtomicBool, + timer_thread_handle: &std::thread::JoinHandle>, +) -> std::io::Result { + loop { + // Check if render thread is finished + if timer_thread_handle.is_finished() { + tracing::info!("[game loop] Timer thread finished, game result is Timeout"); + // Notify all threads to stop + should_stop.store(true, std::sync::atomic::Ordering::Release); + return Ok(GameRunResult::Timeout); + } + + // Check if goal is reached + if game_state.current == game_state.goal { + tracing::info!("[game loop] Goal reached!"); + // Notify all threads to stop + should_stop.store(true, std::sync::atomic::Ordering::Release); + return Ok(GameRunResult::GoalReached); + } + + // Poll user input event + match user_input_event_rx.recv_timeout(input_recv_timeout) { + Err(e) => { + match e { + std::sync::mpsc::RecvTimeoutError::Timeout => { + // Skip to next iteration to check exit conditions again + continue; + } + std::sync::mpsc::RecvTimeoutError::Disconnected => { + // Input thread has exited, set should_stop flag to true + // to tell other threads to stop + should_stop.store(true, std::sync::atomic::Ordering::Release); + return Ok(GameRunResult::Canceled); + } + } } - stdout.queue(style::Print("\r\n"))?; + Ok(event) => match event { + UserInputEvent::KeyPress(key_event) => { + match key_event.code { + KeyCode::Esc => { + // Game should exit on Esc key + should_stop.store(true, std::sync::atomic::Ordering::Release); + // Set the game as canceled + return Ok(GameRunResult::Canceled); + } + KeyCode::Left => { + game_state.move_player(Direction::Left); + } + KeyCode::Right => { + game_state.move_player(Direction::Right); + } + KeyCode::Up => { + game_state.move_player(Direction::Up); + } + KeyCode::Down => { + game_state.move_player(Direction::Down); + } + _ => {} + }; + } + UserInputEvent::Resize => todo!("Check resize"), + }, + } + } +} + +/// Start the game timer, logging remaining time to terminal every tick +/// This function will be run in a separate thread +/// # Arguments +/// * `start_time`: The instant when the game started +/// * `game_run_duration`: The total duration of the game run +/// * `tick_duration`: The duration between each tick to log remaining time +/// * `grid_height`: The height of the maze grid, used to position the log correctly +/// * `should_stop`: Flag to check for exiting early (due to user cancel) +fn start_timer( + start_time: Instant, + game_run_duration: Duration, + tick_duration: Duration, + should_stop: &AtomicBool, + ui_event_tx: Sender, +) -> std::io::Result<()> { + // Get a new stdout handle + while start_time.elapsed() < game_run_duration { + // Check if the timer should stop early + if should_stop.load(std::sync::atomic::Ordering::Acquire) { + return Ok(()); + } + // Clear previous log + if ui_event_tx.send(UiEvent::LogMessage(None)).is_err() { + // Receiver has been dropped, exit the thread + return Ok(()); + } + let remaining_time = GAME_RUN_DURATION - start_time.elapsed(); + + // Set message color based on remaining time + + let msg = if remaining_time <= GAME_RUN_DURATION / 4 { + format!("Time remain: {}", remaining_time.as_secs()) + .with(Color::Red) + .attribute(Attribute::Bold) + } else if remaining_time <= GAME_RUN_DURATION / 2 { + format!("Time remain: {}", remaining_time.as_secs()) + .with(Color::Yellow) + .attribute(Attribute::Bold) + } else { + format!("Time remain: {}", remaining_time.as_secs()) + .with(Color::Green) + .attribute(Attribute::Bold) + }; + // Log new remaining time + if ui_event_tx.send(UiEvent::LogMessage(Some(msg))).is_err() { + // Receiver has been dropped, exit the thread + return Ok(()); + } + // Sleep for 1 second + std::thread::sleep(tick_duration); + } + Ok(()) +} + +/// Listen for user input events (key presses and resize) +/// This function runs in a separate thread, and is the only place where user input is read +fn listen_to_user_input( + user_input_event_tx: Sender, + event_poll_timeout: Duration, + should_stop: &AtomicBool, +) -> std::io::Result<()> { + loop { + // Check if we should stop + if should_stop.load(std::sync::atomic::Ordering::Acquire) { + return Ok(()); + } + + // Poll for events with a timeout + if !event::poll(event_poll_timeout)? { + // No event available, continue loop to check flags again + continue; + } + + // Read the next event + // We only care about key presses events for now + let input_event = match event::read()? { + event::Event::Key(key_event) if key_event.kind == event::KeyEventKind::Press => { + UserInputEvent::KeyPress(key_event) + } + event::Event::Resize(_, _) => UserInputEvent::Resize, + _ => continue, // Ignore other events + }; + + // Should exit input thread on Esc key + let should_exit = matches!( + input_event, + UserInputEvent::KeyPress(event::KeyEvent { + code: KeyCode::Esc, + .. + }) + ); + + // Send the input event to the main thread + if user_input_event_tx.send(input_event).is_err() { + // Receiver has been dropped, exit the thread + return Ok(()); + } + + if should_exit { + tracing::debug!("[input loop] Esc key pressed, exiting"); + return Ok(()); } - stdout.flush()?; - Ok(()) } } pub fn run(stdout: &mut Stdout) -> std::io::Result<()> { + queue!( + stdout, + style::SetAttribute(Attribute::Reverse), + style::PrintStyledContent("Game Mode\r\n".with(Color::Yellow)), + style::SetAttribute(Attribute::NoReverse), + )?; + stdout.flush()?; // Ask user for maze dimensions let (width, height) = match app::ask_maze_dimensions(stdout)? { Some(dims) => dims, @@ -81,20 +645,23 @@ pub fn run(stdout: &mut Stdout) -> std::io::Result<()> { ), style::PrintStyledContent(" Esc: Exit game\r\n\r\n".with(Color::Cyan)), )?; + stdout.flush()?; - let game_state = GameState::new(width, height, generator); - game_state.render_initial_maze(stdout)?; - app::log_terminal( - stdout, - game_state.maze.grid().height(), - Some( - "Game loop not implemented. Press Enter to exit the game." - .with(Color::Magenta) - .attribute(Attribute::Bold), - ), - )?; + tracing::info!( + "[game] Starting game with maze size {}x{} and generator {:?}", + width, + height, + generator + ); + + loop { + let game_result = start_game(stdout, width, height, generator, false)?; + if game_result == GameRunResult::Canceled { + break; + } + tracing::info!("[game] Game result: {:?} Restarting game...", game_result); + } + tracing::info!("[game] Game was canceled by user, exiting..."); - // Wait for Enter key to start the game - app::wait_for_keypress(KeyCode::Enter)?; Ok(()) } diff --git a/src/app/mod.rs b/src/app/mod.rs index f97ba29..237c1ec 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -319,7 +319,7 @@ fn wait_for_keypress(key: KeyCode) -> std::io::Result<()> { Ok(()) } -/// Print a line of message to the reserved area without disrupting the grid display +/// Print a line of message below the grid without disrupting the grid display /// The cursor position is saved and restored after logging /// Returns `Err` if there was an I/O error /// If msg is None, clears all messages from the reserved rows @@ -460,21 +460,9 @@ impl App { )?; match mode { AppMode::Visualize => { - queue!( - self.stdout, - style::SetAttribute(Attribute::Reverse), - style::PrintStyledContent("Visualization Mode\r\n".with(Color::Yellow)), - style::SetAttribute(Attribute::NoReverse), - )?; visualize::run(&mut self.stdout)?; } AppMode::Game => { - queue!( - self.stdout, - style::SetAttribute(Attribute::Reverse), - style::PrintStyledContent("Game Mode\r\n".with(Color::Yellow)), - style::SetAttribute(Attribute::NoReverse), - )?; game::run(&mut self.stdout)?; } } diff --git a/src/app/visualize/mod.rs b/src/app/visualize/mod.rs index 436fa7c..207106d 100644 --- a/src/app/visualize/mod.rs +++ b/src/app/visualize/mod.rs @@ -67,6 +67,14 @@ const MAX_HISTORY_GRID_EVENTS: usize = 100; /// Entry point of the visualizer app pub fn run(stdout: &mut Stdout) -> std::io::Result<()> { + queue!( + stdout, + style::SetAttribute(Attribute::Reverse), + style::PrintStyledContent("Visualization Mode\r\n".with(Color::Yellow)), + style::SetAttribute(Attribute::NoReverse), + )?; + stdout.flush()?; + // Ask user for maze dimensions let (width, height) = match app::ask_maze_dimensions(stdout)? { Some(dims) => dims, @@ -140,6 +148,9 @@ pub fn run(stdout: &mut Stdout) -> std::io::Result<()> { }; // Flag to indicate rendering is done. Set to true by the render thread when it finishes. + // TODO: consider using render_thread_handle.is_finished() to exit app loop instead of using + // an atomic bool flag. Then other threads can exit with just one flag: should_stop. Main + // thread takes care of the flag-setting decision. let render_done = Arc::new(AtomicBool::new(false)); // Flag to indicate rendering should be cancelled. Set to true by the main thread on Esc key event. let render_cancel = Arc::new(AtomicBool::new(false)); @@ -198,12 +209,13 @@ pub fn run(stdout: &mut Stdout) -> std::io::Result<()> { app_loop( user_input_event_rx, user_action_event_tx, + INPUT_RECV_TIMEOUT, render_done, render_cancel, ); // Wait for input thread to finish - let _ = input_thread_handle.join(); + input_thread_handle.join().expect("Input thread panicked")?; // Wait for compute thread to finish let goal_reached = compute_thread_handle @@ -244,6 +256,7 @@ pub fn run(stdout: &mut Stdout) -> std::io::Result<()> { fn app_loop( user_input_event_rx: Receiver, user_action_event_tx: Sender, + input_recv_timeout: Duration, render_done: Arc, render_cancel: Arc, ) { @@ -252,13 +265,15 @@ fn app_loop( let mut is_paused = false; loop { // Check if render is done + // FIXME: Should use Ordering::Release for flag setting (store()) and Ordering::Acquire for flag + // reading (load()) if render_done.load(std::sync::atomic::Ordering::Relaxed) { // Drop the receiver to signal input thread to exit drop(user_input_event_rx); break; } - let event = match user_input_event_rx.recv_timeout(INPUT_RECV_TIMEOUT) { + let event = match user_input_event_rx.recv_timeout(input_recv_timeout) { Err(e) => { match e { std::sync::mpsc::RecvTimeoutError::Timeout => { diff --git a/src/app/visualize/renderer.rs b/src/app/visualize/renderer.rs index 4debc25..1c72c05 100644 --- a/src/app/visualize/renderer.rs +++ b/src/app/visualize/renderer.rs @@ -17,6 +17,7 @@ use crate::{ maze::{cell::GridCell, grid::GridEvent}, }; +// TODO: add tests /// Struct to manage render refresh time scaling based on a quantized level scale struct RenderRefreshTimeScale { delta: Duration, @@ -674,6 +675,10 @@ impl<'a> Renderer<'a> { /// - `Ok(RendererStatus::Completed)` if rendering completed successfully /// - `Ok(RendererStatus::Cancelled)` if rendering was cancelled /// - `Err` if there was an I/O error + /// + /// This function will block on waiting for grid events, but will check for user action events + /// in a non-blocking manner. The reason is that grid events are expected to arrive at a steady pace + /// from the compute thread, while user action events are sporadic and should be handled immediately. pub fn render( &mut self, grid_event_rx: Receiver, diff --git a/src/generators/kruskal.rs b/src/generators/kruskal.rs index 35ee680..6bcd0a7 100644 --- a/src/generators/kruskal.rs +++ b/src/generators/kruskal.rs @@ -65,7 +65,9 @@ pub fn randomized_kruskal(maze: &mut Maze, seed: Option) { maze.fill_walls(); (0..height).for_each(|y| { - (0..width).for_each(|x| maze.set((x, y), GridCell::PATH)); + (0..width).for_each(|x| { + maze.set((x, y), GridCell::PATH); + }); }); // Initialize Union-Find for all cells diff --git a/src/generators/mod.rs b/src/generators/mod.rs index 412dc43..2d0d398 100644 --- a/src/generators/mod.rs +++ b/src/generators/mod.rs @@ -57,3 +57,20 @@ pub fn generate_maze(maze: &mut Maze, generator: Generator, seed: Option) { Generator::Kruskal => randomized_kruskal(maze, seed), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn handle_empty_mazes_without_panic() { + let empty_sizes = [(0u8, 0u8), (0, 9), (9, 0)]; + for (width, height) in empty_sizes { + let mut maze = Maze::new(width, height, None); + generate_maze(&mut maze, Generator::RecurBacktrack, None); + generate_maze(&mut maze, Generator::Prim, None); + generate_maze(&mut maze, Generator::RecurDiv, None); + generate_maze(&mut maze, Generator::Kruskal, None); + } + } +} diff --git a/src/generators/prim.rs b/src/generators/prim.rs index 9ae6e9f..10f730f 100644 --- a/src/generators/prim.rs +++ b/src/generators/prim.rs @@ -13,8 +13,11 @@ pub fn randomized_prim(maze: &mut Maze, seed: Option) { let mut rng = get_rng(seed); // Initialize the maze with walls - (0..maze.height()) - .for_each(|y| (0..maze.width()).for_each(|x| maze.set((x, y), GridCell::WALL))); + (0..maze.height()).for_each(|y| { + (0..maze.width()).for_each(|x| { + maze.set((x, y), GridCell::WALL); + }) + }); // Initialize the starting point let start: (u8, u8) = ( @@ -29,9 +32,9 @@ pub fn randomized_prim(maze: &mut Maze, seed: Option) { .filter(|&coord| maze[coord] == GridCell::WALL) .collect::>(); // Mark all frontier cells as marked in the maze for visualization - frontiers - .iter() - .for_each(|&coord| maze.set(coord, GridCell::MARK)); + frontiers.iter().for_each(|&coord| { + maze.set(coord, GridCell::MARK); + }); // Pick a random frontier cell while let Some(&frontier) = frontiers.get_rand() { diff --git a/src/generators/recur_backtrack.rs b/src/generators/recur_backtrack.rs index 1890cf7..40394fa 100644 --- a/src/generators/recur_backtrack.rs +++ b/src/generators/recur_backtrack.rs @@ -14,8 +14,11 @@ pub fn recursive_backtrack(maze: &mut Maze, seed: Option) { let mut rng = get_rng(seed); // Initialize the maze with walls - (0..maze.height()) - .for_each(|y| (0..maze.width()).for_each(|x| maze.set((x, y), GridCell::WALL))); + (0..maze.height()).for_each(|y| { + (0..maze.width()).for_each(|x| { + maze.set((x, y), GridCell::WALL); + }) + }); // Initialize the starting point let start: (u8, u8) = ( diff --git a/src/maze/grid.rs b/src/maze/grid.rs index 786cf2b..48c6acc 100644 --- a/src/maze/grid.rs +++ b/src/maze/grid.rs @@ -95,7 +95,8 @@ impl Grid { if old != cell { self.data[idx] = cell; if let Some(sender) = &self.grid_event_tx { - // This operation will block if the channel is full, which is acceptable behavior + // This operation will block if the channel is full. + // The receiver should be running in a separate thread to avoid deadlocks. let _ = sender.send(GridEvent::Update { coord, old, diff --git a/src/maze/mod.rs b/src/maze/mod.rs index 19f46cd..eb4f452 100644 --- a/src/maze/mod.rs +++ b/src/maze/mod.rs @@ -51,15 +51,23 @@ impl Maze { self.width } - /// Checks if the maze is empty (zero width and height). + /// Checks if the maze is empty (zero width or height). pub fn is_empty(&self) -> bool { - self.width == 0 && self.height == 0 + self.width == 0 || self.height == 0 } /// Sets the cell at the given coordinate to the specified [`GridCell`] value. - pub fn set(&mut self, coord: (u8, u8), cell: GridCell) { + /// Returns the grid coordinate where the cell was set. + pub fn set(&mut self, coord: (u8, u8), cell: GridCell) -> (u16, u16) { let grid_coord = (coord.0 as u16 * 2 + 1, coord.1 as u16 * 2 + 1); self.grid.set(grid_coord, cell); + grid_coord + } + + /// Returns a reference to the maze cell at the given maze coordinate. + pub fn cell_at(&self, coord: (u8, u8)) -> &GridCell { + let grid_coord = (coord.0 as u16 * 2 + 1, coord.1 as u16 * 2 + 1); + &self.grid[grid_coord] } /// Checks if the given coordinate is within the bounds of the maze. @@ -203,7 +211,10 @@ impl Maze { /// `orientation` determines the orientation of the path to set: /// - `Vertical`: Sets the path cell below the specified cell (between `from` and `(from.0, from.1+1)`) /// - `Horizontal`: Sets the path cell to the right of the specified cell (between `from` and `(from.0+1, from.1)`) - pub fn set_path_cell_after(&mut self, from: (u8, u8), orientation: Orientation) { + /// + /// # Returns + /// The grid coordinate of the wall cell that was set to a path. + pub fn set_path_cell_after(&mut self, from: (u8, u8), orientation: Orientation) -> (u16, u16) { let wall_coord = match orientation { Orientation::Horizontal => { if from.0 + 1 >= self.width { @@ -220,6 +231,7 @@ impl Maze { }; self.grid .set(wall_coord, GridCell::Path(PathType::Path(orientation))); + wall_coord } /// Clears all existing walls within the maze. Boundary walls are preserved.