diff --git a/README.md b/README.md index 3e8e94b..8e4f453 100644 --- a/README.md +++ b/README.md @@ -2,22 +2,22 @@ Personal site for Jet Pham. -The site is a small Vite app with a terminal-style UI, ANSI-rendered text, and a WebGL2 Conway's Game of Life background. +The site is a small Vite app with a terminal-style UI, ANSI-rendered text, and a Rust/WebAssembly Conway's Game of Life background. ## Features - ASCII/ANSI-inspired visual style with the IBM VGA font -- Conway's Game of Life running in the background via WebGL2 +- Conway's Game of Life running in the background via Rust + WebAssembly - Q+A page backed by the site API - Single-file oriented frontend build with Vite -- Fullscreen GPU blur/composite for the frosted panel effect +- Reduced-motion aware background controls with a static fallback mode ## Stack - Vite - TypeScript - Tailwind CSS v4 -- WebGL2 +- Rust + WebAssembly - npm ## Development @@ -25,6 +25,9 @@ The site is a small Vite app with a terminal-style UI, ANSI-rendered text, and a ### Prerequisites - Node.js + npm +- Rust +- `wasm-pack` +- `wasm-opt` (used by `build:wasm`) ### Install @@ -32,6 +35,12 @@ The site is a small Vite app with a terminal-style UI, ANSI-rendered text, and a npm install ``` +### Build the WASM package + +```bash +npm run build:wasm +``` + ### Start the dev server ```bash @@ -54,9 +63,10 @@ npm run build ```text src/ frontend app +cgol/ Rust + WASM Conway's Game of Life module ``` ## Notes - The homepage and Q+A page are the intended public pages. -- The background renderer targets WebGL2 and falls back to the site shell background if WebGL2 is unavailable. +- If WebAssembly fails or motion is disabled, the site falls back to a static background treatment. diff --git a/cgol/.cargo/config.toml b/cgol/.cargo/config.toml new file mode 100644 index 0000000..39c6f19 --- /dev/null +++ b/cgol/.cargo/config.toml @@ -0,0 +1,5 @@ +[build] +target = "wasm32-unknown-unknown" + +[target.wasm32-unknown-unknown] +rustflags = ["-C", "link-arg=--strip-all"] diff --git a/cgol/.gitignore b/cgol/.gitignore new file mode 100644 index 0000000..9f33f25 --- /dev/null +++ b/cgol/.gitignore @@ -0,0 +1,5 @@ +/target +**/*.rs.bk +bin/ +pkg/ +wasm-pack.log \ No newline at end of file diff --git a/cgol/.vscode/settings.json b/cgol/.vscode/settings.json new file mode 100644 index 0000000..8974c7a --- /dev/null +++ b/cgol/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "rust-analyzer.cargo.target": "wasm32-unknown-unknown", +} diff --git a/cgol/Cargo.lock b/cgol/Cargo.lock new file mode 100644 index 0000000..4a58688 --- /dev/null +++ b/cgol/Cargo.lock @@ -0,0 +1,427 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cgol" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-test", + "web-sys", +] + +[[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 = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-bindgen-test" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6311c867385cc7d5602463b31825d454d0837a3aba7cdb5e56d5201792a3f7fe" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67008cdde4769831958536b0f11b3bdd0380bde882be17fff9c2f34bb4549abd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe29135b180b72b04c74aa97b2b4a2ef275161eff9a6c7955ea9eaedc7e1d4e" + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/cgol/Cargo.toml b/cgol/Cargo.toml new file mode 100644 index 0000000..71116a6 --- /dev/null +++ b/cgol/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "cgol" +version = "0.1.0" +authors = ["jet"] +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wasm-bindgen = "0.2" +js-sys = "0.3" +web-sys = { version = "0.3", features = [ + "CanvasRenderingContext2d", + "Document", + "HtmlCanvasElement", + "Window", + "MouseEvent", + "Element", + "EventTarget", + "Performance", + "TouchEvent", + "Touch", + "TouchList", + "ImageData", +] } +console_error_panic_hook = { version = "0.1", optional = true } + +[features] +default = ["console_error_panic_hook"] + +[dev-dependencies] +wasm-bindgen-test = "0.3" + +[package.metadata.wasm-pack.profile.release] +wasm-opt = false + +[profile.dev] +debug = "line-tables-only" + +[profile.release] +debug = 0 +opt-level = 3 +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/cgol/README.md b/cgol/README.md new file mode 100644 index 0000000..6b68408 --- /dev/null +++ b/cgol/README.md @@ -0,0 +1,84 @@ +
+ +

wasm-pack-template

+ + A template for kick starting a Rust and WebAssembly project using wasm-pack. + +

+ Build Status +

+ +

+ Tutorial + | + Chat +

+ + Built with 🦀🕸 by The Rust and WebAssembly Working Group +
+ +## About + +[**📚 Read this template tutorial! 📚**][template-docs] + +This template is designed for compiling Rust libraries into WebAssembly and +publishing the resulting package to NPM. + +Be sure to check out [other `wasm-pack` tutorials online][tutorials] for other +templates and usages of `wasm-pack`. + +[tutorials]: https://rustwasm.github.io/docs/wasm-pack/tutorials/index.html +[template-docs]: https://rustwasm.github.io/docs/wasm-pack/tutorials/npm-browser-packages/index.html + +## 🚴 Usage + +### 🐑 Use `cargo generate` to Clone this Template + +[Learn more about `cargo generate` here.](https://github.com/ashleygwilliams/cargo-generate) + +``` +cargo generate --git https://github.com/rustwasm/wasm-pack-template.git --name my-project +cd my-project +``` + +### 🛠️ Build with `wasm-pack build` + +``` +wasm-pack build +``` + +### 🔬 Test in Headless Browsers with `wasm-pack test` + +``` +wasm-pack test --headless --firefox +``` + +### 🎁 Publish to NPM with `wasm-pack publish` + +``` +wasm-pack publish +``` + +## 🔋 Batteries Included + +* [`wasm-bindgen`](https://github.com/rustwasm/wasm-bindgen) for communicating + between WebAssembly and JavaScript. +* [`console_error_panic_hook`](https://github.com/rustwasm/console_error_panic_hook) + for logging panic messages to the developer console. +* `LICENSE-APACHE` and `LICENSE-MIT`: most Rust projects are licensed this way, so these are included for you + +## License + +Licensed under either of + +* Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) +* MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) + +at your option. + +### Contribution + +Unless you explicitly state otherwise, any contribution intentionally +submitted for inclusion in the work by you, as defined in the Apache-2.0 +license, shall be dual licensed as above, without any additional terms or +conditions. diff --git a/cgol/src/lib.rs b/cgol/src/lib.rs new file mode 100644 index 0000000..31d55b0 --- /dev/null +++ b/cgol/src/lib.rs @@ -0,0 +1,483 @@ +mod utils; + +use std::cell::Cell; +use std::cell::RefCell; +use std::rc::Rc; +use wasm_bindgen::prelude::*; +use wasm_bindgen::Clamped; +use wasm_bindgen::JsCast; + +use js_sys::Math; +use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData, Window}; + +const CELL_SIZE: u32 = 10; +const TICK_MS: f64 = 1000.0 / 60.0; +const HUE_PERIOD_MS: f64 = 3000.0; +const STILL_STEPS: u32 = 5; + +/// Cells: 0 = dead, 1-255 = alive with that hue value. +const DEAD: u8 = 0; + +#[inline(always)] +fn safe_hue(h: u8) -> u8 { + if h == DEAD { + 1 + } else { + h + } +} + +// ── Lookup tables (computed once) ────────────────────────────────── + +struct Luts { + /// RGBA for each hue. Index 0 = black (dead cell). + rgb: [[u8; 4]; 256], + sin: [f32; 256], + cos: [f32; 256], +} + +impl Luts { + fn new() -> Self { + let mut t = Self { + rgb: [[0, 0, 0, 255]; 256], + sin: [0.0; 256], + cos: [0.0; 256], + }; + for i in 1..256u16 { + let h = i as f32 / 255.0 * 6.0; + let x = 1.0 - (h % 2.0 - 1.0).abs(); + let (r, g, b) = match h as u32 { + 0 => (1.0f32, x, 0.0), + 1 => (x, 1.0, 0.0), + 2 => (0.0, 1.0, x), + 3 => (0.0, x, 1.0), + 4 => (x, 0.0, 1.0), + _ => (1.0, 0.0, x), + }; + t.rgb[i as usize] = [(r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255]; + } + for i in 0..256 { + let rad = i as f32 * std::f32::consts::TAU / 256.0; + t.sin[i] = rad.sin(); + t.cos[i] = rad.cos(); + } + t + } + + #[inline] + fn mix(&self, hues: &[u8]) -> u8 { + let mut s = 0.0f32; + let mut c = 0.0f32; + for &h in hues { + s += self.sin[h as usize]; + c += self.cos[h as usize]; + } + safe_hue( + (s.atan2(c).rem_euclid(std::f32::consts::TAU) * 256.0 / std::f32::consts::TAU) as u8, + ) + } +} + +// ── Grid ─────────────────────────────────────────────────────────── + +struct Grid { + w: u32, + h: u32, + cells: Vec, + buf: Vec, + hues: Vec, +} + +impl Grid { + fn new(w: u32, h: u32) -> Self { + let n = (w * h) as usize; + let mut g = Self { + w, + h, + cells: vec![DEAD; n], + buf: vec![DEAD; n], + hues: Vec::with_capacity(8), + }; + g.randomize(); + g + } + + fn randomize(&mut self) { + for c in &mut self.cells { + *c = if Math::random() < 0.5 { + safe_hue((Math::random() * 255.0) as u8) + } else { + DEAD + }; + } + } + + fn tick(&mut self, luts: &Luts) { + let (w, h) = (self.w, self.h); + + for row in 0..h { + let n_off = (if row == 0 { h - 1 } else { row - 1 }) * w; + let r_off = row * w; + let s_off = (if row == h - 1 { 0 } else { row + 1 }) * w; + + for col in 0..w { + let we = if col == 0 { w - 1 } else { col - 1 }; + let ea = if col == w - 1 { 0 } else { col + 1 }; + + // Read all 8 neighbors + let ns = [ + self.cells[(n_off + we) as usize], + self.cells[(n_off + col) as usize], + self.cells[(n_off + ea) as usize], + self.cells[(r_off + we) as usize], + self.cells[(r_off + ea) as usize], + self.cells[(s_off + we) as usize], + self.cells[(s_off + col) as usize], + self.cells[(s_off + ea) as usize], + ]; + + // Count alive neighbors (branchless) + let count = ns.iter().fold(0u8, |a, &v| a + (v != DEAD) as u8); + + let i = (r_off + col) as usize; + let cell = self.cells[i]; + let alive = cell != DEAD; + + self.buf[i] = if alive { + if count == 2 || count == 3 { + cell + } else { + DEAD + } + } else if count == 3 { + // Only collect hues for births + self.hues.clear(); + for &v in &ns { + if v != DEAD { + self.hues.push(v); + } + } + luts.mix(&self.hues) + } else { + DEAD + }; + } + } + std::mem::swap(&mut self.cells, &mut self.buf); + } + + fn stamp(&mut self, cr: i32, cc: i32, half: i32, hue: u8) { + let (h, w) = (self.h as i32, self.w as i32); + let hue = safe_hue(hue); + for dr in -half..=half { + for dc in -half..=half { + let r = (cr + dr).rem_euclid(h) as u32; + let c = (cc + dc).rem_euclid(w) as u32; + self.cells[(r * self.w + c) as usize] = hue; + } + } + } +} + +// ── App state ────────────────────────────────────────────────────── + +struct App { + win: Window, + canvas: HtmlCanvasElement, + ctx: CanvasRenderingContext2d, + luts: Luts, + grid: Grid, + pixels: Vec, + cw: u32, + ch: u32, + crow: i32, + ccol: i32, + cursor_on: bool, + last_tick: f64, + dirty: bool, +} + +thread_local! { + static APP_STATE: RefCell>>> = const { RefCell::new(None) }; + static RUNNING: Cell = const { Cell::new(false) }; + static LISTENERS_READY: Cell = const { Cell::new(false) }; + static RAF_CLOSURE: RefCell>> = const { RefCell::new(None) }; +} + +impl App { + fn new() -> Result { + utils::set_panic_hook(); + let win = web_sys::window().ok_or("no window")?; + let doc = win.document().ok_or("no document")?; + let canvas: HtmlCanvasElement = doc + .get_element_by_id("canvas") + .ok_or("no canvas")? + .dyn_into()?; + let ctx: CanvasRenderingContext2d = + canvas.get_context("2d")?.ok_or("no 2d ctx")?.dyn_into()?; + ctx.set_image_smoothing_enabled(false); + + let mut app = Self { + win, + canvas, + ctx, + luts: Luts::new(), + grid: Grid::new(1, 1), + pixels: Vec::new(), + cw: 0, + ch: 0, + crow: 0, + ccol: 0, + cursor_on: false, + last_tick: 0.0, + dirty: true, + }; + app.resize(); + Ok(app) + } + + fn now(&self) -> f64 { + self.win.performance().unwrap().now() + } + + fn resize(&mut self) { + let vw = self.win.inner_width().unwrap().as_f64().unwrap(); + let vh = self.win.inner_height().unwrap().as_f64().unwrap(); + let cols = (vw as u32 / CELL_SIZE).max(1); + let rows = (vh as u32 / CELL_SIZE).max(1); + + self.cw = cols; + self.ch = rows; + self.canvas.set_width(cols); + self.canvas.set_height(rows); + self.canvas + .dyn_ref::() + .unwrap() + .set_attribute( + "style", + &format!( + "position:fixed;inset:0;width:{vw}px;height:{vh}px;image-rendering:pixelated" + ), + ) + .ok(); + self.ctx.set_image_smoothing_enabled(false); + + self.grid = Grid::new(cols, rows); + self.pixels = vec![0u8; (cols * rows * 4) as usize]; + self.dirty = true; + } + + fn draw(&mut self) { + if !self.dirty { + return; + } + self.dirty = false; + + let cells = &self.grid.cells; + let rgb = &self.luts.rgb; + let px = &mut self.pixels; + + for (i, &cell) in cells.iter().enumerate() { + px[i * 4..i * 4 + 4].copy_from_slice(&rgb[cell as usize]); + } + + if let Ok(img) = ImageData::new_with_u8_clamped_array_and_sh(Clamped(px), self.cw, self.ch) + { + self.ctx.put_image_data(&img, 0.0, 0.0).ok(); + } + } + + fn render_steps(&mut self, steps: u32) { + for _ in 0..steps { + let luts = &self.luts as *const Luts; + // SAFETY: luts is not mutated during tick() + self.grid.tick(unsafe { &*luts }); + } + self.dirty = true; + self.draw(); + } +} + +fn app() -> Result>, JsValue> { + APP_STATE.with(|state| { + if let Some(app) = state.borrow().as_ref() { + return Ok(app.clone()); + } + + let app = Rc::new(RefCell::new(App::new()?)); + state.borrow_mut().replace(app.clone()); + Ok(app) + }) +} + +fn init_listeners(app: Rc>) -> Result<(), JsValue> { + let already_ready = LISTENERS_READY.with(|ready| { + if ready.get() { + true + } else { + ready.set(true); + false + } + }); + + if already_ready { + return Ok(()); + } + + let win = app.borrow().win.clone(); + let doc = win.document().unwrap(); + + let s = app.clone(); + let cb = Closure::wrap(Box::new(move |e: web_sys::MouseEvent| { + let mut a = s.borrow_mut(); + a.ccol = e.client_x() / CELL_SIZE as i32; + a.crow = e.client_y() / CELL_SIZE as i32; + a.cursor_on = true; + }) as Box); + doc.add_event_listener_with_callback("mousemove", cb.as_ref().unchecked_ref())?; + cb.forget(); + + let s = app.clone(); + let cb = Closure::wrap(Box::new(move |e: web_sys::TouchEvent| { + e.prevent_default(); + if let Some(t) = e.touches().get(0) { + let mut a = s.borrow_mut(); + a.ccol = t.client_x() / CELL_SIZE as i32; + a.crow = t.client_y() / CELL_SIZE as i32; + a.cursor_on = true; + } + }) as Box); + doc.add_event_listener_with_callback("touchmove", cb.as_ref().unchecked_ref())?; + cb.forget(); + + let s = app.clone(); + let cb = Closure::wrap(Box::new(move |e: web_sys::TouchEvent| { + e.prevent_default(); + if let Some(t) = e.touches().get(0) { + let mut a = s.borrow_mut(); + a.ccol = t.client_x() / CELL_SIZE as i32; + a.crow = t.client_y() / CELL_SIZE as i32; + a.cursor_on = true; + } + }) as Box); + doc.add_event_listener_with_callback("touchstart", cb.as_ref().unchecked_ref())?; + cb.forget(); + + let s = app.clone(); + let cb = Closure::wrap(Box::new(move |_: web_sys::TouchEvent| { + s.borrow_mut().cursor_on = false; + }) as Box); + doc.add_event_listener_with_callback("touchend", cb.as_ref().unchecked_ref())?; + cb.forget(); + + let s = app; + let cb = Closure::wrap(Box::new(move || { + let mut a = s.borrow_mut(); + a.resize(); + if RUNNING.with(|running| running.get()) { + a.draw(); + } else { + a.render_steps(STILL_STEPS); + } + }) as Box); + win.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref())?; + cb.forget(); + + Ok(()) +} + +fn ensure_animation_loop(app: Rc>) { + RAF_CLOSURE.with(|slot| { + if slot.borrow().is_some() { + return; + } + + let win = app.borrow().win.clone(); + let f: Rc>>> = Rc::new(RefCell::new(None)); + let g = f.clone(); + let s = app.clone(); + let w = win.clone(); + + *g.borrow_mut() = Some(Closure::wrap(Box::new(move || { + if !RUNNING.with(|running| running.get()) { + return; + } + + let now = s.borrow().now(); + + { + let mut a = s.borrow_mut(); + + if now - a.last_tick >= TICK_MS { + a.last_tick = now; + let luts = &a.luts as *const Luts; + // SAFETY: luts is not mutated during tick() + a.grid.tick(unsafe { &*luts }); + a.dirty = true; + } + + if a.cursor_on { + let (cr, cc) = (a.crow, a.ccol); + let hue = ((now % HUE_PERIOD_MS) / HUE_PERIOD_MS * 256.0) as u8; + a.grid.stamp(cr, cc, 2, hue); + a.dirty = true; + } + + a.draw(); + } + + if RUNNING.with(|running| running.get()) { + w.request_animation_frame(f.borrow().as_ref().unwrap().as_ref().unchecked_ref()) + .ok(); + } + }) as Box)); + + slot.borrow_mut().replace(g.borrow_mut().take().unwrap()); + }); +} + +// ── Entry point ──────────────────────────────────────────────────── + +#[wasm_bindgen] +pub fn start() -> Result<(), JsValue> { + let app = app()?; + init_listeners(app.clone())?; + + if RUNNING.with(|running| running.get()) { + return Ok(()); + } + + RUNNING.with(|running| running.set(true)); + ensure_animation_loop(app.clone()); + + RAF_CLOSURE.with(|slot| { + if let Some(cb) = slot.borrow().as_ref() { + app.borrow() + .win + .request_animation_frame(cb.as_ref().unchecked_ref()) + } else { + Err(JsValue::from_str("no animation closure")) + } + })?; + + Ok(()) +} + +#[wasm_bindgen] +pub fn stop() -> Result<(), JsValue> { + let _ = app()?; + RUNNING.with(|running| running.set(false)); + Ok(()) +} + +#[wasm_bindgen] +pub fn render_still(steps: u32) -> Result<(), JsValue> { + let app = app()?; + init_listeners(app.clone())?; + RUNNING.with(|running| running.set(false)); + let steps = steps.max(1); + let mut app = app.borrow_mut(); + app.resize(); + app.render_steps(steps); + Ok(()) +} diff --git a/cgol/src/utils.rs b/cgol/src/utils.rs new file mode 100644 index 0000000..b1d7929 --- /dev/null +++ b/cgol/src/utils.rs @@ -0,0 +1,10 @@ +pub 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(); +} diff --git a/eslint.config.js b/eslint.config.js index a06087d..46809a3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,42 +1,39 @@ -import tseslint from "typescript-eslint"; +import tseslint from 'typescript-eslint'; export default tseslint.config( { - ignores: ["dist"], + ignores: ['dist', 'cgol/pkg/**/*'] }, { - files: ["**/*.ts"], - extends: [ - ...tseslint.configs.recommended, - ...tseslint.configs.recommendedTypeChecked, - ...tseslint.configs.stylisticTypeChecked, + files: ['**/*.ts'], + extends: [ + ...tseslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + ...tseslint.configs.stylisticTypeChecked + ], + rules: { + "@typescript-eslint/array-type": "off", + "@typescript-eslint/consistent-type-definitions": "off", + "@typescript-eslint/consistent-type-imports": [ + "warn", + { prefer: "type-imports", fixStyle: "inline-type-imports" }, ], - rules: { - "@typescript-eslint/array-type": "off", - "@typescript-eslint/consistent-type-definitions": "off", - "@typescript-eslint/consistent-type-imports": [ - "warn", - { prefer: "type-imports", fixStyle: "inline-type-imports" }, - ], - "@typescript-eslint/no-unused-vars": [ - "warn", - { argsIgnorePattern: "^_" }, - ], - "@typescript-eslint/require-await": "off", - "@typescript-eslint/no-misused-promises": [ - "error", - { checksVoidReturn: { attributes: false } }, - ], - }, + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], + "@typescript-eslint/require-await": "off", + "@typescript-eslint/no-misused-promises": [ + "error", + { checksVoidReturn: { attributes: false } }, + ], + }, }, { - linterOptions: { - reportUnusedDisableDirectives: true, - }, - languageOptions: { - parserOptions: { - projectService: true, - }, - }, - }, -); + linterOptions: { + reportUnusedDisableDirectives: true + }, + languageOptions: { + parserOptions: { + projectService: true + } + } + } +) diff --git a/index.html b/index.html index 740d2c0..51ddb40 100644 --- a/index.html +++ b/index.html @@ -103,27 +103,15 @@ -
+
diff --git a/package-lock.json b/package-lock.json index a37fe33..ebad12d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "website", "version": "0.1.0", + "dependencies": { + "cgol": "file:./cgol/pkg" + }, "devDependencies": { "@tailwindcss/vite": "^4.2.1", "@types/node": "^25.3.3", @@ -19,9 +22,15 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.56.1", "vite": "^7.3.1", - "vite-plugin-singlefile": "^2.3.0" + "vite-plugin-singlefile": "^2.3.0", + "vite-plugin-top-level-await": "^1.6.0", + "vite-plugin-wasm": "^3.5.0" } }, + "cgol/pkg": { + "name": "cgol", + "version": "0.1.0" + }, "node_modules/@emnapi/runtime": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", @@ -684,6 +693,24 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@rollup/plugin-virtual": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-virtual/-/plugin-virtual-3.0.2.tgz", + "integrity": "sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -1034,6 +1061,239 @@ "win32" ] }, + "node_modules/@swc/core": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.18.tgz", + "integrity": "sha512-z87aF9GphWp//fnkRsqvtY+inMVPgYW3zSlXH1kJFvRT5H/wiAn+G32qW5l3oEk63KSF1x3Ov0BfHCObAmT8RA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.25" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.18", + "@swc/core-darwin-x64": "1.15.18", + "@swc/core-linux-arm-gnueabihf": "1.15.18", + "@swc/core-linux-arm64-gnu": "1.15.18", + "@swc/core-linux-arm64-musl": "1.15.18", + "@swc/core-linux-x64-gnu": "1.15.18", + "@swc/core-linux-x64-musl": "1.15.18", + "@swc/core-win32-arm64-msvc": "1.15.18", + "@swc/core-win32-ia32-msvc": "1.15.18", + "@swc/core-win32-x64-msvc": "1.15.18" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.18.tgz", + "integrity": "sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.18.tgz", + "integrity": "sha512-wZle0eaQhnzxWX5V/2kEOI6Z9vl/lTFEC6V4EWcn+5pDjhemCpQv9e/TDJ0GIoiClX8EDWRvuZwh+Z3dhL1NAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.18.tgz", + "integrity": "sha512-ao61HGXVqrJFHAcPtF4/DegmwEkVCo4HApnotLU8ognfmU8x589z7+tcf3hU+qBiU1WOXV5fQX6W9Nzs6hjxDw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.18.tgz", + "integrity": "sha512-3xnctOBLIq3kj8PxOCgPrGjBLP/kNOddr6f5gukYt/1IZxsITQaU9TDyjeX6jG+FiCIHjCuWuffsyQDL5Ew1bg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.18.tgz", + "integrity": "sha512-0a+Lix+FSSHBSBOA0XznCcHo5/1nA6oLLjcnocvzXeqtdjnPb+SvchItHI+lfeiuj1sClYPDvPMLSLyXFaiIKw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.18.tgz", + "integrity": "sha512-wG9J8vReUlpaHz4KOD/5UE1AUgirimU4UFT9oZmupUDEofxJKYb1mTA/DrMj0s78bkBiNI+7Fo2EgPuvOJfuAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.18.tgz", + "integrity": "sha512-4nwbVvCphKzicwNWRmvD5iBaZj8JYsRGa4xOxJmOyHlMDpsvvJ2OR2cODlvWyGFH6BYL1MfIAK3qph3hp0Az6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.18.tgz", + "integrity": "sha512-zk0RYO+LjiBCat2RTMHzAWaMky0cra9loH4oRrLKLLNuL+jarxKLFDA8xTZWEkCPLjUTwlRN7d28eDLLMgtUcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.18.tgz", + "integrity": "sha512-yVuTrZ0RccD5+PEkpcLOBAuPbYBXS6rslENvIXfvJGXSdX5QGi1ehC4BjAMl5FkKLiam4kJECUI0l7Hq7T1vwg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.18.tgz", + "integrity": "sha512-7NRmE4hmUQNCbYU3Hn9Tz57mK9Qq4c97ZS+YlamlK6qG9Fb5g/BB3gPDe0iLlJkns/sYv2VWSkm8c3NmbEGjbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", + "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@swc/wasm": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.15.18.tgz", + "integrity": "sha512-zeSORFArxqUwfVMTRHu8AN9k9LlfSn0CKDSzLhJDITpgLoS0xpnocxsgMjQjUcVYDgO47r9zLP49HEjH/iGsFg==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@tailwindcss/node": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", @@ -1650,6 +1910,10 @@ "node": ">=8" } }, + "node_modules/cgol": { + "resolved": "cgol/pkg", + "link": true + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3009,6 +3273,20 @@ "punycode": "^2.1.0" } }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -3101,6 +3379,32 @@ "vite": "^5.4.11 || ^6.0.0 || ^7.0.0" } }, + "node_modules/vite-plugin-top-level-await": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/vite-plugin-top-level-await/-/vite-plugin-top-level-await-1.6.0.tgz", + "integrity": "sha512-bNhUreLamTIkoulCR9aDXbTbhLk6n1YE8NJUTTxl5RYskNRtzOR0ASzSjBVRtNdjIfngDXo11qOsybGLNsrdww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/plugin-virtual": "^3.0.2", + "@swc/core": "^1.12.14", + "@swc/wasm": "^1.12.14", + "uuid": "10.0.0" + }, + "peerDependencies": { + "vite": ">=2.8" + } + }, + "node_modules/vite-plugin-wasm": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.5.0.tgz", + "integrity": "sha512-X5VWgCnqiQEGb+omhlBVsvTfxikKtoOgAzQ95+BZ8gQ+VfMHIjSHr0wyvXFQCa0eKQ0fKyaL0kWcEnYqBac4lQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 10992d7..b3819be 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "build": "vite build", + "build:wasm": "cd cgol && wasm-pack build --release --target web && wasm-opt pkg/cgol_bg.wasm -o pkg/cgol_bg.wasm -O4 --enable-bulk-memory --enable-nontrapping-float-to-int --enable-sign-ext --low-memory-unused --converge", "check": "npm run lint && tsc --noEmit", "dev": "vite", "format:check": "prettier --check \"**/*.{ts,js,jsx,mdx}\" --cache", @@ -14,6 +15,9 @@ "preview": "vite preview", "typecheck": "tsc --noEmit" }, + "dependencies": { + "cgol": "file:./cgol/pkg" + }, "devDependencies": { "@tailwindcss/vite": "^4.2.1", "@types/node": "^25.3.3", @@ -26,7 +30,13 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.56.1", "vite": "^7.3.1", - "vite-plugin-singlefile": "^2.3.0" + "vite-plugin-singlefile": "^2.3.0", + "vite-plugin-top-level-await": "^1.6.0", + "vite-plugin-wasm": "^3.5.0" }, - "knip": {} + "knip": { + "ignore": [ + "cgol/pkg/**" + ] + } } diff --git a/src/components/frosted-box.ts b/src/components/frosted-box.ts index 36d05c2..3254ebc 100644 --- a/src/components/frosted-box.ts +++ b/src/components/frosted-box.ts @@ -1,9 +1,13 @@ export function frostedBox(content: string, extraClasses?: string): string { return ` -
- - -
+
+ + +
${content}
`; diff --git a/src/lib/background.ts b/src/lib/background.ts new file mode 100644 index 0000000..5fbba65 --- /dev/null +++ b/src/lib/background.ts @@ -0,0 +1,78 @@ +const MOTION_QUERY = "(prefers-reduced-motion: reduce)"; +const STILL_STEPS = 5; + +type BackgroundMode = "animated" | "still" | "failed"; + +interface BackgroundActions { + start: () => void; + stop: () => void; + renderStill: (steps: number) => void; +} + +function getMode(reducedMotion: boolean): BackgroundMode { + return reducedMotion ? "still" : "animated"; +} + +function applyCanvasState(mode: BackgroundMode) { + const canvas = document.getElementById("canvas"); + document.body.dataset.backgroundMode = mode; + if (canvas) { + canvas.toggleAttribute("hidden", mode === "failed"); + } +} + +export function initBackgroundControls(actions: BackgroundActions) { + const media = window.matchMedia(MOTION_QUERY); + let mode: BackgroundMode = getMode(media.matches); + let failed = false; + + const applyMode = (restartAnimation = false) => { + if (failed) return; + + mode = getMode(media.matches); + applyCanvasState(mode); + + if (mode === "animated") { + if (restartAnimation) { + actions.stop(); + } + + actions.start(); + return; + } + + actions.stop(); + actions.renderStill(STILL_STEPS); + }; + + const restartAnimation = () => { + if (document.visibilityState === "hidden" || media.matches) { + return; + } + + applyMode(true); + }; + + media.addEventListener("change", () => { + applyMode(); + }); + + document.addEventListener("visibilitychange", () => { + restartAnimation(); + }); + + window.addEventListener("pageshow", () => { + restartAnimation(); + }); + + return { + applyInitialMode() { + applyMode(); + }, + setFailed() { + failed = true; + mode = "failed"; + applyCanvasState(mode); + }, + }; +} diff --git a/src/lib/site.ts b/src/lib/site.ts index 713bd0d..a0f578a 100644 --- a/src/lib/site.ts +++ b/src/lib/site.ts @@ -28,17 +28,15 @@ export function renderFooter() { const mirror = getMirrorLink(); footer.innerHTML = ` -
- - -