From ec0e4ba008556284d261340fc4a83c72319bd9eb Mon Sep 17 00:00:00 2001 From: "Simon B. Gasse" Date: Sat, 12 Oct 2024 16:29:10 +0200 Subject: [PATCH] feat: refactor to bare web-sys instead of yew --- Cargo.toml | 18 +- README.md | 11 +- app.css | 105 --------- build.sh | 1 + src/bin/main.rs | 25 --- src/board.rs | 228 ++++++++++++++++++++ src/board/mod.rs | 226 -------------------- src/board/slide.rs | 113 ---------- src/buttons.rs | 279 ++++++++++++++++++++++++ src/expander.rs | 45 ---- src/lib.rs | 67 +++++- src/settings.rs | 79 ------- src/slide_puzzle.rs | 352 ------------------------------- src/solver/divide_and_conquer.rs | 13 +- src/solver/mod.rs | 2 + src/solver/optimal.rs | 12 +- src/utils.rs | 198 +++++++++++++++-- www/index.html | 42 ++++ www/style.css | 37 ++++ 19 files changed, 865 insertions(+), 988 deletions(-) delete mode 100644 app.css create mode 100755 build.sh delete mode 100644 src/bin/main.rs create mode 100644 src/board.rs delete mode 100644 src/board/mod.rs delete mode 100644 src/board/slide.rs create mode 100644 src/buttons.rs delete mode 100644 src/expander.rs delete mode 100644 src/settings.rs delete mode 100644 src/slide_puzzle.rs create mode 100644 www/index.html create mode 100644 www/style.css diff --git a/Cargo.toml b/Cargo.toml index 6cc42eb..d857cfa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,15 @@ [package] name = "slide-puzzle" -version = "0.2.3" +version = "0.3.0" edition = "2021" +[lib] +crate-type = ["cdylib", "rlib"] + [dependencies] +console_error_panic_hook = { version = "^0.1.7", optional = true } fnv = "1.0.7" getrandom = { version = "0.2.15", features = ["js"] } -gloo-timers = "0.3.0" log = "0.4.22" rand = "0.8.5" rustc-hash = "2.0.0" @@ -14,17 +17,26 @@ simple-error = "0.3.1" wasm-bindgen = "0.2.95" wasm-logger = "0.2.0" web-sys = { version = "0.3.72", features = [ + "CssStyleDeclaration", + "Document", + "Element", + "HtmlCollection", + "HtmlDivElement", + "Location", + "MouseEvent", "Touch", "TouchEvent", "TouchList", "Window", ] } -yew = { git = "https://github.com/yewstack/yew/", features = ["csr"] } [dev-dependencies] criterion = "0.5.1" lazy_static = "1.5.0" +[features] +default = ["console_error_panic_hook"] + [[bench]] name = "optimal_solver_benchmark" harness = false diff --git a/README.md b/README.md index db2497a..e4e7cff 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ # Slide Puzzle Implementation of a slide-puzzle game with random puzzles and two solver -algorithms. Powered by the frontend framework [`yew`][yew] (compiled to -[`Wasm`][wasm]) and written in Rust. +algorithms. Written in Rust for Wasm. ![Example](./assets/slide_puzzle.gif) @@ -20,11 +19,6 @@ to do this is by randomly doing valid swaps. ## Modeling -In code, we represent the puzzle state as an array of fields. To allow for -different sizes of puzzles while always having the same value for the empty -field, we use the maximum value of our data type, so for `u8`, this is -`u8::MAX`. - To know which fields can be swapped with which other fields, we need to tranform the indices of the fields to the coordinates in the square puzzle grid and vice versa to perform swaps. @@ -53,7 +47,7 @@ through the graph of states which builds the graph on the go. We can see that holding all the states in memory will be the main limiting factor for our algorithm. To cut down memory requirements as much as possible, -we use `u8` values. With the empty field value as `u8::MAX` (255), we are +we use `u8` values. With `u8::MAX` (255), we are limited to puzzles of size `floor(sqrt(255)) == 15` which is enough for our purposes. It is indispensible to recognize states which we have already seen before. To do this, we build a set of state hashes. @@ -142,5 +136,4 @@ is quite complicated and tedious: of the empty field (excluding fixed fields and the field to move itself). [d_and_c_algorithm_explained]: https://www.kopf.com.br/kaplof/how-to-solve-any-slide-puzzle-regardless-of-its-size/ -[yew]: https://yew.rs/ [wasm]: https://webassembly.org/ diff --git a/app.css b/app.css deleted file mode 100644 index 21e5448..0000000 --- a/app.css +++ /dev/null @@ -1,105 +0,0 @@ -:root { - font-family: Verdana, Geneva, Tahoma, sans-serif; - --unit-size: 0.2rem; - font-size: 60px; -} - -button { - font-size: inherit; - width: 90%; - max-width: 900px; - border-radius: 20px; - padding: 0.5rem; - cursor: pointer; -} - -input { - font-size: inherit; - padding: 0.5rem; - border: 2px solid rgba(0, 0, 0, 20%); - border-radius: 20px; - box-sizing: border-box; -} - -.clickable:hover { - cursor: pointer; -} - -.content { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - gap: 1rem; -} - -.header { - margin-top: 2rem; - font-weight: 700; -} - -.settings { - display: flex; - flex-direction: column; - justify-content: center; - width: 100%; - max-width: 900px; - gap: 0.5rem; -} - -.dimensions { - display: flex; - flex-direction: row; - justify-content: center; - gap: var(--unit-size); -} - -.dimensions-block { - display: flex; - flex-direction: column; - align-items: center; -} - -.image-settings { - display: flex; - flex-direction: column; - align-items: center; -} - -.image-settings input { - width: 100%; -} - -.dimensions input { - width: 100%; -} - -.left-align-wrapper { - display: flex; - flex-direction: column; - align-items: flex-start; - width: 100%; - gap: var(--unit-size); -} - -.field { - border: 0.2px solid white; -} - -.expander-wrapper { - gap: 0.5rem; - display: flex; - flex-direction: row; -} - -.expander-context { - padding-top: 0.5rem; - padding-bottom: 0.5rem; -} - -.column-flex { - display: flex; - flex-direction: column; -} \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..e89ca27 --- /dev/null +++ b/build.sh @@ -0,0 +1 @@ +wasm-pack build --out-dir www/pkg --target no-modules diff --git a/src/bin/main.rs b/src/bin/main.rs deleted file mode 100644 index ac8516e..0000000 --- a/src/bin/main.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! Frontend entrypoint. -//! -use slide_puzzle::slide_puzzle::SlidePuzzle; -use yew::prelude::*; - -fn main() { - wasm_logger::init(wasm_logger::Config::default()); - log::info!("Logger initialized"); - yew::Renderer::::new().render(); -} - -#[function_component(App)] -fn app() -> Html { - // Default values - let background_url = "https://upload.wikimedia.org/wikipedia/commons/thumb/6/61/Blue_Marble_Western_Hemisphere.jpg/600px-Blue_Marble_Western_Hemisphere.jpg?20130305115950".to_owned(); - let width = 3; - let height = 3; - - html! { -
-
{ "Slide Puzzle" }
- -
- } -} diff --git a/src/board.rs b/src/board.rs new file mode 100644 index 0000000..3936f46 --- /dev/null +++ b/src/board.rs @@ -0,0 +1,228 @@ +use wasm_bindgen::{prelude::Closure, JsCast as _}; +use web_sys::{window, CssStyleDeclaration, Document, MouseEvent, Node}; + +use crate::{ + ui_locked, + utils::{get_left_top, get_row_col_from_idx, Parameters}, + BOARD, +}; + +pub fn create_div(id: u8, board_size: usize, field_size: usize, background_url: &str) -> Node { + let div = window() + .unwrap() + .document() + .unwrap() + .create_element("div") + .unwrap() + .dyn_into::() + .unwrap(); + + // Set class for propper general style. + div.set_class_name("field"); + // Set field ID. + div.set_id(&format!("{id}")); + // Set onclick callback. + let onclick_callback = get_onclick_closure(id as usize, board_size); + div.set_onclick(Some(onclick_callback.as_ref().unchecked_ref())); + // Do not drop the onclick callback by leaking its memory. + onclick_callback.forget(); + + // Get position on board. The ID equals the index here. + let (left, top) = get_left_top(id as usize, board_size, field_size); + + let style = div.style(); + + if is_empty_field(id as usize, board_size) { + // Set positioning with empty background. + style.set_css_text(&format!( + "left: {}rem; top: {}rem; width: {}rem; height: {}rem; \ + position: absolute; transition: all 0.2s; z-index: -1", + left, top, field_size, field_size, + )); + } else { + // Scale and position background to match tile on board. + let background_x = board_size * field_size - left; + let background_y = board_size * field_size - top; + let background_size = board_size * field_size; + + style.set_css_text(&format!( + "left: {}rem; top: {}rem; width: {}rem; height: {}rem; \ + position: absolute; transition: all 0.2s; \ + background-position: {}rem {}rem; background-size: {}rem {}rem; \ + background-image:url({})", + left, + top, + field_size, + field_size, + background_x, + background_y, + background_size, + background_size, + background_url + )); + } + + div.dyn_into::().unwrap() +} + +fn get_onclick_closure(clicked_id: usize, size: usize) -> Closure { + if is_empty_field(clicked_id, size) { + // Unclear which field to swap with the empty field so skip. + log::debug!("Received a click on ID {clicked_id} (empty field)"); + return Closure::wrap(Box::new(|_| ())); + } + + Closure::wrap(Box::new(move |_event: MouseEvent| { + log::debug!("Received a click on ID {clicked_id}"); + + if ui_locked() { + log::debug!("UI is locked"); + return; + } + + if let Some(empty_id) = is_swappable_with_empty(clicked_id, size) { + log::info!("Swapping ID {clicked_id} with empty ID {empty_id}"); + BOARD.with_borrow_mut(|b| { + b.swap_ids(clicked_id as u8, empty_id as u8); + }); + } + })) +} + +fn is_empty_field(clicked_id: usize, size: usize) -> bool { + clicked_id == (size * size - 1) +} + +fn is_swappable_with_empty(clicked_id: usize, size: usize) -> Option { + BOARD.with_borrow(|b| { + let empty_id = b.ids2indices.len() - 1; + let clicked_idx = b.ids2indices[clicked_id]; + let empty_idx = b.ids2indices[empty_id]; + + if is_swappable_neighbour(clicked_idx, empty_idx, size) { + Some(empty_id) + } else { + None + } + }) +} + +fn is_swappable_neighbour(idx_a: usize, idx_b: usize, size: usize) -> bool { + let (row_a, col_a): (isize, isize) = get_row_col_from_idx(idx_a as isize, size as isize); + let (row_b, col_b): (isize, isize) = get_row_col_from_idx(idx_b as isize, size as isize); + + (row_a.abs_diff(row_b) + col_a.abs_diff(col_b)) == 1 +} + +fn get_style_top_left(document: &Document, div_id: u8) -> (CssStyleDeclaration, String, String) { + let element = document + .get_element_by_id(&format!("{div_id}")) + .unwrap() + .dyn_into::() + .unwrap(); + + let style = element.style(); + let top = style.get_property_value("top").unwrap(); + let left = style.get_property_value("left").unwrap(); + (style, top, left) +} + +#[derive(Debug)] +pub(crate) struct Board { + /// Vector mapping indices to IDs. + /// e.g. ids[5] = 6 -> index 5 has tile 6 on the board + indices2ids: Vec, + /// Vector mapping IDs to indices. + /// e.g. indices[2] = 4 -> tile 2 is at index 4 on the board + ids2indices: Vec, +} + +impl Board { + pub(crate) const fn new() -> Self { + Self { + indices2ids: Vec::new(), + ids2indices: Vec::new(), + } + } + + pub(crate) fn init(&mut self, params: Parameters) { + let num_elements = params.size * params.size; + self.indices2ids = (0..(num_elements as u8)).collect(); + self.ids2indices = (0..num_elements).collect(); + + self.init_board_ui(params); + } + + pub(crate) fn indices2ids(&self) -> &Vec { + &self.indices2ids + } + + pub(crate) fn ids2indices(&self) -> &Vec { + &self.ids2indices + } + + pub(crate) fn swap_indices(&mut self, idx_a: usize, idx_b: usize) { + debug_assert!(idx_a < self.indices2ids.len()); + debug_assert!(idx_b < self.indices2ids.len()); + + let id_a = self.indices2ids[idx_a]; + let id_b = self.indices2ids[idx_b]; + + // Swap positions in style. + let document = window().unwrap().document().unwrap(); + + let (a_style, a_top, a_left) = get_style_top_left(&document, id_a); + let (b_style, b_top, b_left) = get_style_top_left(&document, id_b); + + a_style.set_property("top", &b_top).unwrap(); + a_style.set_property("left", &b_left).unwrap(); + b_style.set_property("top", &a_top).unwrap(); + b_style.set_property("left", &a_left).unwrap(); + + // Swap indices / IDs in maps. + self.indices2ids.swap(idx_a, idx_b); + self.ids2indices[id_a as usize] = idx_b; + self.ids2indices[id_b as usize] = idx_a; + } + + fn swap_ids(&mut self, id_a: u8, id_b: u8) { + debug_assert!((id_a as usize) < self.indices2ids.len()); + debug_assert!((id_b as usize) < self.indices2ids.len()); + + // Swap positions in style. + let document = window().unwrap().document().unwrap(); + + let (a_style, a_top, a_left) = get_style_top_left(&document, id_a); + let (b_style, b_top, b_left) = get_style_top_left(&document, id_b); + + a_style.set_property("top", &b_top).unwrap(); + a_style.set_property("left", &b_left).unwrap(); + b_style.set_property("top", &a_top).unwrap(); + b_style.set_property("left", &a_left).unwrap(); + + // Swap IDs / indices in maps. + // Look up at which index which ID is. + // Swap the IDs in both maps. + let idx_a = self.ids2indices[id_a as usize]; + let idx_b = self.ids2indices[id_b as usize]; + self.indices2ids.swap(idx_a, idx_b); + + self.ids2indices[id_a as usize] = idx_b; + self.ids2indices[id_b as usize] = idx_a; + } + + fn init_board_ui(&self, params: Parameters) { + let document = window().unwrap().document().unwrap(); + let board = document.get_element_by_id("board").unwrap(); + + // Adjust field size depending on puzzle size + let field_size = 12 / params.size; + + for id in self.indices2ids.iter() { + let div = create_div(*id, params.size, field_size, ¶ms.bg_url); + board.append_child(&div).unwrap(); + } + } +} + +// TODO: Change button colors when locking UI. diff --git a/src/board/mod.rs b/src/board/mod.rs deleted file mode 100644 index b2b85ff..0000000 --- a/src/board/mod.rs +++ /dev/null @@ -1,226 +0,0 @@ -//! Configurable board. -//! -mod slide; -use rand::seq::SliceRandom; -pub use slide::*; -use web_sys::TouchEvent; -use yew::prelude::*; - -use crate::{ - utils::{get_left_top, get_swappable_neighbours}, - Error, -}; - -#[derive(Properties, PartialEq)] -pub struct PuzzleBoardProps { - pub fields: Vec, - pub width: usize, - pub height: usize, - pub field_size: usize, - pub field_unit: &'static str, - pub background_url: String, - pub on_click: Callback, - pub on_touch_start: Callback<(i32, i32)>, - pub on_touch_end: Callback<(i32, i32)>, -} - -#[function_component(PuzzleBoard)] -pub fn puzzle_board( - PuzzleBoardProps { - fields, - width, - height, - field_size, - field_unit, - background_url, - on_click, - on_touch_start, - on_touch_end, - }: &PuzzleBoardProps, -) -> Html { - let on_click = on_click.clone(); - - log::info!( - "Rendering puzzle board with width {}, height {}, fields {:?}", - width, - height, - &fields - ); - - // Callback to concatenate a size value with the given unit - let as_unit = |value: usize| format!("{value}{field_unit}"); - - let fields_html = get_fields_html( - fields, - *width, - *height, - *field_size, - background_url.clone(), - on_click, - &as_unit, - ); - - let on_touch_start = on_touch_start.clone(); - let on_touch_start = Callback::from(move |event: TouchEvent| { - if let Some(touch) = event.changed_touches().get(0) { - let coords = (touch.client_x(), touch.client_y()); - on_touch_start.emit(coords); - } - }); - - let on_touch_end = on_touch_end.clone(); - let on_touch_end = Callback::from(move |event: TouchEvent| { - if let Some(touch) = event.changed_touches().get(0) { - let coords = (touch.client_x(), touch.client_y()); - on_touch_end.emit(coords); - } - }); - - html! { -
- { fields_html } -
- } -} - -fn get_fields_html( - fields: &[u8], - width: usize, - height: usize, - field_size: usize, - background_url: String, - on_click: Callback, - as_unit: &F, -) -> Html -where - F: Fn(usize) -> String, -{ - // Enumerate values and sort by fields. This is required so that every - // field shows up at the same list index in the DOM regardless of its left/ - // right value. Otherwise, elements would be recreated and the animation - // state lost. - let mut indexes_fields: Vec<_> = fields.iter().copied().enumerate().collect(); - indexes_fields.sort_by(|a, b| b.1.cmp(&a.1)); - - indexes_fields - .into_iter() - .map(|(idx, field)| { - let (left_pos, top_pos) = get_left_top(idx, width, field_size); - - if field == u8::MAX { - // Handle empty field specifically - return html! { -
- // Maybe optionally display field index? - // {name} -
- }; - } - - // Set, style and align background image - let (left_img, top_img) = get_left_top(field as usize, width, field_size); - let img_pos_x = width * field_size - left_img; - let img_pos_y = height * field_size - top_img; - let bg_string = format!( - "background-size: {} {}; \ - background-image: url({});", - as_unit(width * field_size), - as_unit(height * field_size), - background_url - ); - - let on_field_click = { - let on_click = on_click.clone(); - Callback::from(move |_| { - log::info!("Clicked on field with index {}", idx); - on_click.emit(idx); - }) - }; - - html! { -
- // Maybe optionally display field index? - // {name} -
- } - }) - .collect() -} - -/// Get a sequence of valid semi-random shuffles. -/// -/// We prevent fields from being shuffled back and forth, which breaks total -/// randomness. -pub fn get_shuffle_sequence( - width: usize, - height: usize, - mut empty_field_idx: usize, - num_swaps: usize, -) -> Result, Error> { - let mut swaps = Vec::with_capacity(num_swaps); - - // We want to avoid swapping fields back and forth like (2, 1), (1, 2) - // Our approach is to remove the previous empty field from swappable - // neighbours - let mut prev_empty_field_idx = empty_field_idx; - - for _ in 0..num_swaps { - let swappable_neighbours: Vec<_> = - get_swappable_neighbours(width, height, empty_field_idx)? - .into_iter() - .filter(|&element| element != prev_empty_field_idx) - .collect(); - let chosen_neighbour = swappable_neighbours - .choose(&mut rand::thread_rng()) - .ok_or_else(|| -> Error { - simple_error::simple_error!("No random neighbour to choose").into() - })?; - swaps.push((empty_field_idx, *chosen_neighbour)); - prev_empty_field_idx = empty_field_idx; - empty_field_idx = *chosen_neighbour; - } - - Ok(swaps) -} diff --git a/src/board/slide.rs b/src/board/slide.rs deleted file mode 100644 index e06f0d9..0000000 --- a/src/board/slide.rs +++ /dev/null @@ -1,113 +0,0 @@ -//! Slide interaction functions. -//! -use crate::{ - utils::{get_idx_from_row_col, get_idx_of_val, get_row_col_from_idx, in_bounds}, - Error, -}; - -pub enum TouchMoveDirection { - Left, - Right, - Up, - Down, -} - -/// Trigger a field by swapping it with the empty field if it is adjacent. -pub fn handle_field_click( - fields: &mut [u8], - width: usize, - height: usize, - clicked_idx: usize, -) -> bool { - if let Some(&u8::MAX) = fields.get(clicked_idx) { - // Clicked on the empty field - unclear so nothing to do - return false; - } - - let (row, col): (usize, usize) = get_row_col_from_idx(clicked_idx, width); - for (delta_row, delta_col) in [(-1, 0), (1, 0), (0, -1), (0, 1)] { - let neighbour_row = row as isize + delta_row; - let neighbour_col = col as isize + delta_col; - if in_bounds( - neighbour_row, - neighbour_col, - width as isize, - height as isize, - ) { - let idx: isize = get_idx_from_row_col(neighbour_row, neighbour_col, width as isize); - if let Some(&u8::MAX) = fields.get(idx as usize) { - fields.swap(clicked_idx, idx as usize); - // Fields swapped - re-render - return true; - } - } - } - - // No field swapped - do not re-render - false -} - -pub fn get_touch_direction( - x_start: i32, - y_start: i32, - x_end: i32, - y_end: i32, -) -> Option { - let d_x = x_end - x_start; - let d_y = y_end - y_start; - - if d_x.abs() + d_y.abs() < 40 { - // Overall displacement is too small, ignore - return None; - } - - match d_x.abs() > d_y.abs() { - true => { - // Horizontal - if d_x > 0 { - Some(TouchMoveDirection::Right) - } else { - Some(TouchMoveDirection::Left) - } - } - false => { - // Vertical - if d_y > 0 { - Some(TouchMoveDirection::Down) - } else { - Some(TouchMoveDirection::Up) - } - } - } -} - -pub fn handle_touch_move( - fields: &mut [u8], - width: usize, - height: usize, - direction: TouchMoveDirection, -) -> Result { - let empty_field_idx = get_idx_of_val(fields, u8::MAX)?; - let (empty_row, empty_col): (usize, usize) = get_row_col_from_idx(empty_field_idx, width); - let (empty_row, empty_col) = (empty_row as i32, empty_col as i32); - - let (d_row, d_col) = match direction { - // If the slide was to the left, the user wants to move the neighbour - // on the *right* towards the left. - TouchMoveDirection::Left => (0, 1), - TouchMoveDirection::Right => (0, -1), - TouchMoveDirection::Up => (1, 0), - TouchMoveDirection::Down => (-1, 0), - }; - - let (swap_row, swap_col) = (empty_row + d_row, empty_col + d_col); - - if in_bounds(swap_row, swap_col, width as i32, height as i32) { - let swap_idx: i32 = get_idx_from_row_col(swap_row, swap_col, width as i32); - fields.swap(empty_field_idx, swap_idx as usize); - return Ok(true); - } - - // The candidate was not in bounds -> do nothing - Ok(false) -} diff --git a/src/buttons.rs b/src/buttons.rs new file mode 100644 index 0000000..79be66b --- /dev/null +++ b/src/buttons.rs @@ -0,0 +1,279 @@ +use wasm_bindgen::prelude::*; +use web_sys::{window, HtmlElement, MouseEvent, TouchEvent}; + +use crate::{ + lock_ui, + solver::{divide_and_conquer::DacPuzzleSolver, optimal::find_swap_order}, + ui_locked, unlock_ui, + utils::{get_shuffle_sequence, get_touch_direction, handle_touch_move}, + BOARD, TOUCH_COORDS, +}; + +const NUM_SHUFFLES: usize = 10; +const SWAP_TIMEOUT_FAST: i32 = 250; +const SWAP_TIMEOUT_SLOW: i32 = 500; + +pub(crate) fn setup_button_callbacks(size: usize) { + let document = window().unwrap().document().unwrap(); + + #[allow(clippy::type_complexity)] + let ids_get_callbacks: [(_, &dyn Fn(usize) -> Closure); 4] = [ + ("quick_swap", &get_quick_swap_callback), + ("granular_swap", &get_granular_swap_callback), + ("optimal_solve", &get_optimal_solve_callback), + ("d_and_c_solve", &get_dac_solve_callback), + ]; + + for (id, get_callback) in ids_get_callbacks { + let callback = get_callback(size); + let button = document + .get_element_by_id(id) + .unwrap() + .dyn_into::() + .unwrap(); + button.set_onclick(Some(callback.as_ref().unchecked_ref())); + callback.forget(); + } + + let board = document + .get_element_by_id("board") + .unwrap() + .dyn_into::() + .unwrap(); + let touch_start_callback = get_touch_start_callback(); + let touch_move_callback = get_touch_move_callback(); + let touch_end_callback = get_touch_end_callback(size); + board.set_ontouchstart(Some(touch_start_callback.as_ref().unchecked_ref())); + board.set_ontouchmove(Some(touch_move_callback.as_ref().unchecked_ref())); + board.set_ontouchend(Some(touch_end_callback.as_ref().unchecked_ref())); + touch_start_callback.forget(); + touch_move_callback.forget(); + touch_end_callback.forget(); +} + +fn get_quick_swap_callback(size: usize) -> Closure { + Closure::wrap(Box::new(move |_| { + if !lock_ui() { + return; + } + + let empty_field_idx = size - 1; + + match get_shuffle_sequence(size, empty_field_idx, 20) { + Ok(shuffle_sequence) => { + log::info!("Shuffle sequence: {:?}", &shuffle_sequence); + + BOARD.with_borrow_mut(|b| { + for swap in shuffle_sequence { + b.swap_indices(swap.0, swap.1); + } + }); + } + Err(err) => { + log::error!("failed in quick swapping: {err}"); + } + } + + unlock_ui(); + })) +} + +fn get_granular_swap_callback(size: usize) -> Closure { + Closure::wrap(Box::new(move |_| { + if !lock_ui() { + return; + } + + let num_shuffles = NUM_SHUFFLES; + let empty_field_idx = size - 1; + + match get_shuffle_sequence(size, empty_field_idx, num_shuffles) { + Ok(shuffle_sequence) => { + log::info!("Shuffle sequence: {:?}", &shuffle_sequence); + + let window = window().unwrap(); + let mut callbacks = Vec::with_capacity(num_shuffles); + + // Send every shuffle with a separate timeout. + for (i, swap) in shuffle_sequence.into_iter().enumerate() { + let callback = get_swap_callback(swap); + let millis = SWAP_TIMEOUT_FAST * (i as i32 + 1); + + window + .set_timeout_with_callback_and_timeout_and_arguments_0( + callback.as_ref().unchecked_ref(), + millis, + ) + .unwrap(); + + // Keep callback handles to drop at the end. + callbacks.push(callback); + } + + let mut _callbacks = Some(callbacks); + let finish_callback: Closure = Closure::wrap(Box::new(move || { + // Drop callbacks by overwriting with None. + _callbacks = None; + log::debug!("Finished granular swap sequence"); + unlock_ui(); + })); + + window + .set_timeout_with_callback_and_timeout_and_arguments_0( + finish_callback.as_ref().unchecked_ref(), + (num_shuffles as i32 + 1) * SWAP_TIMEOUT_FAST, + ) + .unwrap(); + finish_callback.forget(); + } + Err(err) => { + log::error!("failed in granular swapping: {err}"); + unlock_ui(); + } + } + })) +} + +fn get_optimal_solve_callback(size: usize) -> Closure { + Closure::wrap(Box::new(move |_| { + if !lock_ui() { + return; + } + + let ids = BOARD.with_borrow(|b| b.indices2ids().clone()); + match find_swap_order(&ids, size, size) { + Ok(solve_sequence) => { + apply_solve_sequence(solve_sequence, SWAP_TIMEOUT_SLOW); + } + Err(err) => { + log::error!("failed to find optimal solve sequence: {err}"); + unlock_ui(); + } + } + })) +} + +fn get_dac_solve_callback(size: usize) -> Closure { + Closure::wrap(Box::new(move |_| { + if !lock_ui() { + return; + } + + let ids = BOARD.with_borrow(|b| b.indices2ids().clone()); + match DacPuzzleSolver::new(&ids, size as i32, size as i32) { + Ok(mut solver) => match solver.solve_puzzle() { + Ok(solve_sequence) => { + apply_solve_sequence(solve_sequence, SWAP_TIMEOUT_SLOW); + } + Err(err) => { + log::error!("failed to solve puzzle: {err}"); + unlock_ui(); + } + }, + Err(err) => { + log::error!("failed to create divide&conquer solver: {err}"); + unlock_ui(); + } + } + })) +} + +fn get_swap_callback(swap: (usize, usize)) -> Closure { + Closure::wrap(Box::new(move || { + BOARD.with_borrow_mut(|b| b.swap_indices(swap.0, swap.1)); + })) +} + +fn apply_solve_sequence(solve_sequence: Vec<(usize, usize)>, interval: i32) { + log::info!("Solve sequence: {:?}", &solve_sequence); + let num_swaps = solve_sequence.len(); + + let window = window().unwrap(); + let mut callbacks = Vec::with_capacity(num_swaps); + + // Send every shuffle with a separate timeout. + for (i, swap) in solve_sequence.into_iter().enumerate() { + let callback = get_swap_callback(swap); + let millis = (i as i32 + 1) * interval; + + window + .set_timeout_with_callback_and_timeout_and_arguments_0( + callback.as_ref().unchecked_ref(), + millis, + ) + .unwrap(); + + // Keep callback handles to drop at the end. + callbacks.push(callback); + } + + let mut _callbacks = Some(callbacks); + let finish_callback: Closure = Closure::wrap(Box::new(move || { + // Drop callbacks by overwriting with None. + _callbacks = None; + log::debug!("Finished swap sequence"); + unlock_ui(); + })); + + window + .set_timeout_with_callback_and_timeout_and_arguments_0( + finish_callback.as_ref().unchecked_ref(), + (num_swaps as i32 + 1) * interval, + ) + .unwrap(); + finish_callback.forget(); +} + +fn get_touch_start_callback() -> Closure { + Closure::wrap(Box::new(move |event: TouchEvent| { + if ui_locked() { + log::debug!("UI locked"); + } else { + TOUCH_COORDS.with_borrow_mut(|t| { + if let Some(t) = t.start { + log::warn!("Overwriting existing touch start coords: {:?}", t); + } + + let first_touch = event.target_touches().get(0).unwrap(); + let coords = (first_touch.screen_x(), first_touch.screen_y()); + t.start = Some(coords); + }) + } + })) +} + +fn get_touch_move_callback() -> Closure { + Closure::wrap(Box::new(move |event: TouchEvent| { + if ui_locked() { + log::debug!("UI locked"); + } else { + TOUCH_COORDS.with_borrow_mut(|t| { + let first_touch = event.target_touches().get(0).unwrap(); + let coords = (first_touch.screen_x(), first_touch.screen_y()); + t.end = Some(coords); + }) + } + })) +} + +fn get_touch_end_callback(size: usize) -> Closure { + Closure::wrap(Box::new(move |_| { + if ui_locked() { + log::debug!("UI locked"); + } else { + TOUCH_COORDS.with_borrow_mut(|t| { + if let (Some((x_start, y_start)), Some((x_end, y_end))) = (t.start, t.end) { + if let Some(direction) = get_touch_direction(x_start, y_start, x_end, y_end) { + log::debug!("Handling touch direction {direction:?}"); + handle_touch_move(size, direction).unwrap(); + } + + t.start = None; + t.end = None; + } else { + log::warn!("Incomplete touch coordinates on touch end"); + } + }) + } + })) +} diff --git a/src/expander.rs b/src/expander.rs deleted file mode 100644 index 0e45b5b..0000000 --- a/src/expander.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Expandable area in the frontend. -//! -use yew::prelude::*; - -#[derive(Properties, Debug, PartialEq)] -pub struct ExpanderProps { - pub title: String, - #[prop_or_default] - pub children: Children, -} - -#[function_component] -pub fn Expander(props: &ExpanderProps) -> Html { - let expanded = use_state(|| false); - - let toggle_callback = { - let expanded = expanded.clone(); - Callback::from(move |_| expanded.set(!(*expanded))) - }; - - html! { -
-
-
- if *expanded { - {"▼"} - } else { - {"▶"} - } -
-
- -
-
- {props.title.clone()} -
- if *expanded { -
- {props.children.clone()} -
- } -
-
- } -} diff --git a/src/lib.rs b/src/lib.rs index 30749ad..1f0a8a7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,71 @@ //! Slide puzzle frontend and solvers. //! + +use std::cell::RefCell; + +use board::Board; +use buttons::setup_button_callbacks; +use utils::{extract_parameters, set_panic_hook, TouchCoords}; +use wasm_bindgen::prelude::*; + pub mod board; -pub mod expander; -pub mod settings; -pub mod slide_puzzle; +pub mod buttons; pub mod solver; pub mod utils; pub type Error = Box; + +thread_local! { + static UI_LOCKED: RefCell = const { RefCell::new(true) }; + static BOARD: RefCell = const { RefCell::new(Board::new()) }; + static TOUCH_COORDS: RefCell = const { RefCell::new(TouchCoords::new()) }; +} + +#[wasm_bindgen] +pub fn wasm_main() { + set_panic_hook(); + + wasm_logger::init(wasm_logger::Config::default()); + log::info!("Logger initialized"); + + let params = extract_parameters(); + log::debug!("Params: {:?}", params); + + setup_button_callbacks(params.size); + + BOARD.with_borrow_mut(|b| { + b.init(params); + }); + + unlock_ui(); +} + +fn lock_ui() -> bool { + UI_LOCKED.with_borrow_mut(|locked| { + if *locked { + log::debug!("UI is locked"); + false + } else { + *locked = true; + log::debug!("Locked UI"); + true + } + }) +} + +fn unlock_ui() { + UI_LOCKED.with_borrow_mut(|locked| { + if !*locked { + log::warn!("Should unlock UI which was not locked"); + } else { + *locked = false; + log::debug!("Unlocked UI"); + } + }) +} + +fn ui_locked() -> bool { + UI_LOCKED.with(|locked| *locked.borrow()) +} + +// TODO: Solver not attempting / button greyed out at a certain size diff --git a/src/settings.rs b/src/settings.rs deleted file mode 100644 index 3838a48..0000000 --- a/src/settings.rs +++ /dev/null @@ -1,79 +0,0 @@ -//! Settings module. -//! -use yew::prelude::*; - -#[derive(Properties, PartialEq)] -pub struct SettingsBlockProps { - pub width: usize, - pub height: usize, - pub bg_url: String, - pub width_callback: Callback, - pub height_callback: Callback, - pub bg_url_callback: Callback, -} - -#[function_component(SettingsBlock)] -pub fn settings_block( - SettingsBlockProps { - width, - height, - bg_url, - width_callback, - height_callback, - bg_url_callback, - }: &SettingsBlockProps, -) -> Html { - html! { -
-
-
-
{ "Image URL" }
- -
-
-
-
-
{ "Width" }
- -
-
-
{ "Height" }
- -
-
-
- } -} - -fn get_dimension_callback(parent_callback: Callback) -> Callback { - Callback::from(move |input_event: InputEvent| { - if let Some(value) = input_event.data() { - log::info!("Received value {:?}", value); - if let Ok(value) = value.parse::() { - log::info!("Parsed value {:?}", value); - parent_callback.emit(value); - } - } - }) -} - -fn get_bg_callback(parent_callback: Callback) -> Callback { - Callback::from(move |input_event: InputEvent| { - if let Some(value) = input_event.data() { - log::info!("Updating background URL to {}", &value); - parent_callback.emit(value); - } - }) -} diff --git a/src/slide_puzzle.rs b/src/slide_puzzle.rs deleted file mode 100644 index 144ac18..0000000 --- a/src/slide_puzzle.rs +++ /dev/null @@ -1,352 +0,0 @@ -//! Slize puzzle component with callbacks and settings. -//! -use yew::prelude::*; - -use crate::{ - board::{ - get_shuffle_sequence, get_touch_direction, handle_field_click, handle_touch_move, - PuzzleBoard, - }, - expander::Expander, - settings::SettingsBlock, - solver::{divide_and_conquer::DacPuzzleSolver, optimal::find_swap_order}, - utils::{get_idx_of_val, initialize_fields}, -}; - -#[derive(Debug)] -pub enum SlidePuzzleMsg { - CompleteFieldsUpdate(Vec), - WidthUpdate(usize), - HeightUpdate(usize), - Swap((usize, usize)), - ClickedField(usize), - BackgroundUrlUpdate(String), - TouchStartCoords((i32, i32)), - TouchEndCoords((i32, i32)), -} - -pub struct SlidePuzzle { - fields: Vec, - width: usize, - height: usize, - background_url: String, - touch_start_coords: Option<(i32, i32)>, -} - -#[derive(Properties, PartialEq)] -pub struct SlidePuzzleProps { - pub width: usize, - pub height: usize, - pub background_url: String, -} - -impl Component for SlidePuzzle { - type Message = SlidePuzzleMsg; - type Properties = SlidePuzzleProps; - - fn create(ctx: &Context) -> Self { - let props = ctx.props(); - let fields = initialize_fields(props.width * props.height); - Self { - fields, - width: props.width, - height: props.height, - background_url: props.background_url.clone(), - touch_start_coords: None, - } - } - - fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { - log::info!("Received message {:?}", msg); - match msg { - SlidePuzzleMsg::Swap((a, b)) => match a != b { - true => { - self.fields.swap(a, b); - true - } - false => false, - }, - SlidePuzzleMsg::ClickedField(clicked_idx) => { - handle_field_click(&mut self.fields, self.width, self.height, clicked_idx) - } - SlidePuzzleMsg::WidthUpdate(width) => match width != self.width { - true => { - self.width = width; - self.fields = initialize_fields(self.width * self.height); - true - } - false => false, - }, - SlidePuzzleMsg::HeightUpdate(height) => match height != self.height { - true => { - self.height = height; - self.fields = initialize_fields(self.width * self.height); - true - } - false => false, - }, - SlidePuzzleMsg::BackgroundUrlUpdate(bg_url) => match bg_url != self.background_url { - true => { - self.background_url = bg_url; - true - } - false => false, - }, - SlidePuzzleMsg::CompleteFieldsUpdate(fields) => match fields != self.fields { - true => { - self.fields = fields; - true - } - false => false, - }, - SlidePuzzleMsg::TouchStartCoords((x, y)) => match self.touch_start_coords { - Some(_) => { - log::warn!("Overwriting existing touchStart coordinates"); - self.touch_start_coords = Some((x, y)); - false - } - None => { - self.touch_start_coords = Some((x, y)); - false - } - }, - SlidePuzzleMsg::TouchEndCoords((x_end, y_end)) => match self.touch_start_coords { - Some((x_start, y_start)) => { - if let Some(direction) = get_touch_direction(x_start, y_start, x_end, y_end) { - let should_rerender = match handle_touch_move( - &mut self.fields, - self.width, - self.height, - direction, - ) { - Ok(rerender) => rerender, - Err(err) => { - log::error!("Error in handling touch move: {err}"); - false - } - }; - self.touch_start_coords = None; - return should_rerender; - } - false - } - None => { - log::warn!("TouchEnd received without previous TouchStart"); - false - } - }, - } - } - - fn view(&self, ctx: &Context) -> Html { - let quick_swap_callback = self.get_quick_swap_callback(ctx); - let granular_swap_callback = self.get_granular_swap_callback(ctx); - let solve_callback = self.get_optimal_solve_callback(ctx); - let d_and_c_solve_callback = self.get_d_and_c_solve_callback(ctx); - - let field_click_callback = ctx.link().callback(SlidePuzzleMsg::ClickedField); - - let width_change_callback = ctx.link().callback(SlidePuzzleMsg::WidthUpdate); - let height_change_callback = ctx.link().callback(SlidePuzzleMsg::HeightUpdate); - let bg_url_change_callback = ctx.link().callback(SlidePuzzleMsg::BackgroundUrlUpdate); - let touch_start_callback = ctx.link().callback(SlidePuzzleMsg::TouchStartCoords); - let touch_end_callback = ctx.link().callback(SlidePuzzleMsg::TouchEndCoords); - - // Adjust field size depending on puzzle size - let field_size = 12 / usize::max(self.width, self.height); - - html! { - <> - - - - - - - - - - - - -
{format!("Fields: {:?}", &self.fields)}
-
- - } - } -} - -impl SlidePuzzle { - fn get_quick_swap_callback(&self, ctx: &Context) -> Callback { - // Create a callback to send a fields message that can be passed into - // closures - let swap_callback = ctx.link().callback(SlidePuzzleMsg::CompleteFieldsUpdate); - - // Locally-bind values of self that we want to pass into the closure - let fields = self.fields.clone(); - let empty_field_idx = match get_idx_of_val(&self.fields, u8::MAX) { - Ok(idx) => idx, - Err(err) => { - return Callback::from(move |_| { - log::error!("Could not find empty field idx: {err}"); - }); - } - }; - - let width = self.width; - let height = self.height; - - Callback::from(move |_| { - let mut fields = fields.clone(); - // Calculate a shuffle sequence only when the button is clicked, not - // on every re-render - match get_shuffle_sequence(width, height, empty_field_idx, 20) { - Ok(shuffle_sequence) => { - log::info!("Shuffle sequence: {:?}", &shuffle_sequence); - - for swap in shuffle_sequence { - fields.swap(swap.0, swap.1); - } - - swap_callback.emit(fields); - } - Err(err) => log::error!("Error in quick swapping: {err}"), - } - }) - } - - fn get_granular_swap_callback(&self, ctx: &Context) -> Callback { - // Create a callback to send a swap message that can be passed into - // closures - let swap_callback = ctx.link().callback(move |swap_pair: (usize, usize)| { - SlidePuzzleMsg::Swap((swap_pair.0, swap_pair.1)) - }); - - // Locally-bind values of self that we want to pass into the closure - let empty_field_idx = match get_idx_of_val(&self.fields, u8::MAX) { - Ok(idx) => idx, - Err(err) => { - return Callback::from(move |_| { - log::error!("Could not find empty field idx: {err}"); - }); - } - }; - - let width = self.width; - let height = self.height; - - Callback::from(move |_| { - // Calculate a shuffle sequence only when the button is clicked, not - // on every re-render - match get_shuffle_sequence(width, height, empty_field_idx, 20) { - Ok(shuffle_sequence) => { - log::info!("Shuffle sequence: {:?}", &shuffle_sequence); - - let swap_callback = swap_callback.clone(); - - for (i, swap) in shuffle_sequence.into_iter().enumerate() { - let swap_callback = swap_callback.clone(); - let timeout = - gloo_timers::callback::Timeout::new((i * 250) as u32, move || { - swap_callback.emit((swap.0, swap.1)); - }); - timeout.forget(); - } - } - Err(err) => log::error!("Error in granular swapping: {err}"), - } - }) - } - - fn get_optimal_solve_callback(&self, ctx: &Context) -> Callback { - // Create a callback to send a swap message that can be passed into - // closures - let swap_callback = ctx.link().callback(move |swap_pair: (usize, usize)| { - SlidePuzzleMsg::Swap((swap_pair.0, swap_pair.1)) - }); - - // Locally-bind values of self that we want to pass into the closure - let fields = self.fields.clone(); - let width = self.width; - let height = self.height; - - Callback::from(move |_| { - let fields = fields.clone(); - let swap_callback = swap_callback.clone(); - - // Calculate the solving swap sequence only when the button is - // clicked, not on every re-render - match find_swap_order(&fields, width, height) { - Ok(solve_sequence) => { - log::info!("Solve sequence: {:?}", &solve_sequence); - - for (i, swap) in solve_sequence.into_iter().enumerate() { - let swap_callback = swap_callback.clone(); - let timeout = - gloo_timers::callback::Timeout::new((i * 500) as u32, move || { - swap_callback.emit((swap.0, swap.1)); - }); - timeout.forget(); - } - } - Err(err) => log::error!("Error finding optimal solve sequence: {err}"), - } - }) - } - - fn get_d_and_c_solve_callback(&self, ctx: &Context) -> Callback { - // Create a callback to send a swap message that can be passed into - // closures - let swap_callback = ctx.link().callback(move |swap_pair: (usize, usize)| { - SlidePuzzleMsg::Swap((swap_pair.0, swap_pair.1)) - }); - - // Locally-bind values of self that we want to pass into the closure - let fields = self.fields.clone(); - let width = self.width; - let height = self.height; - - Callback::from(move |_| { - let fields = fields.clone(); - let swap_callback = swap_callback.clone(); - - // Calculate the solving swap sequence only when the button is - // clicked, not on every re-render - match DacPuzzleSolver::new(&fields, width as i32, height as i32) { - Ok(mut solver) => match solver.solve_puzzle() { - Ok(solve_sequence) => { - log::info!("Solve sequence: {:?}", &solve_sequence); - - for (i, swap) in solve_sequence.into_iter().enumerate() { - let swap_callback = swap_callback.clone(); - let timeout = - gloo_timers::callback::Timeout::new((i * 500) as u32, move || { - swap_callback.emit((swap.0, swap.1)); - }); - timeout.forget(); - } - } - Err(err) => log::error!("Could not solve puzzle: {err}"), - }, - Err(err) => log::error!("Error in divide&conquer solver: {err}"), - } - }) - } -} diff --git a/src/solver/divide_and_conquer.rs b/src/solver/divide_and_conquer.rs index b1abbc8..b0264bb 100644 --- a/src/solver/divide_and_conquer.rs +++ b/src/solver/divide_and_conquer.rs @@ -3,6 +3,7 @@ //! See also: //! https://www.kopf.com.br/kaplof/how-to-solve-any-slide-puzzle-regardless-of-its-size/ //! + use std::collections::{HashMap, HashSet, VecDeque}; use simple_error::bail; @@ -46,7 +47,8 @@ impl DacPuzzleSolver { bail!("DacPuzzleSolver: Puzzles below 3x3 are not supported"); } - let empty_field_idx = get_idx_of_val(fields, u8::MAX)? as i32; + let empty_field_val = (width * height - 1) as u8; + let empty_field_idx = get_idx_of_val(fields, empty_field_val)? as i32; let empty_field_pos = get_coords_from_idx(empty_field_idx, width); Ok(Self { @@ -255,7 +257,8 @@ impl DacPuzzleSolver { // In this case, our routine would fail to find a path because it cannot // move the targeted field (2) or any of the already sorted fields (0 // and 1). Thus, we have to check for and handle this case explicitly. - if self.value_at_pos(field_goal_pos)? == u8::MAX + let empty_field_val = (self.width * self.height - 1) as u8; + if self.value_at_pos(field_goal_pos)? == empty_field_val && self.value_at_pos(empty_field_target_pos)? == field_value { // Just swap the field into position and return @@ -642,7 +645,7 @@ mod test { #[test] fn test_solving_regular_4_by_4() -> Result<(), Error> { - let mut fields = vec![8, 5, 6, 1, 14, 4, 7, 2, 0, 13, 11, 9, 255, 12, 10, 3]; + let mut fields = vec![8, 5, 6, 1, 14, 4, 7, 2, 0, 13, 11, 9, 15, 12, 10, 3]; let target_fields = initialize_fields(fields.len()); let mut solver = DacPuzzleSolver::new(&fields, 4, 4)?; @@ -659,7 +662,7 @@ mod test { #[test] fn test_corner_case_corner_presolved_row_end() -> Result<(), Error> { - let mut fields = vec![2, 1, 5, 3, 0, 7, 255, 6, 4]; + let mut fields = vec![2, 1, 5, 3, 0, 7, 8, 6, 4]; let target_fields = initialize_fields(fields.len()); let mut solver = DacPuzzleSolver::new(&fields, 3, 3)?; @@ -676,7 +679,7 @@ mod test { #[test] fn test_corner_case() -> Result<(), Error> { - let mut fields = vec![2, 1, 5, 7, 3, 4, 0, 6, 255]; + let mut fields = vec![2, 1, 5, 7, 3, 4, 0, 6, 8]; let target_fields = initialize_fields(fields.len()); let mut solver = DacPuzzleSolver::new(&fields, 3, 3)?; diff --git a/src/solver/mod.rs b/src/solver/mod.rs index 18852cf..b6523bb 100644 --- a/src/solver/mod.rs +++ b/src/solver/mod.rs @@ -1,2 +1,4 @@ +//! Slide puzzle solver implementations + pub mod divide_and_conquer; pub mod optimal; diff --git a/src/solver/optimal.rs b/src/solver/optimal.rs index 6f0a90e..b887785 100644 --- a/src/solver/optimal.rs +++ b/src/solver/optimal.rs @@ -1,8 +1,9 @@ -//! Naive, optimal puzzle solver. +//! Naive, optimal puzzle solver //! //! This runs a breath-first-search in the state space of possible slides until //! finding the final state. The state space is built on the fly. //! + use std::{ collections::VecDeque, hash::{Hash, Hasher}, @@ -56,7 +57,8 @@ pub fn find_swap_order( return Ok(Vec::with_capacity(0)); } - let empty_field_idx = get_idx_of_val(&fields, u8::MAX)?; + let empty_field_id = (width * height - 1) as u8; + let empty_field_idx = get_idx_of_val(&fields, empty_field_id)?; // Map from a state hash to its parent hash and the last swap that led to // this state from the parent. We need to the swap information to trace back @@ -149,7 +151,7 @@ mod test { #[test] fn test_find_swap_order_zero_moves() -> Result<(), Error> { - let fields = vec![0, 1, 2, u8::MAX]; + let fields = vec![0, 1, 2, 3]; let swap_order = find_swap_order(&fields, 2, 2)?; assert_eq!(swap_order, Vec::with_capacity(0)); Ok(()) @@ -157,7 +159,7 @@ mod test { #[test] fn test_find_swap_order_one_move() -> Result<(), Error> { - let fields = vec![0, 1, u8::MAX, 2]; + let fields = vec![0, 1, 3, 2]; let swap_order = find_swap_order(&fields, 2, 2)?; assert_eq!(swap_order, vec![(2, 3)]); Ok(()) @@ -165,7 +167,7 @@ mod test { #[test] fn test_find_swap_order_four_swaps() -> Result<(), Error> { - let fields = vec![u8::MAX, 1, 2, 0, 3, 5, 6, 4, 7]; + let fields = vec![8, 1, 2, 0, 3, 5, 6, 4, 7]; let swap_order = find_swap_order(&fields, 3, 3)?; assert_eq!(swap_order, vec![(0, 3), (3, 4), (4, 7), (7, 8)]); Ok(()) diff --git a/src/utils.rs b/src/utils.rs index 9b0d5cb..1e32ccf 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,19 +1,22 @@ //! Utility functions for interacting with the board. //! +use std::collections::BTreeMap; + +use rand::prelude::SliceRandom; use web_sys::window; -use crate::Error; +use crate::{Error, BOARD}; /// Coordinates consisting of row and column. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct Coords { - pub row: T, - pub col: T, +pub(crate) struct Coords { + pub(crate) row: T, + pub(crate) col: T, } /// Get the left/top coordinates based on the index of a board field. -pub fn get_left_top(idx: usize, width: usize, unit_size: usize) -> (usize, usize) { +pub(crate) fn get_left_top(idx: usize, width: usize, unit_size: usize) -> (usize, usize) { let (row, col): (usize, usize) = get_row_col_from_idx(idx, width); let left = col * unit_size; let top = row * unit_size; @@ -22,7 +25,7 @@ pub fn get_left_top(idx: usize, width: usize, unit_size: usize) -> (usize, usize } /// Get the row/column coordinates for a linear array representing a board. -pub fn get_row_col_from_idx(idx: U, width: U) -> (U, U) +pub(crate) fn get_row_col_from_idx(idx: U, width: U) -> (U, U) where U: std::ops::Div, U: std::ops::Rem, @@ -35,7 +38,7 @@ where } /// Get the index into a linear array based on row/column coordinates. -pub fn get_idx_from_row_col(row: T, col: T, width: T) -> T +pub(crate) fn get_idx_from_row_col(row: T, col: T, width: T) -> T where T: std::ops::Mul, T: std::ops::Add, @@ -44,7 +47,7 @@ where } /// Get the coordinates matching an index. -pub fn get_coords_from_idx(idx: U, width: U) -> Coords +pub(crate) fn get_coords_from_idx(idx: U, width: U) -> Coords where U: std::ops::Div, U: std::ops::Rem, @@ -55,7 +58,7 @@ where } /// Get the index matching a coordinate pair. -pub fn get_idx_from_coords(coords: Coords, width: T) -> T +pub(crate) fn get_idx_from_coords(coords: Coords, width: T) -> T where T: std::ops::Mul, T: std::ops::Add, @@ -64,7 +67,7 @@ where } /// Check if row/column coordinates are within a field defined by width/height. -pub fn in_bounds(row: T, col: T, width: U, height: U) -> bool +pub(crate) fn in_bounds(row: T, col: T, width: U, height: U) -> bool where T: PartialOrd, T: PartialOrd, @@ -77,7 +80,7 @@ where /// Get the index of a value in a slice. /// /// This is a convenience wrapper and panics if the value cannot be found. -pub fn get_idx_of_val(slice: &[u8], value: u8) -> Result { +pub(crate) fn get_idx_of_val(slice: &[u8], value: u8) -> Result { slice .iter() .position(|&v| v == value) @@ -85,15 +88,13 @@ pub fn get_idx_of_val(slice: &[u8], value: u8) -> Result { } /// Initialize fields as vector. -pub fn initialize_fields(num_elements: usize) -> Vec { +pub(crate) fn initialize_fields(num_elements: usize) -> Vec { let num_elements = usize::min(num_elements, u8::MAX as usize) as u8; - let mut fields: Vec<_> = (0..(num_elements - 1)).collect(); - fields.push(u8::MAX); - fields + (0..num_elements).collect() } /// Get the indices of neighbours that can be swapped with the empty field. -pub fn get_swappable_neighbours( +pub(crate) fn get_swappable_neighbours( width: usize, height: usize, empty_field_idx: usize, @@ -122,8 +123,169 @@ pub fn get_swappable_neighbours( .collect()) } -pub fn search_params() -> Option { +pub(crate) fn search_params() -> Option { window() .and_then(|w| w.location().search().ok()) - .map(|s| s.trim_start_matches('?').to_owned()) + .map(|s| s.trim_start_matches('?').replace("%22", "").to_owned()) +} + +const DEFAULT_SIZE: usize = 3; +const DEFAULT_BACKGROUND: &str = "https://upload.wikimedia.org/wikipedia/commons/thumb/6/61/Blue_Marble_Western_Hemisphere.jpg/600px-Blue_Marble_Western_Hemisphere.jpg?20130305115950"; + +pub(crate) fn extract_parameters() -> Parameters { + let params = search_params() + .map(|s| { + BTreeMap::from_iter( + s.split('&') + .filter_map(|s| s.split_once('=').map(|(k, v)| (k.to_owned(), v.to_owned()))), + ) + }) + .unwrap_or_default(); + + let size: usize = params + .get("size") + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_SIZE); + + let bg_url = params + .get("bg_url") + .map(ToOwned::to_owned) + .unwrap_or_else(|| DEFAULT_BACKGROUND.to_owned()); + + Parameters { size, bg_url } +} + +#[derive(Debug)] +pub(crate) struct Parameters { + pub(crate) size: usize, + pub(crate) bg_url: String, +} + +pub(crate) fn set_panic_hook() { + // When the `console_error_panic_hook` feature is enabled, we can call the + // `set_panic_hook` function at least once during initialization, and then + // we will get better error messages if our code ever panics. + // + // For more details see + // https://github.com/rustwasm/console_error_panic_hook#readme + #[cfg(feature = "console_error_panic_hook")] + console_error_panic_hook::set_once(); +} + +/// Get a sequence of valid semi-random shuffles. +/// +/// We prevent fields from being shuffled back and forth, which breaks total +/// randomness. +pub(crate) fn get_shuffle_sequence( + size: usize, + mut empty_field_idx: usize, + num_swaps: usize, +) -> Result, Error> { + let mut swaps = Vec::with_capacity(num_swaps); + + // We want to avoid swapping fields back and forth like (2, 1), (1, 2) + // Our approach is to remove the previous empty field from swappable + // neighbours + let mut prev_empty_field_idx = empty_field_idx; + + for _ in 0..num_swaps { + let swappable_neighbours: Vec<_> = get_swappable_neighbours(size, size, empty_field_idx)? + .into_iter() + .filter(|&element| element != prev_empty_field_idx) + .collect(); + let chosen_neighbour = swappable_neighbours + .choose(&mut rand::thread_rng()) + .ok_or_else(|| -> Error { + simple_error::simple_error!("no random neighbour to choose").into() + })?; + swaps.push((empty_field_idx, *chosen_neighbour)); + prev_empty_field_idx = empty_field_idx; + empty_field_idx = *chosen_neighbour; + } + + Ok(swaps) +} + +pub(crate) fn get_touch_direction( + x_start: i32, + y_start: i32, + x_end: i32, + y_end: i32, +) -> Option { + let d_x = x_end - x_start; + let d_y = y_end - y_start; + + if d_x.abs() + d_y.abs() < 40 { + // Overall displacement is too small, ignore + return None; + } + + match d_x.abs() > d_y.abs() { + true => { + // Horizontal + if d_x > 0 { + Some(TouchMoveDirection::Right) + } else { + Some(TouchMoveDirection::Left) + } + } + false => { + // Vertical + if d_y > 0 { + Some(TouchMoveDirection::Down) + } else { + Some(TouchMoveDirection::Up) + } + } + } +} + +#[derive(Debug)] +pub(crate) enum TouchMoveDirection { + Left, + Right, + Up, + Down, +} + +pub(crate) fn handle_touch_move(size: usize, direction: TouchMoveDirection) -> Result { + let empty_field_id = size * size - 1; + let empty_field_idx = BOARD.with_borrow(|b| b.ids2indices()[empty_field_id]); + let (empty_row, empty_col): (usize, usize) = get_row_col_from_idx(empty_field_idx, size); + let (empty_row, empty_col) = (empty_row as i32, empty_col as i32); + + let (d_row, d_col) = match direction { + // If the slide was to the left, the user wants to move the neighbour + // on the *right* towards the left. + TouchMoveDirection::Left => (0, 1), + TouchMoveDirection::Right => (0, -1), + TouchMoveDirection::Up => (1, 0), + TouchMoveDirection::Down => (-1, 0), + }; + + let (swap_row, swap_col) = (empty_row + d_row, empty_col + d_col); + + if in_bounds(swap_row, swap_col, size as i32, size as i32) { + let swap_idx: i32 = get_idx_from_row_col(swap_row, swap_col, size as i32); + BOARD.with_borrow_mut(|b| b.swap_indices(empty_field_idx, swap_idx as usize)); + return Ok(true); + } + + // The candidate was not in bounds -> do nothing + Ok(false) +} + +#[derive(Debug)] +pub(crate) struct TouchCoords { + pub(crate) start: Option<(i32, i32)>, + pub(crate) end: Option<(i32, i32)>, +} + +impl TouchCoords { + pub(crate) const fn new() -> Self { + Self { + start: None, + end: None, + } + } } diff --git a/www/index.html b/www/index.html new file mode 100644 index 0000000..bde8c4b --- /dev/null +++ b/www/index.html @@ -0,0 +1,42 @@ + + + + + + + +
+
Slide Puzzle
+
+ + + + + +
+ + + + + diff --git a/www/style.css b/www/style.css new file mode 100644 index 0000000..f2c2c40 --- /dev/null +++ b/www/style.css @@ -0,0 +1,37 @@ +:root { + font-family: Verdana, Geneva, Tahoma, sans-serif; + --unit-size: 0.2rem; + font-size: 60px; +} + +button { + font-size: inherit; + width: 90%; + max-width: 900px; + border-radius: 20px; + padding: 0.5rem; + cursor: pointer; +} + +.clickable:hover { + cursor: pointer; +} + +.content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + gap: 1rem; +} + +.header { + margin-top: 2rem; + font-weight: 700; +} + +.field { + border: 0.2px solid white; +}