diff --git a/.prettierignore b/.prettierignore index 29c02c6..1521c8b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1 @@ -core dist diff --git a/core/Cargo.lock b/core/Cargo.lock deleted file mode 100644 index 77c9999..0000000 --- a/core/Cargo.lock +++ /dev/null @@ -1,221 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 3 - -[[package]] -name = "bumpalo" -version = "3.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6ed94e98ecff0c12dd1b04c15ec0d7d9458ca8fe806cea6f12954efe74c63b" - -[[package]] -name = "cfg-if" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" - -[[package]] -name = "console_error_panic_hook" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" -dependencies = [ - "cfg-if", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.2.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "js-sys" -version = "0.3.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a" -dependencies = [ - "wasm-bindgen", -] - -[[package]] -name = "libc" -version = "0.2.144" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b00cc1c228a6782d0f076e7b232802e0c5689d41bb5df366f2a6b6621cfdfe1" - -[[package]] -name = "life-like-core" -version = "0.1.0" -dependencies = [ - "console_error_panic_hook", - "getrandom", - "js-sys", - "rand", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "log" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "once_cell" -version = "1.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" - -[[package]] -name = "ppv-lite86" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" - -[[package]] -name = "proc-macro2" -version = "1.0.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4ec6d5fe0b140acb27c9a0444118cf55bfbb4e0b259739429abb4521dd67c16" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f4f29d145265ec1c483c7c654450edde0bfe043d3938d6972630663356d9500" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom", -] - -[[package]] -name = "syn" -version = "2.0.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6f671d4b5ffdb8eadec19c0ae67fe2639df8684bd7bc4b83d986b8db549cf01" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "unicode-ident" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4" - -[[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" - -[[package]] -name = "wasm-bindgen" -version = "0.2.87" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" -dependencies = [ - "cfg-if", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.87" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd" -dependencies = [ - "bumpalo", - "log", - "once_cell", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.87" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.87" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-backend", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.87" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" - -[[package]] -name = "web-sys" -version = "0.3.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b" -dependencies = [ - "js-sys", - "wasm-bindgen", -] diff --git a/core/Cargo.toml b/core/Cargo.toml deleted file mode 100644 index 3b650d3..0000000 --- a/core/Cargo.toml +++ /dev/null @@ -1,31 +0,0 @@ -[package] -name = "life-like-core" -version = "0.1.0" -authors = ["Steve Davis "] -edition = "2021" -description = "Life-like" -repository = "https://github.com/celeryclub/life-like" -license = "MIT" - -[lib] -crate-type = ["cdylib", "rlib"] - -[features] -default = ["console_error_panic_hook"] - -[dependencies] -console_error_panic_hook = { version = "0.1.7", optional = true } -getrandom = { version = "0.2.10", features = ["js"] } -js-sys = "0.3.64" -rand = "0.8.5" -wasm-bindgen = "0.2.87" - -[dependencies.web-sys] -version = "0.3.64" -features = [ - 'CanvasRenderingContext2d', - 'Document', - 'Element', - 'HtmlCanvasElement', - 'Window', -] diff --git a/core/Makefile b/core/Makefile deleted file mode 100644 index 521a415..0000000 --- a/core/Makefile +++ /dev/null @@ -1,7 +0,0 @@ -.DEFAULT_GOAL := build - -build: - wasm-pack --log-level warn build --no-pack - -debug: - wasm-pack --log-level warn build --no-pack --debug diff --git a/core/README.md b/core/README.md deleted file mode 100644 index aae1c06..0000000 --- a/core/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Life-like Core - -This module provides core functionality such as lifecycle management and rendering. - -Reference: -https://rustwasm.github.io/docs/book/introduction.html -https://rustwasm.github.io/wasm-bindgen/examples/2d-canvas.html -https://rustwasm.github.io/wasm-bindgen/reference/arbitrary-data-with-serde.html -https://dev.to/deciduously/reactive-canvas-with-rust-webassembly-and-web-sys-2hg2 -https://medium.com/comsystoreply/creating-a-small-game-with-webassembly-and-rust-20c6945efa1d diff --git a/core/src/cell.rs b/core/src/cell.rs deleted file mode 100644 index 2f73c3d..0000000 --- a/core/src/cell.rs +++ /dev/null @@ -1,26 +0,0 @@ -#[derive(Hash, PartialEq, Eq, Clone, Copy, Debug)] -pub struct Cell { - pub x: i32, - pub y: i32, -} - -impl Cell { - pub fn new(x: i32, y: i32) -> Self { - Cell { x, y } - } - - pub fn generate_neighbors(&self) -> [Cell; 8] { - let neighbors = [ - Cell::new(self.x - 1, self.y - 1), - Cell::new(self.x - 1, self.y), - Cell::new(self.x - 1, self.y + 1), - Cell::new(self.x, self.y - 1), - Cell::new(self.x, self.y + 1), - Cell::new(self.x + 1, self.y - 1), - Cell::new(self.x + 1, self.y), - Cell::new(self.x + 1, self.y + 1), - ]; - - neighbors - } -} diff --git a/core/src/config.rs b/core/src/config.rs deleted file mode 100644 index afacc4e..0000000 --- a/core/src/config.rs +++ /dev/null @@ -1,33 +0,0 @@ -use super::rule::Rule; -use std::collections::HashSet; -use wasm_bindgen::prelude::*; - -#[wasm_bindgen] -pub struct Config { - rule: Rule, - pub(crate) birth_set: HashSet, - pub(crate) survival_set: HashSet, -} - -#[wasm_bindgen] -impl Config { - pub fn new() -> Config { - let rule = Rule::Life; - let [birth_set, survival_set] = rule.parse(); - - Config { - rule, - birth_set, - survival_set, - } - } - - pub fn get_rule(&self) -> Rule { - return self.rule; - } - - pub fn set_rule(&mut self, rule: Rule) { - [self.birth_set, self.survival_set] = rule.parse(); - self.rule = rule; - } -} diff --git a/core/src/layout.rs b/core/src/layout.rs deleted file mode 100644 index f1b7d58..0000000 --- a/core/src/layout.rs +++ /dev/null @@ -1,213 +0,0 @@ -use super::world::World; -use wasm_bindgen::prelude::*; -use web_sys::HtmlCanvasElement; - -#[derive(PartialEq)] -#[wasm_bindgen] -pub enum ZoomDirection { - In, - Out, -} - -const ZOOM_INTENSITY: f64 = 0.01; -const MIN_ZOOM_SCALE: f64 = 0.1; // 10% -const MAX_ZOOM_SCALE: f64 = 64.0; // 6400% -const ZOOM_SCALE_STEPS: [f64; 16] = [ - 0.1, 0.15, 0.25, 0.33, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0, 8.0, 12.0, 16.0, 32.0, 64.0, -]; -const ZOOM_TO_FIT_PADDING: f64 = 0.15; // 15% - -#[wasm_bindgen] -pub struct Layout { - canvas: HtmlCanvasElement, - pub pixel_ratio: u8, // window.devicePixelRatio - pub natural_cell_size: u8, // Cell size at 100% zoom - pub offset_x: f64, // Not including pixel ratio - pub offset_y: f64, // Not including pixel ratio - pub zoom_scale: f64, -} - -#[wasm_bindgen] -impl Layout { - pub fn new(canvas: HtmlCanvasElement, pixel_ratio: u8, natural_cell_size: u8) -> Self { - Layout { - canvas, - pixel_ratio, - natural_cell_size, - offset_x: 0.0, - offset_y: 0.0, - zoom_scale: 1.0, // 100% - } - } - - #[wasm_bindgen(js_name = setCanvasSize)] - pub fn set_canvas_size(&self, width: u32, height: u32) { - self.canvas.set_width(width); - self.canvas.set_height(height); - } - - #[wasm_bindgen(js_name = setOffset)] - pub fn set_offset(&mut self, x: f64, y: f64) { - self.offset_x = x; - self.offset_y = y; - } - - #[wasm_bindgen(js_name = translateOffset)] - pub fn translate_offset(&mut self, delta_x: f64, delta_y: f64) { - self.offset_x += delta_x; - self.offset_y += delta_y; - } - - #[wasm_bindgen(js_name = zoomToScale)] - pub fn zoom_to_scale(&mut self, scale: f64) { - // Clamp zoom scale within valid range - let new_zoom_scale = scale.clamp(MIN_ZOOM_SCALE, MAX_ZOOM_SCALE); - - let (canvas_x, canvas_y) = self.get_canvas_center_offset(); - - let (tx, ty) = self.compute_zoom_translation(canvas_x, canvas_y, new_zoom_scale); - - self.offset_x += tx; - self.offset_y += ty; - - self.zoom_scale = new_zoom_scale; - } - - #[wasm_bindgen(js_name = zoomByStep)] - pub fn zoom_by_step(&mut self, direction: ZoomDirection) -> f64 { - let is_zoom_out = direction == ZoomDirection::Out; - let increment: isize = if is_zoom_out { -1 } else { 1 }; - let last_step_index = ZOOM_SCALE_STEPS.len() as isize - 1; - let mut step_index: isize = if is_zoom_out { last_step_index } else { 0 }; - let mut scale_candidate = if is_zoom_out { - MAX_ZOOM_SCALE - } else { - MIN_ZOOM_SCALE - }; - - while step_index >= 0 && step_index <= last_step_index { - scale_candidate = ZOOM_SCALE_STEPS[step_index as usize]; - - // Return the next closest scale step - if (is_zoom_out && scale_candidate < self.zoom_scale) - || (!is_zoom_out && scale_candidate > self.zoom_scale) - { - break; - } - - step_index += increment; - } - - let (canvas_x, canvas_y) = self.get_canvas_center_offset(); - - let (tx, ty) = self.compute_zoom_translation(canvas_x, canvas_y, scale_candidate); - - self.offset_x += tx; - self.offset_y += ty; - - self.zoom_scale = scale_candidate; - - scale_candidate - } - - #[wasm_bindgen(js_name = zoomAt)] - pub fn zoom_at(&mut self, delta: f64, canvas_x: f64, canvas_y: f64) -> f64 { - // Use canvas offset instead of true canvas position - let canvas_x = canvas_x - self.offset_x; - let canvas_y = canvas_y - self.offset_y; - - // I don't understand the next line, but it works... - let new_zoom_scale = self.zoom_scale * ((delta * ZOOM_INTENSITY).exp()); - - // Clamp zoom scale within valid range - let new_zoom_scale = new_zoom_scale.clamp(MIN_ZOOM_SCALE, MAX_ZOOM_SCALE); - - let (tx, ty) = self.compute_zoom_translation(canvas_x, canvas_y, new_zoom_scale); - - self.offset_x += tx; - self.offset_y += ty; - - self.zoom_scale = new_zoom_scale; - - new_zoom_scale - } - - #[wasm_bindgen(js_name = zoomToFit)] - pub fn zoom_to_fit(&mut self, world: &World) -> f64 { - let (world_x, world_y, world_width, world_height) = world.get_bounds(); - - let natural_cell_size = self.natural_cell_size as f64; - - let natural_world_width = natural_cell_size * world_width as f64; - let natural_world_height = natural_cell_size * world_height as f64; - - let (canvas_width, canvas_height) = self.get_canvas_size(); - - let horizontal_fit_scale = - (canvas_width as f64 * (1.0 - ZOOM_TO_FIT_PADDING)) / natural_world_width; - let vertical_fit_scale = - (canvas_height as f64 * (1.0 - ZOOM_TO_FIT_PADDING)) / natural_world_height; - - // Use the minimum of horizontal or vertical fit to ensure everything is visible - let new_zoom_scale = f64::min(horizontal_fit_scale, vertical_fit_scale); - - // Clamp zoom scale within valid range - let new_zoom_scale = new_zoom_scale.clamp(MIN_ZOOM_SCALE, MAX_ZOOM_SCALE); - - // After the new zoom scale is computed, we can use it to compute the new offset - let actual_cell_size = natural_cell_size * new_zoom_scale; - - let actual_world_x = actual_cell_size * world_x as f64; - let actual_world_y = actual_cell_size * world_y as f64; - let actual_world_width = actual_cell_size * world_width as f64; - let actual_world_height = actual_cell_size * world_height as f64; - - let actual_world_center_x = actual_world_x + actual_world_width / 2.0; - let actual_world_center_y = actual_world_y + actual_world_height / 2.0; - - // Offset should be the center of the canvas, - // plus the difference between the world center and true center - self.offset_x = canvas_width as f64 / 2.0 + actual_world_center_x * -1.0; - self.offset_y = canvas_height as f64 / 2.0 + actual_world_center_y * -1.0; - - self.zoom_scale = new_zoom_scale; - - new_zoom_scale - } - - fn get_canvas_center_offset(&self) -> (f64, f64) { - let (canvas_width, canvas_height) = self.get_canvas_size(); - let canvas_center_x = canvas_width as f64 / 2.0 - self.offset_x; - let canvas_center_y = canvas_height as f64 / 2.0 - self.offset_y; - - (canvas_center_x, canvas_center_y) - } - - fn compute_zoom_translation( - &self, - zoom_point_x: f64, - zoom_point_y: f64, - new_zoom_scale: f64, - ) -> (f64, f64) { - let old_zoom_scale = self.zoom_scale; - let zoom_scale_ratio = new_zoom_scale / old_zoom_scale; - - // Get the canvas position of the mouse after scaling - let new_x = zoom_point_x * zoom_scale_ratio; - let new_y = zoom_point_y * zoom_scale_ratio; - - // Reverse the translation caused by scaling - (zoom_point_x - new_x, zoom_point_y - new_y) - } -} - -impl Layout { - pub fn get_canvas_size(&self) -> (u32, u32) { - let pixel_ratio = self.pixel_ratio as u32; - - ( - self.canvas.width() as u32 / pixel_ratio, - self.canvas.height() as u32 / pixel_ratio, - ) - } -} diff --git a/core/src/lib.rs b/core/src/lib.rs deleted file mode 100644 index c0683a8..0000000 --- a/core/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -use wasm_bindgen::prelude::*; - -pub mod cell; -pub mod config; -pub mod layout; -pub mod renderer; -pub mod rule; -pub mod world; - -#[wasm_bindgen] -extern "C" { - #[wasm_bindgen(js_namespace = console)] - pub fn log(s: &str); -} diff --git a/core/src/renderer.rs b/core/src/renderer.rs deleted file mode 100644 index d1728fd..0000000 --- a/core/src/renderer.rs +++ /dev/null @@ -1,53 +0,0 @@ -use super::layout::Layout; -use super::world::World; -use std::f64; -use wasm_bindgen::prelude::*; -use web_sys::CanvasRenderingContext2d; - -#[wasm_bindgen] -pub struct Renderer { - context: CanvasRenderingContext2d, - color: JsValue, -} - -#[wasm_bindgen] -impl Renderer { - pub fn new(context: CanvasRenderingContext2d, color: JsValue) -> Self { - Renderer { context, color } - } - - pub fn update(&self, layout: &Layout, world: &World) { - self.clear(layout); - self.context.set_fill_style(&self.color); - - world.cells.iter().for_each(|cell| { - self.draw_cell(layout, cell.x, cell.y); - }); - } -} - -impl Renderer { - fn draw_cell(&self, layout: &Layout, world_x: i32, world_y: i32) { - let pixel_ratio = layout.pixel_ratio as f64; - let actual_cell_size = layout.natural_cell_size as f64 * layout.zoom_scale; - - self.context.fill_rect( - pixel_ratio * actual_cell_size * world_x as f64 + pixel_ratio * layout.offset_x as f64, - pixel_ratio * actual_cell_size * world_y as f64 + pixel_ratio * layout.offset_y as f64, - pixel_ratio * actual_cell_size, - pixel_ratio * actual_cell_size, - ); - } - - fn clear(&self, layout: &Layout) { - let (canvas_width, canvas_height) = layout.get_canvas_size(); - - self.context.set_fill_style(&JsValue::from_str("#fff")); - self.context.fill_rect( - 0.0, - 0.0, - canvas_width as f64 * layout.pixel_ratio as f64, - canvas_height as f64 * layout.pixel_ratio as f64, - ); - } -} diff --git a/core/src/rule.rs b/core/src/rule.rs deleted file mode 100644 index bde48d1..0000000 --- a/core/src/rule.rs +++ /dev/null @@ -1,82 +0,0 @@ -use std::collections::HashSet; -use wasm_bindgen::prelude::*; - -// https://en.wikipedia.org/wiki/Life-like_cellular_automaton -// http://www.mirekw.com/ca/rullex_life.html -// https://conwaylife.com/wiki/Run_Length_Encoded -#[derive(Clone, Copy)] -#[wasm_bindgen] -pub enum Rule { - Amoeba, - Assimilation, - Coral, - DayAndNight, - Diamoeba, - DryLife, - Flakes, - Gnarl, - HighLife, - Life, - LongLife, - Maze, - MazeAlt1, - MazeAlt2, - Move, - Seeds, - Serviettes, - Stains, - ThreeFourLife, - TwoByTwo, - WalledCities, -} - -impl Rule { - pub fn parse(&self) -> [HashSet; 2] { - let value = self.value(); - let mut halves = value.split('/'); - - let birth_set = halves - .next() - .unwrap() - .chars() - .skip(1) - .map(|char| char.to_digit(10).unwrap() as u8) - .collect(); - - let survival_set = halves - .next() - .unwrap() - .chars() - .skip(1) - .map(|char| char.to_digit(10).unwrap() as u8) - .collect(); - - return [birth_set, survival_set]; - } - - fn value(&self) -> &str { - match self { - Rule::Amoeba => "B357/S1358", - Rule::Assimilation => "B345/S4567", - Rule::Coral => "B3/S45678", - Rule::DayAndNight => "B3678/S34678", - Rule::Diamoeba => "B35678/S5678", - Rule::DryLife => "B37/S23", - Rule::Flakes => "B3/S012345678", - Rule::Gnarl => "B1/S1", - Rule::HighLife => "B36/S23", - Rule::Life => "B3/S23", - Rule::LongLife => "B345/S5", - Rule::Maze => "B3/S12345", - Rule::MazeAlt1 => "B3/S1234", - Rule::MazeAlt2 => "B37/S12345", - Rule::Move => "B368/S245", - Rule::Seeds => "B2/S", - Rule::Serviettes => "B234/S", - Rule::Stains => "B3678/S235678", - Rule::ThreeFourLife => "B34/S34", - Rule::TwoByTwo => "B36/S125", - Rule::WalledCities => "B45678/S2345", - } - } -} diff --git a/core/src/world.rs b/core/src/world.rs deleted file mode 100644 index fcdd707..0000000 --- a/core/src/world.rs +++ /dev/null @@ -1,136 +0,0 @@ -use super::cell::Cell; -use super::config::Config; -use rand::Rng; -use std::collections::{HashMap, HashSet}; -use wasm_bindgen::prelude::*; - -#[wasm_bindgen] -pub struct World { - pub(crate) cells: HashSet, - neighbor_counts: HashMap, -} - -#[wasm_bindgen] -impl World { - pub fn new() -> Self { - World { - cells: HashSet::new(), - neighbor_counts: HashMap::new(), - } - } - - #[wasm_bindgen(js_name = addCell)] - pub fn add_cell(&mut self, world_x: i32, world_y: i32) { - let cell = Cell::new(world_x, world_y); - self.spawn(&cell); - } - - #[wasm_bindgen(js_name = removeCell)] - pub fn remove_cell(&mut self, world_x: i32, world_y: i32) { - let cell = Cell::new(world_x, world_y); - self.kill(&cell) - } - - pub fn randomize(&mut self) { - self.reset(); - - let mut rng = rand::thread_rng(); - - for x in -40..40 { - for y in -40..40 { - if rng.gen_range(0.0..1.0) < 0.5 { - self.add_cell(x, y); - } - } - } - } - - pub fn tick(&mut self, config: &Config) { - let mut cells_to_kill: HashSet = HashSet::new(); - let mut cells_to_spawn: HashSet = HashSet::new(); - - // Mark cells to kill - self.cells.iter().for_each(|cell| { - let neighbor_count = self.neighbor_counts.get(cell); - - if neighbor_count.is_none() || !config.survival_set.contains(neighbor_count.unwrap()) { - cells_to_kill.insert(*cell); - } - }); - - // Mark cells to spawn - self.neighbor_counts.iter().for_each(|(cell, count)| { - if config.birth_set.contains(count) && !self.cells.contains(cell) { - cells_to_spawn.insert(*cell); - } - }); - - // Kill cells - cells_to_kill.iter().for_each(|cell| { - self.kill(cell); - }); - - // Spawn cells - cells_to_spawn.iter().for_each(|cell| { - self.spawn(cell); - }); - } -} - -impl World { - fn spawn(&mut self, cell: &Cell) { - cell.generate_neighbors().iter().for_each(|neighbor| { - self.increment_neighbor_count(neighbor); - }); - - self.cells.insert(*cell); - } - - fn kill(&mut self, cell: &Cell) { - cell.generate_neighbors().iter().for_each(|neighbor| { - self.decrement_neighbor_count(neighbor); - }); - - self.cells.remove(cell); - } - - fn increment_neighbor_count(&mut self, cell: &Cell) { - *self.neighbor_counts.entry(*cell).or_insert(0) += 1; - } - - fn decrement_neighbor_count(&mut self, cell: &Cell) { - let neighbor_count_minus_one = self.neighbor_counts.get(cell).unwrap() - 1; - - if neighbor_count_minus_one == 0 { - self.neighbor_counts.remove(cell); - } else { - self.neighbor_counts.insert(*cell, neighbor_count_minus_one); - } - } - - fn reset(&mut self) { - self.cells.clear(); - self.neighbor_counts.clear(); - } - - pub fn get_bounds(&self) -> (i32, i32, u32, u32) { - let mut min_x = i32::MAX; - let mut max_x = i32::MIN; - let mut min_y = i32::MAX; - let mut max_y = i32::MIN; - - self.cells.iter().for_each(|cell| { - min_x = i32::min(min_x, cell.x); - max_x = i32::max(max_x, cell.x); - min_y = i32::min(min_y, cell.y); - max_y = i32::max(max_y, cell.y); - }); - - // Add 1 to each of these to account for the size of the final cell in the row or column - let width = (max_x - min_x + 1) as u32; - let height = (max_y - min_y + 1) as u32; - - // x, y, width, height - (min_x, min_y, width, height) - } -} diff --git a/package-lock.json b/package-lock.json index 47839f1..b297275 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,6 @@ }, "devDependencies": { "@types/lodash.throttle": "^4.1.7", - "@wasm-tool/wasm-pack-plugin": "^1.7.0", "css-loader": "^6.8.1", "eslint": "^8.44.0", "eslint-config-airbnb": "^19.0.4", @@ -908,89 +907,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@wasm-tool/wasm-pack-plugin": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@wasm-tool/wasm-pack-plugin/-/wasm-pack-plugin-1.7.0.tgz", - "integrity": "sha512-WikzYsw7nTd5CZxH75h7NxM/FLJAgqfWt+/gk3EL3wYKxiIlpMIYPja+sHQl3ARiicIYy4BDfxkbAVjRYlouTA==", - "dev": true, - "dependencies": { - "chalk": "^2.4.1", - "command-exists": "^1.2.7", - "watchpack": "^2.1.1", - "which": "^2.0.2" - } - }, - "node_modules/@wasm-tool/wasm-pack-plugin/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@wasm-tool/wasm-pack-plugin/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@wasm-tool/wasm-pack-plugin/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@wasm-tool/wasm-pack-plugin/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/@wasm-tool/wasm-pack-plugin/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@wasm-tool/wasm-pack-plugin/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/@wasm-tool/wasm-pack-plugin/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@webassemblyjs/ast": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", @@ -1796,12 +1712,6 @@ "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", "dev": true }, - "node_modules/command-exists": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", - "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==", - "dev": true - }, "node_modules/commander": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", @@ -7878,76 +7788,6 @@ "eslint-visitor-keys": "^3.3.0" } }, - "@wasm-tool/wasm-pack-plugin": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@wasm-tool/wasm-pack-plugin/-/wasm-pack-plugin-1.7.0.tgz", - "integrity": "sha512-WikzYsw7nTd5CZxH75h7NxM/FLJAgqfWt+/gk3EL3wYKxiIlpMIYPja+sHQl3ARiicIYy4BDfxkbAVjRYlouTA==", - "dev": true, - "requires": { - "chalk": "^2.4.1", - "command-exists": "^1.2.7", - "watchpack": "^2.1.1", - "which": "^2.0.2" - }, - "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, "@webassemblyjs/ast": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", @@ -8572,12 +8412,6 @@ "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", "dev": true }, - "command-exists": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", - "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==", - "dev": true - }, "commander": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", diff --git a/package.json b/package.json index 5cb6c46..fe19b04 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ }, "devDependencies": { "@types/lodash.throttle": "^4.1.7", - "@wasm-tool/wasm-pack-plugin": "^1.7.0", "css-loader": "^6.8.1", "eslint": "^8.44.0", "eslint-config-airbnb": "^19.0.4", diff --git a/src/controllers/AppController.ts b/src/controllers/AppController.ts index 59c0f2e..998bcc3 100644 --- a/src/controllers/AppController.ts +++ b/src/controllers/AppController.ts @@ -1,6 +1,9 @@ import { makeObservable, observable, action } from "mobx"; -import { Layout, World, Renderer, Config } from "core"; import { LayoutController } from "./LayoutController"; +import { Config } from "../core/Config"; +import { Layout } from "../core/Layout"; +import { Renderer } from "../core/Renderer"; +import { World } from "../core/World"; export class AppController { private _world: World; diff --git a/src/controllers/ConfigController.ts b/src/controllers/ConfigController.ts index 64f3d96..c13e8dc 100644 --- a/src/controllers/ConfigController.ts +++ b/src/controllers/ConfigController.ts @@ -1,7 +1,4 @@ -import { Config, Rule } from "core"; - -// Re-export for UI -export { Rule } from "core"; +import { Config, Rule } from "../core/Config"; export class ConfigController { private _config: Config; @@ -10,8 +7,8 @@ export class ConfigController { this._config = config; } - public getAllRules(): [string, number][] { - const ruleNames = Object.values(Rule).filter(value => isNaN(value as Rule)) as string[]; + public getAllRules(): [string, string][] { + const ruleNames = Object.keys(Rule); return ruleNames.map(ruleName => { const ruleValue = Rule[ruleName as keyof typeof Rule]; @@ -21,10 +18,10 @@ export class ConfigController { } public getRule(): Rule { - return this._config.get_rule(); + return this._config.getRule(); } public setRule(rule: Rule): void { - this._config.set_rule(rule); + this._config.setRule(rule); } } diff --git a/src/controllers/LayoutController.ts b/src/controllers/LayoutController.ts index 4dce555..91e5d28 100644 --- a/src/controllers/LayoutController.ts +++ b/src/controllers/LayoutController.ts @@ -1,9 +1,8 @@ import { makeObservable, observable, runInAction } from "mobx"; -import { Layout, World, Renderer, ZoomDirection } from "core"; import { PIXEL_RATIO, NATURAL_CELL_SIZE, SIDEBAR_WIDTH } from "../Constants"; - -// Re-export for UI -export { ZoomDirection } from "core"; +import { Layout, ZoomDirection } from "../core/Layout"; +import { Renderer } from "../core/Renderer"; +import { World } from "../core/World"; export enum Direction { Up = "Up", @@ -67,7 +66,7 @@ export class LayoutController { } public panInDirection(direction: Direction): void { - const cellSize = NATURAL_CELL_SIZE * this._layout.zoom_scale; + const cellSize = NATURAL_CELL_SIZE * this._layout.zoomScale; const panIncrement = cellSize * 10; let deltaX = 0; diff --git a/src/core/Cell.ts b/src/core/Cell.ts new file mode 100644 index 0000000..09c1d61 --- /dev/null +++ b/src/core/Cell.ts @@ -0,0 +1,32 @@ +export class Cell { + public x: number; + public y: number; + + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } + + public static fromHash(hash: string): Cell { + const splitHash = hash.split(","); + + return new Cell(parseInt(splitHash[0], 10), parseInt(splitHash[1], 10)); + } + + public hash(): string { + return `${this.x},${this.y}`; + } + + public generateNeighbors(): [Cell, Cell, Cell, Cell, Cell, Cell, Cell, Cell] { + return [ + new Cell(this.x - 1, this.y - 1), + new Cell(this.x - 1, this.y), + new Cell(this.x - 1, this.y + 1), + new Cell(this.x, this.y - 1), + new Cell(this.x, this.y + 1), + new Cell(this.x + 1, this.y - 1), + new Cell(this.x + 1, this.y), + new Cell(this.x + 1, this.y + 1), + ]; + } +} diff --git a/src/core/Config.ts b/src/core/Config.ts new file mode 100644 index 0000000..179eed2 --- /dev/null +++ b/src/core/Config.ts @@ -0,0 +1,69 @@ +// https://en.wikipedia.org/wiki/Life-like_cellular_automaton +// http://www.mirekw.com/ca/rullex_life.html +export enum Rule { + amoeba = "B357/S1358", + assimilation = "B345/S4567", + coral = "B3/S45678", + dayAndNight = "B3678/S34678", + diamoeba = "B35678/S5678", + dryLife = "B37/S23", + flakes = "B3/S012345678", + gnarl = "B1/S1", + highLife = "B36/S23", + life = "B3/S23", + longLife = "B345/S5", + maze = "B3/S12345", + mazeAlt1 = "B3/S1234", + mazeAlt2 = "B37/S12345", + move = "B368/S245", + seeds = "B2/S", + serviettes = "B234/S", + stains = "B3678/S235678", + threeFourLife = "B34/S34", + twoByTwo = "B36/S125", + walledCities = "B45678/S2345", +} + +export class Config { + rule: Rule; + birthSet: Set; + survivalSet: Set; + + constructor() { + const rule = Rule.life; + const [birthSet, survivalSet] = this._parseRule(rule); + + this.rule = rule; + this.birthSet = birthSet; + this.survivalSet = survivalSet; + } + + public getRule(): Rule { + return this.rule; + } + + public setRule(rule: Rule): void { + [this.birthSet, this.survivalSet] = this._parseRule(rule); + + this.rule = rule; + } + + private _parseRule(rule: Rule): [Set, Set] { + const halves = rule.split("/"); + + const birthSet = new Set( + halves[0] + .substring(1) + .split("") + .map(s => parseInt(s, 10)) + ); + const survivalSet = new Set( + halves[1] + .substring(1) + .split("") + .map(s => parseInt(s, 10)) + ); + + return [birthSet, survivalSet]; + } +} diff --git a/src/core/Layout.ts b/src/core/Layout.ts new file mode 100644 index 0000000..a21fc98 --- /dev/null +++ b/src/core/Layout.ts @@ -0,0 +1,179 @@ +import { World } from "./World"; +import { MathUtils } from "../utils/MathUtils"; + +const ZOOM_INTENSITY = 0.01; +const MIN_ZOOM_SCALE = 0.1; // 10% +const MAX_ZOOM_SCALE = 64.0; // 6400% +const ZOOM_SCALE_STEPS = [0.1, 0.15, 0.25, 0.33, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0, 8.0, 12.0, 16.0, 32.0, 64.0]; +const ZOOM_TO_FIT_PADDING = 0.15; // 15% + +export enum ZoomDirection { + In, + Out, +} + +export class Layout { + private _canvas: HTMLCanvasElement; + public pixelRatio: number; // window.devicePixelRatio + public naturalCellSize: number; // Cell size at 100% zoom + public offsetX: number; // Not including pixel ratio + public offsetY: number; // Not including pixel ratio + public zoomScale: number; + + constructor(canvas: HTMLCanvasElement, pixelRatio: number, naturalCellSize: number) { + this._canvas = canvas; + this.pixelRatio = pixelRatio; + this.naturalCellSize = naturalCellSize; + this.offsetX = 0.0; + this.offsetY = 0.0; + this.zoomScale = 1.0; // 100% + } + + public getCanvasSize(): [number, number] { + const pixelRatio = this.pixelRatio; + + return [this._canvas.width / pixelRatio, this._canvas.height / pixelRatio]; + } + + public setCanvasSize(width: number, height: number) { + this._canvas.width = width; + this._canvas.height = height; + } + + public setOffset(x: number, y: number) { + this.offsetX = x; + this.offsetY = y; + } + + public translateOffset(deltaX: number, deltaY: number) { + this.offsetX += deltaX; + this.offsetY += deltaY; + } + + public zoomToScale(scale: number) { + // Clamp zoom scale within valid range + const newZoomScale = MathUtils.clamp(scale, MIN_ZOOM_SCALE, MAX_ZOOM_SCALE); + + const [canvasX, canvasY] = this._getCanvasCenterOffset(); + + const [tx, ty] = this._computeZoomTranslation(canvasX, canvasY, newZoomScale); + + this.offsetX += tx; + this.offsetY += ty; + + this.zoomScale = newZoomScale; + } + + public zoomByStep(direction: ZoomDirection): number { + const isZoomOut = direction === ZoomDirection.Out; + const increment = isZoomOut ? -1 : 1; + const lastStepIndex = ZOOM_SCALE_STEPS.length - 1; + + let stepIndex = isZoomOut ? lastStepIndex : 0; + let scaleCandidate = isZoomOut ? MAX_ZOOM_SCALE : MIN_ZOOM_SCALE; + + while (stepIndex >= 0 && stepIndex <= lastStepIndex) { + scaleCandidate = ZOOM_SCALE_STEPS[stepIndex]; + + // Return the next closest scale step + if ((isZoomOut && scaleCandidate < this.zoomScale) || (!isZoomOut && scaleCandidate > this.zoomScale)) { + break; + } + + stepIndex += increment; + } + + const [canvasX, canvasY] = this._getCanvasCenterOffset(); + + const [tx, ty] = this._computeZoomTranslation(canvasX, canvasY, scaleCandidate); + + this.offsetX += tx; + this.offsetY += ty; + + this.zoomScale = scaleCandidate; + + return scaleCandidate; + } + + public zoomAt(delta: number, canvasX: number, canvasY: number): number { + // Use canvas offset instead of true canvas position + canvasX = canvasX - this.offsetX; + canvasY = canvasY - this.offsetY; + + // I don't understand the next line, but it works... + let newZoomScale = this.zoomScale * Math.exp(delta * ZOOM_INTENSITY); + + // Clamp zoom scale within valid range + newZoomScale = MathUtils.clamp(newZoomScale, MIN_ZOOM_SCALE, MAX_ZOOM_SCALE); + + const [tx, ty] = this._computeZoomTranslation(canvasX, canvasY, newZoomScale); + + this.offsetX += tx; + this.offsetY += ty; + + this.zoomScale = newZoomScale; + + return newZoomScale; + } + + public zoomToFit(world: World): number { + const [worldX, worldY, worldWidth, worldHeight] = world.getBounds(); + + const naturalCellSize = this.naturalCellSize; + + const naturalWorldWidth = naturalCellSize * worldWidth; + const naturalWorldHeight = naturalCellSize * worldHeight; + + const [canvasWidth, canvasHeight] = this.getCanvasSize(); + + const horizontalFitScale = (canvasWidth * (1.0 - ZOOM_TO_FIT_PADDING)) / naturalWorldWidth; + const verticalFitScale = (canvasHeight * (1.0 - ZOOM_TO_FIT_PADDING)) / naturalWorldHeight; + + // Use the minimum of horizontal or vertical fit to ensure everything is visible + let newZoomScale = Math.min(horizontalFitScale, verticalFitScale); + + // Clamp zoom scale within valid range + newZoomScale = MathUtils.clamp(newZoomScale, MIN_ZOOM_SCALE, MAX_ZOOM_SCALE); + + // After the new zoom scale is computed, we can use it to compute the new offset + const actualCellSize = naturalCellSize * newZoomScale; + + const actualWorldX = actualCellSize * worldX; + const actualWorldY = actualCellSize * worldY; + const actualWorldWidth = actualCellSize * worldWidth; + const actualWorldHeight = actualCellSize * worldHeight; + + const actualWorldCenterX = actualWorldX + actualWorldWidth / 2.0; + const actualWorldCenterY = actualWorldY + actualWorldHeight / 2.0; + + // Offset should be the center of the canvas, + // plus the difference between the world center and true center + this.offsetX = canvasWidth / 2.0 + actualWorldCenterX * -1.0; + this.offsetY = canvasHeight / 2.0 + actualWorldCenterY * -1.0; + + this.zoomScale = newZoomScale; + + return newZoomScale; + } + + private _getCanvasCenterOffset(): [number, number] { + const [canvasWidth, canvasHeight] = this.getCanvasSize(); + + const canvasCenterX = canvasWidth / 2.0 - this.offsetX; + const canvasCenterY = canvasHeight / 2.0 - this.offsetY; + + return [canvasCenterX, canvasCenterY]; + } + + private _computeZoomTranslation(zoomPointX: number, zoomPointY: number, newZoomScale: number): [number, number] { + const oldZoomScale = this.zoomScale; + const zoomScaleRatio = newZoomScale / oldZoomScale; + + // Get the canvas position of the mouse after scaling + const newX = zoomPointX * zoomScaleRatio; + const newY = zoomPointY * zoomScaleRatio; + + // Reverse the translation caused by scaling + return [zoomPointX - newX, zoomPointY - newY]; + } +} diff --git a/src/core/Renderer.ts b/src/core/Renderer.ts new file mode 100644 index 0000000..59d6a19 --- /dev/null +++ b/src/core/Renderer.ts @@ -0,0 +1,40 @@ +import { World } from "./World"; +import { Layout } from "./Layout"; + +export class Renderer { + private _context: CanvasRenderingContext2D; + private _color: string; + + constructor(context: CanvasRenderingContext2D, color: string) { + this._context = context; + this._color = color; + } + + public update(layout: Layout, world: World) { + this._clear(layout); + this._context.fillStyle = this._color; + + world.cells.forEach(cell => { + this._drawCell(layout, cell.x, cell.y); + }); + } + + private _drawCell(layout: Layout, world_x: number, world_y: number) { + const pixelRatio = layout.pixelRatio; + const actualCellSize = layout.naturalCellSize * layout.zoomScale; + + this._context.fillRect( + pixelRatio * actualCellSize * world_x + pixelRatio * layout.offsetX, + pixelRatio * actualCellSize * world_y + pixelRatio * layout.offsetY, + pixelRatio * actualCellSize, + pixelRatio * actualCellSize + ); + } + + private _clear(layout: Layout) { + const [canvasWidth, canvasHeight] = layout.getCanvasSize(); + + this._context.fillStyle = "#fff"; + this._context.fillRect(0.0, 0.0, canvasWidth * layout.pixelRatio, canvasHeight * layout.pixelRatio); + } +} diff --git a/src/core/World.ts b/src/core/World.ts new file mode 100644 index 0000000..34b0ebf --- /dev/null +++ b/src/core/World.ts @@ -0,0 +1,122 @@ +import { Cell } from "./Cell"; +import { Config } from "./Config"; + +export class World { + // If JS had a way to hash entities for comparison within a Set, + // we could use a Set for cells instead of a Map. + public cells = new Map(); + // Same here - if we could, we would use Cell as the Map key, + // which would remove the need for Cell.fromHash(). + private _neighborCounts = new Map(); + + public getBounds(): [number, number, number, number] { + let min_x = Number.MAX_VALUE; + let max_x = Number.MIN_VALUE; + let min_y = Number.MAX_VALUE; + let max_y = Number.MIN_VALUE; + + this.cells.forEach(cell => { + min_x = Math.min(min_x, cell.x); + max_x = Math.max(max_x, cell.x); + min_y = Math.min(min_y, cell.y); + max_y = Math.max(max_y, cell.y); + }); + + // Add 1 to each of these to account for the size of the final cell in the row or column + const width = max_x - min_x + 1; + const height = max_y - min_y + 1; + + // x, y, width, height + return [min_x, min_y, width, height]; + } + + public addCell(worldX: number, worldY: number) { + const cell = new Cell(worldX, worldY); + this._spawn(cell); + } + + public removeCell(worldX: number, worldY: number) { + const cell = new Cell(worldX, worldY); + this._kill(cell); + } + + public randomize(): void { + this._reset(); + + for (let x = -40; x < 40; x++) { + for (let y = -40; y < 40; y++) { + if (Math.random() < 0.5) { + this.addCell(x, y); + } + } + } + } + + public tick(config: Config) { + const cellsToKill = new Set(); + const cellsToSpawn = new Set(); + + // Mark cells to kill + for (const [cellHash, cell] of this.cells) { + const neighborCount = this._neighborCounts.get(cellHash); + + if (!neighborCount || !config.survivalSet.has(neighborCount)) { + cellsToKill.add(cell); + } + } + + // Mark cells to spawn + for (const [cellHash, count] of this._neighborCounts) { + if (config.birthSet.has(count) && !this.cells.has(cellHash)) { + const cell = Cell.fromHash(cellHash); + cellsToSpawn.add(cell); + } + } + + // Kill cells + for (const cell of cellsToKill) { + this._kill(cell); + } + + // Spawn cells + for (const cell of cellsToSpawn) { + this._spawn(cell); + } + } + + private _spawn(cell: Cell) { + for (const neighbor of cell.generateNeighbors()) { + this._incrementNeighborCount(neighbor); + } + + this.cells.set(cell.hash(), cell); + } + + private _kill(cell: Cell) { + for (const neighbor of cell.generateNeighbors()) { + this._decrementNeighborCount(neighbor); + } + + this.cells.delete(cell.hash()); + } + + private _incrementNeighborCount(cell: Cell): void { + const neighborCount = this._neighborCounts.get(cell.hash()); + this._neighborCounts.set(cell.hash(), neighborCount ? neighborCount + 1 : 1); + } + + private _decrementNeighborCount(cell: Cell): void { + const neighborCountMinusOne = this._neighborCounts.get(cell.hash())! - 1; + + if (neighborCountMinusOne === 0) { + this._neighborCounts.delete(cell.hash()); + } else { + this._neighborCounts.set(cell.hash(), neighborCountMinusOne); + } + } + + private _reset() { + this.cells.clear(); + this._neighborCounts.clear(); + } +} diff --git a/src/main.ts b/src/main.ts index 46d25e6..959a099 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,12 @@ import { configure } from "mobx"; -import { World, Layout, Config, Renderer } from "core"; import { PIXEL_RATIO, NATURAL_CELL_SIZE, SIDEBAR_WIDTH } from "./Constants"; import { AppController } from "./controllers/AppController"; import { ConfigController } from "./controllers/ConfigController"; import { LayoutController } from "./controllers/LayoutController"; +import { Config } from "./core/Config"; +import { Layout } from "./core/Layout"; +import { Renderer } from "./core/Renderer"; +import { World } from "./core/World"; import { PluginBuilder } from "./plugins/PluginBuilder"; import { PluginManager, PluginGroup } from "./plugins/PluginManager"; import "./ui/x-app"; @@ -17,10 +20,10 @@ const context = canvas.getContext("2d", { alpha: false })!; canvas.style.left = `${SIDEBAR_WIDTH}px`; -const world = World.new(); -const config = Config.new(); -const layout = Layout.new(canvas, PIXEL_RATIO, NATURAL_CELL_SIZE); -const renderer = Renderer.new(context, "#A76FDE"); +const world = new World(); +const config = new Config(); +const layout = new Layout(canvas, PIXEL_RATIO, NATURAL_CELL_SIZE); +const renderer = new Renderer(context, "#A76FDE"); const configController = new ConfigController(config); const layoutController = new LayoutController(canvas, layout, world, renderer); diff --git a/src/plugins/PluginManager.ts b/src/plugins/PluginManager.ts index c989399..136306e 100644 --- a/src/plugins/PluginManager.ts +++ b/src/plugins/PluginManager.ts @@ -1,6 +1,7 @@ import { PluginBuilder, ResizePlugin, WheelPlugin, DragPlugin, KeyboardPlugin, Plugin } from "./PluginBuilder"; import { AppController } from "../controllers/AppController"; -import { LayoutController, Direction, ZoomDirection } from "../controllers/LayoutController"; +import { LayoutController, Direction } from "../controllers/LayoutController"; +import { ZoomDirection } from "../core/Layout"; export enum PluginGroup { Default = "Default", diff --git a/src/ui/x-app.ts b/src/ui/x-app.ts index f01b125..7ac7dda 100644 --- a/src/ui/x-app.ts +++ b/src/ui/x-app.ts @@ -3,8 +3,10 @@ import { TemplateResult, html, css } from "lit"; import { customElement, property } from "lit/decorators.js"; import { SIDEBAR_WIDTH } from "../Constants"; import { AppController } from "../controllers/AppController"; -import { ConfigController, Rule } from "../controllers/ConfigController"; -import { LayoutController, ZoomDirection } from "../controllers/LayoutController"; +import { ConfigController } from "../controllers/ConfigController"; +import { LayoutController } from "../controllers/LayoutController"; +import { Rule } from "../core/Config"; +import { ZoomDirection } from "../core/Layout"; import "@shoelace-style/shoelace/dist/themes/light.css"; import "@shoelace-style/shoelace/dist/components/button-group/button-group.js"; import "@shoelace-style/shoelace/dist/components/button/button.js"; @@ -57,7 +59,7 @@ class App extends MobxLitElement { public appController!: AppController; private _changeRule(e: Event): void { - const rule = parseInt((e.target as HTMLSelectElement).value, 10) as Rule; + const rule = (e.target as HTMLSelectElement).value as Rule; this.configController.setRule(rule); } diff --git a/src/utils/MathUtils.ts b/src/utils/MathUtils.ts new file mode 100644 index 0000000..c1c4932 --- /dev/null +++ b/src/utils/MathUtils.ts @@ -0,0 +1,5 @@ +export class MathUtils { + public static clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); + } +} diff --git a/webpack.config.js b/webpack.config.js index 20dfb4a..6affb10 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,5 +1,4 @@ const path = require("path"); -const WasmPackPlugin = require("@wasm-tool/wasm-pack-plugin"); const HtmlWebpackPlugin = require("html-webpack-plugin"); module.exports = { @@ -22,11 +21,6 @@ module.exports = { ], }, plugins: [ - new WasmPackPlugin({ - crateDirectory: path.resolve(__dirname, "core"), - args: "--log-level warn", - extraArgs: "--no-pack", - }), new HtmlWebpackPlugin({ template: "./src/index.html", }),