Back to Powder Tool
SOURCE / PINNED RELEASE

Made of little things.

Powder Tool

Release
242076e6da24…
Author-recorded commit
c4489f440f27…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-48e516e34e5

Archive hash verified: f2fd072590aac7a5…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/main.ts
import './napplet-settings.js';
import { storage, themeGet, themeOnChanged, type Theme, type Subscription } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { Simulation, makeScene, M, GAS_IDS, EMITTABLE, type Body, type SceneData, type EmitterSettings, type Outlet } from './simulation.js';
import './styles.css';

function el<T extends HTMLElement = HTMLElement>(id: string): T {
  const node = document.getElementById(id); if (!node) throw new Error(`Missing control: ${id}`); return node as T;
}
const canvas = el<HTMLCanvasElement>('canvas');
const ctx = canvas.getContext('2d', { alpha: false })!;
const pixels = document.createElement('canvas');
const pixelCtx = pixels.getContext('2d')!;
const app = el('app');
const brushInput = el<HTMLInputElement>('brush'), gravityInput = el<HTMLInputElement>('gravity');
const sceneInput = el<HTMLSelectElement>('scene');
let sim = makeScene('playground');
let image = new ImageData(sim.width, sim.height);
let selected = 'sand', brush = 16, paused = false, time = 0, accumulator = 0;
let viewport = { width: 1, height: 1, scale: 1, x: 0, y: 0, dpr: 1 };
let themeSub: Subscription | null = null;
let palette = { bg: '#15191d', fg: '#e6e9e8', primary: '#e8bc72', light: false };
let pointer: { id: number; x: number; y: number; lastX: number; lastY: number; mode: string } | null = null;
let hover: { x: number; y: number } | null = null;
let undo: SceneData[] = [], toastTimer = 0, frame = 0, fps = 60;
let storageBusy = false, editingVersion = 0;
let resolution = 600;
let selectedEmitter: number | null = null;
let movingEmitter: number | null = null;
let emitterDefaults: EmitterSettings = { material: M.water, rate: 240, radius: 6, direction: 'down', enabled: true };
const storageAvailable = runtimeHasDomain('storage');
const materials = [
  { id: 'sand', name: 'Sand', color: '#dbb777', value: M.sand, kind: 'GRANULAR', detail: 'Falls, piles up, and sinks through water.' },
  { id: 'water', name: 'Water', color: '#5a9ed1', value: M.water, kind: 'LIQUID', detail: 'Flows into gaps. Floats crates, puts out fire, and cools lava.' },
  { id: 'oil', name: 'Oil', color: '#b49c56', value: M.oil, kind: 'FLAMMABLE LIQUID', detail: 'Floats on water. Add fire and watch it ignite.' },
  { id: 'fire', name: 'Fire', color: '#ed8850', value: M.fire, kind: 'REACTION', detail: 'Ignites oil and wood. Rises, flickers, and eventually burns out.' },
  { id: 'steam', name: 'Steam', color: '#a3bec5', value: M.steam, kind: 'GAS', detail: 'Drifts upward and dissipates. Some condenses back into water.' },
  { id: 'lava', name: 'Lava', color: '#d65638', value: M.lava, kind: 'MOLTEN LIQUID', detail: 'Flows slowly and ignites fuel. Water cools it into solid wall.' },
  { id: 'wood', name: 'Wood', color: '#a78058', value: M.wood, kind: 'FIXED SOLID', detail: 'Build bridges and platforms. Stays fixed until it burns.' },
  { id: 'wall', name: 'Wall', color: '#77848a', value: M.wall, kind: 'FIXED SOLID', detail: 'An immovable, fireproof barrier. Draw containers for your experiments.' },
  { id: 'smoke', name: 'Smoke', color: '#899099', value: M.smoke, kind: 'SLOW RISING GAS', detail: 'Rises slowly, spreads, and fades. Fire produces smoke as it burns out.' },
  { id: 'hydrogen', name: 'Hydrogen', color: '#e4b4cf', value: M.hydrogen, kind: 'FLAMMABLE GAS', detail: 'A very light gas. Rises quickly and ignites when it touches fire or lava.' },
  { id: 'helium', name: 'Helium', color: '#ab9bda', value: M.helium, kind: 'INERT GAS', detail: 'Rises rapidly and stays unburned near fire. Collect it under a ceiling.' },
  { id: 'co2', name: 'CO₂', color: '#81ac91', value: M.co2, kind: 'HEAVY GAS', detail: 'Sinks and collects in low places. Smothers fire on contact.' },
  { id: 'emitter', name: 'Emitter', color: '#d4b57b', value: -4, kind: 'CONTINUOUS SOURCE', detail: 'Place a configurable source. Set its material, flow rate, width, direction, and power.' },
  { id: 'box', name: 'Crate', color: '#c39962', value: -1, kind: 'RIGID BODY', detail: 'Tap to drop a wooden crate. It tumbles, stacks, floats, and burns. Use Grab to throw it.' },
  { id: 'stone', name: 'Stone', color: '#89989b', value: -2, kind: 'RIGID BODY', detail: 'Tap to drop a heavy stone. It rolls, collides, and sinks. Brush size sets its size.' },
  { id: 'grab', name: 'Grab', color: '', value: -3, kind: 'PHYSICS TOOL', detail: 'Drag a crate or stone. Grab near a corner to spin it, then release to throw.' },
  { id: 'erase', name: 'Erase', color: '', value: M.empty, kind: 'EDITING TOOL', detail: 'Brush away particles and walls. Touch a rigid body to remove it. Right-drag also erases.' },
];
for (const m of materials) {
  const button = document.createElement('button'); button.type = 'button'; button.dataset.material = m.id;
  button.title = `${m.name} — ${m.detail}`; button.setAttribute('aria-pressed', String(m.id === selected));
  const isTool = ['grab', 'erase', 'emitter'].includes(m.id);
  if (!isTool) {
    button.className = 'material';
    const swatch = document.createElement('span'); swatch.className = `swatch ${m.id}`; swatch.style.setProperty('--swatch', m.color); swatch.setAttribute('aria-hidden', 'true'); button.append(swatch);
  }
  const text = document.createElement('span'); text.textContent = m.name; button.append(text);
  button.addEventListener('click', () => select(m.id));
  el(isTool ? 'tools' : GAS_IDS.includes(m.value) ? 'gases' : m.value < 0 ? 'bodies' : 'materials').append(button);
}
function closePalette(): void { app.classList.remove('palette-open'); el('palette-toggle').setAttribute('aria-expanded', 'false'); }
function select(id: string): void {
  stopPointer(); selected = id;
  const m = materials.find(m => m.id === id)!;
  document.querySelectorAll<HTMLButtonElement>('[data-material]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.material === id)));
  el('emitter-editor').hidden = id !== 'emitter';
  el('quick-emitter').setAttribute('aria-pressed', String(id === 'emitter'));
  el('quick-grab').setAttribute('aria-pressed', String(id === 'grab')); el('quick-erase').setAttribute('aria-pressed', String(id === 'erase'));
  el('detail-kind').textContent = m.kind; el('detail-name').textContent = m.name; el('detail-text').textContent = m.detail;
  el('palette-toggle').title = `Materials and tools · ${m.name} selected`;
  canvas.style.cursor = id === 'grab' ? 'grab' : 'crosshair';
  el('hint').textContent = id === 'emitter' ? 'Tap to place a source · Tap an existing source to edit it' : id === 'grab' ? 'Drag a body or emitter · Release a body to throw' : id === 'box' || id === 'stone' ? `Tap to place a ${m.name.toLowerCase()} · Brush size controls its size` : `Drag to paint ${m.name.toLowerCase()} · Right-drag to erase`;
  if (id === 'emitter') { syncEmitterEditor(); app.classList.add('palette-open'); el('palette-toggle').setAttribute('aria-expanded', 'true'); el('emitter-editor').scrollIntoView({ block: 'nearest' }); }
  else closePalette();
}
function toast(message: string): void {
  const node = el('toast'); node.textContent = message; node.hidden = false; window.clearTimeout(toastTimer);
  toastTimer = window.setTimeout(() => { node.hidden = true; }, 3600);
}
function checkpoint(): void {
  editingVersion++; undo.push(sim.snapshot()); if (undo.length > 8) undo.shift(); el<HTMLButtonElement>('undo').disabled = false;
}
function replace(next: Simulation): void {
  stopPointer(); sim = next; selectedEmitter = null; syncEmitterEditor(); resolution = sim.width; syncResolution(); image = new ImageData(sim.width, sim.height); pixels.width = sim.width; pixels.height = sim.height;
  gravityInput.value = String(Math.round(sim.gravity * 10)); el('gravity-value').textContent = `${sim.gravity.toFixed(1)}×`; fit(); render();
}
function syncResolution(): void {
  const field = el<HTMLSelectElement>('resolution');
  field.querySelector('option[data-custom]')?.remove();
  if (![300, 450, 600].includes(sim.width)) {
    const option = new Option(`Loaded · ${sim.width} wide`, String(sim.width)); option.dataset.custom = 'true'; field.add(option);
  }
  field.value = String(sim.width); el('stage-size').textContent = `${sim.width} × ${sim.height}`;
}
function syncEmitterEditor(): void {
  const source = sim.emitters.find(e => e.id === selectedEmitter);
  if (!source) selectedEmitter = null;
  const settings = source ?? emitterDefaults;
  const list = el<HTMLSelectElement>('emitter-list'); list.replaceChildren(new Option('New emitter', 'new'));
  for (const e of sim.emitters) list.add(new Option(`#${e.id} · ${materials.find(m => m.value === e.material)?.name ?? 'Source'}${e.enabled ? '' : ' · off'}`, String(e.id)));
  list.value = source ? String(source.id) : 'new';
  el('emitter-id').textContent = source ? `#${source.id}` : 'NEW';
  el<HTMLSelectElement>('emitter-material').value = String(settings.material);
  el<HTMLInputElement>('emitter-rate').value = String(settings.rate);
  el<HTMLInputElement>('emitter-radius').value = String(settings.radius);
  el<HTMLSelectElement>('emitter-direction').value = settings.direction;
  el<HTMLInputElement>('emitter-enabled').checked = settings.enabled;
  el('emitter-rate-value').textContent = `${settings.rate} /s`; el('emitter-radius-value').textContent = `${settings.radius * 2} px`;
  el<HTMLButtonElement>('emitter-remove').disabled = !source;
  el('emitter-tip').textContent = source ? 'Changes apply immediately. Grab moves this source; Erase removes it.' : 'Configure a source, then tap the stage to place it.';
  el('emitter-place').textContent = source ? 'Back to stage' : 'Place on stage';
}
for (const material of materials.filter(m => EMITTABLE.includes(m.value))) el<HTMLSelectElement>('emitter-material').add(new Option(material.name, String(material.value)));
function editEmitter(): void {
  const settings: EmitterSettings = {
    material: Number(el<HTMLSelectElement>('emitter-material').value), rate: Number(el<HTMLInputElement>('emitter-rate').value), radius: Number(el<HTMLInputElement>('emitter-radius').value), direction: el<HTMLSelectElement>('emitter-direction').value as Outlet, enabled: el<HTMLInputElement>('emitter-enabled').checked,
  };
  if (selectedEmitter !== null) { checkpoint(); sim.updateEmitter(selectedEmitter, settings); }
  emitterDefaults = { ...settings }; syncEmitterEditor(); render();
}
for (const id of ['emitter-material', 'emitter-rate', 'emitter-radius', 'emitter-direction', 'emitter-enabled']) el(id).addEventListener('change', editEmitter);
for (const id of ['emitter-rate', 'emitter-radius']) el(id).addEventListener('input', () => { el(id + '-value').textContent = id === 'emitter-rate' ? `${el<HTMLInputElement>(id).value} /s` : `${Number(el<HTMLInputElement>(id).value) * 2} px`; });
el('emitter-list').addEventListener('change', () => { const value = el<HTMLSelectElement>('emitter-list').value; selectedEmitter = value === 'new' ? null : Number(value); syncEmitterEditor(); });
el('emitter-new').addEventListener('click', () => { selectedEmitter = null; syncEmitterEditor(); });
el('emitter-place').addEventListener('click', closePalette);
el('emitter-remove').addEventListener('click', () => { if (selectedEmitter === null) return; checkpoint(); sim.emitters = sim.emitters.filter(e => e.id !== selectedEmitter); selectedEmitter = null; syncEmitterEditor(); render(); });
el('resolution').addEventListener('change', () => {
  const width = Number(el<HTMLSelectElement>('resolution').value);
  if (width === sim.width) return;
  checkpoint(); const oldWidth = sim.width;
  replace(sim.resized(width, Math.max(60, Math.min(720, Math.round(sim.height * width / sim.width)))));
  brush = Math.max(1, Math.min(48, Math.round(brush * width / oldWidth))); brushInput.value = String(brush); el('brush-value').textContent = `${brush} px`;
  toast(`Stage: ${sim.width} × ${sim.height}. Undo restores the previous detail.`);
});
function currentDimensions(): [number, number] {
  const box = canvas.getBoundingClientRect();
  return [resolution, Math.max(Math.round(resolution * .53), Math.min(720, Math.round((box.height - 70) / Math.max(1, box.width - 32) * resolution)))];
}
function startScene(name: string): void {
  checkpoint(); const [w, h] = currentDimensions(); replace(makeScene(name, w, h));
  el('scene-name').textContent = sceneInput.selectedOptions[0].textContent;
  el('hint').textContent = name === 'gaslab' ? 'Use Emitter to edit a source · Add fire to hydrogen' : name === 'empty' ? 'Choose a material, then drag to draw' : 'Drag to pour sand · Try dropping a stone into the water';
  closePalette();
}
function setPaused(value: boolean): void {
  paused = value; accumulator = 0; stopPointer();
  const button = el<HTMLButtonElement>('pause'); button.setAttribute('aria-label', paused ? 'Resume simulation' : 'Pause simulation'); button.title = paused ? 'Resume simulation' : 'Pause simulation';
  button.querySelector('path')!.setAttribute('d', paused ? 'm6 4 9 6-9 6Z' : 'M7 5v10M13 5v10');
  button.querySelector('.button-label')!.textContent = paused ? 'Play' : 'Pause';
  el('live-state').innerHTML = paused ? '<i></i>PAUSED' : '<i></i>LIVE'; el('live-state').classList.toggle('is-paused', paused);
  el('paused-badge').hidden = !paused;
  render();
}
el('pause').addEventListener('click', () => setPaused(!paused));
el('step').addEventListener('click', () => { setPaused(true); sim.step(); render(); });
el('palette-toggle').addEventListener('click', () => { const open = app.classList.toggle('palette-open'); el('palette-toggle').setAttribute('aria-expanded', String(open)); });
el('quick-emitter').addEventListener('click', () => select('emitter'));
el('quick-grab').addEventListener('click', () => select('grab')); el('quick-erase').addEventListener('click', () => select('erase'));
sceneInput.addEventListener('change', () => startScene(sceneInput.value));
for (const id of ['reset', 'drawer-reset']) el(id).addEventListener('click', () => startScene(sceneInput.value));
el('clear').addEventListener('click', () => { checkpoint(); replace(new Simulation(sim.width, sim.height)); el('scene-name').textContent = 'Empty canvas'; el('hint').textContent = 'Choose a material, then drag to draw'; closePalette(); toast('Canvas cleared. Undo restores your scene.'); });
el<HTMLButtonElement>('undo').disabled = true;
el('undo').addEventListener('click', () => { const data = undo.pop(); if (data) { editingVersion++; replace(Simulation.restore(data)); toast('Previous edit restored'); } el<HTMLButtonElement>('undo').disabled = !undo.length; });
brushInput.addEventListener('input', () => { brush = Number(brushInput.value); el('brush-value').textContent = `${brush} px`; });
gravityInput.addEventListener('input', () => { sim.gravity = Number(gravityInput.value) / 10; el('gravity-value').textContent = `${sim.gravity.toFixed(1)}×`; editingVersion++; });
async function withTimeout<T>(operation: Promise<T>): Promise<T> {
  let timer = 0;
  try { return await Promise.race([operation, new Promise<never>((_, reject) => { timer = window.setTimeout(() => reject(new Error('The host did not respond')), 6000); })]); }
  finally { window.clearTimeout(timer); }
}
function updateStorageButtons(): void {
  for (const id of ['save', 'load', 'drawer-save', 'drawer-load']) { const b = el<HTMLButtonElement>(id); b.disabled = !storageAvailable || storageBusy; if (!storageAvailable) b.title = 'This host does not provide scene storage'; }
}
async function saveScene(): Promise<void> {
  if (!storageAvailable || storageBusy) return;
  storageBusy = true; updateStorageButtons();
  try {
    const raw = JSON.stringify(sim.snapshot());
    if (new TextEncoder().encode(raw).length > 480000) throw new Error('Scene is too detailed to save; erase some material first');
    await withTimeout(storage.setItem('powder-scene-v1', raw)); toast('Scene saved');
  } catch (error) { toast(`Scene was not saved: ${error instanceof Error ? error.message : 'storage unavailable'}`); }
  finally { storageBusy = false; updateStorageButtons(); }
}
async function loadScene(): Promise<void> {
  if (!storageAvailable || storageBusy) return;
  storageBusy = true; updateStorageButtons(); const version = editingVersion;
  try {
    const raw = await withTimeout(storage.getItem('powder-scene-v1'));
    if (!raw) { toast('No saved scene yet'); return; }
    const next = Simulation.restore(JSON.parse(raw));
    if (version !== editingVersion) { toast('Canvas changed while loading. Load again to replace it.'); return; }
    checkpoint(); replace(next); el('scene-name').textContent = 'Saved scene'; closePalette(); toast('Saved scene restored');
  } catch (error) { toast(`Could not load scene: ${error instanceof Error ? error.message : 'storage unavailable'}`); }
  finally { storageBusy = false; updateStorageButtons(); }
}
for (const id of ['save', 'drawer-save']) el(id).addEventListener('click', () => { void saveScene(); });
for (const id of ['load', 'drawer-load']) el(id).addEventListener('click', () => { void loadScene(); });
function position(event: PointerEvent): { x: number; y: number } {
  const box = canvas.getBoundingClientRect(); return { x: (event.clientX - box.left - viewport.x) / viewport.scale, y: (event.clientY - box.top - viewport.y) / viewport.scale };
}
function inside(p: { x: number; y: number }): boolean { return p.x >= 0 && p.y >= 0 && p.x < sim.width && p.y < sim.height; }
function applyBrush(): void {
  if (!pointer || ['grab', 'box', 'stone', 'emitter'].includes(pointer.mode)) return;
  const m = materials.find(m => m.id === pointer!.mode)!.value;
  const dx = pointer.x - pointer.lastX, dy = pointer.y - pointer.lastY;
  const steps = Math.min(600, Math.max(1, Math.ceil(Math.hypot(dx, dy) / Math.max(1, brush / 3))));
  for (let n = 1; n <= steps; n++) sim.paint(pointer.lastX + dx * n / steps, pointer.lastY + dy * n / steps, brush / 2, m);
  pointer.lastX = pointer.x; pointer.lastY = pointer.y;
  if (m === M.empty) {
    sim.emitters = sim.emitters.filter(e => Math.hypot(e.x - pointer!.x, e.y - pointer!.y) > Math.max(brush / 2, 6 * sim.units));
    if (selectedEmitter !== null && !sim.emitters.some(e => e.id === selectedEmitter)) { selectedEmitter = null; syncEmitterEditor(); }
    sim.rasterBodies();
  }
}
canvas.addEventListener('pointerdown', event => {
  if (pointer || ![0, 2].includes(event.button)) return;
  const p = position(event); if (!inside(p)) return;
  event.preventDefault(); closePalette(); canvas.focus({ preventScroll: true }); checkpoint();
  const mode = event.button === 2 ? 'erase' : selected;
  pointer = { id: event.pointerId, ...p, lastX: p.x, lastY: p.y, mode }; canvas.setPointerCapture(event.pointerId);
  if (mode === 'emitter') {
    const existing = sim.emitterAt(p.x, p.y, Math.max(8 * sim.units, 14 / viewport.scale));
    if (existing) { selectedEmitter = existing.id; stopPointer(); select('emitter'); }
    else {
      const source = sim.addEmitter(p.x, p.y, emitterDefaults);
      if (source) { selectedEmitter = source.id; syncEmitterEditor(); toast('Emitter placed. Tap it to edit; use Grab to move it.'); }
      else toast(sim.emitters.length >= 24 ? '24 emitters is the limit. Remove one to add another.' : 'Place emitters in an open space');
    }
  } else if (mode === 'box' || mode === 'stone') {
    const body = sim.addBody(mode, p.x, p.y, Math.max(4 * sim.units, brush * .65 + 3 * sim.units));
    if (!body) toast(sim.bodies.length >= 48 ? '48 bodies is the limit. Erase one to make room.' : 'Place bodies in an open space');
  } else if (mode === 'grab') {
    const source = sim.emitterAt(p.x, p.y, Math.max(8 * sim.units, 14 / viewport.scale));
    const body = sim.bodyAt(p.x, p.y);
    if (source) { movingEmitter = source.id; selectedEmitter = source.id; }
    else if (body) {
      const dx = p.x - body.x, dy = p.y - body.y, c = Math.cos(body.angle), s = Math.sin(body.angle);
      sim.grab = { id: body.id, ...p, localX: dx * c + dy * s, localY: -dx * s + dy * c };
      canvas.style.cursor = 'grabbing';
      if (paused) toast('Press Play to move and throw the body');
    }
  } else applyBrush();
  render();
});
canvas.addEventListener('pointermove', event => {
  const p = position(event); hover = inside(p) ? p : null;
  if (!pointer || pointer.id !== event.pointerId) return;
  pointer.x = Math.max(-24, Math.min(sim.width + 24, p.x)); pointer.y = Math.max(-24, Math.min(sim.height + 24, p.y));
  if (movingEmitter !== null) {
    const source = sim.emitters.find(e => e.id === movingEmitter);
    if (source) { source.x = Math.max(0, Math.min(sim.width - 1, p.x)); source.y = Math.max(0, Math.min(sim.height - 1, p.y)); }
  } else if (sim.grab) { sim.grab.x = pointer.x; sim.grab.y = pointer.y; }
  else applyBrush();
});
function stopPointer(): void {
  const id = pointer?.id; pointer = null; sim.grab = null; movingEmitter = null;
  if (id !== undefined && canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id);
  canvas.style.cursor = selected === 'grab' ? 'grab' : 'crosshair';
}
canvas.addEventListener('pointerup', stopPointer); canvas.addEventListener('pointercancel', stopPointer); canvas.addEventListener('lostpointercapture', stopPointer);
canvas.addEventListener('pointerleave', () => { hover = null; });
canvas.addEventListener('contextmenu', event => event.preventDefault());
window.addEventListener('blur', stopPointer);
document.addEventListener('visibilitychange', () => { stopPointer(); time = 0; accumulator = 0; });

function fit(): void {
  const box = canvas.getBoundingClientRect();
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  const padding = box.width < 240 || box.height < 200 ? 6 : 16;
  const top = box.height < 200 ? 8 : 48, bottom = box.height < 200 ? 8 : 46;
  const scale = Math.max(.01, Math.min((box.width - padding * 2) / sim.width, (box.height - top - bottom) / sim.height));
  viewport = { width: box.width, height: box.height, dpr, scale, x: (box.width - sim.width * scale) / 2, y: top + (Math.max(1, box.height - top - bottom) - sim.height * scale) / 2 };
  canvas.width = Math.max(1, Math.round(box.width * dpr)); canvas.height = Math.max(1, Math.round(box.height * dpr));
}
const colors = [[0, 0, 0], [211, 175, 108], [59, 132, 185], [156, 130, 65], [247, 134, 46], [164, 189, 197], [220, 70, 32], [140, 103, 65], [91, 108, 116], [121, 130, 143], [224, 167, 205], [164, 142, 219], [111, 164, 130]];
function drawBody(b: Body): void {
  ctx.save(); ctx.translate(b.x, b.y); ctx.rotate(b.angle);
  ctx.lineWidth = .55;
  if (b.kind === 'box') {
    const r = b.size;
    ctx.fillStyle = b.heat > .5 ? '#83522e' : '#967044'; ctx.fillRect(-r, -r, r * 2, r * 2);
    ctx.strokeStyle = '#553e28';
    for (let i = 1; i < 4; i++) { const y = -r + i * r / 2; ctx.beginPath(); ctx.moveTo(-r, y); ctx.lineTo(r, y); ctx.stroke(); }
    ctx.strokeStyle = '#d3ad72'; ctx.lineWidth = 1.3; ctx.strokeRect(-r + .8, -r + .8, 2 * r - 1.6, 2 * r - 1.6);
    ctx.beginPath(); ctx.moveTo(-r + 1.5, -r + 1.5); ctx.lineTo(r - 1.5, r - 1.5); ctx.moveTo(r - 1.5, -r + 1.5); ctx.lineTo(-r + 1.5, r - 1.5); ctx.stroke();
    ctx.fillStyle = '#4d4030'; for (const x of [-r + 2, r - 2]) for (const y of [-r + 2, r - 2]) ctx.fillRect(x - .4, y - .4, .8, .8);
  } else {
    ctx.beginPath(); b.local.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)); ctx.closePath(); ctx.fillStyle = '#78888e'; ctx.fill();
    for (let i = 0; i < b.local.length; i++) {
      const a = b.local[i], q = b.local[(i + 1) % b.local.length]; ctx.beginPath(); ctx.moveTo(-b.size * .15, -b.size * .2); ctx.lineTo(a.x, a.y); ctx.lineTo(q.x, q.y); ctx.closePath();
      ctx.fillStyle = ['#83969c', '#64777e', '#596b73', '#778a91', '#a0b1b4', '#aebcbf', '#95a6aa'][i]; ctx.fill();
    }
    ctx.beginPath(); b.local.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)); ctx.closePath(); ctx.strokeStyle = '#c2cece'; ctx.lineWidth = .45; ctx.stroke();
  }
  if (sim.grab?.id === b.id) { ctx.strokeStyle = palette.primary; ctx.lineWidth = .7; ctx.strokeRect(-b.size - 2, -b.size - 2, b.size * 2 + 4, b.size * 2 + 4); }
  ctx.restore();
}
function drawEmitters(): void {
  for (const e of sim.emitters) {
    const radius = Math.max(5 * sim.units, 8 / viewport.scale);
    const angle = e.direction === 'up' ? -Math.PI / 2 : e.direction === 'down' ? Math.PI / 2 : e.direction === 'left' ? Math.PI : 0;
    ctx.save(); ctx.translate(e.x, e.y); ctx.rotate(angle);
    ctx.fillStyle = palette.bg; ctx.strokeStyle = materials.find(m => m.value === e.material)?.color || palette.primary;
    ctx.lineWidth = (e.id === selectedEmitter ? 2 : 1.3) / viewport.scale; ctx.globalAlpha = e.enabled ? 1 : .45;
    ctx.beginPath(); ctx.arc(0, 0, radius, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
    ctx.beginPath(); ctx.moveTo(-radius * .35, -radius * .45); ctx.lineTo(radius * .4, 0); ctx.lineTo(-radius * .35, radius * .45); ctx.stroke();
    ctx.restore();
  }
}
function render(): void {
  ctx.setTransform(viewport.dpr, 0, 0, viewport.dpr, 0, 0); ctx.fillStyle = palette.bg; ctx.fillRect(0, 0, viewport.width, viewport.height);
  ctx.save(); ctx.translate(viewport.x, viewport.y); ctx.scale(viewport.scale, viewport.scale);
  ctx.fillStyle = palette.fg; ctx.globalAlpha = .12;
  for (let y = 0; y <= sim.height; y += 10) for (let x = 0; x <= sim.width; x += 10) ctx.fillRect(x, y, .45, .45);
  ctx.globalAlpha = .16; ctx.strokeStyle = palette.fg; ctx.lineWidth = .4; ctx.strokeRect(0, 0, sim.width, sim.height); ctx.globalAlpha = 1;
  const data = image.data; let count = 0;
  for (let i = 0; i < sim.cells.length; i++) {
    const m = sim.cells[i], offset = i * 4;
    if (!m || sim.occupancy[i]) { data[offset + 3] = 0; continue; }
    count++;
    const color = colors[m], x = i % sim.width, y = Math.floor(i / sim.width);
    const noise = ((i * 113 + (i >>> 5) * 73) % 29) - 14;
    const variation = m === M.water ? noise * .25 : m === M.wall ? noise * .4 : noise;
    data[offset] = color[0] + variation; data[offset + 1] = color[1] + variation; data[offset + 2] = color[2] + variation;
    data[offset + 3] = GAS_IDS.includes(m) ? (m === M.smoke || m === M.steam ? Math.min(m === M.smoke ? 170 : 160, sim.life[i]) : 190) : 255;
    if (m === M.water && sim.get(x, y - 1) !== M.water) { data[offset] += 40; data[offset + 1] += 45; data[offset + 2] += 35; }
    if (m === M.fire) { data[offset + 1] = 70 + sim.life[i] * 1.2; data[offset + 2] = sim.life[i] / 2; }
    if (m === M.wall && sim.get(x, y - 1) !== M.wall) { data[offset] += 38; data[offset + 1] += 38; data[offset + 2] += 38; }
  }
  pixelCtx.putImageData(image, 0, 0); ctx.imageSmoothingEnabled = false; ctx.drawImage(pixels, 0, 0);
  for (const body of sim.bodies) drawBody(body);
  drawEmitters();
  if (sim.grab) {
    const b = sim.bodies.find(b => b.id === sim.grab!.id);
    if (b) { ctx.strokeStyle = palette.primary; ctx.lineWidth = .7; ctx.setLineDash([2, 2]); ctx.beginPath(); ctx.moveTo(b.x, b.y); ctx.lineTo(sim.grab.x, sim.grab.y); ctx.stroke(); ctx.setLineDash([]); }
  }
  if (hover && !pointer && !matchMedia('(pointer: coarse)').matches) {
    ctx.strokeStyle = materials.find(m => m.id === selected)?.color || palette.fg; ctx.globalAlpha = .7; ctx.lineWidth = 1 / viewport.scale;
    ctx.beginPath(); ctx.arc(hover.x, hover.y, ['box', 'stone'].includes(selected) ? brush * .65 + 3 * sim.units : selected === 'emitter' ? 7 * sim.units : selected === 'grab' ? 3 : brush / 2, 0, Math.PI * 2); ctx.stroke(); ctx.globalAlpha = 1;
  }
  ctx.restore();
  if (frame % 10 === 0 || paused) {
    el('particles').textContent = count.toLocaleString(); el('body-count').textContent = String(sim.bodies.length); el('fps').textContent = String(Math.round(fps));
    canvas.dataset.tick = String(sim.tick); canvas.dataset.particles = String(count); canvas.dataset.bodies = String(sim.bodies.length); canvas.dataset.emitters = String(sim.emitters.length); canvas.dataset.width = String(sim.width); canvas.dataset.height = String(sim.height);
  }
}
function applyTheme(theme: Theme): void {
  const root = document.documentElement.style;
  root.setProperty('--bg', theme.colors.background); root.setProperty('--fg', theme.colors.text); root.setProperty('--primary', theme.colors.primary);
  const hex = theme.colors.background.replace('#', '');
  const rgb = (hex.length === 3 ? hex.split('').map(c => c + c).join('') : hex).match(/.{2}/g)?.map(c => parseInt(c, 16)) ?? [20, 20, 20];
  palette = { bg: theme.colors.background, fg: theme.colors.text, primary: theme.colors.primary, light: rgb[0] * .299 + rgb[1] * .587 + rgb[2] * .114 > 145 };
  root.colorScheme = palette.light ? 'light' : 'dark';
}
if (runtimeHasDomain('theme')) {
  try { void themeGet().then(applyTheme).catch(() => undefined); themeSub = themeOnChanged(applyTheme); } catch { /* Keep the complete fallback palette. */ }
}
updateStorageButtons();
const observer = new ResizeObserver(() => { stopPointer(); fit(); render(); }); observer.observe(canvas);
const [initialWidth, initialHeight] = currentDimensions(); replace(makeScene('playground', initialWidth, initialHeight));
if (matchMedia('(prefers-reduced-motion: reduce)').matches) setPaused(true);
let raf = 0;
function animate(now: number): void {
  const elapsed = time ? Math.min(100, now - time) : 16.67; time = now;
  fps = fps * .95 + 1000 / Math.max(1, elapsed) * .05;
  if (!document.hidden) {
    if (pointer) applyBrush();
    if (!paused) {
      accumulator += elapsed; let steps = 0;
      while (accumulator >= 1000 / 60 && steps < 3) { sim.step(); accumulator -= 1000 / 60; steps++; }
      if (steps === 3) accumulator = 0;
    }
    frame++; render();
  }
  raf = requestAnimationFrame(animate);
}
raf = requestAnimationFrame(animate);
window.addEventListener('pagehide', () => { stopPointer(); themeSub?.close(); observer.disconnect(); cancelAnimationFrame(raf); window.clearTimeout(toastTimer); }, { once: true });