diff --git a/system/public/aesthetic.computer/disks/flocking.js b/system/public/aesthetic.computer/disks/flocking.js new file mode 100644 index 0000000000..3d639d991c --- /dev/null +++ b/system/public/aesthetic.computer/disks/flocking.js @@ -0,0 +1,109 @@ +// flocking — Reynolds-style boids, after Daniel Shiffman's classic port. +// Each boid steers by three forces: separation, alignment, cohesion. + +const N = 90; +const MAX_SPEED = 2.4; +const MAX_FORCE = 0.06; +const PERCEPTION = 38; +const SEP_RADIUS = 18; + +let boids = []; + +function setup() { + createCanvas(windowWidth, windowHeight); + for (let i = 0; i < N; i++) { + const a = random(TWO_PI); + boids.push({ + x: random(width), + y: random(height), + vx: cos(a) * MAX_SPEED * 0.6, + vy: sin(a) * MAX_SPEED * 0.6, + ax: 0, ay: 0, + h: random(360), + }); + } + colorMode(HSB, 360, 100, 100, 1); +} + +function limit(vx, vy, max) { + const m = sqrt(vx * vx + vy * vy); + if (m > max) return [vx / m * max, vy / m * max]; + return [vx, vy]; +} + +function draw() { + background(220, 25, 8, 0.18); + + for (const b of boids) { + let sepX = 0, sepY = 0, sepN = 0; + let aliX = 0, aliY = 0, aliN = 0; + let cohX = 0, cohY = 0, cohN = 0; + + for (const o of boids) { + if (o === b) continue; + const dx = b.x - o.x; + const dy = b.y - o.y; + const d = sqrt(dx * dx + dy * dy); + if (d > 0 && d < PERCEPTION) { + aliX += o.vx; aliY += o.vy; aliN++; + cohX += o.x; cohY += o.y; cohN++; + if (d < SEP_RADIUS) { + sepX += dx / d; sepY += dy / d; sepN++; + } + } + } + + let fx = 0, fy = 0; + if (sepN > 0) { + [sepX, sepY] = limit(sepX / sepN, sepY / sepN, MAX_SPEED); + fx += (sepX - b.vx) * 1.6; + fy += (sepY - b.vy) * 1.6; + } + if (aliN > 0) { + [aliX, aliY] = limit(aliX / aliN, aliY / aliN, MAX_SPEED); + fx += (aliX - b.vx) * 1.0; + fy += (aliY - b.vy) * 1.0; + } + if (cohN > 0) { + const cx = cohX / cohN - b.x; + const cy = cohY / cohN - b.y; + const [tx, ty] = limit(cx, cy, MAX_SPEED); + fx += (tx - b.vx) * 0.9; + fy += (ty - b.vy) * 0.9; + } + [b.ax, b.ay] = limit(fx, fy, MAX_FORCE); + + b.vx += b.ax; + b.vy += b.ay; + [b.vx, b.vy] = limit(b.vx, b.vy, MAX_SPEED); + b.x += b.vx; + b.y += b.vy; + + if (b.x < 0) b.x += width; + if (b.x > width) b.x -= width; + if (b.y < 0) b.y += height; + if (b.y > height) b.y -= height; + + noStroke(); + fill(b.h, 70, 90, 0.9); + const ang = atan2(b.vy, b.vx); + push(); + translate(b.x, b.y); + rotate(ang); + triangle(6, 0, -5, 3, -5, -3); + pop(); + } +} + +function mousePressed() { + // scatter + for (const b of boids) { + const a = random(TWO_PI); + b.vx = cos(a) * MAX_SPEED; + b.vy = sin(a) * MAX_SPEED; + } +} + +function windowResized() { + resizeCanvas(windowWidth, windowHeight); +} diff --git a/system/public/aesthetic.computer/disks/mandelbrot.js b/system/public/aesthetic.computer/disks/mandelbrot.js new file mode 100644 index 0000000000..dce595f27d --- /dev/null +++ b/system/public/aesthetic.computer/disks/mandelbrot.js @@ -0,0 +1,84 @@ +// mandelbrot — direct pixel-array fractal. Tests p5's loadPixels() +// round-trip and demonstrates pan/zoom via mouse. + +const MAX_ITER = 80; +let centerX = -0.5; +let centerY = 0; +let zoom = 1; +let needsRender = true; + +function setup() { + createCanvas(windowWidth, windowHeight); + pixelDensity(1); + noLoop(); // recompute only on input + renderFractal(); +} + +function renderFractal() { + loadPixels(); + const w = width, h = height; + const span = 3 / zoom; + const aspect = h / w; + const minX = centerX - span / 2; + const minY = centerY - (span * aspect) / 2; + const stepX = span / w; + const stepY = (span * aspect) / h; + + for (let py = 0; py < h; py++) { + const cy = minY + py * stepY; + for (let px = 0; px < w; px++) { + const cx = minX + px * stepX; + let zx = 0, zy = 0; + let it = 0; + while (it < MAX_ITER && zx * zx + zy * zy < 4) { + const t = zx * zx - zy * zy + cx; + zy = 2 * zx * zy + cy; + zx = t; + it++; + } + const idx = (py * w + px) * 4; + if (it === MAX_ITER) { + pixels[idx] = pixels[idx + 1] = pixels[idx + 2] = 0; + } else { + const t = it / MAX_ITER; + pixels[idx] = (9 * (1 - t) * t * t * t * 255) | 0; + pixels[idx + 1] = (15 * (1 - t) * (1 - t) * t * t * 255) | 0; + pixels[idx + 2] = (8.5 * (1 - t) * (1 - t) * (1 - t) * t * 255) | 0; + } + pixels[idx + 3] = 255; + } + } + updatePixels(); + needsRender = false; +} + +function draw() { + if (needsRender) renderFractal(); +} + +function mousePressed() { + // zoom in centered at click + const span = 3 / zoom; + const aspect = height / width; + centerX = centerX - span / 2 + (mouseX / width) * span; + centerY = centerY - (span * aspect) / 2 + (mouseY / height) * (span * aspect); + zoom *= 2.2; + needsRender = true; + redraw(); +} + +function keyPressed() { + if (key === "r" || key === "R") { + centerX = -0.5; + centerY = 0; + zoom = 1; + needsRender = true; + redraw(); + } +} + +function windowResized() { + resizeCanvas(windowWidth, windowHeight); + needsRender = true; + redraw(); +} diff --git a/system/public/aesthetic.computer/disks/process-18.js b/system/public/aesthetic.computer/disks/process-18.js new file mode 100644 index 0000000000..fd041b5504 --- /dev/null +++ b/system/public/aesthetic.computer/disks/process-18.js @@ -0,0 +1,70 @@ +// process-18 — homage to Casey Reas' Process 18. +// Translation from Reas' instructions: "A surface filled with N medium to +// small sized circles. Each circle has a different size and direction, but +// moves at the same slow rate. Display the instantaneous intersections +// between the circles and accumulate the aggregate trail." + +const N = 100; +const SPEED = 0.6; +let elements = []; +let bg = 12; + +function setup() { + createCanvas(windowWidth, windowHeight); + background(bg); + for (let i = 0; i < N; i++) { + const a = random(TWO_PI); + elements.push({ + x: random(width), + y: random(height), + r: random(8, 36), + vx: cos(a) * SPEED, + vy: sin(a) * SPEED, + }); + } +} + +function draw() { + // gentle decay leaves trails behind + noStroke(); + fill(bg, 18); + rect(0, 0, width, height); + + // move + for (const e of elements) { + e.x += e.vx; + e.y += e.vy; + if (e.x < -e.r) e.x += width + 2 * e.r; + if (e.x > width + e.r) e.x -= width + 2 * e.r; + if (e.y < -e.r) e.y += height + 2 * e.r; + if (e.y > height + e.r) e.y -= height + 2 * e.r; + } + + // draw intersections only — pairs whose distance is less than sum of radii + stroke(255, 200); + strokeWeight(0.6); + noFill(); + for (let i = 0; i < N; i++) { + const a = elements[i]; + for (let j = i + 1; j < N; j++) { + const b = elements[j]; + const dx = a.x - b.x; + const dy = a.y - b.y; + const d = sqrt(dx * dx + dy * dy); + if (d < a.r + b.r && d > abs(a.r - b.r)) { + // approximate intersection by drawing both arcs lightly + circle(a.x, a.y, a.r * 2); + circle(b.x, b.y, b.r * 2); + } + } + } +} + +function mousePressed() { + background(bg); +} + +function windowResized() { + resizeCanvas(windowWidth, windowHeight); + background(bg); +} diff --git a/system/public/aesthetic.computer/lib/p5-worker.mjs b/system/public/aesthetic.computer/lib/p5-worker.mjs index 2d99140921..45be19135d 100644 --- a/system/public/aesthetic.computer/lib/p5-worker.mjs +++ b/system/public/aesthetic.computer/lib/p5-worker.mjs @@ -252,6 +252,10 @@ function setViewport(w, h) { if (typeof self.window === "undefined") return; self.window.innerWidth = w; self.window.innerHeight = h; + self.window.windowWidth = w; + self.window.windowHeight = h; + self.window.displayWidth = w; + self.window.displayHeight = h; if (self.window.screen) { self.window.screen.width = w; self.window.screen.height = h; @@ -260,6 +264,34 @@ function setViewport(w, h) { } } +// p5 keyCode mappings — match the values p5 uses so `keyCode === UP_ARROW` +// etc. works without surprise. +const NAMED_KEYS = { + arrowup: 38, arrowdown: 40, arrowleft: 37, arrowright: 39, + enter: 13, escape: 27, tab: 9, backspace: 8, delete: 46, + shift: 16, control: 17, alt: 18, meta: 91, capslock: 20, + space: 32, " ": 32, + pageup: 33, pagedown: 34, home: 36, end: 35, + insert: 45, + f1: 112, f2: 113, f3: 114, f4: 115, f5: 116, f6: 117, + f7: 118, f8: 119, f9: 120, f10: 121, f11: 122, f12: 123, +}; + +function keyToKeyCode(key) { + if (!key) return 0; + const lower = String(key).toLowerCase(); + if (NAMED_KEYS[lower] !== undefined) return NAMED_KEYS[lower]; + if (key.length === 1) return key.toUpperCase().charCodeAt(0); + return 0; +} + +function normalizeKey(key) { + if (!key) return ""; + // p5's `key` is the printed char for single chars; leave named keys as-is + // (e.g. "Enter", "ArrowUp") since most sketches check key/keyCode by name. + return key; +} + // Clean up globals so a previously-loaded sketch can't leak into the next. const SKETCH_GLOBALS = [ "setup", "draw", "preload", "mousePressed", "mouseReleased", "mouseClicked", @@ -450,33 +482,68 @@ export async function makeP5WorkerModule({ slug, source }) { return true; }, - sim: ({ screen }) => { - // Reframe support: if AC screen resizes, update p5 viewport and ask the - // sketch to resize via its windowResized() handler. - if (screen.width !== lastWidth || screen.height !== lastHeight) { - lastWidth = screen.width; - lastHeight = screen.height; - setViewport(screen.width, screen.height); + // sim is intentionally empty — reframe is handled via the `reframed` + // event in act() (AC's canonical channel for screen-size changes). + + act: ({ event: e, jump, handle, notice }) => { + // Lazy-expose an `ac` global so sketches can navigate / read AC state. + if (!self.ac && jump) { + self.ac = { + jump: (path, opts) => jump(path, opts), + handle: () => (typeof handle === "function" ? handle() : null), + notice: (msg, color) => notice && notice(msg, color), + }; + } + + if (!p5Instance) return; + + // 📐 Reframe → resize p5 canvas + fire windowResized + if (e.is("reframed")) { + const w = e.width ?? e.content?.width ?? lastWidth; + const h = e.height ?? e.content?.height ?? lastHeight; + lastWidth = w; + lastHeight = h; + setViewport(w, h); + try { + p5Instance.resizeCanvas?.(w, h); + // Hard-reset the OffscreenCanvas as a fallback for any p5 path + // that doesn't propagate the resize to the underlying canvas. + if (canvasShim && (canvasShim.offscreen.width !== w || canvasShim.offscreen.height !== h)) { + canvasShim._oc.width = w; + canvasShim._oc.height = h; + } + } catch (err) { console.warn("[p5-worker] resizeCanvas:", err); } if (typeof self.windowResized === "function") { try { self.windowResized(); } catch (err) { console.warn("[p5-worker] windowResized:", err); } } + console.log(`[p5-worker] 📐 reframed → ${w}x${h}`); + return; } - }, - act: ({ event: e }) => { - if (!p5Instance) return; + // Scale AC screen coords → p5 canvas coords (rarely matters once + // resize is honored, but covers sketches that lock a custom canvas size). const updateMouse = (x, y) => { - self.window.pmouseX = self.window.mouseX ?? x; - self.window.pmouseY = self.window.mouseY ?? y; - self.window.mouseX = x; - self.window.mouseY = y; + const cw = canvasShim?.offscreen.width || lastWidth || 1; + const ch = canvasShim?.offscreen.height || lastHeight || 1; + const sx = cw / (lastWidth || cw); + const sy = ch / (lastHeight || ch); + const mx = x * sx; + const my = y * sy; + self.window.pmouseX = self.window.mouseX ?? mx; + self.window.pmouseY = self.window.mouseY ?? my; + self.window.mouseX = mx; + self.window.mouseY = my; + self.window.winMouseX = mx; + self.window.winMouseY = my; + self.window.pwinMouseX = self.window.winMouseX; + self.window.pwinMouseY = self.window.winMouseY; if (p5Instance) { p5Instance.pmouseX = p5Instance.mouseX; p5Instance.pmouseY = p5Instance.mouseY; - p5Instance.mouseX = x; - p5Instance.mouseY = y; - p5Instance.winMouseX = x; - p5Instance.winMouseY = y; + p5Instance.mouseX = mx; + p5Instance.mouseY = my; + p5Instance.winMouseX = mx; + p5Instance.winMouseY = my; } }; @@ -487,22 +554,46 @@ export async function makeP5WorkerModule({ slug, source }) { } else if (e.is("touch")) { mouseDown = true; self.window.mouseIsPressed = true; - if (p5Instance) p5Instance.mouseIsPressed = true; + // p5 LEFT=37 RIGHT=39 CENTER=3 — we only get plain "touch" from AC + self.window.mouseButton = self.LEFT || "left"; + if (p5Instance) { + p5Instance.mouseIsPressed = true; + p5Instance.mouseButton = p5Instance.LEFT; + } updateMouse(e.x, e.y); if (typeof self.mousePressed === "function") { try { self.mousePressed(); } catch (err) { console.warn(err); } } + if (typeof self.touchStarted === "function") { try { self.touchStarted(); } catch (err) { console.warn(err); } } } else if (e.is("lift")) { mouseDown = false; self.window.mouseIsPressed = false; if (p5Instance) p5Instance.mouseIsPressed = false; if (typeof self.mouseReleased === "function") { try { self.mouseReleased(); } catch (err) { console.warn(err); } } if (typeof self.mouseClicked === "function") { try { self.mouseClicked(); } catch (err) { console.warn(err); } } + if (typeof self.touchEnded === "function") { try { self.touchEnded(); } catch (err) { console.warn(err); } } + } else if (e.device === "wheel") { + // AC wheel events → p5 mouseWheel({delta: ...}) + if (typeof self.mouseWheel === "function") { + try { self.mouseWheel({ delta: e.y ?? e.deltaY ?? 0, deltaX: e.x ?? 0, deltaY: e.y ?? 0 }); } + catch (err) { console.warn(err); } + } } else if (e.is("keyboard:down")) { - self.window.key = e.key || ""; - self.window.keyCode = (e.key || "").toUpperCase().charCodeAt(0) || 0; + const k = e.key || ""; + self.window.key = normalizeKey(k); + self.window.keyCode = keyToKeyCode(k); self.window.keyIsPressed = true; + if (p5Instance) { + p5Instance.key = self.window.key; + p5Instance.keyCode = self.window.keyCode; + p5Instance.keyIsPressed = true; + } if (typeof self.keyPressed === "function") { try { self.keyPressed(); } catch (err) { console.warn(err); } } + // keyTyped fires only for "printable" character keys + if (k.length === 1 && typeof self.keyTyped === "function") { + try { self.keyTyped(); } catch (err) { console.warn(err); } + } } else if (e.is("keyboard:up")) { self.window.keyIsPressed = false; + if (p5Instance) p5Instance.keyIsPressed = false; if (typeof self.keyReleased === "function") { try { self.keyReleased(); } catch (err) { console.warn(err); } } } },