diff --git a/aoc_examples/2016d13.ipynb b/prototypes/2016d13.ipynb similarity index 100% rename from aoc_examples/2016d13.ipynb rename to prototypes/2016d13.ipynb diff --git a/aoc_examples/2021d12.ipynb b/prototypes/2021d12.ipynb similarity index 100% rename from aoc_examples/2021d12.ipynb rename to prototypes/2021d12.ipynb diff --git a/aoc_examples/inputs/2021d12 b/prototypes/inputs/2021d12 similarity index 100% rename from aoc_examples/inputs/2021d12 rename to prototypes/inputs/2021d12 diff --git a/search_viz/src/bfs_ui.rs b/search_viz/src/bfs_ui.rs new file mode 100644 index 0000000..fd39c67 --- /dev/null +++ b/search_viz/src/bfs_ui.rs @@ -0,0 +1,221 @@ +use std::collections::HashMap; +use std::error::Error; + +use egui::Rect; +use egui::{vec2, Color32, RichText, Sense, Slider, Stroke, TextEdit, Vec2}; +use instant::Instant; + +use crate::bfs::*; + +const CELL_SIZE: f32 = 16.0; +const WALL_COLOR: Color32 = Color32::from_rgb(125, 0, 255); +const START_COLOR: Color32 = Color32::from_rgb(0, 0, 255); +const GOAL_COLOR: Color32 = Color32::from_rgb(0, 255, 0); +const PATH_COLOR: Color32 = Color32::from_rgb(255, 255, 0); +const EXPLORED_COLOR: Color32 = Color32::from_rgb(0, 50, 100); + +#[derive(Debug, Clone, PartialEq)] +pub struct State { + user_input: UserInput, + validated: Validated, + path: Vec, + explored: HashMap>, + time: Instant, + time_tick: u32, + animate_bfs: bool, + show_path: bool, +} + +impl Default for State { + fn default() -> Self { + let user_input = UserInput { + fav_number: "1350".to_string(), + levels: 50, + start_x: "1".to_string(), + start_y: "1".to_string(), + goal_x: "31".to_string(), + goal_y: "39".to_string(), + }; + let validated = user_input.validate().unwrap(); + let (path, explored) = + path_and_explored_by_generation_for_animation(validated.fav_number, validated.start, validated.goal); + + Self { + user_input, + validated, + path, + explored, + time: Instant::now(), + time_tick: 0, + animate_bfs: true, + show_path: true, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct UserInput { + fav_number: String, + levels: u32, + start_x: String, + start_y: String, + goal_x: String, + goal_y: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Validated { + fav_number: u32, + levels: u32, + start: Pos, + goal: Pos, +} + +impl UserInput { + fn validate(&self) -> Result> { + let fav_number = self + .fav_number + .parse::() + .map_err(|err| format!("Incorrect value for Favorite Number: {}", err.to_string()))?; + let start = Pos { + x: self.start_x.parse::().map_err(|err| err.to_string())?, + y: self.start_y.parse::().map_err(|err| err.to_string())?, + }; + let goal = Pos { + x: self.goal_x.parse::().map_err(|err| err.to_string())?, + y: self.goal_y.parse::().map_err(|err| err.to_string())?, + }; + + Ok(Validated { + fav_number, + levels: self.levels, + start, + goal, + }) + } +} + +impl State { + pub fn ui(&mut self, ui: &mut egui::Ui) { + ui.collapsing( + RichText::new("Breadth-First Search Graph Algorithm Demo").heading(), + |ui| { + ui.label("This demo allows you to try out an example Advent of Code problem for BFS."); + ui.horizontal(|ui| { + ui.label("You can find the source code for this example "); + ui.hyperlink_to("here", "https://github.com/lakret/gir"); + ui.label("."); + }); + }, + ); + + ui.hyperlink_to("Tutorial Video", "https://youtu.be/ZDy3tqn-DKA"); + ui.hyperlink_to("Advent of Code 2016, Day 13", "https://adventofcode.com/2016/day/13"); + ui.add_space(15.0); + + ui.horizontal(|ui| { + ui.label("Favorite Number: "); + ui.add(TextEdit::singleline(&mut self.user_input.fav_number).margin(vec2(10.0, 6.0))); + + ui.add( + Slider::new(&mut self.user_input.levels, 1..=200) + .text("Levels to Draw") + .integer(), + ); + }); + ui.add_space(15.0); + + match self.user_input.validate() { + Ok(new_validated) => { + let (path, explored) = path_and_explored_by_generation_for_animation( + new_validated.fav_number, + new_validated.start, + new_validated.goal, + ); + self.path = path; + self.explored = explored; + self.validated = new_validated; + } + Err(msg) => { + ui.label(RichText::new(msg.to_string()).color(Color32::DARK_RED)); + } + } + + ui.horizontal(|ui| { + let animate_bfs_checkbox = ui.checkbox(&mut self.animate_bfs, "Play Animation"); + if animate_bfs_checkbox.changed() && self.animate_bfs { + self.time = Instant::now(); + } + + ui.add(Slider::new(&mut self.time_tick, 0..=100).text("Tick").integer()); + + ui.checkbox(&mut self.show_path, "Show Path"); + }); + ui.add_space(15.0); + + egui::ScrollArea::both().show(ui, |ui| self.draw_animated_grid(ui)); + } + + fn draw_animated_grid(&mut self, ui: &mut egui::Ui) { + if self.animate_bfs { + // each animation tick is 1/20 of a second = 50 milliseconds + self.time_tick = (self.time.elapsed().as_secs_f32() * 20.0).ceil() as u32 % 100; + } + + let (response, painter) = + ui.allocate_painter(Vec2::splat(self.validated.levels as f32 * CELL_SIZE), Sense::hover()); + + let rect = response.rect; + painter.rect_stroke(rect, 0.0, Stroke::new(1.0, WALL_COLOR)); + + let min_x = *rect.x_range().start(); + let min_y = *rect.y_range().start(); + for y in 0..self.validated.levels { + for x in 0..self.validated.levels { + let pos = Pos { x, y }; + let cell = logical_pos_to_screen_rect(pos, min_x, min_y); + + if !pos.is_open(self.validated.fav_number) { + painter.rect_filled(cell, 0.0, WALL_COLOR); + } + + if self.validated.start == pos { + painter.circle_filled(cell.center(), CELL_SIZE / 2.0, START_COLOR); + } + + if self.validated.goal == pos { + painter.rect_filled(cell, 4.0, GOAL_COLOR); + } + } + } + + if self.show_path { + // can be used for both path length and generation to show + let elements_to_show = (self.path.len() as f32 / 100.0 * self.time_tick as f32).ceil() as usize; + for &pos in &self.path[..elements_to_show] { + if pos != self.validated.start && pos != self.validated.goal { + let cell = logical_pos_to_screen_rect(pos, min_x, min_y); + painter.rect_filled(cell, 6.0, PATH_COLOR); + } + } + + for generation in 0..(elements_to_show as u32) { + if let Some(positions) = self.explored.get(&generation) { + for &pos in positions { + let cell = logical_pos_to_screen_rect(pos, min_x, min_y); + painter.rect_filled(cell, 0.0, EXPLORED_COLOR); + } + } + } + } + } +} + +fn logical_pos_to_screen_rect(pos: Pos, min_x: f32, min_y: f32) -> Rect { + let Pos { x, y } = pos; + let screen_min_x = x as f32 * CELL_SIZE + min_x; + let screen_max_x = (x + 1) as f32 * CELL_SIZE + min_x; + let screen_min_y = (y as f32 * CELL_SIZE + min_y).ceil(); + let screen_max_y = ((y + 1) as f32 * CELL_SIZE + min_y).ceil(); + Rect::from_x_y_ranges(screen_min_x..=screen_max_x, screen_min_y..=screen_max_y) +} diff --git a/search_viz/src/main.rs b/search_viz/src/main.rs index f545e6a..205c7ad 100644 --- a/search_viz/src/main.rs +++ b/search_viz/src/main.rs @@ -1,18 +1,10 @@ -use std::collections::HashMap; -use std::error::Error; - -use egui::{vec2, Color32, Frame, Margin, Rect, RichText, Sense, Slider, Stroke, TextEdit, Vec2}; -use instant::{Duration, Instant}; +use egui::{vec2, Color32, Frame, Margin, RichText}; +use instant::Duration; mod bfs; -use bfs::*; - -const CELL_SIZE: f32 = 16.0; -const WALL_COLOR: Color32 = Color32::from_rgb(125, 0, 255); -const START_COLOR: Color32 = Color32::from_rgb(0, 0, 255); -const GOAL_COLOR: Color32 = Color32::from_rgb(0, 255, 0); -const PATH_COLOR: Color32 = Color32::from_rgb(255, 255, 0); -const EXPLORED_COLOR: Color32 = Color32::from_rgb(0, 50, 100); +mod bfs_ui; +mod minimax; +mod minimax_ui; // when compiling natively #[cfg(not(target_arch = "wasm32"))] @@ -51,83 +43,26 @@ fn main() { }); } -#[derive(Debug, Clone, PartialEq)] -pub struct UserInput { - fav_number: String, - levels: u32, - start_x: String, - start_y: String, - goal_x: String, - goal_y: String, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Validated { - fav_number: u32, - levels: u32, - start: Pos, - goal: Pos, -} - -impl UserInput { - fn validate(&self) -> Result> { - let fav_number = self - .fav_number - .parse::() - .map_err(|err| format!("Incorrect value for Favorite Number: {}", err.to_string()))?; - let start = Pos { - x: self.start_x.parse::().map_err(|err| err.to_string())?, - y: self.start_y.parse::().map_err(|err| err.to_string())?, - }; - let goal = Pos { - x: self.goal_x.parse::().map_err(|err| err.to_string())?, - y: self.goal_y.parse::().map_err(|err| err.to_string())?, - }; - - Ok(Validated { - fav_number, - levels: self.levels, - start, - goal, - }) - } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Tabs { + BFS, + Minimax, } #[derive(Debug, Clone, PartialEq)] pub struct TemplateApp { - user_input: UserInput, - validated: Validated, - path: Vec, - explored: HashMap>, - time: Instant, - time_tick: u32, - animate_bfs: bool, - show_path: bool, + tab: Tabs, + bfs_state: bfs_ui::State, + minimax_state: minimax_ui::State, } impl Default for TemplateApp { fn default() -> Self { - let user_input = UserInput { - fav_number: "1350".to_string(), - levels: 50, - start_x: "1".to_string(), - start_y: "1".to_string(), - goal_x: "31".to_string(), - goal_y: "39".to_string(), - }; - let validated = user_input.validate().unwrap(); - let (path, explored) = - path_and_explored_by_generation_for_animation(validated.fav_number, validated.start, validated.goal); - - Self { - user_input, - validated, - path, - explored, - time: Instant::now(), - time_tick: 0, - animate_bfs: true, - show_path: true, + TemplateApp { + // TODO: return it to be the BFS default + tab: Tabs::Minimax, + bfs_state: bfs_ui::State::default(), + minimax_state: minimax_ui::State::default(), } } } @@ -163,137 +98,36 @@ impl eframe::App for TemplateApp { /// Called each time the UI needs repainting, which may be many times per second. /// Put your widgets into a `SidePanel`, `TopPanel`, `CentralPanel`, `Window` or `Area`. fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + let tab_bg_fill = if self.tab == Tabs::BFS { + Color32::from_rgb(20, 0, 50) + } else { + Color32::from_rgb(0, 30, 30) + }; + egui::CentralPanel::default() - .frame( - Frame::default() - .fill(Color32::from_rgb(20, 0, 50)) - .inner_margin(Margin::same(20.0)), - ) + .frame(Frame::default().fill(tab_bg_fill).inner_margin(Margin::same(20.0))) .show(ctx, |ui| { - ui.collapsing( - RichText::new("Breadth-First Search Graph Algorithm Demo").heading(), - |ui| { - ui.label("This demo allows you to try out an example Advent of Code problem for BFS."); - ui.horizontal(|ui| { - ui.label("You can find the source code for this example "); - ui.hyperlink_to("here", "https://github.com/lakret/gir"); - ui.label("."); - }); - }, - ); - - ui.hyperlink_to("Advent of Code 2016, Day 13", "https://adventofcode.com/2016/day/13"); - ui.add_space(15.0); - - ui.horizontal(|ui| { - ui.label("Favorite Number: "); - ui.add(TextEdit::singleline(&mut self.user_input.fav_number).margin(vec2(10.0, 6.0))); - - ui.add( - Slider::new(&mut self.user_input.levels, 1..=200) - .text("Levels to Draw") - .integer(), + ui.horizontal_top(|ui| { + ui.selectable_value( + &mut self.tab, + Tabs::BFS, + RichText::new("Breadth-First Search Example").size(26.0), + ); + ui.selectable_value( + &mut self.tab, + Tabs::Minimax, + RichText::new("Minimax Example").size(26.0), ); }); - ui.add_space(15.0); - match self.user_input.validate() { - Ok(new_validated) => { - let (path, explored) = path_and_explored_by_generation_for_animation( - new_validated.fav_number, - new_validated.start, - new_validated.goal, - ); - self.path = path; - self.explored = explored; - self.validated = new_validated; - } - Err(msg) => { - ui.label(RichText::new(msg.to_string()).color(Color32::DARK_RED)); - } + if self.tab == Tabs::BFS { + self.bfs_state.ui(ui); + } else { + self.minimax_state.ui(ui); } - - ui.horizontal(|ui| { - let animate_bfs_checkbox = ui.checkbox(&mut self.animate_bfs, "Play Animation"); - if animate_bfs_checkbox.changed() && self.animate_bfs { - self.time = Instant::now(); - } - - ui.add(Slider::new(&mut self.time_tick, 0..=100).text("Tick").integer()); - - ui.checkbox(&mut self.show_path, "Show Path"); - }); - ui.add_space(15.0); - - egui::ScrollArea::both().show(ui, |ui| self.draw_animated_grid(ui)); }); // critical to make sure the animation is running ctx.request_repaint_after(Duration::from_millis(50)); } } - -impl TemplateApp { - fn draw_animated_grid(&mut self, ui: &mut egui::Ui) { - if self.animate_bfs { - // each animation tick is 1/20 of a second = 50 milliseconds - self.time_tick = (self.time.elapsed().as_secs_f32() * 20.0).ceil() as u32 % 100; - } - - let (response, painter) = - ui.allocate_painter(Vec2::splat(self.validated.levels as f32 * CELL_SIZE), Sense::hover()); - - let rect = response.rect; - painter.rect_stroke(rect, 0.0, Stroke::new(1.0, WALL_COLOR)); - - let min_x = *rect.x_range().start(); - let min_y = *rect.y_range().start(); - for y in 0..self.validated.levels { - for x in 0..self.validated.levels { - let pos = bfs::Pos { x, y }; - let cell = logical_pos_to_screen_rect(pos, min_x, min_y); - - if !pos.is_open(self.validated.fav_number) { - painter.rect_filled(cell, 0.0, WALL_COLOR); - } - - if self.validated.start == pos { - painter.circle_filled(cell.center(), CELL_SIZE / 2.0, START_COLOR); - } - - if self.validated.goal == pos { - painter.rect_filled(cell, 4.0, GOAL_COLOR); - } - } - } - - if self.show_path { - // can be used for both path length and generation to show - let elements_to_show = (self.path.len() as f32 / 100.0 * self.time_tick as f32).ceil() as usize; - for &pos in &self.path[..elements_to_show] { - if pos != self.validated.start && pos != self.validated.goal { - let cell = logical_pos_to_screen_rect(pos, min_x, min_y); - painter.rect_filled(cell, 6.0, PATH_COLOR); - } - } - - for generation in 0..(elements_to_show as u32) { - if let Some(positions) = self.explored.get(&generation) { - for &pos in positions { - let cell = logical_pos_to_screen_rect(pos, min_x, min_y); - painter.rect_filled(cell, 0.0, EXPLORED_COLOR); - } - } - } - } - } -} - -fn logical_pos_to_screen_rect(pos: Pos, min_x: f32, min_y: f32) -> Rect { - let Pos { x, y } = pos; - let screen_min_x = x as f32 * CELL_SIZE + min_x; - let screen_max_x = (x + 1) as f32 * CELL_SIZE + min_x; - let screen_min_y = (y as f32 * CELL_SIZE + min_y).ceil(); - let screen_max_y = ((y + 1) as f32 * CELL_SIZE + min_y).ceil(); - Rect::from_x_y_ranges(screen_min_x..=screen_max_x, screen_min_y..=screen_max_y) -} diff --git a/search_viz/src/minimax.rs b/search_viz/src/minimax.rs new file mode 100644 index 0000000..20fed22 --- /dev/null +++ b/search_viz/src/minimax.rs @@ -0,0 +1,220 @@ +use std::fmt::Display; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Mark { + Cross, + Circle, +} + +impl Mark { + fn opponent(self) -> Mark { + match self { + Mark::Cross => Mark::Circle, + Mark::Circle => Mark::Cross, + } + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Game { + state: [Option; 9], + circle_turn: bool, +} + +impl Display for Game { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for row in 0..3 { + for col in 0..3 { + let ch = match self.mark_at(row, col) { + None => "口", + Some(Mark::Cross) => "X", + Some(Mark::Circle) => "⚪", + }; + write!(f, "{} ", ch)?; + } + writeln!(f)?; + } + + Ok(()) + } +} + +const LINES: [[usize; 3]; 8] = [ + // rows + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + // columns + [0, 3, 6], + [1, 4, 7], + [2, 5, 8], + // diagonals + [0, 4, 8], + [2, 4, 6], +]; + +impl Game { + pub fn mark_at(&self, row: usize, col: usize) -> Option { + if let Some(pos) = row_col_to_pos(row, col) { + return self.state[pos]; + } + + None + } + + pub fn is_occupied(&self, row: usize, col: usize) -> bool { + if let Some(pos) = row_col_to_pos(row, col) { + return self.state[pos].is_some(); + } + + false + } + + pub fn is_cross_turn(&self) -> bool { + !self.circle_turn + } + + pub fn is_circle_turn(&self) -> bool { + self.circle_turn + } + + pub fn do_move_row_col(&mut self, row: usize, col: usize) { + let pos = row_col_to_pos(row, col).expect("invalid row or col"); + self.do_move(pos); + } + + pub fn do_move(&mut self, pos: usize) { + assert!(pos < 9); + + let mark = if self.is_cross_turn() { + Mark::Cross + } else { + Mark::Circle + }; + + self.state[pos] = Some(mark); + self.circle_turn = !self.circle_turn; + } + + pub fn next_moves_with_pos(&self) -> Vec<(Game, usize)> { + let mut moves = vec![]; + + for pos in 0..9 { + if self.state[pos].is_none() { + let mut new_game = self.clone(); + new_game.do_move(pos); + + moves.push((new_game, pos)); + } + } + + moves + } + + pub fn is_won(&self) -> bool { + self.winning_mark().is_some() + } + + pub fn is_draw(&self) -> bool { + self.state.iter().all(|cell| cell.is_some()) && !self.is_won() + } + + pub fn winning_mark(&self) -> Option { + for line in LINES { + if let Some(mark) = self.state[line[0]] { + if self.state[line[1]] == Some(mark) && self.state[line[2]] == Some(mark) { + return Some(mark); + } + } + } + + None + } + + pub fn select_next_move(&self) -> Option { + self + .next_moves_with_pos() + .into_iter() + .map(|(game, pos)| (pos, game.minimax(false, 0))) + .max_by_key(|(_pos, score)| *score) + .map(|(best_pos, _best_score)| best_pos) + } + + pub fn minimax(&self, is_maximizing: bool, depth: i32) -> i32 { + if self.is_won() { + if is_maximizing { + return -10 + depth; + } else { + return 10 - depth; + } + }; + + if self.is_draw() { + return 0; + } + + if is_maximizing { + // maximizing player + let mut best_score = i32::MIN; + for (next_move, _pos) in self.next_moves_with_pos() { + best_score = best_score.max(next_move.minimax(false, depth + 1)); + } + return best_score; + } else { + // minimizing player + let mut best_score = i32::MAX; + for (next_move, _pos) in self.next_moves_with_pos() { + best_score = best_score.min(next_move.minimax(true, depth + 1)); + } + return best_score; + } + } +} + +fn row_col_to_pos(row: usize, col: usize) -> Option { + let pos = row * 3 + col; + + if pos < 9 { + Some(pos) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn select_next_moves_test() { + let mut game = Game::default(); + // cross in the center + game.do_move_row_col(1, 1); + println!("{}", game); + + let pos = game.select_next_move(); + game.do_move(pos.unwrap()); + println!("{}", game); + + game.do_move_row_col(0, 1); + println!("{}", game); + + let pos = game.select_next_move(); + game.do_move(pos.unwrap()); + println!("{}", game); + + game.do_move_row_col(2, 0); + println!("{}", game); + + let pos = game.select_next_move(); + game.do_move(pos.unwrap()); + println!("{}", game); + + game.do_move_row_col(1, 2); + println!("{}", game); + + let pos = game.select_next_move(); + game.do_move(pos.unwrap()); + println!("{}", game); + } +} diff --git a/search_viz/src/minimax_ui.rs b/search_viz/src/minimax_ui.rs new file mode 100644 index 0000000..ff7da9e --- /dev/null +++ b/search_viz/src/minimax_ui.rs @@ -0,0 +1,100 @@ +use egui::{vec2, Button, Color32, Grid, RichText}; + +use crate::minimax::{Game, Mark}; + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct State { + game: Game, + manual_mode: bool, +} + +impl State { + pub fn ui(&mut self, ui: &mut egui::Ui) { + ui.collapsing(RichText::new("Minimax Algorithm Demo").heading(), |ui| { + ui.label("This demo allows you to play tic-tac-toe against the computer."); + ui.label("Computer will use minimax to select its moves."); + ui.horizontal(|ui| { + ui.label("You can find the source code for this example "); + ui.hyperlink_to("here", "https://github.com/lakret/gir"); + ui.label("."); + }); + }); + + ui.horizontal(|ui| { + ui.label("Play against:"); + ui.radio_value(&mut self.manual_mode, false, "Computer"); + ui.radio_value(&mut self.manual_mode, true, "Person"); + }); + ui.add_space(8.0); + + Grid::new("game_grid") + .num_columns(3) + .spacing([8.0, 8.0]) + .show(ui, |ui| { + for row in 0..3 { + for col in 0..3 { + let mark = self.game.mark_at(row, col); + let label = mark.map_or("", mark_image); + + let cell = Button::new(RichText::new(label).size(46.0)) + .fill(match mark { + Some(Mark::Cross) => egui::Color32::from_rgb(150, 0, 150), + Some(Mark::Circle) => egui::Color32::from_rgb(0, 100, 200), + None => egui::Color32::from_gray(200), + }) + .min_size(vec2(64.0, 64.0)); + let cell = ui.add(cell); + + if cell.clicked() && !self.game.is_occupied(row, col) && !self.game.is_won() { + self.game.do_move_row_col(row, col); + + // do computer turn if computer mode is enabled + if !self.manual_mode { + if let Some(pos) = self.game.select_next_move() { + self.game.do_move(pos); + } + } + } + } + + ui.end_row(); + } + }); + ui.add_space(8.0); + + match self.game.winning_mark() { + None => { + if self.game.is_draw() { + ui.label("It's a draw."); + } else { + ui.label(format!( + "{:?}'s turn.", + if self.game.is_cross_turn() { + Mark::Cross + } else { + Mark::Circle + } + )); + } + } + Some(winning_mark) => { + ui.label(format!("{:?} won.", winning_mark)); + } + } + ui.add_space(8.0); + + if ui + .add(Button::new(RichText::new("New Game").size(26.0)).fill(Color32::DARK_GREEN)) + .clicked() + { + self.game = Game::default(); + } + } +} + +fn mark_image(mark: Mark) -> &'static str { + match mark { + Mark::Cross => "X", + Mark::Circle => "O", + } +}