feat: rewrite readme and clean up support

This commit is contained in:
Jet 2026-03-26 21:27:26 -07:00
parent 38af26d959
commit 7758be92b4
No known key found for this signature in database
16 changed files with 383 additions and 453 deletions

View file

@ -1,5 +1,6 @@
mod utils;
use std::cell::Cell;
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
@ -12,13 +13,18 @@ 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 }
if h == DEAD {
1
} else {
h
}
}
// ── Lookup tables (computed once) ──────────────────────────────────
@ -66,7 +72,9 @@ impl Luts {
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)
safe_hue(
(s.atan2(c).rem_euclid(std::f32::consts::TAU) * 256.0 / std::f32::consts::TAU) as u8,
)
}
}
@ -136,7 +144,11 @@ impl Grid {
let alive = cell != DEAD;
self.buf[i] = if alive {
if count == 2 || count == 3 { cell } else { DEAD }
if count == 2 || count == 3 {
cell
} else {
DEAD
}
} else if count == 3 {
// Only collect hues for births
self.hues.clear();
@ -185,6 +197,13 @@ struct App {
dirty: bool,
}
thread_local! {
static APP_STATE: RefCell<Option<Rc<RefCell<App>>>> = const { RefCell::new(None) };
static RUNNING: Cell<bool> = const { Cell::new(false) };
static LISTENERS_READY: Cell<bool> = const { Cell::new(false) };
static RAF_CLOSURE: RefCell<Option<Closure<dyn FnMut()>>> = const { RefCell::new(None) };
}
impl App {
fn new() -> Result<Self, JsValue> {
utils::set_panic_hook();
@ -194,7 +213,8 @@ impl App {
.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()?;
let ctx: CanvasRenderingContext2d =
canvas.get_context("2d")?.ok_or("no 2d ctx")?.dyn_into()?;
ctx.set_image_smoothing_enabled(false);
let mut app = Self {
@ -261,26 +281,53 @@ impl App {
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)
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();
}
}
// ── Entry point ────────────────────────────────────────────────────
fn app() -> Result<Rc<RefCell<App>>, JsValue> {
APP_STATE.with(|state| {
if let Some(app) = state.borrow().as_ref() {
return Ok(app.clone());
}
#[wasm_bindgen]
pub fn start() -> Result<(), JsValue> {
let app = App::new()?;
let rc = Rc::new(RefCell::new(app));
let app = Rc::new(RefCell::new(App::new()?));
state.borrow_mut().replace(app.clone());
Ok(app)
})
}
let win = rc.borrow().win.clone();
fn init_listeners(app: Rc<RefCell<App>>) -> 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();
// Mouse
let s = rc.clone();
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;
@ -290,8 +337,7 @@ pub fn start() -> Result<(), JsValue> {
doc.add_event_listener_with_callback("mousemove", cb.as_ref().unchecked_ref())?;
cb.forget();
// Touch move
let s = rc.clone();
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) {
@ -304,8 +350,7 @@ pub fn start() -> Result<(), JsValue> {
doc.add_event_listener_with_callback("touchmove", cb.as_ref().unchecked_ref())?;
cb.forget();
// Touch start
let s = rc.clone();
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) {
@ -318,56 +363,121 @@ pub fn start() -> Result<(), JsValue> {
doc.add_event_listener_with_callback("touchstart", cb.as_ref().unchecked_ref())?;
cb.forget();
// Touch end
let s = rc.clone();
let s = app.clone();
let cb = Closure::wrap(Box::new(move |_: web_sys::TouchEvent| {
s.borrow_mut().cursor_on = false;
}) as Box<dyn FnMut(_)>);
doc.add_event_listener_with_callback("touchend", cb.as_ref().unchecked_ref())?;
cb.forget();
// Resize
let s = rc.clone();
let s = app;
let cb = Closure::wrap(Box::new(move || {
s.borrow_mut().resize();
let mut a = s.borrow_mut();
a.resize();
if RUNNING.with(|running| running.get()) {
a.draw();
} else {
a.render_steps(STILL_STEPS);
}
}) as Box<dyn FnMut()>);
win.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref())?;
cb.forget();
// Animation loop
let f: Rc<RefCell<Option<Closure<dyn FnMut()>>>> = Rc::new(RefCell::new(None));
let g = f.clone();
let s = rc.clone();
let w = win.clone();
*g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
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();
}
w.request_animation_frame(f.borrow().as_ref().unwrap().as_ref().unchecked_ref())
.ok();
}) as Box<dyn FnMut()>));
win.request_animation_frame(g.borrow().as_ref().unwrap().as_ref().unchecked_ref())?;
Ok(())
}
fn ensure_animation_loop(app: Rc<RefCell<App>>) {
RAF_CLOSURE.with(|slot| {
if slot.borrow().is_some() {
return;
}
let win = app.borrow().win.clone();
let f: Rc<RefCell<Option<Closure<dyn FnMut()>>>> = 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<dyn FnMut()>));
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(())
}