diff --git a/JoshHeaps.Net/Pages/Index.cshtml b/JoshHeaps.Net/Pages/Index.cshtml index 3f329cb..80d4533 100644 --- a/JoshHeaps.Net/Pages/Index.cshtml +++ b/JoshHeaps.Net/Pages/Index.cshtml @@ -49,6 +49,9 @@ Play Chess + + Life + More coming soon... diff --git a/JoshHeaps.Net/Pages/Particles.cshtml b/JoshHeaps.Net/Pages/Particles.cshtml new file mode 100644 index 0000000..857aa22 --- /dev/null +++ b/JoshHeaps.Net/Pages/Particles.cshtml @@ -0,0 +1,110 @@ +@page +@model JoshHeaps.Net.Pages.ParticlesModel +@{ + Layout = "_Layout"; + ViewData["Title"] = "Particle Simulator"; +} + + + + ☰ + + Seed + + 🎲 + + + Particles + + + + Speed + + @Model.Speed + + + Restart + Permalink + + + + Edit interaction rules + + + Active color + + Red (R) + Green (G) + Blue (B) + Yellow (Y) + Cyan (C) + Magenta (M) + + + + + + to Red (R) + + + + + to Green (G) + + + + + to Blue (B) + + + + + to Cyan (C) + + + + + to Yellow (Y) + + + + + to Magenta (M) + + + + + + + Copy Rules + Import Rules + + + + + Apply + Cancel + + + + + + + + + + +@section Styles { + +} + +@section Scripts { + +} \ No newline at end of file diff --git a/JoshHeaps.Net/Pages/Particles.cshtml.cs b/JoshHeaps.Net/Pages/Particles.cshtml.cs new file mode 100644 index 0000000..201c4e9 --- /dev/null +++ b/JoshHeaps.Net/Pages/Particles.cshtml.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace JoshHeaps.Net.Pages; + +public class ParticlesModel : PageModel +{ + public uint Seed { get; private set; } = 123456789u; + public int N { get; private set; } = 600; + public float Speed { get; private set; } = 1.0f; + + // GET /Particles?seed=123&n=800&speed=1.5 + public void OnGet(uint? seed, int? n, float? speed) + { + Seed = seed ?? Seed; + N = Math.Clamp(n ?? N, 50, 5000); + Speed = Math.Clamp(speed ?? Speed, 0.1f, 5f); + } +} diff --git a/JoshHeaps.Net/wwwroot/css/particles/page.css b/JoshHeaps.Net/wwwroot/css/particles/page.css new file mode 100644 index 0000000..5277a2c --- /dev/null +++ b/JoshHeaps.Net/wwwroot/css/particles/page.css @@ -0,0 +1,400 @@ +/* ------- page background + overlays ------- */ +html, body { + height: 100%; + margin: 0; +} + +body { + background: #000; + color: #e6e6e6; + font-family: ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif; + overflow: hidden; +} + +/* canvas as full-screen background */ +.sim { + position: fixed; + inset: 0; /* top:0 right:0 bottom:0 left:0 */ + width: 100vw; + height: 100vh; + display: block; + z-index: 0; +} + +/* floating toolbar */ +.toolbar { + position: fixed; + top: 12px; + left: 12px; + right: 12px; + z-index: 20; + display: flex; + gap: 1rem; + align-items: center; + padding: .6rem .75rem; + background: rgba(15,15,15,.65); + border: 1px solid #2a2a2a; + border-radius: 10px; + backdrop-filter: blur(6px); +} + +/* collapse/expand toolbar */ +.toolbar .spacer { + flex: 1 1 auto; +} + +#toggleToolbar { + padding: .35rem .5rem; + border-radius: .4rem; +} + +.toolbar.collapsed { + padding: .35rem .5rem; + gap: .5rem; +} + +.toolbar.collapsed .group, +.toolbar.collapsed #restart, +.toolbar.collapsed #permalink, +.toolbar.collapsed .spacer { + display: none; +} + +/* floating rules panel (details) */ +.rulepanel { + position: fixed; + left: 12px; + right: 12px; + bottom: 12px; + z-index: 20; + background: rgba(11,11,11,.65); + border: 1px solid #1a1a1a; + border-radius: 10px; + backdrop-filter: blur(6px); + overflow: hidden; /* collapsed state shows only summary */ + max-height: 42vh; /* when open, content scrolls inside */ +} + +.rulepanel[open] { + overflow: auto; +} + +.rulepanel > summary { + cursor: pointer; + font-weight: 600; + list-style: none; + user-select: none; + padding: .6rem .75rem; + margin: 0; + position: sticky; + top: 0; + background: transparent; + display: flex; + align-items: center; + gap: .5rem; + border-bottom: 1px solid #1a1a1a; +} + +.rulepanel > summary::-webkit-details-marker { + display: none; +} + +.rulepanel > summary::after { + content: "▾"; + margin-left: auto; + opacity: .8; +} + +.rulepanel:not([open]) > summary::after { + content: "▸"; +} + +.rules-body { + padding: .75rem 1rem 1rem; + display: grid; + gap: .75rem; +} + +.rule-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px,1fr)); + gap: .5rem 1rem; +} + +.rule-item { + align-items: center; + gap: .5rem; +} + +/* buttons + inputs (keep your existing styles, just a few tweaks) */ +button { + background: #1f1f1f; + border: 1px solid #2a2a2a; + color: #e6e6e6; + padding: .4rem .7rem; + border-radius: .5rem; + cursor: pointer; +} + +button:hover { + background: #2a2a2a; +} + +/* range styling (from your sheet; leave as-is) */ +:root { + --slider-track: #444; + --slider-fill: #4cafef; + --slider-thumb: #ff5722; +} + +input[type="range"] { + -webkit-appearance: none; + appearance: none; + background: transparent; + width: 100%; +} + +input[type="range"]::-webkit-slider-runnable-track { + height: 6px; + background: var(--slider-track); + border-radius: 3px; +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + border: 2px solid #222; + margin-top: -6px; + cursor: pointer; + background: var(--slider-thumb); +} + +input[type="range"]::-moz-range-track { + height: 6px; + background: var(--slider-track); + border-radius: 3px; +} + +input[type="range"]::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + border: 2px solid #222; + cursor: pointer; + background: var(--thumb-color, var(--slider-thumb)); +} + +input[type="range"]::-moz-range-progress { + background: var(--slider-fill); + height: 6px; + border-radius: 3px; +} + +/* container no longer controls layout; keep for semantics */ +.container { + position: relative; + height: 100vh; +} + +/* Canvas already full screen; nothing to change there */ + +/* --- Toolbar square when collapsed --- */ +.toolbar { + position: fixed; + top: 12px; + left: 12px; + right: 12px; + z-index: 20; + background: rgba(15,15,15,.65); + border: 1px solid #2a2a2a; + border-radius: 10px; + backdrop-filter: blur(6px); +} + +.toolbar.collapsed { + left: 12px; + right: auto; /* stop spanning full width */ + width: 44px; + height: 44px; + padding: 0; + gap: 0; + display: grid; + place-items: center; +} + +.toolbar.collapsed .group, +.toolbar.collapsed #restart, +.toolbar.collapsed #permalink, +.toolbar.collapsed .spacer { + display: none; +} + +.toolbar.collapsed #toggleToolbar { + width: 100%; + height: 100%; + padding: 0; + border-radius: 8px; + font-size: 0; /* hide the word "Toolbar" */ +} + +.toolbar.collapsed #toggleToolbar::before { + content: "☰"; /* icon only */ + font-size: 18px; +} + +/* --- Rules panel as a floating card; tiny square when closed --- */ +.rulepanel { + position: fixed; + left: 12px; + right: 12px; + bottom: 12px; + z-index: 20; + background: rgba(11,11,11,.65); + border: 1px solid #1a1a1a; + border-radius: 10px; + backdrop-filter: blur(6px); + max-height: 42vh; + overflow: auto; +} + +.rulepanel[open] { + max-height: calc(100dvh - 24px - 60px); + overflow: auto; + scrollbar-gutter: stable both-edges; /* avoids layout jump */ + overscroll-behavior: contain; +} + +.rulepanel[open] { + scrollbar-width: none; +} +/* Firefox */ +.rulepanel[open]::-webkit-scrollbar { + width: 0; + height: 0; +} +/* WebKit */ + +.rulepanel > summary { + position: sticky; + top: 0; + padding: .6rem .75rem; + margin: 0; + cursor: pointer; + font-weight: 600; + border-bottom: 1px solid #1a1a1a; + display: flex; + align-items: center; + gap: .5rem; +} + +.rulepanel > summary::-webkit-details-marker { + display: none; +} + +.rulepanel[open] > summary::after { + content: "▾"; + margin-left: auto; + opacity: .8; +} + +/* closed => tiny square pinned bottom-right */ +.rulepanel:not([open]) { + left: 12px; + right: auto; /* stop spanning full width */ + width: 44px; + height: 44px; + padding: 0; + gap: 0; + display: grid; + place-items: center; +} + +.rulepanel:not([open]) > summary { + border: 0; + padding: 0; + margin: 0; + width: 100%; + height: 100%; + display: grid; + place-items: center; + font-size: 0; /* hide label text */ +} + +.rulepanel:not([open]) > summary::after { + content: ""; +} +/* no caret */ +.rulepanel:not([open]) > summary::before { + content: "Rules"; /* icon only */ + font-size: 18px; +} + +/* Keep sliders/layout rules you already have */ + +@media (max-width: 640px) { + /* Default to FAB */ + .toolbar { + top: 12px; + left: 12px; + width: 44px; + height: 44px; + padding: 0; + gap: 0; + display: grid; + place-items: center; + } + + .toolbar .group, .toolbar #restart, .toolbar #permalink, .toolbar .spacer { + display: none; + } + + /* FAB button icon */ + #toggleToolbar { + width: 100%; + height: 44px; + padding: 0; + border-radius: 8px; + font-size: 18px; + } + + #toggleToolbar::before { + width: 44px; + font-size: 18px; + } + + /* Expanded drawer */ + .toolbar.mobile.open { + left: 12px; + width: min(85vw, 420px); + height: 70vh; /* scroll within drawer if needed */ + padding: .6rem .75rem; + display: flex; + flex-direction: column; + gap: .65rem; + overflow: auto; + } + + .toolbar.mobile.open .group, + .toolbar.mobile.open #restart, + .toolbar.mobile.open #permalink { + display: inline-flex; + } + + .toolbar.mobile.open .group { + flex-wrap: wrap; + gap: .5rem; + } + + .toolbar.mobile.open input[type="number"], + .toolbar.mobile.open input[type="range"] { + width: 100%; + min-width: 0; + } + + .toolbar.mobile.open label { + white-space: nowrap; + } +} \ No newline at end of file diff --git a/JoshHeaps.Net/wwwroot/js/particles/page.bundle.js b/JoshHeaps.Net/wwwroot/js/particles/page.bundle.js new file mode 100644 index 0000000..17bae5d --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/particles/page.bundle.js @@ -0,0 +1,495 @@ +(function () { + "use strict"; + + // ===== Utilities ===== + function $(id) { return document.getElementById(id); } + + // Deterministic PRNG (mulberry32) + function mulberry32(seed) { + let t = (seed >>> 0) || 1; + return function () { + t += 0x6D2B79F5; + let r = Math.imul(t ^ (t >>> 15), 1 | t); + r ^= r + Math.imul(r ^ (r >>> 7), 61 | r); + return ((r ^ (r >>> 14)) >>> 0) / 4294967296; + }; + } + + // Colors + palette (R, G, B, Y, C, M) + const COLOR_NAMES = ["R", "G", "B", "Y", "C", "M"]; + const PALETTE = [ + "#ff0000", // R + "#00ff00", // G + "#0066ff", // B + "#ffff00", // Y + "#00ffff", // C + "#ff00ff" // M + ]; + const COLOR_LABELS = ["Red", "Green", "Blue", "Yellow", "Cyan", "Magenta"]; + + // Build a 6x6 interaction matrix in [-1, +1] + function buildRules(rand) { + const n = COLOR_NAMES.length; + const rules = Array.from({ length: n }, () => Array(n).fill(0)); + for (let i = 0; i < n; i++) { + for (let j = 0; j < n; j++) { + if (i === j) { + // self interaction: mild cohesion/dispersion + rules[i][j] = (rand() * 2 - 1) * 0.6; // [-0.6, 0.6] + } else { + // cross interaction: wider range + rules[i][j] = (rand() * 2 - 1); // [-1, 1] + } + } + } + // stability pass: cap per-row energy + for (let i = 0; i < n; i++) { + const s = rules[i].reduce((a, b) => a + Math.abs(b), 0); + if (s > 3) { + const k = 3 / s; + for (let j = 0; j < n; j++) rules[i][j] *= k; + } + } + return rules; + } + + // ===== Canvas helpers ===== + function prepareContext(canvas) { + const ctx = canvas.getContext("2d", { alpha: false }); + resizeToDisplay(canvas, ctx); + return ctx; + } + + function resizeToDisplay(canvas, ctx) { + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + const w = Math.max(1, Math.floor(rect.width * dpr)); + const h = Math.max(1, Math.floor(rect.height * dpr)); + if (canvas.width !== w || canvas.height !== h) { + canvas.width = w; canvas.height = h; + } + // Scale so drawing uses CSS pixels + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + } + + // ===== Physics constants ===== + const SOFTEN = 25; // px^2 in denominator + const MAX_ACC = 1.5; // acceleration clamp (px/s^2) + const DAMP = 0.985; // velocity damping per step + const RANGE = 240; // interaction cutoff (px); 0 to disable + const RANGE2 = RANGE * RANGE; + const RADIUS = 2; // render radius + const RULE_MIN = -1, RULE_MAX = 1; + + // Collisions + const COLLISION_RADIUS = RADIUS; // collide when centers < 2*R + const COLLISION_DIAM = COLLISION_RADIUS * 2; + const COLLISION_ITER = 1; // solver iterations per substep (raise to 2 for tighter packing) + const REST = 0.2; // coefficient of restitution [0..1] + const FRICTION = 0.02; // tangential friction + + // ===== Typed arrays (SoA) ===== + let px, py, vx, vy, col; // Float32Array, Float32Array, Float32Array, Float32Array, Uint8Array + let N = 0; + + function createParticlesTyped(rand, count, width, height) { + px = new Float32Array(count); + py = new Float32Array(count); + vx = new Float32Array(count); + vy = new Float32Array(count); + col = new Uint8Array(count); + for (let i = 0; i < count; i++) { + px[i] = rand() * width; + py[i] = rand() * height; + vx[i] = (rand() - 0.5) * 0.1; + vy[i] = (rand() - 0.5) * 0.1; + col[i] = i % COLOR_NAMES.length; // round-robin for even distribution + } + N = count; + } + + // ===== Spatial grid (uniform grid / spatial hash) ===== + const CELL_SIZE = Math.max(RANGE, COLLISION_DIAM * 2); // must cover interaction/collision radius + let GRID_W = 0, GRID_H = 0, GRID = []; // array of arrays of indices + + function rebuildGrid(W, H) { + GRID_W = Math.ceil(W / CELL_SIZE) | 0; + GRID_H = Math.ceil(H / CELL_SIZE) | 0; + const cells = GRID_W * GRID_H; + + if (GRID.length !== cells) GRID = Array.from({ length: cells }, () => []); + else for (let i = 0; i < cells; i++) GRID[i].length = 0; + + for (let i = 0; i < N; i++) { + let cx = (Math.floor(px[i] / CELL_SIZE) % GRID_W + GRID_W) % GRID_W; + let cy = (Math.floor(py[i] / CELL_SIZE) % GRID_H + GRID_H) % GRID_H; + GRID[cy * GRID_W + cx].push(i); + } + } + + function forEachNeighborIndices(cx, cy, fn) { + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const nx = (cx + dx + GRID_W) % GRID_W; + const ny = (cy + dy + GRID_H) % GRID_H; + const cell = GRID[ny * GRID_W + nx]; + for (let k = 0; k < cell.length; k++) fn(cell[k]); + } + } + } + + // ===== Physics step (typed arrays + grid) ===== + function step(dt, speed, W, H, rules) { + const halfW = W * 0.5, halfH = H * 0.5; + + for (let i = 0; i < N; i++) { + let ax = 0, ay = 0; + + const cx = (Math.floor(px[i] / CELL_SIZE) % GRID_W + GRID_W) % GRID_W; + const cy = (Math.floor(py[i] / CELL_SIZE) % GRID_H + GRID_H) % GRID_H; + + forEachNeighborIndices(cx, cy, (j) => { + if (j === i) return; + + let dx = px[j] - px[i]; + let dy = py[j] - py[i]; + + // toroidal shortest vector + if (dx > halfW) dx -= W; else if (dx < -halfW) dx += W; + if (dy > halfH) dy -= H; else if (dy < -halfH) dy += H; + + const d2 = dx * dx + dy * dy; + if (RANGE && d2 > RANGE2) return; + + const k = rules[col[i]][col[j]]; + const inv = 1 / (d2 + SOFTEN); + ax += k * dx * inv; + ay += k * dy * inv; + }); + + // clamp acceleration + const a2 = ax * ax + ay * ay; + if (a2 > MAX_ACC * MAX_ACC) { + const s = MAX_ACC / Math.sqrt(a2); + ax *= s; ay *= s; + } + + // integrate + vx[i] = (vx[i] + ax * dt * speed) * DAMP; + vy[i] = (vy[i] + ay * dt * speed) * DAMP; + px[i] += vx[i] * dt * speed; + py[i] += vy[i] * dt * speed; + + // wrap + if (px[i] < 0) px[i] += W; else if (px[i] >= W) px[i] -= W; + if (py[i] < 0) py[i] += H; else if (py[i] >= H) py[i] -= H; + } + } + + // ===== Collision resolver (typed arrays + grid) ===== + function resolveCollisions(W, H) { + const halfW = W * 0.5, halfH = H * 0.5; + const minDist = COLLISION_DIAM; + const minDist2 = minDist * minDist; + + for (let i = 0; i < N; i++) { + const cx = (Math.floor(px[i] / CELL_SIZE) % GRID_W + GRID_W) % GRID_W; + const cy = (Math.floor(py[i] / CELL_SIZE) % GRID_H + GRID_H) % GRID_H; + + forEachNeighborIndices(cx, cy, (j) => { + if (j <= i) return; + + let dx = px[j] - px[i]; + let dy = py[j] - py[i]; + if (dx > halfW) dx -= W; else if (dx < -halfW) dx += W; + if (dy > halfH) dy -= H; else if (dy < -halfH) dy += H; + + const d2 = dx * dx + dy * dy; + if (d2 >= minDist2 || d2 === 0) return; + + const dist = Math.sqrt(d2); + const nx = dx / dist, ny = dy / dist; + const overlap = (minDist - dist) * 0.5; + + // separate + px[i] -= nx * overlap; py[i] -= ny * overlap; + px[j] += nx * overlap; py[j] += ny * overlap; + + // wrap after correction + if (px[i] < 0) px[i] += W; else if (px[i] >= W) px[i] -= W; + if (py[i] < 0) py[i] += H; else if (py[i] >= H) py[i] -= H; + if (px[j] < 0) px[j] += W; else if (px[j] >= W) px[j] -= W; + if (py[j] < 0) py[j] += H; else if (py[j] >= H) py[j] -= H; + + // velocity impulse + const rvx = vx[j] - vx[i]; + const rvy = vy[j] - vy[i]; + const vrel = rvx * nx + rvy * ny; + if (vrel < 0) { + const jimp = -(1 + REST) * vrel * 0.5; // equal mass + const jx = jimp * nx, jy = jimp * ny; + vx[i] -= jx; vy[i] -= jy; + vx[j] += jx; vy[j] += jy; + + // tangential friction + const tvx = rvx - vrel * nx; + const tvy = rvy - vrel * ny; + const tmag = Math.hypot(tvx, tvy); + if (tmag > 1e-8) { + const tx = tvx / tmag, ty = tvy / tmag; + const fj = FRICTION * tmag * 0.5; + vx[i] += tx * fj; vy[i] += ty * fj; + vx[j] -= tx * fj; vy[j] -= ty * fj; + } + } + }); + } + } + + // ===== Render (typed arrays) ===== + function render(ctx, W, H) { + // fade to black + ctx.globalAlpha = 0.1; // lower alpha = longer trails + ctx.fillStyle = "#000"; + ctx.fillRect(0, 0, W, H); + ctx.globalAlpha = 1; + + for (let i = 0; i < N; i++) { + ctx.fillStyle = PALETTE[col[i]]; + ctx.beginPath(); + ctx.arc(px[i], py[i], RADIUS, 0, Math.PI * 2); + ctx.fill(); + } + } + + // ===== Main bootstrap ===== + function main() { + const boot = window.__PARTICLE_BOOT__ || { seed: 123, n: 600, speed: 1 }; + + const canvas = $("sim"); + const ctx = prepareContext(canvas); + + const seedInput = $("seed"); + const countInput = $("count"); + const speedRange = $("speed"); + const speedOut = $("speedOut"); + + const restartBtn = $("restart"); + const permalinkBtn = $("permalink"); + const randomSeedBtn = $("randomSeed"); + + // === Rule editor UI === + const activeColorSel = $("ruleActiveColor"); + const sliderIds = ["R", "G", "B", "Y", "C", "M"]; // targets + const sliders = sliderIds.map(id => ({ + input: $("rule_" + id), + out: $("rule_" + id + "_out") + })); + const copyRulesBtn = $("copyRules"); + const toggleImportBtn = $("toggleImport"); + const importArea = $("importArea"); + const rulesJson = $("rulesJson"); + const applyRulesBtn = $("applyRules"); + const cancelImportBtn = $("cancelImport"); + + const toolbarEl = document.querySelector(".toolbar"); + const toggleToolbarBtn = document.getElementById("toggleToolbar"); + + const mq = window.matchMedia("(max-width: 640px)"); + function applyToolbarMode() { + if (mq.matches) { + toolbarEl.classList.add("mobile"); + // In mobile, default closed as FAB + toolbarEl.classList.remove("open", "collapsed"); + } else { + toolbarEl.classList.remove("mobile", "open"); + // Desktop keeps your collapsed/expanded behavior + } + } + mq.addEventListener("change", applyToolbarMode); + applyToolbarMode(); + + toggleToolbarBtn.addEventListener("click", () => { + if (toolbarEl.classList.contains("mobile")) { + toolbarEl.classList.toggle("open"); // mobile: toggle drawer + } else { + toolbarEl.classList.toggle("collapsed"); // desktop: toggle bar + } + }); + + + // Reflect initial values + seedInput.value = String(boot.seed >>> 0); + countInput.value = String(boot.n); + speedRange.value = String(boot.speed); + speedOut.textContent = speedRange.value; + + // State + let rand, rules; + let W = canvas.clientWidth; + let H = canvas.clientHeight; + + // Fixed-step accumulator + const DT = 1 / 60; // seconds + let acc = 0; + let last = performance.now(); + let running = true; + + function init(seed, n) { + rand = mulberry32(seed >>> 0); + rules = buildRules(rand); + W = canvas.clientWidth; + H = canvas.clientHeight; + createParticlesTyped(rand, n, W, H); + // sync UI with current active row + syncRuleUI(); + } + + function frame(now) { + if (!running) return; + // keep canvas sized & transformed + resizeToDisplay(canvas, ctx); + W = canvas.clientWidth; + H = canvas.clientHeight; + + const speed = +speedRange.value || 1; + + acc += Math.min(0.1, (now - last) / 1000); + last = now; + + while (acc >= DT) { + rebuildGrid(W, H); // build grid for forces + step(DT, speed, W, H, rules); + rebuildGrid(W, H); // positions changed; rebuild for collisions + for (let it = 0; it < COLLISION_ITER; it++) resolveCollisions(W, H); + acc -= DT; + } + + render(ctx, W, H); + requestAnimationFrame(frame); + } + + // UI + speedRange.addEventListener("input", () => { + speedOut.textContent = speedRange.value; + }); + + function fmt(v) { return (v >= 0 ? "+" : "") + v.toFixed(2); } + + function syncRuleUI() { + if (!rules) return; + const src = parseInt(activeColorSel.value, 10) || 0; + for (let t = 0; t < sliders.length; t++) { + const val = Math.max(RULE_MIN, Math.min(RULE_MAX, rules[src][t] ?? 0)); + sliders[t].input.value = String(val); + sliders[t].out.textContent = fmt(val); + } + } + + // Slider change -> update rules matrix live + sliders.forEach((s, tIdx) => { + const color = PALETTE[tIdx]; + s.input.style.setProperty("--slider-thumb", color); + + s.input.addEventListener("input", () => { + const src = parseInt(activeColorSel.value, 10) || 0; + const v = Math.max(RULE_MIN, Math.min(RULE_MAX, parseFloat(s.input.value))); + rules[src][tIdx] = v; + s.out.textContent = fmt(v); + }); + }); + + activeColorSel.addEventListener("change", syncRuleUI); + + copyRulesBtn.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(JSON.stringify(rules)); + copyRulesBtn.textContent = "Copied!"; + setTimeout(() => copyRulesBtn.textContent = "Copy Rules", 800); + } catch { /* ignore */ } + }); + + toggleImportBtn.addEventListener("click", () => { + importArea.hidden = !importArea.hidden; + }); + + cancelImportBtn.addEventListener("click", () => { + importArea.hidden = true; + rulesJson.value = ""; + }); + + applyRulesBtn.addEventListener("click", () => { + try { + const obj = JSON.parse(rulesJson.value); + if (!Array.isArray(obj) || obj.length !== 6 || !obj.every(row => Array.isArray(row) && row.length === 6)) throw new Error("Shape 6x6 required"); + // clamp values and assign + for (let i = 0; i < 6; i++) for (let j = 0; j < 6; j++) { + const v = +obj[i][j]; + if (!Number.isFinite(v)) throw new Error("Non-numeric rule"); + obj[i][j] = Math.max(RULE_MIN, Math.min(RULE_MAX, v)); + } + rules = obj; + syncRuleUI(); + importArea.hidden = true; + } catch (e) { + alert("Invalid rules JSON: " + e.message); + } + }); + + // end rules UI + + restartBtn.addEventListener("click", () => { + const seed = (seedInput.value === "") ? (boot.seed >>> 0) : (parseInt(seedInput.value, 10) >>> 0); + const n = Math.max(50, Math.min(5000, parseInt(countInput.value, 10) || boot.n)); + init(seed, n); + }); + + // ensure rule UI matches after manual seed change without clicking restart + seedInput.addEventListener("change", () => { /* no-op until restart */ }); + + permalinkBtn.addEventListener("click", async () => { + const url = new URL(location.href); + url.searchParams.set("seed", String(seedInput.value || boot.seed)); + url.searchParams.set("n", String(countInput.value || boot.n)); + url.searchParams.set("speed", String(speedRange.value || boot.speed)); + history.replaceState({}, "", url); + try { + await navigator.clipboard.writeText(url.toString()); + permalinkBtn.textContent = "Copied!"; + setTimeout(() => permalinkBtn.textContent = "Permalink", 800); + } catch { /* ignore */ } + }); + + randomSeedBtn.addEventListener("click", () => { + // cryptographically strong uint32 + const buf = new Uint32Array(1); + (window.crypto || window.msCrypto).getRandomValues(buf); + seedInput.value = String(buf[0] >>> 0); + }); + + // Pause on tab hide (saves battery) + document.addEventListener("visibilitychange", () => { + running = document.visibilityState !== "hidden"; + if (running) { + last = performance.now(); + requestAnimationFrame(frame); + } + }); + + // Initial start + init(boot.seed >>> 0, boot.n); + requestAnimationFrame(frame); + + // Handle window resizes + window.addEventListener("resize", () => { + resizeToDisplay(canvas, ctx); + }); + } + + // Start when DOM is ready + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", main, { once: true }); + } else { + main(); + } +})(); \ No newline at end of file