From b0ebf778b99ff729f29bd8927864abf9e0e76429 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 20:30:35 +0000 Subject: [PATCH 01/17] =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=E2=80=94=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC=2022=ED=8C=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ABfNAXnhF5FB3o17FyZ3oS From 276c996e6c2da3af070a0a71ba68072efe1c49bc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 20:39:17 +0000 Subject: [PATCH 02/17] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2022=ED=8C=90=20=E2=80=94=20=EB=AA=B8=EA=B3=BC=20=EB=B9=84?= =?UTF-8?q?=ED=96=89=20=EA=B0=88=EB=9E=98=EB=A5=BC=20=EC=97=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ABfNAXnhF5FB3o17FyZ3oS --- incoming/pattern-study/families.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/incoming/pattern-study/families.json b/incoming/pattern-study/families.json index a80b1a256..a1530ebed 100644 --- a/incoming/pattern-study/families.json +++ b/incoming/pattern-study/families.json @@ -38,5 +38,7 @@ "bird": { "label": "새", "color": "#778800" }, "chemistry": { "label": "화학·실험", "color": "#00bb00" }, "paper": { "label": "종이", "color": "#996600" }, - "sport": { "label": "운동·경기", "color": "#337777" } + "sport": { "label": "운동·경기", "color": "#337777" }, + "body": { "label": "몸", "color": "#bb5566" }, + "flight": { "label": "비행", "color": "#9944ee" } } From 13d0d6ee543d1fc2154eec94c3d52075a5943b19 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 20:55:10 +0000 Subject: [PATCH 03/17] pattern: Flipdot Co-Authored-By: Claude Opus 5.5 --- incoming/pattern-study/patterns/flipdot.js | 333 +++++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 incoming/pattern-study/patterns/flipdot.js diff --git a/incoming/pattern-study/patterns/flipdot.js b/incoming/pattern-study/patterns/flipdot.js new file mode 100644 index 000000000..cbb49d2d8 --- /dev/null +++ b/incoming/pattern-study/patterns/flipdot.js @@ -0,0 +1,333 @@ +// ===== Patternflow pattern ===== +// Title: Flipdot +// Family: type +// Idea: A bus destination sign of fluorescent flip discs stands on end, the route number in its own box above the destination, and between pages a wave of discs flips the whole board yellow and back, each disc showing a thin edge as it turns. +// Author: patternflow-lab +// Date: 2026-10-23 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 22, after the electromagnetic flip-disc destination signs on buses and trams from the 1970s on +// Round: 22 +// =============================== +// @matrix 64x128 +// @knobs Disc=0..1, Speed=0.2..3, Route=0..3, Wipe=0..3 +// +// Knob 1: Disc. The colour painted on the bright side of the discs: fluorescent yellow at 0, then lime green, white, amber and red at 1. +// Knob 2: Speed. How fast the sign runs its pages and its waves: at 2 the destination holds three seconds, the stops a little over two, and a wave crosses the board in under a second. +// Knob 3: Route. Which bus the sign is set for, each with its own number, destination and stops: the 17 to Pier Head, the 7 to Dock Yard, the N9 night bus and the X3 express to Sea Wall. +// Knob 4: Wipe. How the controller drives the discs between pages: column by column at 0, a diagonal at 1, a scatter of single discs at 2, row by row at 3. +// Buttons: K1 tap ends the page now and the next wave starts. + +const W = 64; +const H = 128; +const N = W * H; +const { min, max, floor, round } = Math; +const rnd = () => Math.random(); +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); + +// The board: 20 discs across and 40 down on a 3 LED pitch, each disc 2 by 2 +// LEDs with one dark LED between. The top box holds 16 rows for the route +// number; a housing bar; then 24 rows for the destination. +const COLS = 20; +const R1 = 16; +const R2 = 24; +const ROWS = R1 + R2; +const ND = COLS * ROWS; +const discX = (c) => 3 + 3 * c; +const discY = (r) => (r < R1 ? 2 + 3 * r : 55 + 3 * (r - R1)); + +// One disc's turn, in sign seconds. The first quarter shows the old face, the +// second a thin edge of it on the top row, the third a thin edge of the new +// face on the bottom row, the last the new face. +const FD = 0.14; +const FIRST = 2.15; +const HOLD = 3.0; +const HOLD_INV = 2.2; +const WAVE = [0.8, 1.05, 0.9, 1.0]; + +// A condensed 7 row dot face. Each glyph is its rows left to right, top to +// bottom; the width is the length over 7. Most letters are 4 discs wide, the +// I and the 1 are 3, M T V W X Y are 5. +const FONT = { + A: ".##.#..##..######..##..##..#", B: "###.#..##..####.#..##..####.", C: ".##.#..##...#...#...#..#.##.", + D: "###.#..##..##..##..##..####.", E: "#####...#...###.#...#...####", F: "#####...#...###.#...#...#...", + G: ".##.#..##...#.###..##..#.###", H: "#..##..##..######..##..##..#", I: "###.#..#..#..#..#.###", + J: "...#...#...#...##..##..#.##.", K: "#..##..##.#.##..#.#.#..##..#", L: "#...#...#...#...#...#...####", + M: "#...###.###.#.##.#.##...##...##...#", N: "#..###.###.##.###.###..##..#", O: ".##.#..##..##..##..##..#.##.", + P: "###.#..##..####.#...#...#...", R: "###.#..##..####.#.#.#..##..#", S: ".##.#..##....##....##..#.##.", + T: "#####..#....#....#....#....#....#..", U: "#..##..##..##..##..##..#.##.", V: "#...##...##...#.#.#..#.#..#.#...#..", + W: "#...##...##...##.#.##.#.###.###...#", X: "#...##...#.#.#...#...#.#.#...##...#", Y: "#...##...#.#.#...#....#....#....#..", + Z: "####...#..#...#..#..#...####", + 0: ".##.#..##.###..###.##..#.##.", 1: ".#.##..#..#..#..#.###", 2: ".##.#..#...#..#..#..#...####", + 3: ".##.#..#...#.##....##..#.##.", 4: "..#..##.#.#.#.#.####..#...#.", 5: "#####...###....#...##..#.##.", + 6: ".##.#...#...###.#..##..#.##.", 7: "####...#..#...#..#...#...#..", 8: ".##.#..##..#.##.#..##..#.##.", + 9: ".##.#..##..#.###...#...#.##.", "-": ".........###.........", ":": "..#..#.", ".": "......#", +}; + +// Each route runs four pages: the destination, a page of stops shown +// inverse, the destination again, and a second inverse page. +const ROUTES = [ + { no: "17", pages: [["PIER", "HEAD"], ["VIA", "OLD", "MILL"], ["PIER", "HEAD"], ["VIA", "FISH", "DOCK"]] }, + { no: "7", pages: [["DOCK", "YARD"], ["VIA", "FAIR", "LANE"], ["DOCK", "YARD"], ["VIA", "ROPE", "WALK"]] }, + { no: "N9", pages: [["ZOO", "GATE"], ["LAST", "BUS", "0115"], ["ZOO", "GATE"], ["VIA", "ELM", "PARK"]] }, + { no: "X3", pages: [["SEA", "WALL"], ["VIA", "HIGH", "ST"], ["SEA", "WALL"], ["VIA", "LIDO"]] }, +]; + +// Disc paint, from the yellow most of these signs carried to the red a few did. +const PAINT = [ + [255, 226, 20], + [110, 255, 40], + [236, 240, 226], + [255, 150, 12], + [255, 58, 34], +]; +const BLACK = [13, 13, 12]; +const GAP = [3, 3, 4]; + +const textWidth = (s, k) => { + let w = 0; + for (const ch of s) w += (ch === " " ? 2 : FONT[ch].length / 7) * k + k; + return max(0, w - k); +}; + +// Sets the discs of one line of text, k times size, its top left at disc +// (c0, r0). +function stamp(bits, s, c0, r0, k) { + let c = c0; + for (const ch of s) { + if (ch === " ") { + c += 3 * k; + continue; + } + const g = FONT[ch]; + const gw = g.length / 7; + for (let y = 0; y < 7; y++) + for (let x = 0; x < gw; x++) { + if (g[y * gw + x] !== "#") continue; + for (let a = 0; a < k; a++) + for (let b = 0; b < k; b++) { + const cc = c + x * k + b; + const rr = r0 + y * k + a; + if (cc >= 0 && cc < COLS && rr >= 0 && rr < ROWS) bits[rr * COLS + cc] = 1; + } + } + c += (gw + 1) * k; + } +} + +// A page as the discs it wants yellow. Odd pages are inverse: yellow ground, +// black letters, the route box with them. +function pageBits(route, n) { + const R = ROUTES[route]; + const lines = R.pages[n % R.pages.length]; + const bits = new Uint8Array(ND); + stamp(bits, R.no, floor((COLS - textWidth(R.no, 2)) / 2), 1, 2); + const h = lines.length * 8 - 1; + const top = R1 + floor((R2 - h) / 2); + lines.forEach((s, j) => stamp(bits, s, floor((COLS - textWidth(s, 1) + 1) / 2), top + j * 8, 1)); + if (n & 1) for (let i = 0; i < ND; i++) bits[i] ^= 1; + return bits; +} + +const ALL = [new Uint8Array(ND), new Uint8Array(ND).fill(1)]; + +function palette(P, u) { + P.hue = u; + const t = u * 4; + const j = min(3, floor(t)); + const f = t - j; + for (let c = 0; c < 3; c++) P.col[c] = PAINT[j][c] + (PAINT[j + 1][c] - PAINT[j][c]) * f; +} + +// The housing: a grey bezel round the edge, the dark backplate the discs sit +// on, and the bar between the two boxes with its four screws. +function housing(P) { + const b = P.base; + for (let p = 0; p < N; p++) [b[p * 3], b[p * 3 + 1], b[p * 3 + 2]] = GAP; + const put = (x, y, v, t) => { + const o = (y * W + x) * 3; + b[o] = v; + b[o + 1] = v; + b[o + 2] = v + t; + }; + for (let x = 0; x < W; x++) { + put(x, 0, 40, 4); + put(x, H - 1, 34, 4); + put(x, 50, 40, 4); + for (let y = 51; y < 53; y++) put(x, y, 26, 3); + put(x, 53, 32, 3); + } + for (let y = 0; y < H; y++) { + put(0, y, 38, 4); + put(W - 1, y, 34, 4); + } + for (const sx of [5, 57]) for (let y = 51; y < 53; y++) for (let x = sx; x < sx + 2; x++) put(x, y, x === sx && y === 51 ? 86 : 58, 2); +} + +function init(P, route) { + P.route = route; + P.base = new Float32Array(N * 3); + P.out = new Float32Array(N * 3); + P.col = [0, 0, 0]; + P.fa = new Uint8Array(ND); + P.fb = new Uint8Array(ND); + P.ts = new Float32Array(ND).fill(-9); + // How bright each disc's paint still is, and how much light falls on its + // row from the strip under the top of each box. + P.lit = new Float32Array(ND); + P.dim = new Float32Array(ND); + P.q = new Int32Array(ND); + for (let i = 0; i < ND; i++) { + const r = floor(i / COLS); + const c = i % COLS; + const shade = r < R1 ? 1 - 0.04 * (r / (R1 - 1)) : 1 - 0.06 * ((r - R1) / (R2 - 1)); + P.lit[i] = (0.96 + 0.04 * rnd()) * shade; + P.dim[i] = 0.85 + 0.3 * rnd(); + P.q[i] = discY(r) * W + discX(c); + } + // One disc jammed on its edge, and a few loose ones that flutter as the bus + // idles. + P.stuck = (R1 + 2 + floor(rnd() * (R2 - 4))) * COLS + 1 + floor(rnd() * (COLS - 2)); + P.loose = [0, 1, 2, 3].map(() => floor(rnd() * ND)); + P.fli = -1; + P.flt = 0; + housing(P); + P.st = 0; + P.pg = 0; + const bits = pageBits(route, 0); + P.fa.set(bits); + P.fb.set(bits); + P.k = 0; + P.kt = 0; + P.kd = FIRST; +} + +const face = (P, i) => (P.st >= P.ts[i] ? P.fb[i] : P.fa[i]); + +// Starts a wave towards the target at sign time t0. Discs already showing +// the right face do not move; two random ones in each wave are sticky and +// turn late. +function wave(P, target, t0, dir) { + const mode = P.mode; + const wd = WAVE[mode]; + const sticky = [floor(rnd() * ND), floor(rnd() * ND)]; + P.fli = -1; + for (let i = 0; i < ND; i++) { + const cur = face(P, i); + const tgt = target[i]; + P.fa[i] = cur; + P.fb[i] = tgt; + if (cur === tgt) { + P.ts[i] = -9; + continue; + } + const r = floor(i / COLS); + const c = dir > 0 ? i % COLS : COLS - 1 - (i % COLS); + const rr = dir > 0 ? r : ROWS - 1 - r; + let u; + if (mode === 0) u = c / (COLS - 1) + r * 0.0012; + else if (mode === 1) u = (c + rr) / (COLS + ROWS - 2); + else if (mode === 2) u = rnd(); + else u = rr / (ROWS - 1) + c * 0.0015; + P.ts[i] = t0 + u * wd + (i === sticky[0] || i === sticky[1] ? 0.12 + 0.15 * rnd() : 0); + } + return wd + FD + 0.03; +} + +// The sign's cycle: page, fill to yellow, hold, inverse page, clear to +// black, hold, next page. +function enter(P, k, t0) { + P.k = k; + P.kt = t0; + if (k === 0) P.kd = HOLD; + else if (k === 1) P.kd = wave(P, ALL[1], t0, 1); + else if (k === 2) P.kd = 0.4; + else if (k === 3) P.kd = wave(P, pageBits(P.route, ++P.pg), t0, 1); + else if (k === 4) P.kd = HOLD_INV; + else if (k === 5) P.kd = wave(P, ALL[0], t0, -1); + else if (k === 6) P.kd = 0.25; + else P.kd = wave(P, pageBits(P.route, ++P.pg), t0, -1); +} + +export function setup(params) { + params.base = null; +} + +export function update(dt, input, params) { + const P = params; + const k = input.knobValues || [0, 2, 0, 0.06]; + dt = cl(dt, 0, 0.1); + const hue = cl(k[0], 0, 1); + const sp = cl(k[1], 0.2, 3) / 2; + const route = cl(round(k[2]), 0, 3); + P.mode = cl(round(k[3]), 0, 3); + if (!P.base) init(P, route); + else if (route !== P.route) { + P.route = route; + P.pg = -1; + enter(P, 7, P.st); + } + if (hue !== P.hue) palette(P, hue); + P.st += dt * sp; + const hold = P.k % 2 === 0; + if (input.btnPressed && input.btnPressed[0] && hold) P.kd = P.st - P.kt; + while (P.st - P.kt >= P.kd) enter(P, (P.k + 1) % 8, P.kt + P.kd); + if (P.fli >= 0 && P.st - P.flt > 3 * FD) P.fli = -1; + if (P.k % 2 === 0 && P.fli < 0 && rnd() < dt * sp * 1.1) { + P.fli = P.loose[floor(rnd() * P.loose.length)]; + P.flt = P.st; + } +} + +// Paints one disc part way through a turn from face a to face b. +function paint(P, i, a, b, p) { + const o = P.out; + const q = P.q[i]; + let f = p < 0.5 ? a : b; + let rows = 3; + let s = 1; + if (p >= 0.25 && p < 0.5) [rows, s] = [1, 0.6]; + else if (p >= 0.5 && p < 0.75) [rows, s] = [2, 0.6]; + if (i === P.stuck) [f, rows, s] = [1, 1, 0.5]; + const c = P.col; + const L = P.lit[i] * s; + const D = P.dim[i] * s; + const cr = f ? c[0] * L : BLACK[0] * D; + const cg = f ? c[1] * L : BLACK[1] * D; + const cb = f ? c[2] * L : BLACK[2] * D; + for (let y = 0; y < 2; y++) { + if (!(rows & (1 << y))) continue; + for (let x = 0; x < 2; x++) { + const j = (q + y * W + x) * 3; + // The face is not quite flat: its top left catches a little more of + // the light than its bottom right. + const g = 1.03 - 0.03 * (x + y); + o[j] = cr * g; + o[j + 1] = cg * g; + o[j + 2] = cb * g; + } + } +} + +export function draw(display, params) { + const P = params; + if (!P.base) return; + const o = P.out; + o.set(P.base); + const st = P.st; + for (let i = 0; i < ND; i++) { + const a = P.fa[i]; + const b = P.fb[i]; + let p = a === b ? 1 : (st - P.ts[i]) / FD; + if (i === P.fli) { + const e = (st - P.flt) / FD; + const cur = face(P, i); + if (e < 1) paint(P, i, cur, 1 - cur, e); + else if (e < 2) paint(P, i, 1 - cur, 1 - cur, 1); + else paint(P, i, 1 - cur, cur, e - 2); + continue; + } + paint(P, i, a, b, p < 0 ? 0 : p > 1 ? 1 : p); + } + for (let y = 0, j = 0; y < H; y++) + for (let x = 0; x < W; x++, j += 3) display.setPixel(x, y, min(255, o[j]) | 0, min(255, o[j + 1]) | 0, min(255, o[j + 2]) | 0); +} From 4ac92febf17fe8fa3758d46ded943c08bb6c9a3a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 21:01:11 +0000 Subject: [PATCH 04/17] pattern: Pin Art Co-Authored-By: Claude Opus 5.5 --- incoming/pattern-study/patterns/pin-art.js | 328 +++++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 incoming/pattern-study/patterns/pin-art.js diff --git a/incoming/pattern-study/patterns/pin-art.js b/incoming/pattern-study/patterns/pin-art.js new file mode 100644 index 000000000..4531f16f6 --- /dev/null +++ b/incoming/pattern-study/patterns/pin-art.js @@ -0,0 +1,328 @@ +// ===== Patternflow pattern ===== +// Title: Pin Art +// Family: toy +// Idea: A pin-art desk toy seen from the front fills the panel with four thousand steel pins in a staggered grid, and a spread hand, a key, a seashell, a toy car and a face press in from behind one after another, each relief rising out of the dim pins into a raking lamp until the toy is tilted and the pins slide flat again in a ripple. +// Author: patternflow-lab +// Date: 2026-10-23 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 22, after Ward Fleming's pinscreen desk toy (1985) +// Round: 22 +// =============================== +// @matrix 64x128 +// @knobs Metal=0..1, Speed=0.2..3, Object=0..4, Light=0..1 +// +// Knob 1: Metal. The finish of the pins and the colour the lamp gives them: blue steel at 0, then violet and pink, brass in the middle, green, and blue steel again at 1. +// Knob 2: Speed. How fast things press in, hold and are tilted flat again: at 2 an object takes 3 s to press in and a round is 8 s; at 0.2 it all runs ten times slower. +// Knob 3: Object. What presses in next: a spread hand, a key, a seashell, a toy car or a face, the rest following in that order. Turning it tilts the toy flat and brings the new one. +// Knob 4: Light. Where the desk lamp stands, going once round the toy from its left side, upper left at the start. The relief shines on the lamp's side and throws its shadow away from it, and the flat pins are brighter nearer the lamp. +// Buttons: K1 tap tilts the toy flat now and presses the next object in. + +const W = 64; +const H = 128; +const N = W * H; +const NOBJ = 5; +// The round at Speed 2, in seconds: press in, hold, tilt flat, rest. +const TP = 3.0; +const TH = 3.3; +const TW = 1.25; +const TR = 0.5; +const TC = TP + TH + TW + TR; +const { min, max, sqrt, sin, cos, exp, abs, round, hypot, atan2, PI } = Math; +const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); +const sstep = (a, b, v) => { + const t = clamp((v - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; +// A fixed hash, so the pins are the same uneven pins on every run. +const hash = (i) => { + let h = Math.imul(i ^ 0x9e3779b9, 0x85ebca6b); + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +}; + +// Distances to shapes, negative inside. seg() leaves how far along the +// segment the nearest point lies in T. +let T = 0; +function seg(px, py, ax, ay, bx, by) { + const dx = bx - ax; + const dy = by - ay; + T = clamp(((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy), 0, 1); + return hypot(px - ax - dx * T, py - ay - dy * T); +} +function box(px, py, cx, cy, hx, hy, r) { + const qx = abs(px - cx) - hx + r; + const qy = abs(py - cy) - hy + r; + return hypot(max(qx, 0), max(qy, 0)) + min(max(qx, qy), 0) - r; +} +function ell(px, py, cx, cy, rx, ry) { + return (hypot((px - cx) / rx, (py - cy) / ry) - 1) * min(rx, ry); +} +function smin(a, b, k) { + const h = clamp(0.5 + (0.5 * (b - a)) / k, 0, 1); + return b + (a - b) * h - k * h * (1 - h); +} +// A soft round bump, near enough a Gaussian, that ends 2 radii out. +const bump = (px, py, cx, cy, rx, ry) => { + const q = ((px - cx) / rx) ** 2 + ((py - cy) / ry) ** 2; + return q < 4 ? (1 - q / 4) ** 3 : 0; +}; +// A rounded edge: 0 at the outline, 1 once r LEDs inside it. +const prof = (d, r) => { + const t = clamp(-d / r, 0, 1); + return sqrt(t * (2 - t)); +}; +// Pins next to the outline are dragged a little way out with it. +const halo = (d) => (d < 5 ? 0.17 * exp(-d / 1.6) : 0); + +// Each object is a height above the pins' rest plane, 0..1, at an LED. +// The fingers: base and tip of each, and the radius. +const FING = [ + [15, 84, 3, 56, 6], + [20, 61, 10, 22, 5.8], + [30, 58, 29, 9, 6], + [39, 59, 47, 15, 5.8], + [47, 65, 59, 35, 5.2], +]; +function hand(x, y) { + let d = box(x, y, 32, 77, 19, 17, 9); + let crease = 0; + for (let f = 0; f < 5; f++) { + const [ax, ay, bx, by, r] = FING[f]; + const e = seg(x, y, ax, ay, bx, by) - r; + if (e < 0) { + const len = hypot(bx - ax, by - ay); + const j = f ? [0.4, 0.68] : [0.55, 0.55]; + crease = max(crease, exp(-(((T - j[0]) * len) ** 2) / 0.8), exp(-(((T - j[1]) * len) ** 2) / 0.8)); + } + d = smin(d, e, 2.5); + } + d = smin(d, seg(x, y, 32, 92, 32, 150) - 15.5, 8); + if (d > 0) return halo(d); + let r = 0.5 + 0.38 * prof(d, 8); + // The pads below the fingers, the ball of the thumb, and the lines of the palm. + r += 0.08 * bump(x, y, 22, 92, 8, 10) + 0.05 * bump(x, y, 34, 66, 14, 4) + 0.05 * bump(x, y, 43, 86, 6, 9); + r -= 0.1 * crease; + const heart = abs(y - (68 + 0.012 * (x - 30) ** 2 - 0.1 * (x - 30))); + const life = abs(hypot(x - 14, y - 92) - 15); + if (x > 18 && x < 48 && y < 80) r -= 0.1 * exp(-heart * heart); + if (y > 70 && y < 104 && x > 18) r -= 0.1 * exp(-life * life); + return clamp(r, 0, 1); +} + +function face(x, y) { + let d = ell(x, y, 32, 54, 24 - max(0, y - 60) * 0.24, 35); + d = smin(d, min(ell(x, y, 7, 56, 3.5, 8), ell(x, y, 57, 56, 3.5, 8)), 1.5); + const head = d; + d = smin(d, seg(x, y, 32, 88, 32, 140) - 10.5, 4); + d = smin(d, ell(x, y, 32, 142, 54, 28), 10); + if (d > 0) return halo(d); + let r = 0.42 + 0.24 * prof(d, 12); + // The neck and shoulders sit back under the jaw. + r -= 0.2 * sstep(-2, 2, head); + // Hair, combed back in strands from the hairline. + const hl = 33 + 0.012 * (x - 32) ** 2; + if (y < hl) r += 0.04 * sstep(hl, hl - 3, y) + 0.06 * sin(x * 1.25 + (y - 20) * (x - 32) * 0.012); + // Brows, eye sockets and eyes, the nose, cheeks, lips and chin. + r += 0.06 * bump(x, y, 32, 36, 16, 9); + r += 0.12 * (bump(x, y, 22, 44, 7, 1.8) + bump(x, y, 42, 44, 7, 1.8)); + r -= 0.3 * (bump(x, y, 22, 51, 6, 3.4) + bump(x, y, 42, 51, 6, 3.4)); + r += 0.16 * (bump(x, y, 22, 51.5, 3, 1.8) + bump(x, y, 42, 51.5, 3, 1.8)); + r += 0.3 * bump(x, y, 32, 59, 2.4, 9) + 0.18 * bump(x, y, 32, 67.5, 4.5, 2.6); + r -= 0.14 * (bump(x, y, 29, 70, 1.4, 1.1) + bump(x, y, 35, 70, 1.4, 1.1)); + r += 0.12 * (bump(x, y, 19, 64, 6, 6) + bump(x, y, 45, 64, 6, 6)); + r += 0.14 * bump(x, y, 32, 76.5, 6.5, 1.5) + 0.16 * bump(x, y, 32, 80.5, 5.5, 1.9) - 0.2 * bump(x, y, 32, 78.5, 7.5, 0.7); + r += 0.12 * bump(x, y, 32, 87, 6, 3); + return clamp(r, 0, 1); +} + +function key(x, y) { + const rr = hypot(x - 32, y - 25); + let d = max(rr - 21, 9 - rr); + d = min(d, box(x, y, 32, 47, 9, 3, 1), box(x, y, 32, 55, 7, 2.5, 1), box(x, y, 32, 83, 4.5, 38, 2)); + let bit = box(x, y, 44, 107, 8, 11, 1); + bit = max(bit, -box(x, y, 51, 101, 2.5, 2, 0), -box(x, y, 51, 111, 2.5, 2.5, 0), -box(x, y, 44, 106, 1.2, 5, 0)); + d = min(d, bit); + if (d > 0) return halo(d); + let r = 0.72 + 0.2 * prof(d, 3); + // A raised rim round the bow's hole and a groove down the shank. + r += 0.08 * exp(-((rr - 12) ** 2) / 2); + if (y > 60 && y < 118) r -= 0.12 * exp(-((x - 32) ** 2) / 1.2); + return clamp(r, 0, 1); +} + +// A scallop, hinge down: ribs fan out from the hinge to a wavy edge. +function shell(x, y) { + const dx = x - 32; + const dy = 92 - y; + const rho = hypot(dx, dy); + const th = atan2(dx, dy); + const rib = cos(th * 48); + let d = max(rho - 54 * (1 - 0.3 * th * th) - 1.2 * rib, (abs(th) - 0.85) * rho); + d = smin(d, box(x, y, 32, 94, 15, 5, 2), 3); + if (d > 0) return halo(d); + let r = 0.5 + 0.32 * prof(d, 14); + r += 0.13 * rib * sstep(5, 22, rho) * (dy > 0) + 0.025 * sin(rho * 1.4); + r -= 0.12 * exp(-((y - 89) ** 2) / 1.5) * (abs(dx) < 14); + return clamp(r, 0, 1); +} + +// A toy car seen from above, its nose at the top. +function car(x, y) { + let body = box(x, y, 32, 65, 14 + min(3, max(0, y - 14) * 0.12), 52, 11); + body = smin(body, min(ell(x, y, 18, 36, 5, 12), ell(x, y, 46, 36, 5, 12), ell(x, y, 17, 96, 5, 12), ell(x, y, 47, 96, 5, 12)), 3); + const tyre = min(box(x, y, 11, 36, 3, 8, 1.5), box(x, y, 53, 36, 3, 8, 1.5), box(x, y, 10, 96, 3, 8, 1.5), box(x, y, 54, 96, 3, 8, 1.5)); + const d = min(body, tyre); + if (d > 0) return halo(d); + if (body > 0) return 0.36 + 0.1 * prof(tyre, 2.5); + let r = 0.5 + 0.16 * prof(body, 4); + // The cabin: windscreen and rear window sunk between bonnet, roof and boot. + const xw = abs(x - 32); + const scr = xw < 12 - (58 - y) * 0.25 && y > 45 && y < 58; + const rear = xw < 11 && y > 91 && y < 99; + const roof = box(x, y, 32, 74.5, 11, 16, 3); + r += 0.14 * sstep(1, -1, roof) - 0.2 * (scr || rear); + // Bonnet creases, headlights, wing mirrors, the spoiler at the back. + r -= 0.14 * (exp(-((x - 23) ** 2) / 0.6) + exp(-((x - 41) ** 2) / 0.6)) * (y > 17 && y < 42); + r += 0.16 * (bump(x, y, 22, 16, 3.5, 2.2) + bump(x, y, 42, 16, 3.5, 2.2)) + 0.18 * (bump(x, y, 15, 56, 3, 2) + bump(x, y, 49, 56, 3, 2)); + r += 0.18 * sstep(1, -1, box(x, y, 32, 112, 16, 2, 1)) - 0.12 * sstep(1, -1, box(x, y, 32, 108, 14, 1, 0.5)); + return clamp(r, 0, 1); +} + +const OBJECTS = [hand, key, shell, car, face]; + +export function setup(params) { + const F = (n) => new Float32Array(n); + params.pin = { r: F(N), gx: F(N), gy: F(N), ht: F(N), jit: F(N), started: false }; + const P = params.pin; + for (let i = 0; i < N; i++) P.jit[i] = 0.88 + 0.24 * hash(i); +} + +// The object's heights, and their slopes for the lamp. +function load(P, o) { + const { r, gx, gy } = P; + const fn = OBJECTS[o]; + for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) r[y * W + x] = fn(x + 0.5, y + 0.5); + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const i = y * W + x; + gx[i] = (r[y * W + min(W - 1, x + 1)] - r[y * W + max(0, x - 1)]) / 2; + gy[i] = (r[min(H - 1, y + 1) * W + x] - r[max(0, y - 1) * W + x]) / 2; + } +} + +function tilt(P) { + if (P.c < TP + TH) { + P.sw = P.s; + P.c = TP + TH; + } +} + +export function update(dt, input, params) { + if (!params.pin) setup(params); + const P = params.pin; + const k = input.knobValues || [0, 2, 0, 0.06]; + dt = clamp(dt || 0, 0, 0.1); + P.hue = clamp(k[0], 0, 1); + P.rate = clamp(k[1], 0.2, 3) / 2; + const base = clamp(round(k[2]), 0, NOBJ - 1); + P.lamp = clamp(k[3], 0, 1); + if (!P.started) { + P.started = true; + P.base = base; + P.count = -1; + // Open on the last object of the round, just as the toy is tilted. + load(P, (base + NOBJ - 1) % NOBJ); + P.c = TP + TH - 0.05; + P.s = 1; + P.sw = 1; + } + if (base !== P.base) { + P.base = base; + P.count = -1; + tilt(P); + } + if (input.btnPressed && input.btnPressed[0]) tilt(P); + P.c += dt * P.rate; + if (P.c >= TC) { + P.c -= TC; + P.count++; + load(P, (P.base + P.count + NOBJ * 8) % NOBJ); + } + if (P.c < TP) { + const u = P.c / TP; + P.s = u * (1.3 - 0.3 * u); + } else if (P.c < TP + TH) P.s = 1; +} + +function hsv(h, s, v) { + const f = (n) => { + const k = (n + h * 6) % 6; + return v - v * s * max(0, min(k, 4 - k, 1)); + }; + return [f(5), f(3), f(1)]; +} + +export function draw(display, params) { + const P = params.pin; + if (!P || !P.started) return; + const { r, gx, gy, ht, jit } = P; + const lift = 1 - P.s; + // The tilt: a front runs down the panel, the pins behind it slide back and + // rattle a little as they go. + const wt = P.c - TP - TH; + const wiping = wt >= 0; + const yf = -16 + (wt / TW) * 170; + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const i = y * W + x; + if (!wiping) { + const h = r[i] - lift; + ht[i] = h > 0 ? h : 0; + continue; + } + const dd = yf - y - 5 * sin(x * 0.19 + 1.3) - 3 * sin(x * 0.47); + let h = r[i] - (1 - P.sw); + h = h > 0 ? h * (1 - sstep(0, 12, dd)) : 0; + if (dd > 0) h += 0.16 * max(0, sin(dd * 0.6)) * exp(-dd / 22); + ht[i] = h; + } + + // The lamp, low and to one side. + const ang = PI * (0.87 - 2 * P.lamp); + const lx = cos(ang); + const ly = -sin(ang); + const tint = hsv((0.6 + P.hue) % 1, 0.85, 1); + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const i = y * W + x; + const h = ht[i]; + // Shadow: does a taller pin stand between this one and the lamp? + let ex = 0; + for (let j = 1; j <= 7; j++) { + const sx = round(x + j * 1.5 * lx); + const sy = round(y + j * 1.5 * ly); + if (sx < 0 || sx >= W || sy < 0 || sy >= H) break; + const e = ht[sy * W + sx] - h - j * 0.12; + if (e > ex) ex = e; + } + const shadow = 1 - 0.34 * sstep(0, 0.12, ex); + const q = clamp(0.5 + ((x - 31.5) * lx + (y - 63.5) * ly) / 140, 0, 1); + let v; + let wh; + if (((x + y) & 1) === 0) { + const flat = 106 * (0.72 + 0.56 * q) * jit[i]; + const up = sstep(0, 0.1, h); + const shade = clamp(1.02 - 4.5 * (gx[i] * lx + gy[i] * ly), 0.5, 1.3); + const raised = min(255, 255 * (0.66 + 0.4 * min(1, h)) * shade); + v = (flat + (raised - flat) * up) * shadow; + wh = 0.3 + 0.4 * clamp((v - 90) / 165, 0, 1); + } else { + // A gap: dark, unless the pins round it stand out and show their shafts. + const n = (ht[i - (x > 0 ? 1 : 0)] + ht[i + (x < W - 1 ? 1 : 0)] + ht[i - (y > 0 ? W : 0)] + ht[i + (y < H - 1 ? W : 0)]) / 4; + const shade = clamp(0.92 - 3 * (gx[i] * lx + gy[i] * ly), 0.35, 1.15); + v = 175 * sstep(0.02, 0.3, n) * shade * shadow; + wh = 0.15; + } + display.setPixel(x, y, v * (tint[0] + (1 - tint[0]) * wh), v * (tint[1] + (1 - tint[1]) * wh), v * (tint[2] + (1 - tint[2]) * wh)); + } +} From fc5d9448b512d734eb40427d8af59c2b81046410 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 21:04:05 +0000 Subject: [PATCH 05/17] pattern: Galilean Moons Co-Authored-By: Claude Opus 5.5 --- .../pattern-study/patterns/galilean-moons.js | 523 ++++++++++++++++++ 1 file changed, 523 insertions(+) create mode 100644 incoming/pattern-study/patterns/galilean-moons.js diff --git a/incoming/pattern-study/patterns/galilean-moons.js b/incoming/pattern-study/patterns/galilean-moons.js new file mode 100644 index 000000000..26a0c2c3c --- /dev/null +++ b/incoming/pattern-study/patterns/galilean-moons.js @@ -0,0 +1,523 @@ +// ===== Patternflow pattern ===== +// Title: Galilean Moons +// Family: celestial +// Idea: Jupiter's four big moons as an observer's chart: the eyepiece at the top shows the belted planet turning, its moons in a line and their shadows crossing the disc, and below it each moon traces its own sine round Jupiter's band, ten rows a day, as the days to come scroll up to the eyepiece. +// Author: patternflow-lab +// Date: 2026-10-23 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 22, after the Jupiter's-moons chart Sky & Telescope prints each month, computed with Jean Meeus's low-accuracy theory of the four satellites +// Round: 22 +// =============================== +// @matrix 64x128 +// @knobs Sky=0..1, Speed=0.5..24, Power=0..1, Seeing=0..1 +// +// Knob 1: Sky. The light the chart is read by: a night chart in navy at 0, red-light observing mode at the middle, the printed paper chart at 1. +// Knob 2: Speed. Hours of Jupiter's time a second: at 2 the Great Red Spot comes round every 5 seconds and the chart climbs a row every 1.2 seconds; 0.5 to 24, a day a second. +// Knob 3: Power. The eyepiece's magnification, which also sets the chart's scale: at 0 the field holds Callisto's whole orbit; turned up, Callisto, then Ganymede, then Europa run off the sides at elongation until only Io's orbit fits. +// Knob 4: Seeing. Turbulence in the air over the telescope: steady near 0, and toward 1 the disc wobbles, boils and blurs into a glow until the belts are gone. +// Buttons: K1 tap jumps ahead to the next shadow transit, an hour before the shadow touches Jupiter's limb. + +const W = 64; +const H = 128; +const { sin, cos, asin, atan2, sqrt, floor, round, min, max, abs, exp, pow, PI } = Math; +const RAD = PI / 180; +const CX = 31.5; +// The eyepiece fills rows 0 to 37: the close-up round row 16 and the +// low-power line on row 35. The chart starts at row 38 with row 35 as now. +const EY = 16; +const LY = 35; +const C0 = 38; +const EYE = C0; +// 2026 October 23, 18:00 UT, three months before the February 2027 +// opposition, when Earth crosses Jupiter's equator and the moons pass right +// over each other; the panel opens at the first shadow transit after it. +const JD0 = 2461337.25; +// The Great Red Spot's System II longitude, about where it drifts to by late 2026. +const GRS = 78; +// Meeus: each moon's longitude at J2000 and daily motion in degrees, its +// radius in Jupiter radii. +const U0 = [163.8069, 358.414, 5.7176, 224.8092]; +const UN = [203.4058646, 101.2916335, 50.234518, 21.48798]; +const RM = [0.0255, 0.0219, 0.0368, 0.0337]; +// Dot and shadow radii in the close-up, in LEDs. Io is 0.7 LEDs across at +// the lowest power, so the moons are drawn two and a half times too big. +const DOT = [0.85, 0.75, 1.15, 1.05]; +// Io yellow-orange, Europa white, Ganymede grey-brown, Callisto grey-blue, +// as they look in the eyepiece, and the albedo they show against the disc. +const EYEC = [ + [255, 196, 84], + [240, 242, 248], + [206, 178, 146], + [128, 150, 200], +]; +const ALB = [1, 1, 0.78, 0.62]; +// Chart palettes: night, red light, paper. bg0/bg1 alternate by day. +const PAL = [ + { + bg0: [8, 14, 40], bg1: [16, 28, 72], band: [196, 170, 128], shadow: [26, 18, 20], label: [104, 124, 178], + moon: [[255, 170, 40], [236, 240, 250], [190, 156, 118], [118, 146, 210]], + }, + { + bg0: [14, 0, 0], bg1: [42, 2, 0], band: [128, 12, 4], shadow: [4, 0, 0], label: [120, 8, 0], + moon: [[230, 44, 10], [255, 96, 66], [176, 26, 8], [128, 14, 4]], + }, + { + bg0: [232, 224, 204], bg1: [212, 204, 184], band: [190, 162, 118], shadow: [36, 26, 20], label: [112, 100, 88], + moon: [[222, 100, 8], [28, 32, 52], [126, 80, 42], [34, 76, 152]], + }, +]; +// 3x5 digits for the day labels. +const DIG = [0x7b6f, 0x2c97, 0x73e7, 0x73cf, 0x5bc9, 0x79cf, 0x79ef, 0x7249, 0x7bef, 0x7bcf]; + +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sm = (a, b, x) => { + const t = cl((x - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; +const mix = (a, b, t) => a + (b - a) * t; +// Signed difference of two angles in degrees, -180..180. +const wrap = (a) => a - 360 * floor((a + 180) / 360); + +// Jupiter seen from Earth at Julian date jd: distance, phase angle, the +// declinations of the Earth and the Sun over its equator, the shadow's +// direction and the System I and II central meridians (Meeus, chapter 44). +function sky(jd, S) { + const d = jd - 2451545; + const V = (172.74 + 0.00111588 * d) * RAD; + const M = (357.529 + 0.9856003 * d) * RAD; + const N = (20.02 + 0.0830853 * d + 0.329 * sin(V)) * RAD; + const J = (66.115 + 0.9025179 * d - 0.329 * sin(V)) * RAD; + const A = (1.915 * sin(M) + 0.02 * sin(2 * M)) * RAD; + const B = (5.555 * sin(N) + 0.168 * sin(2 * N)) * RAD; + const K = J + A - B; + const R = 1.00014 - 0.01671 * cos(M) - 0.00014 * cos(2 * M); + const r = 5.20872 - 0.25208 * cos(N) - 0.00611 * cos(2 * N); + const dist = sqrt(r * r + R * R - 2 * r * R * cos(K)); + const psi = asin((R / dist) * sin(K)); + const lam = (34.35 + 0.083091 * d + 0.329 * sin(V)) * RAD + B; + S.Ds = 3.12 * RAD * sin(lam + 42.8 * RAD); + S.De = S.Ds - 2.22 * RAD * sin(psi) * cos(lam + 22 * RAD) - 1.3 * RAD * ((r - dist) / dist) * sin(lam - 100.5 * RAD); + S.lt = dist / 173; + S.ph = psi - B; + // The shadow points away from the Sun: west (right) before opposition. + S.sx = -sin(psi); + S.sz = cos(psi); + const t = d - S.lt; + S.w1 = 210.98 + 877.8169088 * t + S.ph / RAD; + S.w2 = 187.23 + 870.1869088 * t + S.ph / RAD; +} + +function makeMoons() { + const F = () => new Float64Array(4); + return { X: F(), Y: F(), Z: F(), vis: F(), sx: F(), sy: F(), son: F(), u: F() }; +} + +// The four moons at jd using the slow terms in S. X is west (right) and Y +// north in Jupiter radii, Z away from Earth. vis is how much of the moon is +// neither behind Jupiter nor in its shadow; son is how much of its shadow +// lies on the side of the disc we see, at (sx, sy). +function moons(jd, S, Mo) { + const t = jd - 2451545 - S.lt; + const u = Mo.u; + for (let i = 0; i < 4; i++) u[i] = (U0[i] + UN[i] * t) * RAD + S.ph; + const G = (331.18 + 50.310482 * t) * RAD; + const Hh = (87.45 + 21.569231 * t) * RAD; + const a = u[0], b = u[1], c = u[2]; + u[0] += 0.473 * RAD * sin(2 * (a - b)); + u[1] += 1.065 * RAD * sin(2 * (b - c)); + u[2] += 0.165 * RAD * sin(G); + u[3] += 0.843 * RAD * sin(Hh); + const rr0 = 5.9057 - 0.0244 * cos(2 * (a - b)); + const rr1 = 9.3966 - 0.0882 * cos(2 * (b - c)); + const rr2 = 14.9883 - 0.0216 * cos(G); + const rr3 = 26.3627 - 0.1939 * cos(Hh); + const sDe = sin(S.De); + for (let i = 0; i < 4; i++) { + const r = i === 0 ? rr0 : i === 1 ? rr1 : i === 2 ? rr2 : rr3; + const X = r * sin(u[i]); + const Z = -r * cos(u[i]); + const Y = Z * sDe; + Mo.X[i] = X; + Mo.Y[i] = Y; + Mo.Z[i] = Z; + let vis = 1; + if (Z > 0) vis = sm(1 - RM[i], 1 + RM[i], sqrt(X * X + (Y / 0.935) ** 2)); + const al = X * S.sx + Z * S.sz; + if (al > 0) { + const pe = X * S.sz - Z * S.sx; + const v = (al * S.Ds) / 0.935; + const w = RM[i] + al * 0.0012; + vis *= sm(1 - w, 1 + w, sqrt(pe * pe + v * v)); + } + Mo.vis[i] = vis; + Mo.son[i] = 0; + if (Z < 0) { + const cc = X * X + Z * Z - 1; + const disc = al * al - cc; + if (disc > 0) { + const tt = -al - sqrt(disc); + const zh = Z + tt * S.sz; + if (tt > 0 && zh < 0) { + const xh = X + tt * S.sx; + const yh = Y - tt * S.Ds; + Mo.sx[i] = xh; + Mo.sy[i] = yh; + Mo.son[i] = 1 - sm(0.93, 1, sqrt(xh * xh + (yh / 0.935) ** 2)); + } + } + } + } +} + +// Day of the month of the civil day that starts at jd (Meeus, chapter 7). +function dayOf(jd) { + const Z = floor(jd + 0.5); + const al = floor((Z - 1867216.25) / 36524.25); + const A = Z + 1 + al - floor(al / 4); + const B = A + 1524; + const C = floor((B - 122.1) / 365.25); + const D = floor(365.25 * C); + const E = floor((B - D) / 30.6001); + return B - D - floor(30.6001 * E); +} + +// Cloud colours at planetographic latitude lat (degrees) and System I and II +// longitudes L1, L2 (degrees, west). Writes albedo 0..1 into o. +function cloud(lat, L1, L2, o) { + const al = abs(lat); + let r = 0.94, g = 0.89, b = 0.77; + const put = (cr, cg, cb, a) => { + r += (cr - r) * a; + g += (cg - g) * a; + b += (cb - b) * a; + }; + const l1 = L1 * RAD, l2 = L2 * RAD; + // Polar hoods. + put(0.6, 0.57, 0.54, 0.85 * sm(28, 58, al)); + // North Equatorial Belt, festoons trailing from its south edge. + const ns = 6.8 + 1.1 * sin(6 * l1 + 1) + 0.6 * sin(13 * l1); + const nn = 17.5 + 1.2 * sin(2 * l2 + 0.4) + 0.7 * sin(5 * l2 + 2); + let a = sm(ns - 1, ns + 1, lat) * (1 - sm(nn - 1, nn + 1, lat)); + put(0.6, 0.37, 0.23, 0.88 * a); + // Festoons: dark plumes, unevenly spaced, slanting down into the EZ. + const fe = sin(10 * l1 + 0.12 * lat + 0.9 * sin(3 * l1 + 2)); + put(0.4, 0.43, 0.54, 0.7 * sm(0.5, 0.9, fe) * sm(1.5 + 2.5 * sin(7 * l1) ** 2, 6.5, lat) * (1 - sm(6.5, 8, lat))); + // Barges: dark red ovals in the NEB's north half. + for (const bl of [40, 165, 290]) { + const e = (wrap(L2 - bl) / 5) ** 2 + ((lat - 15) / 1.8) ** 2; + put(0.42, 0.2, 0.14, 0.9 * (1 - sm(0.5, 1.1, e))); + } + // The Great Red Spot and the hollow it presses into the SEB. + const eg = (wrap(L2 - GRS) / 8) ** 2 + ((lat + 22.5) / 5) ** 2; + // South Equatorial Belt, paler down its middle. + const ss = -19.5 + 0.9 * sin(3 * l2 + 2) + 0.5 * sin(8 * l2); + const sn = -7.5 + 0.8 * sin(4 * l2) + 0.5 * sin(11 * l1 + 1); + a = sm(ss - 1, ss + 1, lat) * (1 - sm(sn - 1, sn + 1, lat)); + a *= 1 - 0.4 * exp(-(((lat + 13.5) / 2.6) ** 2)) * (0.6 + 0.4 * sin(5 * l2 + 1)); + a *= sm(1.1, 2.4, eg); + put(0.66, 0.44, 0.28, 0.8 * a); + put(0.88, 0.45, 0.31, 1 - sm(0.6, 1, eg)); + // Temperate belts, white ovals in the south one. + const st = -29 + 0.8 * sin(3 * l2 + 4); + put(0.7, 0.55, 0.41, 0.62 * sm(st - 2.5, st - 1, lat) * (1 - sm(st + 1, st + 2.5, lat))); + for (const wl of [100, 215, 335]) { + const e = (wrap(L2 - wl) / 4.5) ** 2 + ((lat + 33) / 2.4) ** 2; + put(1, 0.99, 0.95, 0.85 * (1 - sm(0.4, 1, e))); + } + const nt = 26 + 0.9 * sin(4 * l2 + 1); + put(0.68, 0.51, 0.37, 0.6 * sm(nt - 2.5, nt - 1, lat) * (1 - sm(nt + 1, nt + 2.5, lat))); + put(0.72, 0.62, 0.52, 0.4 * exp(-(((al - 37) / 1.6) ** 2))); + o[0] = r; + o[1] = g; + o[2] = b; +} + +// A soft dot of colour c and strength a added into buffer f (stride W). +function dot(f, rows, x, y, rad, c, a) { + const x0 = max(0, floor(x - rad - 1)), x1 = min(W - 1, floor(x + rad + 1)); + const y0 = max(0, floor(y - rad - 1)), y1 = min(rows - 1, floor(y + rad + 1)); + for (let j = y0; j <= y1; j++) + for (let i = x0; i <= x1; i++) { + const k = cl(rad + 0.5 - sqrt((i - x) ** 2 + (j - y) ** 2), 0, 1) * a; + if (k <= 0) continue; + const o = (j * W + i) * 3; + f[o] += (c[0] - f[o]) * k; + f[o + 1] += (c[1] - f[o + 1]) * k; + f[o + 2] += (c[2] - f[o + 2]) * k; + } +} + +export function setup(params) { + const P = params; + P.S = {}; + P.Mo = makeMoons(); + P.eye = new Float32Array(W * C0 * 3); + P.tmp = new Float32Array(W * EYE * 3); + P.row = new Float32Array(W * 3); + P.al = new Float32Array(W * 4); + P.sh = new Float32Array(W); + P.xp = new Float64Array(4); + P.vp = new Float64Array(4); + P.cs = new Float32Array(3); + P.clock = 0; + Object.assign(P, { skyk: 0, speed: 2, power: 0, seeing: 0.06 }); + // Open five hours before the first shadow touches the limb, which at the + // opening speed brings it on at 2.5 seconds. + P.jd = nextShadow(JD0) - 5 / 24; +} + +// The next time after jd that a moon's shadow reaches the disc. +function nextShadow(jd) { + const S = {}, Mo = makeMoons(); + let was = 0; + for (let k = 0; k < 2000; k++) { + const t = jd + k * 0.01; + if (k % 10 === 0) sky(t, S); + moons(t, S, Mo); + let on = 0; + for (let i = 0; i < 4; i++) if (Mo.son[i] > 0.5) on |= 1 << i; + if (k > 0 && on & ~was) return t; + was = on; + } + return jd; +} + +export function update(dt, input, params) { + const P = params; + const k = input.knobValues || [0, 2, 0, 0.06]; + dt = cl(dt || 0, 0, 0.1); + P.skyk = cl(k[0], 0, 1); + P.speed = cl(k[1], 0.5, 24); + P.power = cl(k[2], 0, 1); + P.seeing = cl(k[3], 0, 1); + if (input.btnPressed && input.btnPressed[0]) P.jd = nextShadow(P.jd + 0.02) - 1 / 24; + P.jd += (dt * P.speed) / 24; + P.clock += dt; +} + +// The close-up eyepiece: Jupiter at Rc LEDs a radius, with seeing. +function eyepiece(P, S, Mo, Rc) { + const f = P.eye; + const see = P.seeing; + const tm = P.clock; + const cs = P.cs; + // Image motion: the whole disc wanders, and each row ripples on its own. + const gx = see * (2.6 * sin(tm * 3.1) * sin(tm * 1.27 + 1) + 1.3 * sin(tm * 7.3 + 2)); + const gy = see * (1.5 * sin(tm * 2.3 + 4) * sin(tm * 0.9) + 0.8 * sin(tm * 6.1 + 1)); + const Rp = Rc * 0.935; + const cy = EY + gy; + const w1 = S.w1, w2 = S.w2; + for (let y = 0; y < EYE; y++) { + const rip = see * 1.3 * sin(y * 0.9 + tm * 11) * sin(y * 0.37 - tm * 5.3 + 1.3); + const cx = CX + gx + rip; + const ny = (y - cy) / Rp; + for (let x = 0; x < W; x++) { + const o = (y * W + x) * 3; + const nx = (x - cx) / Rc; + const q = nx * nx + ny * ny; + const rho = sqrt(q); + // Glare round the disc: the seeing disc's wings. + const out = max(0, rho - 1) * Rc; + const halo = (4 + 110 * see) / pow(1 + (out / (2 + 10 * see)) ** 2, 1.5) * (rho > 1 ? 1 : 0); + f[o] = 3 + halo * 0.95; + f[o + 1] = 4 + halo * 0.9; + f[o + 2] = 8 + halo * 0.8; + if (rho >= 1 + 1 / Rc) continue; + const edge = cl((1 - rho) * Rc + 0.6, 0, 1); + const mu = sqrt(max(0, 1 - min(q, 1))); + const lat = -asin(cl(ny, -1, 1)) / RAD; + const lon = atan2(nx, mu) / RAD; + cloud(lat, w1 - lon, w2 - lon, cs); + let lum = 255 * (0.18 + 0.82 * pow(mu, 0.55)); + // The phase: a thin shade on the limb away from the Sun. + lum *= 1 - 0.35 * sm(0.75, 1, nx * (S.sx > 0 ? 1 : -1)) * abs(S.sx) * 5; + for (let i = 0; i < 4; i++) { + if (Mo.son[i] <= 0) continue; + const dx = (x - cx - Mo.sx[i] * Rc), dy = (y - cy + Mo.sy[i] * Rc); + lum *= 1 - 0.9 * Mo.son[i] * cl(DOT[i] + 0.55 - sqrt(dx * dx + dy * dy), 0, 1); + } + f[o] += (cs[0] * lum - f[o]) * edge; + f[o + 1] += (cs[1] * lum - f[o + 1]) * edge; + f[o + 2] += (cs[2] * lum - f[o + 2]) * edge; + } + } + // Moons in the field, in front of the disc or beside it. + for (let i = 0; i < 4; i++) { + const x = CX + gx + Mo.X[i] * Rc, y = cy - Mo.Y[i] * Rc; + if (x < -3 || x > W + 3 || Mo.vis[i] <= 0.01) continue; + const onDisc = Mo.Z[i] < 0 && Mo.X[i] ** 2 + (Mo.Y[i] / 0.935) ** 2 < 1; + const tw = 1 + see * 0.35 * sin(tm * (17 + i * 3) + i); + const c = EYEC[i].map((v) => v * (onDisc ? ALB[i] : 1) * min(1.1, tw)); + dot(f, EYE, x, y, DOT[i] * (1 + see * 0.6), c, Mo.vis[i]); + } + // Seeing blurs the image: a separable Gaussian. + const sg = 0.2 + 1.7 * see; + if (sg > 0.35) { + const rad = min(5, Math.ceil(sg * 2.5)); + const ker = []; + let ks = 0; + for (let i = -rad; i <= rad; i++) ks += ker[i + rad] = exp(-(i * i) / (2 * sg * sg)); + const t = P.tmp; + for (let y = 0; y < EYE; y++) + for (let x = 0; x < W; x++) + for (let c = 0; c < 3; c++) { + let s = 0; + for (let i = -rad; i <= rad; i++) s += ker[i + rad] * f[(y * W + cl(x + i, 0, W - 1)) * 3 + c]; + t[(y * W + x) * 3 + c] = s / ks; + } + for (let y = 0; y < EYE; y++) + for (let x = 0; x < W; x++) + for (let c = 0; c < 3; c++) { + let s = 0; + for (let i = -rad; i <= rad; i++) s += ker[i + rad] * t[(cl(y + i, 0, EYE - 1) * W + x) * 3 + c]; + f[(y * W + x) * 3 + c] = s / ks; + } + } +} + +// The low-power line under the close-up: Jupiter and its moons at the chart's scale. +function line(P, Mo, Rj, Rc) { + const f = P.eye; + // Two faint lines from under the close-up down to the planet it magnifies. + for (const s of [-1, 1]) { + const xa = CX + s * Rc * 0.45, ya = EY + Rc * 0.935 + 1; + const xb = CX + s * (Rj + 0.8), yb = LY - 1.5; + const n = max(2, round(yb - ya)); + for (let k = 0; k <= n; k++) { + const x = round(mix(xa, xb, k / n)), y = round(mix(ya, yb, k / n)); + const o = (y * W + x) * 3; + if (y < C0 && x >= 0 && x < W) for (let c = 0; c < 3; c++) f[o + c] = max(f[o + c], [34, 40, 62][c]); + } + } + const hj = min(Rj * 0.935, 2.2); + for (let y = LY - 3; y <= LY + 3; y++) + for (let x = 0; x < W; x++) { + const nx = (x - CX) / Rj, ny = (y - LY) / hj; + const rho = sqrt(nx * nx + ny * ny); + const k = cl((1 - rho) * max(1.2, Rj) + 0.7, 0, 1); + if (k <= 0) continue; + const lum = 0.55 + 0.45 * sqrt(max(0, 1 - rho * rho)); + const o = (y * W + x) * 3; + f[o] += (236 * lum - f[o]) * k; + f[o + 1] += (220 * lum - f[o + 1]) * k; + f[o + 2] += (186 * lum - f[o + 2]) * k; + } + for (let i = 0; i < 4; i++) { + if (Mo.vis[i] <= 0.01) continue; + dot(f, C0, CX + Mo.X[i] * Rj, LY - Mo.Y[i] * Rj, 0.7, EYEC[i], Mo.vis[i]); + } +} + +function blend(k1) { + const s = k1 * 2; + const i = min(1, floor(s)), t = sm(0, 1, s - i); + const a = PAL[i], b = PAL[i + 1]; + const m = (u, v) => u.map((x, j) => mix(x, v[j], t)); + const o = {}; + for (const key of ["bg0", "bg1", "band", "shadow", "label"]) o[key] = m(a[key], b[key]); + o.moon = a.moon.map((c, j) => m(c, b.moon[j])); + return o; +} + +// The chart: ten rows a day at the lowest power, now at row LY, the day +// numbers down the left edge under the line each day starts on. +function chart(P, display, pal, Rj) { + const rpd = 10 * pow(Rj / 1.15, 0.6); + const S = P.S2 || (P.S2 = {}); + const Mo = P.Mo2 || (P.Mo2 = makeMoons()); + const { row, al, sh } = P; + const hw = 0.55; + const marks = new Set(); + for (let d = floor(P.jd + (C0 - 6 - LY) / rpd - 0.5) + 1.5; d < P.jd + (H - LY) / rpd; d++) { + const n = dayOf(d + 0.01), yb = round(LY + (d - P.jd) * rpd); + const s = n < 10 ? [n] : [floor(n / 10), n % 10]; + s.forEach((dg, j) => { + for (let b = 0; b < 15; b++) if ((DIG[dg] >> (14 - b)) & 1) marks.add((yb + 1 + floor(b / 3)) * W + 1 + j * 4 + (b % 3)); + }); + } + for (let y = C0; y < H; y++) { + const tc = P.jd + (y - LY) / rpd; + const ta = tc - 0.5 / rpd, tb = tc + 0.5 / rpd; + // Alternate days light and dark, split where a day starts inside the row. + const da = floor(ta - 0.5); + let odd = da & 1; + const nb = da + 1.5; + if (nb < tb) { + const fr = (tb - nb) * rpd; + odd = odd * (1 - fr) + (1 - (da & 1)) * fr; + } + for (let x = 0; x < W; x++) { + const lab = marks.has(y * W + x); + for (let c = 0; c < 3; c++) row[x * 3 + c] = lab ? pal.label[c] : mix(pal.bg0[c], pal.bg1[c], odd); + } + // Jupiter's band, rounded off by its limb. + for (let x = floor(CX - Rj - 1); x <= CX + Rj + 1; x++) { + const k = cl(min(x + 0.5, CX + Rj) - max(x - 0.5, CX - Rj), 0, 1); + if (k <= 0) continue; + const dx = (x - CX) / Rj; + const lum = 0.62 + 0.38 * sqrt(max(0, 1 - dx * dx)); + for (let c = 0; c < 3; c++) row[x * 3 + c] = mix(row[x * 3 + c], pal.band[c] * lum, k); + } + sky(tc, S); + al.fill(0); + sh.fill(0); + const xp = P.xp, vp = P.vp; + for (let s = 0; s <= 4; s++) { + moons(ta + (s / 4) * (tb - ta), S, Mo); + for (let i = 0; i < 4; i++) { + const x = CX + Mo.X[i] * Rj, v = Mo.vis[i]; + if (Mo.son[i] > 0) { + const xs = CX + Mo.sx[i] * Rj; + for (let xx = max(0, floor(xs - 1)); xx <= min(W - 1, xs + 1); xx++) + sh[xx] = max(sh[xx], Mo.son[i] * cl(1 - abs(xx - xs) * 0.8, 0, 1)); + } + if (s > 0) { + const lo = min(x, xp[i]) - hw, hi = max(x, xp[i]) + hw, vv = min(v, vp[i]); + if (vv > 0.02 && hi > -1 && lo < W) + for (let xx = max(0, floor(lo)); xx <= min(W - 1, floor(hi + 1)); xx++) { + const k = cl(min(xx + 0.5, hi) - max(xx - 0.5, lo), 0, 1) * vv; + if (k > al[xx * 4 + i]) al[xx * 4 + i] = k; + } + } + xp[i] = x; + vp[i] = v; + } + } + for (let x = 0; x < W; x++) { + if (sh[x] > 0) for (let c = 0; c < 3; c++) row[x * 3 + c] = mix(row[x * 3 + c], pal.shadow[c], sh[x] * 0.9); + for (let i = 3; i >= 0; i--) { + const k = al[x * 4 + i]; + if (k > 0) for (let c = 0; c < 3; c++) row[x * 3 + c] = mix(row[x * 3 + c], pal.moon[i][c], k); + } + display.setPixel(x, y, min(255, row[x * 3]), min(255, row[x * 3 + 1]), min(255, row[x * 3 + 2])); + } + } +} + +export function draw(display, params) { + const P = params; + if (!P.eye) setup(P); + const S = P.S, Mo = P.Mo; + sky(P.jd, S); + moons(P.jd, S, Mo); + const p = P.power; + const Rj = 1.15 * pow(5.1 / 1.15, p); + const Rc = 14 + 2.5 * p; + eyepiece(P, S, Mo, Rc); + line(P, Mo, Rj, Rc); + // Red light turns the eyepiece's colours to shades of red. + const red = sm(0, 1, 1 - abs(P.skyk * 2 - 1)); + const f = P.eye; + for (let i = 0, o = 0; i < W * C0; i++, o += 3) { + let r = f[o], g = f[o + 1], b = f[o + 2]; + if (red > 0) { + const l = 0.5 * r + 0.4 * g + 0.1 * b; + r = mix(r, l, red); + g = mix(g, l * 0.06, red); + b = mix(b, 0, red); + } + display.setPixel(i % W, (i / W) | 0, min(255, r), min(255, g), min(255, b)); + } + const pal = blend(P.skyk); + chart(P, display, pal, Rj); +} From 0b4792de08443eb1bb9f89ad4733387c56201013 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 21:05:03 +0000 Subject: [PATCH 06/17] pattern: Flame Test Co-Authored-By: Claude Opus 5.5 --- incoming/pattern-study/patterns/flame-test.js | 539 ++++++++++++++++++ 1 file changed, 539 insertions(+) create mode 100644 incoming/pattern-study/patterns/flame-test.js diff --git a/incoming/pattern-study/patterns/flame-test.js b/incoming/pattern-study/patterns/flame-test.js new file mode 100644 index 000000000..0e7ef719a --- /dev/null +++ b/incoming/pattern-study/patterns/flame-test.js @@ -0,0 +1,539 @@ +// ===== Patternflow pattern ===== +// Title: Flame Test +// Family: chemistry +// Idea: On a dark lab bench a hand dips a nichrome wire loop into a row of labelled watch glasses and holds each salt in the pale blue flame of a Bunsen burner, which bursts into the element's colour, sputters with grains of salt and fades back to blue before the loop is cleaned in acid and goes for the next. +// Author: patternflow-lab +// Date: 2026-10-23 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 22, after the flame test of the school chemistry lab +// Round: 22 +// =============================== +// @matrix 64x128 +// @knobs Salt=0..8, Speed=0.2..3, Room=0..1, Air=-1..1 +// +// Knob 1: Salt. Which watch glass the loop goes to next: at 0 it works along the row from copper (blue-green) through sodium (yellow-orange), lithium (crimson), barium (apple green), potassium (lilac), calcium (brick red), boric acid (green) and strontium (red); 1 to 8 keeps to one glass, copper at 1 to strontium at 8. +// Knob 2: Speed. How fast the hand works and the flame moves: at 2 a salt burns about 4.5 s and the next one goes into the flame 12 s after it; at 0.2 the hand works ten times slower and the flame slows to a lazy flicker. +// Knob 3: Room. The lab lights, on at 0 and off at 1, where only the flame lights the tiles, the bench and the hand. +// Knob 4: Air. The burner's collar: shut at -1 for a tall, wavering yellow flame that hides the salt's colour, half open near 0 for the quiet blue flame of the test, wide open at 1 for a short roaring flame with a sharp inner cone. +// Buttons: K1 tap sends the loop to the next salt now. + +const W = 64; +const H = 128; +const N = W * H; +// The burner's axis, the first row of flame above its barrel, the bench top. +const FX = 21; +const MOUTH = 79; +const BENCH = 94; +const { min, max, floor, ceil, round, sin, cos, exp, abs, sqrt, hypot, PI } = Math; +const rnd = () => Math.random(); +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sm = (a, b, v) => { + const t = cl((v - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; +const mix = (a, b, t) => a + (b - a) * t; +const mix3 = (a, b, t) => a.map((v, i) => mix(v, b[i], t)); + +// The row of watch glasses, left to right: symbol, flame colour, the salt's +// own colour, how long its colour lasts in the flame (s), how strong it is. +const WHITE = [0.92, 0.91, 0.87]; +const SALTS = [ + ["Cu", [0, 240, 185], [0.35, 0.75, 0.7], 2, 1], + ["Na", [255, 175, 10], WHITE, 3.5, 1.25], + ["Li", [255, 10, 85], WHITE, 2, 1], + ["Ba", [170, 255, 35], WHITE, 2, 0.95], + ["K", [190, 100, 255], WHITE, 1.3, 0.8], + ["Ca", [255, 85, 10], WHITE, 2.2, 1], + ["B", [35, 255, 60], WHITE, 1.8, 1], + ["Sr", [255, 5, 25], WHITE, 2.2, 1.1], +]; +// A heap of salt in a watch glass: x offset, row, shade. +const HEAP = [[0, 107, 1], [-1, 108, 0.95], [0, 108, 1], [1, 108, 0.8], [-2, 109, 0.85], [-1, 109, 0.9], [0, 109, 0.85], [1, 109, 0.75], [2, 109, 0.65]]; +// 3x5 letters for the labels. +const FONT = { + C: "011/100/100/100/011", + u: "000/000/101/101/011", + N: "110/101/101/101/101", + a: "000/011/101/101/011", + L: "100/100/100/100/111", + i: "1/0/1/1/1", + B: "110/101/110/101/110", + K: "101/101/110/101/101", + S: "011/100/010/001/110", + r: "000/000/101/110/100", + H: "101/101/111/101/101", + l: "1/1/1/1/1", +}; + +// The hand's round: in the flame with a salt, to the acid, dipped, back, +// in the flame to burn the loop clean, to the next glass, dipped, back. +const DUR = [4.5, 1.1, 0.8, 1, 1.4, 1.2, 0.7, 1]; +// Where the loop goes, and the holder's angle there. +const FP = [FX + 4, 62, 0.45]; +const AT = [51, 75, 1.22]; +const AD = [51, 90, 1.22]; +const ST = (i) => [4 + 8 * i, 100, 0.52]; +const SD = (i) => [4 + 8 * i, 108, 0.52]; + +// Smooth value noise, wrapping both ways. +function noise(w, h, cell, oct) { + const out = new Float32Array(w * h); + let amp = 1; + let tot = 0; + for (let o = 0; o < oct; o++, cell /= 2, amp *= 0.55) { + const gw = w / cell; + const gh = h / cell; + const g = Float32Array.from({ length: gw * gh }, rnd); + const at = (i, j) => g[(j % gh) * gw + (i % gw)]; + for (let y = 0; y < h; y++) { + const j = floor(y / cell); + const ty = sm(0, 1, y / cell - j); + for (let x = 0; x < w; x++) { + const i = floor(x / cell); + const tx = sm(0, 1, x / cell - i); + const a = at(i, j) + (at(i + 1, j) - at(i, j)) * tx; + const b = at(i, j + 1) + (at(i + 1, j + 1) - at(i, j + 1)) * tx; + out[y * w + x] += amp * (a + (b - a) * ty); + } + } + tot += amp; + } + return out.map((v) => v / tot); +} + +// Bilinear lookup in the 64x128 noise tile. +function nz(T, x, y) { + const xi = floor(x); + const yi = floor(y); + const fx = x - xi; + const r0 = (yi & 127) * 64; + const r1 = ((yi + 1) & 127) * 64; + const a = T[r0 + (xi & 63)]; + const b = T[r0 + ((xi + 1) & 63)]; + const c = T[r1 + (xi & 63)]; + const d = T[r1 + ((xi + 1) & 63)]; + return a + (b - a) * fx + (c - a + (a - b - c + d) * fx) * (y - yi); +} + +// Everything that stands still, as albedo: the tiled wall and its periodic +// table, the gas tap and hose, the burner, the acid, the glasses, the labels. +function build(P) { + const { alb, spec, G, benchM } = P; + const put = (x, y, c, s) => { + if (x < 0 || x >= W || y < 0 || y >= H) return; + const p = y * W + x; + alb.set(c, p * 3); + benchM[p] = 0; + if (s !== undefined) spec[p] = s; + }; + const at = (x, y) => alb.slice((y * W + x) * 3, (y * W + x) * 3 + 3); + const text = (str, cx, top, ink) => { + const gl = [...str].map((c) => FONT[c].split("/")); + let x0 = cx - (gl.reduce((s, g) => s + g[0].length + 1, -1) >> 1); + for (const g of gl) { + for (let j = 0; j < 5; j++) for (let i = 0; i < g[j].length; i++) if (g[j][i] === "1") put(x0 + i, top + j, ink); + x0 += g[0].length + 1; + } + }; + const shade = Float32Array.from({ length: 256 }, rnd); + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + let v; + if (y < BENCH - 1) { + const gx = (x + 5) % 11; + const gy = (y + 7) % 11; + v = gx === 10 || gy === 10 ? 0.36 : 0.45 + 0.05 * shade[(((x + 5) / 11) | 0) * 19 + (((y + 7) / 11) | 0) * 7 & 255] + 0.05 * (1 - (gx + gy) / 18) + (gx === 0 || gy === 0 ? 0.03 : 0); + put(x, y, [v, v * 1.01, v * 1.03]); + } else if (y === BENCH - 1) put(x, y, [0.06, 0.06, 0.07]); + else if (y === BENCH) put(x, y, [0.3, 0.3, 0.32], 0.3); + else if (y < 125) { + v = 0.17 + 0.014 * rnd(); + put(x, y, [v, v, v * 1.08]); + benchM[y * W + x] = 1; + } else put(x, y, y === 125 ? [0.42, 0.42, 0.44] : [0.1, 0.1, 0.11], y === 125 ? 0.4 : 0); + // The flame lights the room like a lamp along its height. + const dx = x - FX; + const dy = y < 22 ? 22 - y : y > 78 ? y - 78 : 0; + G[y * W + x] = 1 / (1 + (dx * dx + dy * dy) / 480); + } + // A periodic table pinned to the tiles. + for (let y = 3; y <= 27; y++) for (let x = 39; x <= 62; x++) put(x, y, [0.85, 0.84, 0.78]); + for (let x = 42; x <= 59; x++) put(x, 5, [0.2, 0.2, 0.28]); + const cell = (c, y, f) => + put(42 + c, y, f ? [0.55, 0.85, 0.8] : c === 0 ? (y === 8 ? [0.7, 0.7, 0.75] : [0.95, 0.35, 0.35]) : c === 1 ? [0.98, 0.62, 0.3] : c < 12 ? [0.95, 0.85, 0.35] : c < 17 ? (c - 12 > (y - 8) / 2 - 1 ? [0.45, 0.8, 0.5] : [0.45, 0.65, 0.95]) : [0.7, 0.5, 0.95]); + for (let n = 1; n <= 7; n++) for (let c = 0; c < 18; c++) if (n > 3 || (n === 1 ? c === 0 || c === 17 : c < 2 || c > 11)) cell(c, 6 + 2 * n, false); + for (let c = 3; c < 17; c++) cell(c, 23, true), cell(c, 25, true); + // The heatproof mat the burner stands on. + for (let y = 96; y <= 104; y++) + for (let x = 7 - ((y - 96) >> 1); x <= 35 + ((y - 96) >> 1); x++) { + const v = y === 104 ? 0.22 : y === 96 ? 0.42 : 0.5 + 0.04 * rnd(); + put(x, y, [v, v * 0.97, v * 0.92]); + } + // The gas tap on the wall, its lever, and the orange rubber hose to the burner. + for (let y = 82; y <= 91; y++) for (let x = 0; x <= 3; x++) put(x, y, [0.4, 0.4, 0.42], x === 0 ? 0.3 : 0); + for (let y = 86; y <= 89; y++) for (let x = 1; x <= 6; x++) put(x, y, y === 86 ? [0.9, 0.72, 0.2] : [0.62, 0.48, 0.2], y === 86 ? 0.5 : 0); + for (let x = 7; x <= 9; x++) put(x, 88, [0.72, 0.56, 0.24]); + for (let j = 0; j < 4; j++) put(4 + (j >> 1), 85 - j, [0.12, 0.12, 0.13]); + for (let k = 0; k <= 80; k++) { + const t = k / 80; + const u = 1 - t; + const bx = round(u * u * u * 9 + 3 * u * u * t * 17 + 3 * u * t * t * 2 + t * t * t * 10); + const by = round(u * u * u * 88 + 3 * u * u * t * 94 + 3 * u * t * t * 106 + t * t * t * 101); + put(bx, by, [0.88, 0.38, 0.14], 0.25); + put(bx, by + 1, [0.6, 0.24, 0.08]); + } + // The burner: a heavy base, the gas inlet, the chrome barrel and its collar. + const BW = [5, 6, 7, 7, 7]; + for (let j = 0; j < 5; j++) + for (let x = FX - BW[j]; x <= FX + BW[j]; x++) { + const v = j === 0 ? 0.42 : j === 4 ? 0.1 : 0.22 - 0.03 * j; + put(x, 99 + j, [v, v, v * 1.15], j === 0 ? 0.5 : 0); + } + for (let x = FX - 7; x <= FX + 8; x++) put(x, 104, at(x, 104).map((v) => v * 0.55)); + for (let x = FX - 11; x < FX - 7; x++) put(x, 100, [0.78, 0.62, 0.3], 0.4), put(x, 101, [0.5, 0.38, 0.18]); + const BC = [0.28, 0.75, 0.56, 0.38, 0.2]; + for (let y = 80; y <= 98; y++) + for (let i = 0; i < 5; i++) { + const v = BC[i] + (y === 80 ? 0.15 : 0); + put(FX - 2 + i, y, [v, v, v * 1.06], i === 1 ? 0.6 : 0); + } + const CC = [0.25, 0.5, 0.8, 0.64, 0.46, 0.3, 0.18]; + for (let y = 89; y <= 95; y++) + for (let i = 0; i < 7; i++) { + const v = CC[i] * (y === 89 || y === 95 ? 0.75 : 1); + put(FX - 3 + i, y, [v, v * 0.8, v * 0.42], i === 2 ? 0.5 : 0); + } + // A beaker of dilute hydrochloric acid to clean the loop in. + for (let y = 75; y <= 101; y++) + for (let x = 43; x <= 58; x++) { + if (x === 43 && y > 76) continue; + if (y === 75 || y === 101 || x === 44 || x === 58 || x === 43) { + put(x, y, [0.5, 0.55, 0.6], x === 44 || y === 75 ? 0.8 : 0.4); + continue; + } + let c = at(x, y).map((v, i) => v * [0.9, 0.94, 0.98][i]); + if (y >= 85) c = c.map((v, i) => v * [0.62, 0.68, 0.7][i] + [0.08, 0.11, 0.14][i]); + put(x, y, c, y === 85 ? 0.6 : x === 45 ? 0.3 : 0); + } + P.front = []; + for (let y = 93; y <= 99; y++) for (let x = 46; x <= 56; x++) put(x, y, [0.88, 0.86, 0.78]), P.front.push(y * W + x); + text("HCl", 51, 94, [0.07, 0.07, 0.1]); + // The watch glasses, each with its heap of salt, and the tape with their names. + for (let i = 0; i < 8; i++) { + const c = 4 + 8 * i; + for (let x = c - 3; x <= c + 3; x++) put(x, 112, at(x, 112).map((v) => v * 0.6)); + for (let dy = -2; dy <= 1; dy++) + for (let dx = -3; dx <= 3; dx++) { + const e = (dx / 3.7) ** 2 + ((dy + 0.5) / 2.1) ** 2; + if (e < 1) put(c + dx, 110 + dy, e > 0.45 ? [0.5, 0.56, 0.6] : at(c + dx, 110 + dy).map((v) => v * 1.3 + 0.07), e > 0.45 && dy < 0 ? 0.9 : 0.3); + } + const k = SALTS[i][2]; + for (const [dx, y, s] of HEAP) put(c + dx, y, k.map((v) => v * s)); + } + for (let y = 115; y <= 121; y++) + for (let x = 0; x < W; x++) { + const v = y === 115 || y === 121 ? 0.62 : 0.82; + if (x % 8) put(x, y, [v, v * 0.97, v * 0.82]); + } + for (let i = 0; i < 8; i++) text(SALTS[i][0], 4 + 8 * i, 116, [0.06, 0.06, 0.09]); +} + +export function setup(params) { + const P = params; + const F = (n) => new Float32Array(n); + Object.assign(P, { alb: F(N * 3), spec: F(N), G: F(N), buf: F(N * 3), benchM: new Uint8Array(N), NT: noise(64, 128, 16, 3) }); + build(P); + Object.assign(P, { started: false, t: 0, tf: 0, ph: 0, pt: 0, cur: 0, salt: 0, pick: 0, ks: 0, kd: 0, kc: SALTS[0][1], heat: 0, bead: 0, sput: 0, flare: 0, sp: [], hot: [], sAcc: 0, bAcc: 0, air: 0.5, room: 0, from: FP, pos: FP, m: 1 }); +} + +// Kind 0 is a grain carried up the flame in the salt's colour, kind 1 a grain +// spat out of the loop that glows as it falls, kind 2 a bubble in the acid. +function spark(P, k) { + if (P.sp.length >= 160) return; + P.sp.push({ + k, + x: P.pos[0] + (rnd() - 0.5) * 2.5, + y: P.pos[1] + (rnd() - 0.5) * 2, + vx: (rnd() - 0.5) * [14, 60, 2][k], + vy: -[25 + 45 * rnd(), 10 + 35 * rnd(), 7 + 6 * rnd()][k], + a: 0, + l: [0.25 + 0.55 * rnd(), 0.4 + 0.6 * rnd(), 2][k], + c: P.kc.map((v) => v * 0.6 + 100), + }); +} + +function enter(P) { + const ph = P.ph; + P.from = P.pos; + if (ph === 5) P.cur = P.pick ? P.pick - 1 : (P.cur + 1) % 8; + if (ph === 7) P.bead = 1; + if (ph === 0) { + P.salt = P.cur; + P.kc = SALTS[P.cur][1]; + for (let i = 0; i < 16; i++) spark(P, i & 1); + } + // Even a clean loop shows a flash of sodium from the acid and the fingers. + if (ph === 4) P.kc = SALTS[1][1]; +} + +function step(P, h) { + P.t += h; + P.pt += h; + while (P.pt >= DUR[P.ph]) { + P.pt -= DUR[P.ph]; + P.ph = (P.ph + 1) % 8; + enter(P); + } + const ph = P.ph; + const s = P.pt / DUR[ph]; + const e = sm(0, 1, s); + const dip = sm(0, 0.35, s) * sm(1, 0.62, s); + let pos; + if (ph === 0 || ph === 4) pos = [FP[0] + 0.5 * sin(P.t * 1.3), FP[1] + 0.5 * sin(P.t * 1.9 + 1), FP[2]]; + else if (ph === 2 || ph === 6) { + pos = ph === 2 ? mix3(AT, AD, dip) : mix3(ST(P.cur), SD(P.cur), dip); + pos[0] += sin(s * 19) * 0.7 * dip; + } else { + pos = mix3(P.from, ph === 1 ? AT : ph === 5 ? ST(P.cur) : FP, e); + pos[1] -= 5 * sin(PI * e); + } + P.pos = pos; + const inF = ph === 0 || ph === 4; + const wet = ph === 2 && dip > 0.6; + P.heat += ((inF ? 1 : 0) - P.heat) * (1 - exp(-h / (inF ? 0.35 : wet ? 0.07 : 0.9))); + if (ph === 0) P.bead = max(0, 1 - P.pt / 0.9); + + // The salt's colour: a burst as the loop goes in, a while at full, then a fade. + const S = SALTS[P.salt]; + let tgt = 0; + P.sput = 0; + if (ph === 0) { + tgt = S[4] * (P.pt < 1.3 ? sm(0, 0.18, P.pt) : exp(-(P.pt - 1.3) / S[3])); + P.sput = S[4] * (exp(-P.pt / 0.9) + 0.3 * exp(-P.pt / 2.5)); + } else if (ph === 4) { + tgt = 0.85 * sm(0, 0.15, P.pt) * exp(-P.pt / 0.5); + P.sput = 0.5 * exp(-P.pt / 0.35); + } + P.ks += (tgt - P.ks) * (1 - exp(-h / (tgt > P.ks ? 0.06 : 0.22))); + P.flare *= exp(-h / 0.15); + P.kd = P.ks * (0.78 + 0.3 * nz(P.NT, P.t * 9, 40.5)) + P.flare; + // Grains crackle off the loop, now and then a whole crust at once. + if (P.sput > 0.02) { + P.sAcc += 40 * P.sput * h; + for (; P.sAcc >= 1; P.sAcc--) spark(P, rnd() < 0.7 ? 0 : 1); + if (rnd() < 3 * P.sput * h) { + P.flare += 0.3 * P.ks; + for (let i = 0; i < 8; i++) spark(P, i % 3 ? 0 : 1); + } + } + if (wet) for (P.bAcc += 22 * h; P.bAcc >= 1; P.bAcc--) spark(P, 2); + P.sp = P.sp.filter((p) => { + p.a += h; + if (p.k === 0) { + p.vy -= 30 * h; + p.vx *= exp(-2 * h); + } else if (p.k === 1) p.vy += 90 * h; + p.x += p.vx * h; + p.y += p.vy * h; + return p.a < p.l && !(p.k === 2 && p.y < 85.5) && p.x >= 0 && p.x <= 63 && p.y >= 0 && p.y <= 124; + }); +} + +export function update(dt, input, params) { + const P = params; + const k = input.knobValues || [0, 2, 0, 0.06]; + dt = cl(dt, 0, 0.1); + P.pick = cl(round(k[0]), 0, 8); + P.m = cl(k[1], 0.2, 3) / 2; + const room = cl(k[2], 0, 1); + const air = (cl(k[3], -1, 1) + 1) / 2; + if (!P.started) { + P.started = true; + P.air = air; + P.room = room; + P.cur = P.pick ? P.pick - 1 : 0; + enter(P); + // Open a moment after the first salt went into the flame. + for (let i = 0; i < 8; i++) step(P, 1 / 30); + } + if (input.btnPressed && input.btnPressed[0] && P.ph < 5) { + P.ph = 5; + P.pt = 0; + enter(P); + } + P.air += (air - P.air) * (1 - exp(-dt / 0.25)); + P.room += (room - P.room) * (1 - exp(-dt / 0.12)); + P.tf += dt * (0.3 + 0.7 * P.m); + step(P, dt * P.m); +} + +// The holder: the wire loop, the nichrome wire, the aluminium handle, the +// hand round it and the white sleeve of a lab coat. The loop and the first +// few LEDs of wire are kept to glow when hot. +function holder(P, light) { + const [lx, ly, th] = P.pos; + const ux = cos(th); + const uy = -sin(th); + P.hot = []; + for (let y = max(0, floor(ly + 100 * uy - 7)); y <= min(H - 1, ceil(ly + 7)); y++) + for (let x = max(0, floor(lx - 3)); x < W; x++) { + const dx = x - lx; + const dy = y - ly; + const s = dx * ux + dy * uy; + const q = dx * uy - dy * ux; + if (s < -2.5 || abs(q) > 6.2) continue; + let c = null; + const hand = ((s - 31) / 4.3) ** 2 + ((q + 0.3) / 3.7) ** 2; + if (((s - 27.5) / 2.6) ** 2 + ((q - 2.5) / 1.3) ** 2 < 1) c = [1, 0.78, 0.64]; + else if (hand < 1) { + // Curled fingers under the handle, the knuckles above it. + const k = 0.84 + 0.07 * q - (q < -0.8 && abs(((s - 29.5) % 2) - 1) < 0.45 ? 0.22 : 0); + c = [k, 0.72 * k, 0.58 * k]; + } else if (s > 34 && s < 37.5 && abs(q + 0.5) < 3) c = [0.88, 0.62, 0.5]; + else if (s >= 37 && abs(q + 0.6) < 5.4) { + const k = (s < 39 ? 0.74 : 0.9) + 0.03 * q; + c = [0.88 * k, 0.89 * k, 0.92 * k]; + } else if (s >= 9 && s < 30 && q > -1 && q < 0.6) c = q > -0.2 ? [0.66, 0.68, 0.74] : [0.36, 0.37, 0.42]; + else if ((s >= 1 && s < 9 && abs(q) < 0.6) || (s < 1.8 && abs(hypot(dx, dy) - 1.25) < 0.55)) { + c = [0.55, 0.55, 0.52]; + if (s < 5) P.hot.push(y * W + x, 1 - max(0, s) / 6); + } + if (c) light(y * W + x, c); + } +} + +function flame(P) { + const { buf, NT, kd, kc } = P; + const a = P.air; + const t = P.tf; + const k1 = min(1, kd); + const Yf = sm(0.5, 0.06, a); + const Rf = sm(0.6, 1, a); + const Cf = sm(0.12, 0.6, a); + const bl = sm(0.06, 0.5, a); + const fl = 1 + (0.06 * sin(t * 7.3) + 0.05 * sin(t * 12.9 + 1)) * (1 + Yf); + const Hf = mix(mix(76, 58, bl), 47, Rf) * (1 + 0.2 * k1) * fl; + const Wm = mix(8.5, 6.4, bl) - Rf + 1.6 * k1; + const Hi = mix(7, 15, sm(0.15, 1, a)); + const A = mix(5, 2, bl) * (1 - 0.6 * Rf); + const ws = mix(4.5, 6.5, a) + 7 * Rf; + const vs = mix(30, 46, a) + 40 * Rf; + const tn = mix(0.75, 0.5, a); + const [lx, ly] = P.pos; + const inF = P.heat > 0.5; + const salt = 1 - 0.8 * Yf; + for (let y = max(0, floor(MOUTH - Hf * 1.3)); y <= MOUTH; y++) { + const hg = MOUTH + 1 - y; + const v = hg / Hf; + const cx = FX + A * v ** 1.4 * sin(0.11 * hg - ws * t) + 0.45 * A * v * sin(0.23 * hg - 1.7 * ws * t + 2); + const hw = max(0.7, (2.6 + (Wm - 2.6) * sm(0, 0.3, v)) * sqrt(max(0, 1.05 - v))); + const yw = Yf * sm(2, 9, hg); + const ri = 2.5 * max(0, 1 - hg / Hi) ** 0.8 + 0.25; + const cxi = FX + 0.35 * sin(t * 9 + hg * 0.3) * (1 - Rf); + const above = sm(ly + 5, ly - 2, y); + for (let x = max(0, floor(cx - hw * 1.7 - 3)); x <= min(W - 1, ceil(cx + hw * 1.7 + 3)); x++) { + const n = nz(NT, x * 1.1 + 7, y * 0.55 + t * vs * 0.55); + const d2 = abs(x - cx) / hw + (n - 0.5) * tn * (0.25 + v); + const O = sm(1.12, 0.55, d2) * sm(1, 0.72, v + (n - 0.5) * 0.45 * v); + let r = 0; + let g = 0; + let b = 0; + // The outer flame: pale blue, or yellow with soot when the collar is shut. + if (O > 0.002) { + const ob = O * (0.62 + 0.38 * (1 - v)) * (0.85 + 0.25 * n) * (0.8 + 0.25 * Rf); + const core = sm(0.85, 0.2, d2) * (1 - 0.5 * v); + const lum = O * (0.75 + 0.35 * n); + r = mix(58 * ob, 255 * lum, yw); + g = mix(98 * ob, lum * (95 + 150 * core - 60 * v), yw); + b = mix(225 * ob, O * (10 + 120 * core * core), yw); + } + // The inner cone, brightest along its surface. + if (hg < Hi + 1) { + const di = abs(x - cxi); + const C = sm(ri + 0.75, ri - 0.35, di) * Cf * (0.92 + 0.08 * sin(t * 61 + hg)); + if (C > 0) { + const rim = sm(ri - 1.3, ri - 0.3, di); + r += (60 + 100 * rim) * C; + g += (140 + 100 * rim) * C; + b += 255 * C; + } + } + // The salt's colour rises from the loop and fills the flame above it in tongues. + let ps = 0; + if (kd > 0.01) { + const n2 = nz(NT, x * 1.6 + 31, y * 0.8 + t * vs * 0.9 + 53); + ps = kd * salt * (O * above * (0.4 + 0.95 * n2) + (inF ? 0.9 * exp(-((x - lx) ** 2 + (y - ly) ** 2) / 10) : 0)); + const q = max(0, 1 - 1.4 * ps); + r = r * q + kc[0] * ps; + g = g * q + kc[1] * ps; + b = b * q + kc[2] * ps; + } + if (r + g + b < 1) continue; + const o = (y * W + x) * 3; + const dim = 1 - 0.4 * min(1, O + 0.5 * ps); + buf[o] = buf[o] * dim + r; + buf[o + 1] = buf[o + 1] * dim + g; + buf[o + 2] = buf[o + 2] * dim + b; + } + } +} + +export function draw(display, params) { + const P = params; + if (!P.started) return; + const { buf, alb, spec, G, benchM, kc } = P; + const a = P.air; + const Yf = sm(0.5, 0.06, a); + const k1 = min(1.3, P.kd) * (1 - 0.8 * Yf); + const fl = 0.9 + 0.1 * nz(P.NT, P.tf * 11, 70.5) + 0.12 * Yf * sin(P.tf * 9); + // The flame's own colour, which lights the room. + const FC = [0, 1, 2].map((i) => (mix([58, 98, 225][i] * 0.85, [255, 165, 40][i], Yf) * (1 - 0.8 * min(1, k1)) + kc[i] * k1) * fl); + const [gr, gg, gb] = FC.map((v) => v * (0.52 + 0.3 * Yf)); + const A = 150 * (1 - P.room) ** 1.3 + 1.5; + for (let p = 0, o = 0; p < N; p++, o += 3) { + const g = G[p]; + const s = spec[p]; + buf[o] = alb[o] * (A + gr * g) + s * (0.3 * A + 1.4 * gr * g); + buf[o + 1] = alb[o + 1] * (A + gg * g) + s * (0.3 * A + 1.4 * gg * g); + buf[o + 2] = alb[o + 2] * (A * 0.95 + gb * g) + s * (0.3 * A + 1.4 * gb * g); + } + const add = (p, c, f) => { + for (let i = 0; i < 3; i++) buf[p * 3 + i] += c[i] * f; + }; + // The glossy bench: the ceiling lights along its back and the flame under the burner. + for (let x = 0; x < W; x++) for (const [y, k] of [[96, 0.22], [97, 0.12]]) if (benchM[y * W + x]) add(y * W + x, [A, A, A], k); + const sway = 1.5 * sin(P.tf * 4.1); + for (let y = 105; y <= 124; y++) { + const w = 2 + (y - 105) * 0.22; + const k = 0.34 * exp(-(y - 105) / 7); + for (let x = max(0, floor(FX - 3 * w)); x <= min(W - 1, ceil(FX + 3 * w)); x++) + if (benchM[y * W + x]) add(y * W + x, FC, k * exp(-(((x - FX - (sway * (y - 105)) / 20) / w) ** 2))); + } + const light = (p, c) => { + const g = G[p]; + buf[p * 3] = c[0] * (A + gr * g); + buf[p * 3 + 1] = c[1] * (A + gg * g); + buf[p * 3 + 2] = c[2] * (A * 0.95 + gb * g); + }; + // The air hole in the collar opens as the collar turns. + const hole = round(4 * a); + for (let y = 91; y <= 93; y++) for (let x = FX - (hole >> 1); x < FX - (hole >> 1) + hole; x++) light(y * W + x, [0.03, 0.03, 0.03]); + for (const p of P.sp) if (p.k === 2) light(round(p.y) * W + round(p.x), [0.7, 0.75, 0.8]); + holder(P, light); + // The label on the beaker hides the loop behind it. + if (P.ph >= 1 && P.ph <= 3) for (const p of P.front) light(p, alb.subarray(p * 3, p * 3 + 3)); + flame(P); + for (const p of P.sp) { + const f = 1 - p.a / p.l; + if (p.k < 2) add(round(p.y) * W + round(p.x), p.k ? [255 * sqrt(f), 210 * f * f, 120 * f * f * f] : p.c, p.k ? 1 : f); + } + // The loop glows red hot in the flame; the salt on it shows until it melts. + const ht = P.heat; + if (ht > 0.02) for (let i = 0; i < P.hot.length; i += 2) add(P.hot[i], [255, 70 + 90 * ht, 25 * ht], ht * P.hot[i + 1]); + if (P.bead > 0.05) { + const c = SALTS[P.cur][2]; + const p = round(P.pos[1]) * W + round(P.pos[0]); + for (let i = 0; i < 3; i++) buf[p * 3 + i] = mix(buf[p * 3 + i], c[i] * (A + 60) + [180, 140, 90][i] * ht, P.bead); + } + for (let p = 0, o = 0; p < N; p++, o += 3) display.setPixel(p % W, (p / W) | 0, min(255, buf[o]), min(255, buf[o + 1]), min(255, buf[o + 2])); +} From 289a8b6f0ba56654e6b1a01c69325e6cdee3992f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 22:34:34 +0000 Subject: [PATCH 07/17] pattern: Senbazuru Co-Authored-By: Claude Opus 5.5 --- incoming/pattern-study/patterns/senbazuru.js | 540 +++++++++++++++++++ 1 file changed, 540 insertions(+) create mode 100644 incoming/pattern-study/patterns/senbazuru.js diff --git a/incoming/pattern-study/patterns/senbazuru.js b/incoming/pattern-study/patterns/senbazuru.js new file mode 100644 index 000000000..d9a170156 --- /dev/null +++ b/incoming/pattern-study/patterns/senbazuru.js @@ -0,0 +1,540 @@ +// ===== Patternflow pattern ===== +// Title: Senbazuru +// Family: paper +// Idea: Strings of folded paper cranes hang from a red lacquered ring in a dim temple room, in bands of colour from the ring down to the silk tassels, and a soft draught sways the strands and turns the cranes so their folds catch the lamp one facet at a time. +// Author: patternflow-lab +// Date: 2026-10-23 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 22, after the senbazuru strings of a thousand origami cranes hung in Japanese temples and at the Children's Peace Monument in Hiroshima +// Round: 22 +// =============================== +// @matrix 64x128 +// @knobs Paper=0..1, Breeze=0.2..3, Strands=8..16, Light=0..1 +// +// Knob 1: Paper. Which colours the cranes are folded from: under a row of gold foil cranes at the ring, the bands run from orange through green and blue to violet and pink at the tassels at 0, and turning it moves every band along the rainbow. +// Knob 2: Breeze. How hard the draught blows: at 0.2 the strands hang almost still, at 2 they lean and sway and the cranes turn, at 3 they swing wide and spin. +// Knob 3: Strands. How many strands hang from the ring, from 8 to 16; with more strands the cranes are folded smaller and each strand carries more of them. +// Knob 4: Light. Where the light comes from: a paper lamp low on the left at night at 0, with the round window blue with moonlight, through dusk to daylight from the upper right at 1. The lit folds and the cranes' shadows on the wall swing with it. +// Buttons: K1 tap sends a gust through the room, which spins the cranes and shakes one loose to glide to the floor. + +const W = 64; +const H = 128; +const N = W * H; +// The cranes, threads and ring are drawn at twice the resolution and averaged +// down, so a neck one LED wide still reads. +const SW = 128; +const SH = 256; +const SN = SW * SH; +// The ring: its centre column, its row, its radius and how far its front +// hangs below its back. Then the round window behind it and the first row +// of the floor. +const CX = 31.5; +const RY = 12; +const RR = 22; +const RE = 3.6; +const WY = 60; +const WR = 26; +const FLOOR = 121; +const MAXS = 16; +const { sin, cos, min, max, floor, round, sqrt, exp, hypot, PI } = Math; +const TAU = 2 * PI; +const rnd = () => Math.random(); +const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); +const mix = (a, b, t) => a + (b - a) * t; +const mix3 = (a, b, t) => [mix(a[0], b[0], t), mix(a[1], b[1], t), mix(a[2], b[2], t)]; + +function hsv(h, s, v) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6; + const f = h - floor(h); + const p = v * (1 - s); + const q = v * (1 - s * f); + const t = v * (1 - s * (1 - f)); + return [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]][i]; +} + +// A folded crane, head forward along -x, up along y, wings out along z. Each +// face is one flat facet of the paper; the far side mirrors the near one. +const PT = { + F: [-2.3, 0.3, 0], B: [2.3, 0.3, 0], D: [0, -1.9, 0], S: [0, 0, 1.3], + W0: [-2.4, 0.5, 0.3], W1: [2.4, 0.5, 0.3], Wm: [0.3, -0.4, 1.3], Wt: [1.5, 2.4, 4.8], + N0: [-1, -1.6, 0.6], N1: [-3.4, 0.9, 0], Nt: [-4.9, 5.4, 0], H0: [-4.1, 5.8, 0.4], Hp: [-6.6, 4.1, 0], + T0: [1, -1.6, 0.6], T1: [3.4, 0.9, 0], Tt: [5.3, 5.8, 0], +}; +const MESH = Float32Array.from( + [1, -1].flatMap((s) => + "F S D,S B D,W0 Wm Wt,Wm W1 Wt,N0 N1 Nt,Nt H0 Hp,T0 T1 Tt" + .split(",") + .flatMap((f) => f.split(" ").flatMap((k) => [PT[k][0], PT[k][1], PT[k][2] * s])), + ), +); +const TV = new Float32Array(9); +const CE = cos(0.38); +const SE = sin(0.38); +const GOLD = [1, 0.74, 0.3]; + +export function setup(params) { + const P = params; + Object.assign(P, { + cb: new Float32Array(SN * 3), + zb: new Float32Array(SN), + bg: new Float32Array(N * 3), + rec: new Float32Array(N), + grain: Float32Array.from({ length: N }, () => rnd() - 0.5), + key: "", + started: false, + ph: 0, + psi: 0, + vpsi: 0, + gust: 0, + gx: -30, + next: 7, + fall: [], + paper: 0, + B: 2, + n: 8, + light: 0.06, + }); + P.S = Array.from({ length: MAXS }, () => ({ + ax: 0, vx: 0, az: 0, vz: 0, tw: 0, vt: 0, + p: rnd() * TAU, q: rnd() * TAU, cut: floor(rnd() * 3.6), dir: rnd() < 0.5 ? 0 : PI, hj: (rnd() - 0.5) * 0.05, lost: 0, + })); +} + +// Crane size, spacing and count for the strand count. +function layout(n) { + const k = 1.45 - 0.04 * n; + const dy = 6.6 * k + 1; + return { k, dy, nmax: floor((104 - RY - 7) / dy) + 1 }; +} + +function gust(P, g, loosen) { + P.gust = max(P.gust, g); + P.gx = -12; + P.vpsi += 0.35 * g * (rnd() < 0.5 ? -1 : 1); + for (let s = 0; s < MAXS; s++) P.S[s].vt += g * (2 + 3 * rnd()) * (rnd() < 0.5 ? -1 : 1); + if (!loosen) return; + // The bottom crane of the nearest strand that still has a few slips off. + const { nmax, dy } = layout(P.n); + let best = -1; + let bz = -2; + for (let s = 0; s < P.n; s++) { + const st = P.S[s]; + const sa = sin(P.psi + ((s + 0.25) / P.n) * TAU); + if (st.lost < 2 && nmax - st.cut - st.lost > 5 && sa > bz) { + bz = sa; + best = s; + } + } + if (best < 0) return; + const st = P.S[best]; + const i = nmax - st.cut - st.lost - 1; + st.lost++; + const phi = P.psi + ((best + 0.25) / P.n) * TAU; + const u = (7 + i * dy) / 100; + const z = RR * sin(phi); + const x = CX + (RR * cos(phi) + st.ax * u) * (1 + z * 0.004); + P.fall.push({ x, x0: x, y: RY + RE * sin(phi) + 7 + i * dy, z: z + 4, yaw: -phi, t: 0, i, s: best, down: false }); + if (P.fall.length > 5) P.fall.shift(); +} + +function step(P, h) { + const B = P.B; + P.ph += h * (0.35 + 0.65 * sqrt(B / 2)); + const T = P.ph; + if ((P.next -= h) <= 0) { + gust(P, 0.35 + 0.3 * rnd(), false); + P.next = 6 + 7 * rnd(); + } + P.gust *= exp(-h / 1.8); + P.gx += h * 36; + const n = P.n; + for (let s = 0; s < n; s++) { + const st = P.S[s]; + const phi = P.psi + ((s + 0.25) / n) * TAU; + const x = CX + RR * cos(phi); + // The draught: a lean to the right that breathes, a flutter that runs + // across the bundle, and the gust front passing left to right. + const front = 1 / (1 + exp((x - P.gx) / 6)); + const wind = 3 * B * (0.4 + 0.6 * sin(0.61 * T + st.p) + 0.35 * sin(1.37 * T - 0.06 * x + st.q)) + (3 + 2.5 * B) * P.gust * front; + st.vx += h * (5.5 * (wind - st.ax) - 1.1 * st.vx); + st.ax += h * st.vx; + const wz = 2 * B * sin(0.43 * T + st.q) + 3 * B * P.gust * front * sin(st.p); + st.vz += h * (5.5 * (wz - st.az) - 1.1 * st.vz); + st.az += h * st.vz; + const tq = 0.3 * B * sin(0.53 * T + 2 * st.p); + st.vt += h * (3 * (tq - st.tw) - 0.45 * st.vt); + st.tw += h * st.vt; + } + // The whole hanging twists on its one cord. + const tr = 0.16 * B * sin(0.23 * T) + 0.06 * B * sin(0.67 * T + 1); + P.vpsi += h * (1.4 * (tr - P.psi) - 0.35 * P.vpsi); + P.psi += h * P.vpsi; + // A loose crane glides down like a leaf, rocking and turning. + for (const f of P.fall) { + if (f.down) continue; + f.t += h; + const sw = sin(2.1 * f.t); + f.y += h * (8 + 10 * sw * sw); + f.x = f.x0 + 7 * sin(2.1 * f.t - 0.3) * min(1, f.t); + f.yaw += h * 1.6; + f.roll = 0.55 * cos(2.1 * f.t) * min(1, f.t * 2); + if (f.y >= FLOOR + 2.6) { + f.y = FLOOR + 2.6; + f.roll = 0; + f.down = true; + } + } +} + +export function update(dt, input, params) { + const P = params; + const k = input.knobValues || [0, 2, 8, 0.06]; + dt = clamp(dt || 0, 0, 0.1); + P.paper = clamp(k[0], 0, 1); + P.B = clamp(k[1], 0.2, 3); + P.n = clamp(round(k[2]), 8, MAXS); + P.light = clamp(k[3], 0, 1); + if (!P.started) { + P.started = true; + for (let i = 0; i < 60; i++) step(P, 1 / 15); + } + if (input.btnPressed && input.btnPressed[0]) gust(P, 1, true); + for (let i = 0; i < 2; i++) step(P, dt / 2); +} + +// The room: dark wood boards, a round shoji window behind the cranes, the +// lamp, a polished floor. Kept until the light changes. +function room(P) { + const k = P.light; + const night = 1 - k; + const pap = k < 0.5 ? mix3([0.1, 0.15, 0.38], [0.66, 0.38, 0.4], k * 2) : mix3([0.66, 0.38, 0.4], [0.94, 0.88, 0.74], k * 2 - 1); + const { bg, rec, grain } = P; + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const p = y * W + x; + const o = p * 3; + const g = grain[p]; + const dx = x - 31.5; + const dy = y - WY; + const lamp = night * exp(-((x - 7) * (x - 7)) / 700 - ((y - 106) * (y - 106)) / 2500); + const lit = 0.3 + 1.4 * lamp + 0.9 * k * (0.4 + (0.6 * x) / 63) * (1.1 - (0.5 * y) / 127); + const r = sqrt(dx * dx + dy * dy); + // Each LED is wood taking w of the light, plus paper glowing pp, and + // the cranes' shadows fall on it rc dark. + let w = 0; + let pp = 0; + let rc = 0.7; + if (y < 3) w = y === 2 ? 0.35 : 0.7; + else if (y >= FLOOR) { + // Boards, and the window seen dimly in the polish. + const b = (y - FLOOR) % 3 === 0 ? 0.6 : 1; + w = 0.75 * b; + pp = exp(-(dx * dx) / 500) * 0.22 * b; + } else if (x < 3 || x > 60) w = x === 2 || x === 61 ? 0.8 : 0.53; + else if (r < WR) { + // Paper between the kumiko bars, brightest where the moon or sun is. + const spot = exp(-((dx - 10) * (dx - 10) + (dy + 12) * (dy + 12)) / 500); + const bar = (x + 1) % 7 === 0 || (y - WY + 99) % 9 === 0; + pp = (0.62 + 0.55 * spot + 0.04 * g) * (bar ? 0.28 : 1); + rc = 0.2; + } else if (r < WR + 2.2) w = r < WR + 1 ? 0.62 : 0.35; + else { + const tone = 0.8 + 0.25 * sin(floor((x + 3) / 7) * 12.9898) ** 2 + 0.12 * g; + w = 0.55 * tone * ((x + 3) % 7 === 0 ? 0.55 : 1); + } + w *= lit; + let cr = 0.42 * w + pap[0] * pp; + let cg = 0.26 * w + pap[1] * pp; + let cb = 0.15 * w + pap[2] * pp; + // The andon: a paper lamp on short legs in the corner, lit at night. + if (x >= 3 && x <= 12 && y >= 98 && y < FLOOR && (y < 117 || x === 4 || x === 11)) { + rc = 0; + if (y >= 117 || x === 3 || x === 12 || y === 98 || y === 107 || y === 116) { + const f = 0.3 * (0.4 + lit); + cr = 0.42 * f; + cg = 0.26 * f; + cb = 0.15 * f; + } else { + const glow = night * (0.55 + 0.3 * exp(-((x - 7.5) * (x - 7.5)) / 6 - ((y - 106) * (y - 106)) / 40)); + cr = 0.14 * lit + glow; + cg = 0.13 * lit + 0.62 * glow; + cb = 0.12 * lit + 0.26 * glow; + } + } + rec[p] = rc; + bg[o] = cr * 255; + bg[o + 1] = cg * 255; + bg[o + 2] = cb * 255; + } +} + +function lights(P) { + const k = P.light; + const lx = mix(-0.8, 0.65, k); + const ly = mix(0.35, 0.6, k); + const lz = mix(0.6, 0.45, k); + const l = hypot(lx, ly, lz); + P.L = [lx / l, ly / l, lz / l]; + const hl = hypot(P.L[0], P.L[1], P.L[2] + 1); + P.Hv = [P.L[0] / hl, P.L[1] / hl, (P.L[2] + 1) / hl]; + P.lc = k < 0.5 ? mix3([1, 0.8, 0.56], [1, 0.68, 0.6], k * 2) : mix3([1, 0.68, 0.6], [0.9, 0.95, 1], k * 2 - 1); + P.amb = mix3([0.14, 0.16, 0.3], [0.3, 0.29, 0.27], k); +} + +function put(P, x, y, z, r, g, b) { + x = floor(x); + y = floor(y); + if (x < 0 || x >= SW || y < 0 || y >= SH) return; + const i = y * SW + x; + if (z <= P.zb[i]) return; + P.zb[i] = z; + P.cb[i * 3] = r; + P.cb[i * 3 + 1] = g; + P.cb[i * 3 + 2] = b; +} + +function line(P, x0, y0, z0, x1, y1, z1, c) { + const n = max(1, Math.ceil(max(Math.abs(x1 - x0), Math.abs(y1 - y0)))); + for (let j = 0; j <= n; j++) { + const t = j / n; + put(P, mix(x0, x1, t), mix(y0, y1, t), mix(z0, z1, t), c[0], c[1], c[2]); + } +} + +// A lit sphere: a bead or the knot over the ring. +function bead(P, x, y, z, rad, col, occl) { + const { L, Hv, lc, amb } = P; + for (let j = floor(y - rad); j <= y + rad; j++) + for (let i = floor(x - rad); i <= x + rad; i++) { + const nx = (i + 0.5 - x) / rad; + const ny = -(j + 0.5 - y) / rad; + const q = 1 - nx * nx - ny * ny; + if (q < 0) continue; + const nz = sqrt(q); + const d = max(0, nx * L[0] + ny * L[1] + nz * L[2]); + const sp = 0.9 * max(0, nx * Hv[0] + ny * Hv[1] + nz * Hv[2]) ** 10; + const f = 255 * occl; + put(P, i, j, z + nz * rad * 0.5, f * (col[0] * (amb[0] + 1.2 * d * lc[0]) + sp * lc[0]), f * (col[1] * (amb[1] + 1.2 * d * lc[1]) + sp * lc[1]), f * (col[2] * (amb[2] + 1.2 * d * lc[2]) + sp * lc[2])); + } +} + +function tri(P, x0, y0, z0, x1, y1, z1, x2, y2, z2, r, g, b) { + const A = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0); + if (A > -0.02 && A < 0.02) return; + const ia = 1 / A; + const xa = max(0, floor(min(x0, x1, x2))); + const xb = min(SW - 1, floor(max(x0, x1, x2))); + const ya = max(0, floor(min(y0, y1, y2))); + const yb = min(SH - 1, floor(max(y0, y1, y2))); + const { zb, cb } = P; + for (let y = ya; y <= yb; y++) { + const py = y + 0.5; + for (let x = xa; x <= xb; x++) { + const px = x + 0.5; + const w0 = ((x1 - px) * (y2 - py) - (x2 - px) * (y1 - py)) * ia; + const w1 = ((x2 - px) * (y0 - py) - (x0 - px) * (y2 - py)) * ia; + const w2 = 1 - w0 - w1; + if (w0 < 0 || w1 < 0 || w2 < 0) continue; + const z = w0 * z0 + w1 * z1 + w2 * z2; + const i = y * SW + x; + if (z <= zb[i]) continue; + zb[i] = z; + cb[i * 3] = r; + cb[i * 3 + 1] = g; + cb[i * 3 + 2] = b; + } + } +} + +// One crane at screen point (cx, cy) in LEDs, depth cz, size k, turned yaw +// about its thread and tilted roll in the picture plane. Each facet is lit +// on its own: the side facing the lamp is bright, a facet that turns to +// mirror the lamp into the room flashes. +function crane(P, cx, cy, cz, k, yaw, roll, col, occl, gold) { + const cw = cos(yaw); + const sw = sin(yaw); + const cr = cos(roll); + const sr = sin(roll); + const { L, Hv, lc, amb } = P; + const shine = gold ? 1.1 : 0.35; + const pw = gold ? 6 : 14; + for (let t = 0; t < MESH.length; t += 9) { + for (let v = 0; v < 9; v += 3) { + const x = MESH[t + v]; + const y = MESH[t + v + 1]; + const z = MESH[t + v + 2]; + const x1 = x * cw + z * sw; + const z1 = z * cw - x * sw; + const y1 = x1 * sr + y * cr; + // Seen from a little above. + TV[v] = x1 * cr - y * sr; + TV[v + 1] = y1 * CE - z1 * SE; + TV[v + 2] = y1 * SE + z1 * CE; + } + const ax = TV[3] - TV[0]; + const ay = TV[4] - TV[1]; + const az = TV[5] - TV[2]; + const bx = TV[6] - TV[0]; + const by = TV[7] - TV[1]; + const bz = TV[8] - TV[2]; + let nx = ay * bz - az * by; + let ny = az * bx - ax * bz; + let nz = ax * by - ay * bx; + let nl = sqrt(nx * nx + ny * ny + nz * nz); + if (nl < 1e-6) continue; + // Paper has two sides: light the one that faces us. + if (nz < 0) nl = -nl; + nx /= nl; + ny /= nl; + nz /= nl; + // Paper lets a little light round the fold, so no facet goes black. + const d = max(0, nx * L[0] + ny * L[1] + nz * L[2] + 0.35) / 1.35; + const sp = shine * max(0, nx * Hv[0] + ny * Hv[1] + nz * Hv[2]) ** pw; + const r = 255 * occl * (col[0] * (amb[0] + 1.15 * d * lc[0]) + sp * lc[0]); + const g = 255 * occl * (col[1] * (amb[1] + 1.15 * d * lc[1]) + sp * lc[1]); + const b = 255 * occl * (col[2] * (amb[2] + 1.15 * d * lc[2]) + sp * lc[2]); + tri(P, 2 * (cx + k * TV[0]), 2 * (cy - k * TV[1]), cz + k * TV[2], 2 * (cx + k * TV[3]), 2 * (cy - k * TV[4]), cz + k * TV[5], 2 * (cx + k * TV[6]), 2 * (cy - k * TV[7]), cz + k * TV[8], r, g, b); + } +} + +// The colour of crane i from the top: bands from orange through the +// rainbow, under a first crane of gold foil on every strand. +function paperOf(P, i, nmax, st) { + if (i === 0) return GOLD; + const c = hsv(P.paper + 0.12 + (0.76 * (i - 1)) / max(1, nmax - 2) + st.hj, 0.82, 1); + return [c[0] * 0.92 + 0.08, c[1] * 0.92 + 0.08, c[2] * 0.92 + 0.08]; +} + +function hanging(P) { + const { nmax, k, dy } = layout(P.n); + const n = P.n; + const { lc, amb } = P; + const thread = [0.86, 0.8, 0.7]; + const silk = [0.95, 0.2, 0.14]; + for (let s = 0; s < n; s++) { + const st = P.S[s]; + const phi = P.psi + ((s + 0.25) / n) * TAU; + const ca = cos(phi); + const sa = sin(phi); + const ya = RY + RE * sa; + // The inside of the bundle is in shade. + const occl = 0.4 + 0.6 * (0.5 + 0.5 * sa); + const cnt = nmax - st.cut - st.lost; + const tc = thread.map((v, m) => 255 * occl * v * (amb[m] + 0.55 * lc[m])); + let px = 2 * (CX + RR * ca * (1 + RR * sa * 0.004)); + let py = 2 * ya; + let pz = RR * sa - 2; + const pts = []; + for (let i = 0; i <= cnt; i++) { + const yy = 7 + i * dy - (i === cnt ? dy * 0.35 : 0); + const u = yy / 100; + const dx = st.ax * u + 0.7 * P.B * u * sin(2.1 * P.ph - 3.2 * u + st.p); + const z = RR * sa + st.az * u; + const pp = 1 + z * 0.004; + const x = CX + (RR * ca + dx) * pp; + const y = ya + yy; + line(P, px, py, pz, 2 * x, 2 * y, z - 2 * k, tc); + px = 2 * x; + py = 2 * y; + pz = z - 2 * k; + pts.push([x, y, z, pp, u]); + } + for (let i = 0; i < cnt; i++) { + const [x, y, z, pp, u] = pts[i]; + const slope = (pts[i + 1][0] - x) / dy; + const yaw = -phi - PI / 2 + st.dir + st.tw * (0.3 + u) + (0.25 + 0.2 * P.B) * sin(1.3 * P.ph - 0.6 * i + st.q); + crane(P, x, y, z, k * pp, yaw, -slope, paperOf(P, i, nmax, st), occl, i === 0); + } + // A gold bead and a silk tassel that trails the swing. + const [x, y, z] = pts[cnt]; + bead(P, 2 * x, 2 * y, z, 2 * k + 0.2, GOLD, occl); + const lag = -0.25 * st.vx; + const sc = silk.map((v, m) => 255 * occl * v * (amb[m] + 0.9 * lc[m])); + const len = round(15 * k); + for (let j = 0; j < len; j++) { + const hw = (1.1 + j * 0.13) * k; + const xc = 2 * x + lag * j * 0.1; + for (let xx = floor(xc - hw); xx <= xc + hw; xx++) { + const f = (xx & 1 ? 0.7 : 1) * (j > len - 3 ? 0.75 : 1); + put(P, xx, 2 * y + 2 + j, z, sc[0] * f, sc[1] * f, sc[2] * f); + } + } + } +} + +function ring(P) { + const { L, Hv, lc, amb } = P; + for (let a = 0; a < TAU; a += 0.01) { + const ca = cos(a); + const sa = sin(a); + const z = RR * sa; + const x = 2 * (CX + RR * ca * (1 + z * 0.004)); + const y = 2 * (RY + RE * sa); + const occl = 0.45 + 0.55 * (0.5 + 0.5 * sa); + // Lacquered wood, round in section: lit on top and toward the lamp. + for (let q = -2; q <= 1; q++) { + const ny = -(q + 0.5) * 0.7; + const nl = sqrt(1 + ny * ny); + const d = max(0, (ca * L[0] + ny * L[1] + sa * L[2]) / nl); + const sp = 0.8 * max(0, (ca * Hv[0] + ny * Hv[1] + sa * Hv[2]) / nl) ** 12; + const f = 255 * occl; + put(P, x, y + q, z + 1, f * (0.85 * (amb[0] + 1.1 * d * lc[0]) + sp * lc[0]), f * (0.16 * (amb[1] + 1.1 * d * lc[1]) + sp * lc[1]), f * (0.09 * (amb[2] + 1.1 * d * lc[2]) + sp * lc[2])); + } + } + // Four cords up to a knot under the beam. + const cc = [0.9, 0.75, 0.4].map((v, m) => 255 * v * (amb[m] + 0.6 * lc[m])); + for (let j = 0; j < 4; j++) { + const a = P.psi + PI / 4 + (j * PI) / 2; + const z = RR * sin(a); + line(P, 2 * (CX + RR * cos(a) * (1 + z * 0.004)), 2 * (RY + RE * sin(a)), z, 2 * CX, 10, 0, cc); + } + line(P, 2 * CX, 0, 0, 2 * CX, 10, 0, cc); + bead(P, 2 * CX, 10, 1, 3.2, [0.9, 0.16, 0.12], 1); +} + +export function draw(display, params, time) { + const P = params; + if (!P.S) return; + const key = `${P.light}`; + if (key !== P.key) { + P.key = key; + room(P); + } + lights(P); + P.zb.fill(-1e9); + ring(P); + hanging(P); + const { nmax, k } = layout(P.n); + for (const f of P.fall) crane(P, f.x, f.y, f.z, k, f.yaw, f.roll || 0, paperOf(P, f.i, nmax, P.S[f.s]), 1, false); + const { zb, cb, bg, rec, L } = P; + // The cranes' shadows on the wall, thrown away from the light. + const ox = round(clamp((-L[0] / L[2]) * 16, -24, 24)); + const oy = round(clamp((L[1] / L[2]) * 16, -16, 16)); + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + let r = 0; + let g = 0; + let b = 0; + let c = 0; + for (let q = 0; q < 4; q++) { + const i = (2 * y + (q >> 1)) * SW + 2 * x + (q & 1); + if (zb[i] > -1e8) { + r += cb[i * 3]; + g += cb[i * 3 + 1]; + b += cb[i * 3 + 2]; + c++; + } + } + const o = (y * W + x) * 3; + // Thin paper edges count a little more than their share, so a neck + // half an LED wide still shows. + const a = min(1, c * 0.36); + const f = 1 - a; + const ic = c ? a / c : 0; + const sx = 2 * x - ox; + const sy = 2 * y - oy; + const sh = f * (sx >= 0 && sx < SW && sy >= 0 && sy < SH && zb[sy * SW + sx] > -1e8 ? 1 - 0.6 * rec[o / 3] : 1); + display.setPixel(x, y, min(255, r * ic + sh * bg[o]), min(255, g * ic + sh * bg[o + 1]), min(255, b * ic + sh * bg[o + 2])); + } +} From d05c172c38ee06e29235dd259e93541f5628a166 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 22:35:04 +0000 Subject: [PATCH 08/17] pattern: Night Flight Co-Authored-By: Claude Opus 5.5 --- .../pattern-study/patterns/night-flight.js | 478 ++++++++++++++++++ 1 file changed, 478 insertions(+) create mode 100644 incoming/pattern-study/patterns/night-flight.js diff --git a/incoming/pattern-study/patterns/night-flight.js b/incoming/pattern-study/patterns/night-flight.js new file mode 100644 index 000000000..a5213ebea --- /dev/null +++ b/incoming/pattern-study/patterns/night-flight.js @@ -0,0 +1,478 @@ +// ===== Patternflow pattern ===== +// Title: Night Flight +// Family: city +// Idea: A city at night seen straight down from an airliner window: a lace of single-LED street lights traces the grid, the boulevards, a highway full of car lights and the bridges over a dark river, all on the sodium glow of the town, sliding slowly past the wingtip and its blinking lights while wisps of cloud drift under the plane. +// Author: patternflow-lab +// Date: 2026-10-23 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 22, after night photographs of cities taken from airliner windows +// Round: 22 +// =============================== +// @matrix 64x128 +// @knobs Lamps=0..1, Speed=0.2..3, Grid=0..1, Haze=0..1 +// +// Knob 1: Lamps. The street lights: an old sodium city at 0, orange with the odd green mercury lamp; turned up, the lamps are swapped for white LEDs one by one until the whole town is white at 1. +// Knob 2: Speed. Ground speed: at 2 the city slides about 3.4 LEDs a second toward the lower left, at 0.2 it barely creeps; the cars on the highway keep their own pace on top of it. +// Knob 3: Grid. Street spacing inside the boulevards: blocks eight or nine LEDs apart at 0, twelve or thirteen at 1, with more house lights inside the wider ones. +// Knob 4: Haze. Cloud under the plane: a clear night with the odd wisp at the low end, a broken layer lit from below by the town in the middle, and at 1 an overcast the city only glows through. +// Buttons: K3 tap flies over another city. + +const W = 64; +const H = 128; +const N = W * H; +// Boulevards about every 44 LEDs, rivers about every 380 LEDs along the track. +const SB = 44; +const RV = 380; +const HW = 3.3; +// The city carpet: every LED leans this far away from its neighbours. +const AMP = 18.5; +const [RES, DOWN, DIAG, YARD, SUB] = [0, 1, 2, 3, 4]; +const [STREET, BLVD, WHITE, HOUSE, FLOOD, HWY, PATH] = [1, 2, 3, 4, 5, 6, 7]; +const { min, max, floor, round, sin, cos, abs, sqrt, exp } = Math; +const rnd = () => Math.random(); +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const md = (a, m) => ((a % m) + m) % m; +const R2 = 0.70710678; + +function hash(i, j) { + let h = (Math.imul(i, 374761393) + Math.imul(j, 668265263)) | 0; + h = Math.imul(h ^ (h >>> 13), 1274126177); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +} +const h3 = (i, j, k) => hash(i + Math.imul(k, 1013904223), j ^ Math.imul(k, 1664525)); +function vn(x, y) { + const xi = floor(x), yi = floor(y); + let u = x - xi, v = y - yi; + u = u * u * (3 - 2 * u); + v = v * v * (3 - 2 * v); + const a = hash(xi, yi), b = hash(xi + 1, yi), c = hash(xi, yi + 1), d = hash(xi + 1, yi + 1); + return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v; +} + +// Boulevard lines, jittered a little each. +const AX = (S, i) => i * SB + floor(h3(i, 0, S + 101) * 9) - 4; +const AY = (S, j) => j * SB + floor(h3(j, 0, S + 202) * 9) - 4; + +// River k's centre line across the track (u) and its slope, in LEDs. +function riverAt(S, k, u) { + const p1 = u / 19 + h3(k, 7, S) * 6.283; + const p2 = u / 7.3 + h3(k, 8, S) * 6.283; + const v = k * RV + (h3(k, 9, S) - 0.5) * 50 + 7 * sin(p1) + 2.2 * sin(p2); + return [v, (7 / 19) * cos(p1) + (2.2 / 7.3) * cos(p2)]; +} +function riverDist(S, p, q) { + const u = p * R2; + const v = q * R2; + const [vr, dv] = riverAt(S, round(v / RV), u); + return abs(v - vr) / sqrt(1 + dv * dv); +} + +// One cell of the city, kept in a window-sized ring buffer by world position. +function gen(P, wx, wy) { + const S = P.seed; + const p = wx + wy; + const q = wx - wy; + const plus = (p & 1) === 0; + let i = floor(wx / SB); + if (wx < AX(S, i)) i--; + else if (wx >= AX(S, i + 1)) i++; + let j = floor(wy / SB); + if (wy < AY(S, j)) j--; + else if (wy >= AY(S, j + 1)) j++; + const x0 = AX(S, i); + const y0 = AY(S, j); + const bw = AX(S, i + 1) - x0; + const bh = AY(S, j + 1) - y0; + const dx = wx - x0; + const dy = wy - y0; + const ed = cl(min(dx - 1, bw - dx, dy - 1, bh - dy) / 9, 0, 1); + const hk = h3(i, j, S + 3); + const kind = hk < 0.1 ? DOWN : hk < 0.2 ? DIAG : hk < 0.28 ? YARD : hk < 0.42 ? SUB : RES; + const sp = P.sp + (h3(i, j, S + 4) < 0.4 ? 1 : 0); + const r = h3(wx, wy, S + 5); + const r2 = h3(wy, wx, S + 6); + let M = 26 + 10 * vn(wx / 23 + 0.37, wy / 23 + (S & 255)); + if (kind === DOWN) M += 12 * ed; + else if (kind === YARD) M -= 5 * ed; + let k = 0; + let b = 0; + const blvd = dx < 2 || dy < 2; + const house = 0.035 + P.grid * 0.05; + if (plus) { + if (blvd) [k, b] = [BLVD, 1]; + else if (kind === DOWN) { + const s = sp - 1; + if ((dx - 1) % s === 0 || (dy - 1) % s === 0) [k, b] = [WHITE, 0.95]; + else if (r < 0.1) [k, b] = [WHITE, 0.9 + 0.1 * r2]; + } else if (kind === DIAG) { + const D = 2 * sp + 2; + if (md(q, D) === 0 || md(p, D) === 0) [k, b] = [STREET, 0.96 + 0.04 * r2]; + else if (r < house) [k, b] = [HOUSE, 0.94]; + } else if (kind === YARD) { + const s = sp + 3; + const lx = (dx - 1) % s; + const ly = (dy - 1) % s; + const lot = h3(i * 31 + floor((dx - 1) / s), j * 31 + floor((dy - 1) / s), S + 11); + if ((lx === 0 || ly === 0) && r > 0.25) [k, b] = [FLOOD, 0.92]; + else if (lot < 0.4 && lx > 1 && ly > 1 && lx < s - 1 && ly < s - 1 && (wy & 1) === 0) [k, b] = [FLOOD, 0.92 + 0.08 * r2]; + } else if (kind === SUB) { + // Curving suburban streets: contour lines of a slow noise. + const u0 = i * 3.1 + 0.5; + const v0 = j * 1.7 + 0.5; + const lv = floor(vn(wx / 15 + u0, wy / 15 + v0) * 3); + if (lv !== floor(vn((wx + 1) / 15 + u0, wy / 15 + v0) * 3) || lv !== floor(vn(wx / 15 + u0, (wy + 1) / 15 + v0) * 3)) + [k, b] = [STREET, 0.96 + 0.04 * r2]; + else if (r < house * 1.8) [k, b] = [HOUSE, 0.94]; + } else { + const onX = (dx - 1) % sp === 0 && dx < bw - 2; + const onY = (dy - 1) % sp === 0 && dy < bh - 2; + if (onX || onY) { + if (r > 0.08) [k, b] = [STREET, 0.96 + 0.04 * r2]; + } else if (r < house) [k, b] = [HOUSE, 0.94]; + } + } + // A park over a few blocks, dark, with one lit path across it. + if (kind === RES && h3(i, j, S + 7) < 0.45) { + const nx = max(1, floor((bw - 3) / sp)); + const ny = max(1, floor((bh - 3) / sp)); + const pw = min(nx, 1 + (h3(i, j, S + 8) < 0.6 ? 1 : 0)); + const ph = min(ny, 2); + const px0 = 1 + floor(h3(i, j, S + 9) * (nx - pw + 1)) * sp; + const py0 = 1 + floor(h3(i, j, S + 10) * (ny - ph + 1)) * sp; + const pd = min(dx - px0, px0 + pw * sp - dx, dy - py0, py0 + ph * sp - dy); + if (pd > 0) { + if (!blvd) k = 0; + M += (21 - M) * cl(pd / 8, 0, 1); + if (plus && dx - px0 === dy - py0) [k, b] = [PATH, 0.75]; + } + } + // The river, its embankment roads and bridges where the boulevards cross. + const d = riverDist(S, p, q); + M += (20 - M) * cl((HW + 6 - d) / 9, 0, 1); + const wt = cl((HW + 0.6 - d) / 1.6, 0, 1); + if (d < HW + 2.8 && !blvd) k = 0; + if (plus && d >= HW + 0.9 && d < HW + 2.3) [k, b] = [STREET, 0.92]; + // The highway corridor: boulevards bridge it, the median carries lamps, + // the two carriageways (dp 0 and 4) carry the cars drawn each frame. + const dp = p - P.ph; + if (dp >= -4 && dp <= 8) { + if (!blvd || abs(dp - 2) <= 3) k = 0; + M += (21 - M) * cl((7 - abs(dp - 2)) / 7, 0, 1); + if ((dp === -2 || dp === 6) && md(q + dp * 2, 8) === 0) [k, b] = [HWY, 1]; + } + const slot = (wx & 63) + md(wy, H) * W; + P.KD[slot] = k; + P.LB[slot] = b; + P.LH[slot] = h3(wx, wy, S + 12); + P.MS[slot] = M; + P.WT[slot] = wt; +} + +function build(P) { + for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) gen(P, P.ox + x, P.oy + y); +} + +// One step along the track: the ground moves one LED left and one down. +function step(P) { + P.ox += 1; + P.oy -= 1; + for (let y = 0; y < H; y++) gen(P, P.ox + W - 1, P.oy + y); + for (let x = 0; x < W; x++) gen(P, P.ox + x, P.oy); +} + +function place(P) { + P.seed = floor(rnd() * 4096); + let ox = 20000 + floor(rnd() * 4000); + let oy = 20000 + floor(rnd() * 4000); + // Put a river across the middle of the window. + const u = (ox + oy + 96) * R2; + const qc = ox - oy - 32; + const [vr] = riverAt(P.seed, round((qc * R2) / RV), u); + const dl = round((vr / R2 - qc + (rnd() - 0.5) * 30) / 2); + ox += dl; + oy -= dl; + P.ox = ox; + P.oy = oy; + P.p0 = ox + oy; + const ph = P.p0 + 80 + floor(rnd() * 12); + P.ph = ph + (ph & 1); +} + +// The wingtip, fixed in the window: a swept wing in from the left edge. +const WING = [ + [-1, 92], + [30, 110.5], + [31, 113], + [27.5, 116.5], + [-1, 131], +]; +function inPoly(px, py, poly) { + let c = false; + for (let a = 0, b = poly.length - 1; a < poly.length; b = a++) { + const [xa, ya] = poly[a]; + const [xb, yb] = poly[b]; + if (ya > py !== yb > py && px < ((xb - xa) * (py - ya)) / (yb - ya) + xa) c = !c; + } + return c; +} + +export function setup(params) { + const P = params; + Object.assign(P, { + KD: new Uint8Array(N), + LB: new Float32Array(N), + LH: new Float32Array(N), + MS: new Float32Array(N), + WT: new Float32Array(N), + wing: new Float32Array(N), + CL: new Float32Array(33 * 65), + GA: new Float32Array(N), + GB: new Float32Array(N), + glowDirty: true, + acc: 0, + cs: 0, + t: 0, + sp: 0, + built: false, + }); + place(P); + // Wing shading: darker toward the root, a faint sheen along the leading edge. + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + if (!inPoly(x + 0.5, y + 0.5, WING)) continue; + const lead = abs((y + 0.5 - 92) * 31 - (x + 1.5) * 18.5) / 36.1; + P.wing[x + y * W] = 26 + 8 * (x / 30) + 40 * cl(1 - lead / 1.6, 0, 1) ** 1.5; + } + // The lights at the tip sit on cells the carpet pushes up. + P.nav = (P.p0 + 29 + 111) & 1 ? [30, 111] : [29, 111]; + P.strobe = (P.p0 + 27 + 115) & 1 ? [28, 115] : [27, 115]; +} + +export function update(dt, input, params) { + const P = params; + const k = input.knobValues || [0, 2, 0, 0.06]; + dt = cl(dt, 0, 0.1); + P.k1 = cl(k[0], 0, 1); + P.spd = cl(k[1], 0.2, 3); + P.grid = cl(k[2], 0, 1); + P.haze = cl(k[3], 0, 1); + const sp = 8 + round(P.grid * 4); + if (input.btnPressed && input.btnPressed[2]) { + place(P); + P.built = false; + } + if (!P.built || sp !== P.sp) { + P.sp = sp; + P.built = true; + P.glowDirty = true; + build(P); + } + P.acc += dt * P.spd * 1.2; + let n = 0; + while (P.acc >= 1) { + P.acc -= 1; + n++; + } + if (n > 24) { + P.ox += n; + P.oy -= n; + build(P); + } else for (let s = 0; s < n; s++) step(P); + // Cloud is nearer than the ground, so it slides past faster. + P.cs += dt * P.spd * 1.2 * 1.414 * 1.7; + P.t += dt; + if (n || P.glowDirty) glow(P); + // Cloud cover on a 2-LED lattice. + const th = 0.8 - 0.78 * P.haze; + const veil = 0.25 * P.haze * P.haze; + const thick = 0.45 + 0.4 * P.haze; + for (let gy = 0; gy < 65; gy++) + for (let gx = 0; gx < 33; gx++) { + const x = gx * 2; + const y = gy * 2; + const a = (y - x) * R2 - P.cs; + const bb = (x + y) * R2; + const nz = 0.65 * vn(a / 30 + 40, bb / 16 + 5.3 + P.t * 0.02) + 0.35 * vn(a / 11 + 70, bb / 7 + 11 - P.t * 0.03); + let dn = cl((nz - th) / 0.28, 0, 1); + dn = dn * dn * (3 - 2 * dn); + P.CL[gx + gy * 33] = min(0.88, dn * thick + veil); + } +} + +// The town's light blurred, for cloud to glow with: two box passes each way. +function glow(P) { + P.glowDirty = false; + const A = P.GA; + const B = P.GB; + for (let y = 0; y < H; y++) { + const row = md(P.oy + y, H) * W; + for (let x = 0; x < W; x++) { + const slot = ((P.ox + x) & 63) + row; + A[x + y * W] = P.KD[slot] ? P.LB[slot] : 0; + } + } + for (let pass = 0; pass < 4; pass++) { + const [src, dst] = pass & 1 ? [B, A] : [A, B]; + const horiz = pass < 2; + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + let sum = 0; + for (let d = -2; d <= 2; d++) { + const u = horiz ? cl(x + d, 0, W - 1) : x; + const v = horiz ? y : cl(y + d, 0, H - 1); + sum += src[u + v * W]; + } + dst[x + y * W] = sum / 5; + } + } +} + +const SOD = [255, 132, 26]; +const LEDW = [[255, 214, 168], [226, 236, 255]]; +const MERC = [150, 255, 196]; +const HOUSEC = [255, 176, 92]; +const FLOODC = [214, 230, 255]; +const HEAD = [255, 250, 232]; +const TAIL = [255, 30, 20]; +const C = [0, 0, 0]; +const NB = [0.5, 0.62, 1]; +const NIGHT = 0.7; + +// A lamp's colour into C, from its kind, its dice and the Lamps knob. +function lampColour(kind, h, k1) { + const h2 = (h * 7.31) % 1; + const h3c = (h * 13.7) % 1; + if (kind === STREET || kind === BLVD || kind === HWY || kind === PATH) { + const base = kind === BLVD ? 0.3 : kind === PATH ? 0.6 : 0.06; + if (h < base + k1 * (1 - base)) { + for (let c = 0; c < 3; c++) C[c] = LEDW[0][c] + (LEDW[1][c] - LEDW[0][c]) * h3c; + } else if (h2 > 0.94 && kind === STREET) { + for (let c = 0; c < 3; c++) C[c] = MERC[c]; + } else { + C[0] = SOD[0]; + C[1] = SOD[1] + 24 * h3c; + C[2] = SOD[2] + 14 * h3c; + } + } else if (kind === WHITE) { + for (let c = 0; c < 3; c++) C[c] = LEDW[0][c] + (LEDW[1][c] - LEDW[0][c]) * h2; + } else if (kind === HOUSE) { + for (let c = 0; c < 3; c++) C[c] = HOUSEC[c]; + } else { + for (let c = 0; c < 3; c++) C[c] = FLOODC[c]; + } + // Every lamp burns at full strength in its brightest channel. + const m = 255 / max(C[0], C[1], C[2]); + for (let c = 0; c < 3; c++) C[c] *= m; +} + +export function draw(display, params) { + const P = params; + if (!P.built) return; + const t = P.t; + const k1 = P.k1; + const haze = [1 - 0.18 * k1, 0.64 + 0.24 * k1, 0.36 + 0.64 * k1]; + const cloudT = [1 - 0.12 * k1, 0.8 + 0.12 * k1, 0.64 + 0.36 * k1]; + const water = [0.32, 0.5, 1]; + const wingT = [0.78, 0.84, 1]; + // Strobe: a double flash every 1.25 s. + const ph = t % 1.25; + const flash = ph < 0.05 || (ph > 0.15 && ph < 0.2) ? 1 : 0; + // Cars: lane A runs with the ground, lane B against it, in cells along the lane. + const vA = 0.9 * t; + const vB = -0.9 * t; + for (let y = 0; y < H; y++) { + const wy = P.oy + y; + const row = md(wy, H) * W; + const gy = y >> 1; + const fy = (y & 1) * 0.5; + for (let x = 0; x < W; x++) { + const wx = P.ox + x; + const slot = (wx & 63) + row; + const plus = ((x + y + P.p0) & 1) === 0; + const gx = x >> 1; + const fx = (x & 1) * 0.5; + const ci = gx + gy * 33; + const c0 = P.CL[ci] + (P.CL[ci + 1] - P.CL[ci]) * fx; + const c1 = P.CL[ci + 33] + (P.CL[ci + 34] - P.CL[ci + 33]) * fx; + const al = c0 + (c1 - c0) * fy; + const wg = P.wing[x + y * W]; + let M; + let tr; + let tg; + let tb; + if (wg > 0) { + const nd = (x - P.nav[0]) ** 2 + (y - P.nav[1]) ** 2; + const sd = (x - P.strobe[0]) ** 2 + (y - P.strobe[1]) ** 2; + M = wg + 80 * flash * exp(-sd / 60); + const gl = 0.5 * exp(-nd / 10); + tr = wingT[0] * (1 - gl); + tg = wingT[1]; + tb = wingT[2] * (1 - gl * 0.6); + let v = max(0, M + (plus ? 10 : -10)); + let r = tr * v; + let g = tg * v; + let b = tb * v; + if (x === P.nav[0] && y === P.nav[1]) [r, g, b] = [40, 255, 90]; + else if (x === P.strobe[0] && y === P.strobe[1]) { + if (flash) [r, g, b] = [255, 255, 255]; + else [r, g, b] = [70, 74, 86]; + } + display.setPixel(x, y, min(255, r), min(255, g), min(255, b)); + continue; + } + const wt = P.WT[slot]; + M = P.MS[slot]; + // Ground, water and cloud tints mix, and the brightest channel is put + // back to 1 so the mix never dims a step. + tr = haze[0] + (water[0] - haze[0]) * wt; + tg = haze[1] + (water[1] - haze[1]) * wt; + tb = haze[2] + (water[2] - haze[2]) * wt; + tr += (cloudT[0] - tr) * al; + tg += (cloudT[1] - tg) * al; + tb += (cloudT[2] - tb) * al; + // The cells between the carpet's dots fall toward blue-black. + if (!plus) { + const nb = NIGHT * (1 - al); + tr += (NB[0] - tr) * nb; + tg += (NB[1] - tg) * nb; + tb += (NB[2] - tb) * nb; + } + // Cloud lit from below by the town. + const Lc = 0.55 * M + 14 + 210 * P.GA[x + y * W] + 24 * al; + const G = M + (Lc - M) * al; + const v = max(0, G + (plus ? AMP : -AMP)) / max(tr, tg, tb); + let r = v * tr; + let g = v * tg; + let b = v * tb; + if (plus) { + let kind = P.KD[slot]; + let lb = P.LB[slot]; + let col = null; + const dp = x + y + P.p0 - P.ph; + if (dp === 0 || dp === 4) { + // Carriageways: m counts cells along the lane. + const m = (x - y + P.ox - P.oy) >> 1; + const lane = dp === 0 ? 0 : 1; + const slotK = floor(m + (lane ? vB : vA) + 0.5); + if (h3(slotK, lane, P.seed + 21) < 0.45) { + col = lane ? TAIL : HEAD; + lb = 1; + } + } + if (col || kind) { + if (!col) { + lampColour(kind, P.LH[slot], k1); + col = C; + } + const h = P.LH[slot]; + const tw = lb * (0.97 + 0.03 * sin(t * (2.5 + 4 * h) + h * 40)); + const keep = 1 - 0.85 * al; + r = col[0] * tw * keep + r * (1 - keep); + g = col[1] * tw * keep + g * (1 - keep); + b = col[2] * tw * keep + b * (1 - keep); + } + } + display.setPixel(x, y, min(255, r), min(255, g), min(255, b)); + } + } +} From 2f2a0778eb606652f420630835c55bc87841aa24 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 22:36:28 +0000 Subject: [PATCH 09/17] pattern: Weaver Co-Authored-By: Claude Opus 5.5 --- incoming/pattern-study/patterns/weaver.js | 438 ++++++++++++++++++++++ 1 file changed, 438 insertions(+) create mode 100644 incoming/pattern-study/patterns/weaver.js diff --git a/incoming/pattern-study/patterns/weaver.js b/incoming/pattern-study/patterns/weaver.js new file mode 100644 index 000000000..62690066c --- /dev/null +++ b/incoming/pattern-study/patterns/weaver.js @@ -0,0 +1,438 @@ +// ===== Patternflow pattern ===== +// Title: Weaver +// Family: bird +// Idea: A male village weaver builds his hanging nest at the tip of an acacia twig over a river: he tears a green strip off a reed, flies back and threads it through, and the nest grows strand by strand from a ring to a round chamber with a downward entrance while the old strips fade to straw. +// Author: patternflow-lab +// Date: 2026-10-23 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 22, after field photographs of village weaver (Ploceus cucullatus) colonies +// Round: 22 +// =============================== +// @matrix 64x128 +// @knobs Plumage=0..2, Speed=0.2..3, Nests=3..9, Sky=0..1 +// +// Knob 1: Plumage. A village weaver at 0, yellow with a black mask and a red eye, over a river; a golden weaver at 1 over a reed bed; a brown-masked baya weaver at 2 over a paddy field, its nests hung from a woven neck with a long tube. +// Knob 2: Speed. How fast the birds, the wind and the water go: at 2 a strand takes him about eight seconds, there, back and woven in. +// Knob 3: Nests. Three to nine nests in the tree, his among them. The bigger the colony, the further along his nest is at the start, from a ring with its roof begun to a chamber that wants only its entrance. +// Knob 4: Sky. Morning at 0 with the sun low on the right, noon at 0.5, evening at 1 with the sun going down on the left. +// Buttons: K1 tap calls a female: he hangs under his nest and flutters while she looks it over, and a finished nest she turns down he cuts loose into the river. + +const [W, H] = [64, 128]; +const N = W * H; +// The far bank, the water, the near bank; his twig tip and where the twig leaves the limb. +const [HOR, WTOP, NEAR, AX, AY, RX, RY] = [89, 95, 121, 37, 38, 40, 19]; +const { min, max, floor, ceil, round, sin, cos, abs, hypot, pow, atan2, sign, PI } = Math; +const rnd = () => Math.random(); +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const mix = (a, b, t) => a + (b - a) * t; +const mixc = (a, b, t) => a.map((v, i) => mix(v, b[i], t)); +const sm = (x) => ((x = cl(x, 0, 1)), x * x * (3 - 2 * x)); +function hash(i, j) { + let h = (Math.imul(i, 374761393) + Math.imul(j, 668265263)) | 0; + h = Math.imul(h ^ (h >>> 13), 1274126177); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +} +// Value noise that wraps every `per` cells. +function vn(x, y, per = 4096) { + const xi = floor(x), yi = floor(y), u = x - xi, v = y - yi, su = u * u * (3 - 2 * u), sv = v * v * (3 - 2 * v); + const x0 = ((xi % per) + per) % per, x1 = (x0 + 1) % per, a = hash(x0, yi), b = hash(x1, yi), c = hash(x0, yi + 1); + return a + (b - a) * su + (c - a) * sv + (a - b - c + hash(x1, yi + 1)) * su * sv; +} +const lcg = (s) => () => ((s = (Math.imul(s, 1664525) + 1013904223) >>> 0) / 4294967296); +const hx = (s) => s.split(" ").map((h) => [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16))); + +// Village, golden, baya: crown, mask, eye, bill, nape, breast, back, streak, wing, wing edge, tail, +// fresh strip; then the nest's tube, neck and chamber radii. +const KEYS = "CKEBNYMmWyT"; +const SP = [ + [hx("18140e 100c0a f02418 221c18 b25c1a fcd61c f0ca20 28220c 2e2816 e8ca38 706426 5cb234"), [5, 0, 9, 8.5]], + [hx("ffcc18 fa8c12 faf0ba 1e1812 ffc816 ffc610 e4ac10 c49216 a48c1c fcd02c a48a20 82bc38"), [2, 0, 9.5, 9]], + [hx("ffd828 482e1a 402616 30241c fad22c f6d040 98703a 5c4226 684e2c d2b476 7c623c 9cc458"), [15, 5, 7.5, 8.5]], +]; +const pal = (a) => Object.fromEntries([...KEYS].map((k, i) => [k, a[i]]).concat([["F", [204, 150, 128]]])); +const FPAL = pal(hx("b4a04a d8c670 7c4a28 ccae86 b4a04a e8da92 a08a4a 64522e 725e34 c8b472 867240")); +const [STRAW, OLD] = hx("ceb874 96805c"); + +// The bird facing right: rows, anchor, bill tip. +const pose = (s, ax, ay, tx, ty) => ({ rows: s.split(" "), ax, ay, tx, ty }); +const PERCH = pose("......CCC .....NCCCC .....NKEKKB ....MMKKKBBB ...MmMYKK ..WWWMYYY .yWWWYYYY TyWWyYYY TT..YYY T...F.F", 6, 5, 11, 3); +const POKE = pose(". ...MMM ..WWMNCCC .yWWMNCCCC TyWWYMKEKKB TT.YYYKKKBBB T...YYYKK ....F.F", 5, 4, 11, 5); +const FA = pose("...yW ...yWW ....yWW..CC TTMMMWWNCCKB ..TYYYYYKEKBB .....YYYYKK", 6, 4, 12, 4); +const FB = pose(".........CC TTMMMMMNCCKB ..TYYWWWYKEKBB .....yWWWKK ......yWW .......yW", 6, 2, 12, 2); + +// Morning, noon, evening: sky overhead, at the horizon, the light, the sun. +const SKY = [ + [[84, 128, 198], [252, 194, 142], [1.08, 0.9, 0.74], 50, 79], + [[44, 114, 224], [172, 214, 246], [1, 1, 1], 40, -40], + [[62, 54, 134], [255, 132, 66], [1.12, 0.74, 0.56], 16, 84], +]; +// The acacia: limbs as x0, y0, x1, y1, w0, w1, and the flat plates of its crown. +const LIMBS = [[6, 25, 24, 18, 3.6, 2.6], [24, 18, 44, 19, 2.6, 2], [44, 19, 63, 14, 2, 1.3], [40, 19, 57, 26, 1.7, 1.2], [57, 26, 64, 31, 1.2, 1], [7, 50, 20, 45, 2.8, 1.7], [20, 45, 28, 49, 1.7, 1], [29, 18, 25, 12, 1.4, 1], [50, 17, 54, 11, 1.2, 1]]; +const PLATES = [[32, 3, 42, 6.5], [6, 11, 13, 5], [48, 10, 20, 4.5], [22, 12, 10, 3], [62, 16, 8, 3]]; +// The rest of the colony: twig tip, twig root, size, which way it faces. +const COLONY = [[13, 31, 14, 21, 0.72, -1], [55, 56, 57, 26, 0.8, 1], [19, 64, 19, 47, 0.72, 1], [52, 31, 50, 19, 0.62, -1], [29, 82, 26, 48, 0.62, 1], [61, 84, 62, 30, 0.6, -1], [26, 27, 27, 19, 0.52, 1], [14, 85, 12, 51, 0.58, -1]]; +// The two tall blades he strips, by the trunk and on the right: foot, height, lean. +const PICK = [[15, 123, 20, 3], [50, 122, 21, -2.5]]; +const [WEAVE, OUT, TEAR, BACK, SHOW, CUT] = [0, 1, 2, 3, 4, 5]; +const DUR = [3.2, 1.5, 1.4, 1.7, 1, 1.6]; + +// A nest as strands in the order they go in, in LEDs from the twig tip, facing right: the neck +// round the twig, the ring he stands in, the egg chamber from the roof round the back, the front +// wall down to the entrance, the entrance tube. F says which strand closes each LED of the wall. +function gen([tube, nk, rx, ry], seed, s) { + const r = lcg(seed), S = [], cy = nk + 8, bx = -3, by = cy + 1.5, F = []; + const add = (p) => S.push({ p, sh: 0.85 + 0.3 * r() }); + const arcs = (n, cx, cy, ax, ay, th, f0, keep) => { + for (let j = 0; j < n; j++) { + const p = [], f = f0 + (1 - f0) * r(), t = th(j) + (r() - 0.5) * 0.7, sw = 1.6 + r(), ox = 2 * r() - 1, oy = 2 * r() - 1; + for (let a = -sw / 2; a < sw / 2; a += 0.5 / (ax * f)) { + const x = cx + ox + cos(t + a) * ax * f, y = cy + oy + sin(t + a) * ay * f; + if (keep(x, y)) p.push(x, y); + } + add(p); + } + }; + const ring = (x, y) => (x / 5) ** 2 + ((y - cy) / 7) ** 2, ball = (x, y) => ((x - bx) / rx) ** 2 + ((y - by) / ry) ** 2; + const door = (x, y) => y > cy + 4.5 && abs(x - 2) < 2.5; + for (let y = 0; y < nk - 0.5; y += 1.5) { + const p = [], w = 1 + y * 0.22; + for (let i = 0; i <= 8; i++) p.push(-w + (w * i) / 4, y + i * 0.3); + add(p); + } + for (let i = 0; i < 6; i++) { + const p = [], f = 0.9 + 0.2 * r(), a0 = r() * 6.3, sw = 4 + 2 * r(); + for (let a = 0; a < sw; a += 0.12) p.push(cos(a0 + a) * 5 * f, cy + sin(a0 + a) * 7 * f); + add(p); + } + const pre = S.length; + arcs(16, bx, by, rx, ry, (j) => -PI / 2 - (j / 15) * PI * 1.1, 0.45, (x, y) => ball(x, y) < 1); + const ch = S.length; + arcs(7, 0, cy, 6, 8, (k) => -1.2 + k * 0.42, 0.35, (x, y) => ring(x, y) < 1 && !door(x, y)); + const fr = S.length, t0 = cy + 5, nt = 2 + round(tube / 2), dy = (tube + 3) / nt; + for (let k = 0; k < nt; k++) { + const p = [], y = t0 + k * dy, hw = k < 2 ? 4.4 - k : 2.6, c = 2.6 + (y - t0) * 0.08; + for (let i = 0; i <= 8; i++) p.push(c - hw + (hw * i) / 4, y + i * 0.28); + add(p); + } + for (let y = 0; y < t0 + tube + 4; y += 1 / s) + for (let x = -13; x < 8; x += 1 / s) { + const g = ring(x, y), tk = floor((y - t0) / dy), w = ((-PI / 2 - atan2((y - by) / ry, (x - bx) / rx)) % (2 * PI) + 2 * PI) % (2 * PI); + let k = -1; + if (y < nk) k = abs(x) < 1 + y * 0.22 ? floor(y / 1.5) : -1; + else if (g < 1 && !door(x, y)) k = g > 0.6 ? pre - 6 : ch + cl(round((atan2((y - cy) / 8, x / 6) + 1.2) / 0.42), 0, 6); + else if (ball(x, y) < 1 && !door(x, y)) k = pre + min(15, round(w / (PI * 0.0733))); + else if (y >= t0 && tk < nt && abs(x - 2.6 - (y - t0) * 0.08) < (tk < 2 ? 4.4 - tk : 2.6)) k = fr + tk; + if (k >= 0) F.push(x, y, k); + } + return Object.assign(S, { pre, fr, by, F }); +} + +// A nest's point on the panel, swaying on its twig and dipping when a bird lands. +const nxy = (n, lx, ly) => [n.x + n.sw * (1 + (ly * n.s) / 20) + lx * n.s * n.face, n.y + n.dip + n.fall + ly * n.s]; + +function build(P) { + const { alb, mat } = P; + const set = (x, y, m, r, g, b) => x >= 0 && x < W && y >= 0 && y < H && ((mat[y * W + x] = m), alb.set([r, g, b], (y * W + x) * 3)); + for (let p = 0; p < N; p++) { + const x = p & 63, y = p >> 6, k = 0.8 + 0.3 * vn(x * 0.4, y * 0.8) + 0.1 * hash(x + 7, y); + if (y >= NEAR + round(2 * vn(x * 0.2, 4))) set(x, y, 5, 118 * k, 96 * k, 58 * k); + else if (y >= WTOP) set(x, y, 4, 0, 0, 0); + else if (y >= HOR) set(x, y, 3, 200 * k, 166 * k, 96 * k); + } + // Flat-topped acacias on the far bank. + for (let i = 0; i < 6; i++) { + const cx = 12 + i * 10.5 + rnd() * 5, top = HOR - 4 - floor(rnd() * 4), w = 2 + rnd() * 4; + for (let y = top; y < top + 2; y++) for (let x = round(cx - w - (y - top) * 1.5); x <= cx + w + (y - top) * 1.5; x++) set(x, y, 6, 44, 60, 50); + for (let y = top + 2; y < HOR; y++) set(round(cx), y, 6, 52, 56, 46); + } + const bark = (x, y, e, k = (0.72 + 0.4 * vn(x * 0.8, y * 0.16)) * (1.1 - 0.45 * e * e) * (e > 0.3 ? 1.12 : 1)) => set(x, y, 1, 112 * k, 94 * k, 76 * k); + for (let y = 10; y < H; y++) for (let c = 5.5 + 1.8 * sin(y * 0.03 + 0.5), w = 2 + (y / H) * 2.2, x = floor(c - w); x <= c + w; x++) bark(x, y, (x - c) / w); + for (const [x0, y0, x1, y1, w0, w1] of LIMBS) + for (let i = 0, n = ceil(hypot(x1 - x0, y1 - y0) * 2); i <= n; i++) { + const f = i / n, x = mix(x0, x1, f), y = mix(y0, y1, f), w = mix(w0, w1, f); + for (let dy = -w / 2; dy <= w / 2; dy += 0.5) bark(round(x), round(y + dy), (-2 * dy) / w); + } + for (const [cx, cy, rx, ry] of PLATES) + for (let y = floor(cy - ry); y <= cy + ry; y++) + for (let x = floor(cx - rx); x <= cx + rx; x++) { + const d = ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2, n = vn(x * 0.35, y * 0.5 + 20) * 0.7 + vn(x * 0.9, y * 1.1) * 0.3; + const k = 0.55 + 0.6 * n + (y < cy - ry * 0.3 ? 0.28 : 0) - (y > cy + ry * 0.35 ? 0.2 : 0); + if (d < 1 && n > 0.25 + 0.4 * d) set(x, y, 2, 80 * k, 118 * k, 44 * k); + } + // Reeds at the water's edge, and more out in a reed bed. + P.reeds = Array.from({ length: 40 }, (_, i) => + i < 12 ? [(i & 1 ? 44 : 10) + (i >> 1) * 2.4 + rnd() * 2, NEAR + 1, 12 + floor(rnd() * 9), rnd() * 7, i % 3 === 0] : [3 + rnd() * 60, WTOP + 4 + floor(rnd() * 20), 7 + floor(rnd() * 9), rnd() * 7, i % 2 === 0]); + P.cloud = Float32Array.from({ length: 128 * 36 }, (_, i, x = i & 127, y = i >> 7) => sm((vn(x / 16, y / 4, 8) * 0.65 + vn(x / 7, y / 2.5 + 9, 128 / 7) * 0.35 - 0.5) * 3) * sm(1 - abs(y - 16) / 18)); +} + +// The sky for the hour, and the water and paddy that reflect it. +function paint(P) { + const f = P.sk * 2, i = min(1, floor(f)), [top, hor, tint, sx, sy] = SKY[i].map((v, c) => (v.map ? mixc(v, SKY[i + 1][c], f - i) : mix(v, SKY[i + 1][c], f - i))); + const { alb, mat, bg, wet, rice } = P, G = [70, 160, 44]; + Object.assign(P, { hor, tint, sx, sun: cl(1 - abs(sy - 70) / 60, 0, 1) }); + for (let p = 0; p < N; p++) { + const x = p & 63, y = p >> 6, m = mat[p], o = p * 3, ds = hypot(x - sx, (y - sy) * 1.2), z = (y - WTOP) / (NEAR - WTOP), row = (y - WTOP + 2) ** 0.7 * 1.6; + const band = m === 4 && row % 1 < 0.42 && hash(x, floor(row)) > 0.15 ? rice : 0; + wet[p] = m === 4 ? 1 - band : 0; + for (let c = 0, a; c < 3; c++) + (a = alb[o + c] * tint[c]), + (bg[o + c] = + m === 0 ? mix(top[c], hor[c], pow(y / HOR, 1.5)) + (sy > 0 ? (ds < 3.2 ? 255 : 120 * Math.exp(-ds / 9)) : 0) + : m === 4 ? mix(mix(hor[c], top[c], 0.3 + 0.5 * z) * (0.8 - 0.2 * z), G[c] * tint[c] * (0.8 + 0.4 * hash(x, y)), band) + : m === 3 || m === 6 ? mix(a, hor[c], m === 6 ? 0.4 : 0.22) + : a); + } +} + +function species(P, s) { + const i = min(1, floor(s)), f = s - i, [c0, q0] = SP[i], [c1, q1] = SP[i + 1], v = c0.map((c, j) => mixc(c, c1[j], f)), M = P.main; + Object.assign(P, { pal: pal(v), fresh: v[11], rice: cl(s - 1, 0, 1), reedN: 12 + round(28 * (1 - abs(s - 1))) }); + for (const n of [M, ...P.col]) n.S = gen(mixc(q0, q1, f), n.seed, n.s); + M.nb = min(M.nb, M.S.length); +} + +const nest = (x, y, rx, ry, s, face, seed) => ({ x, y, rx, ry, s, face, seed, nb: 0, age: [], sw: 0, dip: 0, dv: 0, fall: 0, ph: rnd() * 7 }); + +export function setup(params) { + const P = params, F = (n) => new Float32Array(n); + Object.assign(P, { alb: F(N * 3), mat: new Uint8Array(N), bg: F(N * 3), buf: F(N * 3), wet: F(N), sp: -1, sk: -1, t: 0, fs: 0, side: 0, ri: [] }); + P.main = nest(AX, AY, RX, RY, 1.3, 1, 7); + P.col = COLONY.map((c, i) => Object.assign(nest(...c, 101 + i * 37), { old: (i * 0.37) % 1, fresh: i === 2 })); + build(P); +} + +// Where he clings to weave strand j at reveal u: outside the wall at that point, facing it. +function wpos(P, j, u) { + const M = P.main, p = M.S[min(j, M.S.length - 1)].p, i = min(p.length - 2, floor((u * p.length) / 2) * 2); + const [x, y] = nxy(M, p[i], p[i + 1]), [cx, cy] = nxy(M, -2, M.S.by), l = hypot(x - cx, y - cy) || 1, dx = (x - cx) / l, dy = (y - cy) / l; + return [x + dx * 4.5, y + dy * 4.5 + (dy > 0.55 ? 1 : -1), dx > 0 ? -1 : 1, dy > 0.55]; +} +function bladeTip(P, k) { + const [bx, by, h, lean] = PICK[k], b = P.bend[k]; + return [bx + lean + sin(P.t * 1.3 + k * 2) * 0.8 + b * sign(lean) * 0.8, by - h + b]; +} + +function stepMale(P, d) { + const M = P.main, held = P.fs && P.fs < 4; + let u = (P.bt += d) / DUR[P.bs]; + const go = (s) => ((P.bs = s), (P.bt = 0), (P.x0 = P.bx), (P.y0 = P.by)); + const fly = ([tx, ty], arc) => ([P.face, P.bx, P.by, P.pose, P.hang] = [tx >= P.x0 ? 1 : -1, mix(P.x0, tx, sm(u)), mix(P.y0, ty, sm(u)) - arc * sin(PI * u), floor(P.bt / 0.065) & 1 ? FB : FA, false]); + P.show = false; + if (held && !P.bs) (P.bt -= d), (P.show = true), (u = -1); + switch (P.bs) { + case WEAVE: { + if (u < 0) break; + // Five passes, each a push through the wall and a tug that pulls it tight and shakes the twig. + const k = cl(u, 0, 1) * 5, q = floor(k), f = k - q, rv = min(1, (q + sm(f * 2.5)) / 5); + [P.bx, P.by, P.face, P.hang] = wpos(P, M.nb, rv); + [P.rev, P.pose, P.strip] = [rv, f < 0.42 ? POKE : PERCH, (1 - rv) * 13]; + if (f >= 0.42 && !P.tug) M.dv += 5; + P.tug = f >= 0.42; + if (u >= 1) (M.age[M.nb++] = P.t), (P.rev = P.strip = 0), go(M.nb >= M.S.length ? SHOW : OUT); + break; + } + case OUT: + P.tip = bladeTip(P, P.side); + fly([P.tip[0], P.tip[1] - 4], 5); + if (u >= 1) go(TEAR); + break; + case TEAR: { + // Bite a leaf low on the blade and back off up the stem to tear a strip off it. + const t = bladeTip(P, P.side), e = sm((u - 0.25) / 0.6), fc = PICK[P.side][3] > 0 ? -1 : 1; + P.bend[P.side] = min(3, P.bend[P.side] + d * 12); + [P.face, P.pose, P.hang, P.bx, P.by] = [fc, u < 0.25 || (u > 0.5 && u < 0.6) ? POKE : PERCH, false, t[0] - fc * 3 * e, t[1] - 4 - 2 * e]; + P.tear = u > 0.2 && u < 0.88 ? [t[0] + fc * 3, t[1] + 6] : null; + if (u >= 1) (P.tear = null), (P.strip = 13), go(BACK), (P.side ^= 1); + break; + } + case BACK: + fly(wpos(P, M.nb, 0), 6); + if (u >= 1) (M.dv += 16), go(WEAVE); + break; + case SHOW: + P.show = true; + if (!P.fs && P.bt > 0.8) callFemale(P); + if (P.reject) (P.reject = false), go(CUT); + break; + case CUT: { + const [x, y] = nxy(M, 0, 0); + [P.bx, P.by, P.face, P.hang, P.pose] = [x - 3, y - 3, 1, false, floor(P.bt / 0.2) & 1 ? POKE : PERCH]; + if (u >= 1) (M.fv = -10), (M.fall = 0.01), (M.dv -= 20), go(OUT); + } + } + if (P.bs !== TEAR) P.bend = P.bend.map((b) => max(0, b - d * 8)); +} + +function callFemale(P) { + if (!P.fs) Object.assign(P, { fs: 1, ft: 0, fx0: 70, fy0: 30 + rnd() * 20 }); +} +function stepFemale(P, d) { + const M = P.main, done = M.nb >= M.S.length, [rx, ry] = nxy(M, -3, M.S.by - 8.5).map((v, i) => v - 1 - 4 * i), u = (P.ft += d) / [1, 1.8, 2.6, 2.4, 1.8][P.fs]; + const go = (s) => ((P.fs = s), (P.ft = 0), (P.fx0 = P.fx), (P.fy0 = P.fy)); + if (P.fs === 1 || P.fs === 4) { + const [tx, ty] = P.fs === 1 ? [rx, ry] : [-12, 24]; + [P.fx, P.fy, P.ff, P.fp] = [mix(P.fx0, tx, sm(u)), mix(P.fy0, ty, sm(u)) - 5 * sin(PI * u), tx > P.fx0 ? 1 : -1, floor(P.ft / 0.07) & 1 ? FB : FA]; + if (P.fs === 1 && u >= 1) (M.dv += 10), go(2); + if (P.fs === 4 && u >= 1) (P.fs = 0), (P.reject = done); + } else if (P.fs === 2) { + [P.fx, P.fy, P.ff, P.fp] = [rx, ry, floor(P.ft / 0.9) & 1 ? 1 : -1, floor(P.ft / 0.45) % 3 === 1 ? POKE : PERCH]; + if (u >= 1) go(done ? 3 : 4); + } else { + // She goes in by the entrance and comes out again. + P.fin = u < 1; + if (u >= 1) ([P.fx, P.fy] = nxy(M, 3, M.S.by + 12)), go(4); + } +} + +export function update(dt, input, params) { + const P = params, k = input.knobValues || [0, 2, 3, 0.06], sp = cl(k[0], 0, 2), sk = cl(k[3], 0, 1), M = P.main; + if (abs(sp - P.sp) > 0.004) (P.sp = sp), species(P, sp), (P.sk = -1); + if (abs(sk - P.sk) > 0.002) (P.sk = sk), paint(P); + P.nn = round(cl(k[2], 3, 9)) - 1; + if (!P.bend) { + // Start halfway through a strand. + M.nb = min(M.S.length - 1, M.S.pre + 3 + round(((cl(k[2], 3, 9) - 3) / 6) * (M.S.fr - M.S.pre - 3))); + for (let j = 0; j < M.nb; j++) M.age[j] = -(M.nb - j) * 7.8; + Object.assign(P, { bs: WEAVE, bt: 1.4, rev: 0, strip: 0, bend: [0, 0] }); + } + if (input.btnPressed?.[0]) callFemale(P); + const d = cl(dt, 0, 0.1) * (cl(k[1], 0.2, 3) / 2); + P.t += d; + for (const n of [M, ...P.col]) { + n.sw = (0.55 * sin(P.t * 0.9 + n.ph) + 0.3 * sin(P.t * 2.3 + n.ph * 2)) * (1.4 - n.s * 0.4); + n.dv += (-70 * n.dip - 5 * n.dv) * d; + n.dip = cl(n.dip + n.dv * d, -3, 4); + } + if (M.fall) { + M.fall += (M.fv += 90 * d) * d; + if (M.y + M.fall > WTOP + 2) P.ri.push([M.x - 2, 0, 1]), (M.fall = M.nb = 0), (M.seed += 11), species(P, P.sp); + } + stepMale(P, d); + if (P.fs) stepFemale(P, d); + P.ri = P.ri.filter((r) => ((r[1] += 9 * d), (r[2] -= 0.4 * d) > 0)); +} + +let BUF, TT; +function Ao(o, r, g, b, a = 1) { + BUF[o] += (r - BUF[o]) * a; + BUF[o + 1] += (g - BUF[o + 1]) * a; + BUF[o + 2] += (b - BUF[o + 2]) * a; +} +const A = (x, y, r, g, b, a) => x >= 0 && x < W && y >= 0 && y < H && Ao((y * W + x) * 3, r, g, b, a); +const AL = (x, y, c, k = 1) => A(x, y, c[0] * k * TT[0], c[1] * k * TT[1], c[2] * k * TT[2]); + +// Flipped, he hangs upside down; turned, he hangs head down under a nest with his wings going. +function drawBird(S, pl, x0, y0, face, flip, turn) { + const ox = round(x0), oy = round(y0); + S.rows.forEach((row, r) => + [...row].forEach((ch, c, _, col = ch === "M" && (c + r) & 1 ? pl.m : pl[ch]) => col && (turn ? AL(ox + (r - S.ay) * face, oy + c - S.ax, col) : AL(ox + (c - S.ax) * face, oy + (flip ? S.ay - r : r - S.ay), col))), + ); + return [ox + (S.tx - S.ax) * face, oy + (flip ? S.ay - S.ty : S.ty - S.ay)]; +} +const flap = (t) => (floor(t / 0.06) & 1 ? FB : FA); + +// A strip of grass from the bill: trailing in flight, hanging while he weaves. +function strip(P, [x, y], len, face, flying) { + let a = flying ? (face > 0 ? PI * 0.9 : PI * 0.1) : PI / 2 - face * 0.6; + const tg = flying ? a : PI / 2; + for (let i = 0; i < len; i++) { + a += (tg - a) * 0.15 + 0.12 * sin(P.t * 9 + i * 0.8); + AL(round((x += cos(a))), round((y += sin(a))), P.fresh, 1.1 + 0.15 * sin(i)); + } +} + +function drawNest(P, n, nb, rev, light) { + const S = n.S, F = S.F, sd = (P.sx > 32 ? 1 : -1) * n.face, { x: nx, s, face, sw } = n, ny = n.y + n.dip + n.fall, lim = min(nb, S.length - 1), C = []; + for (let j = 0; j <= lim; j++) { + C[j] = (n.fresh ? mixc(P.fresh, STRAW, (0.7 * (nb - j)) / 12) : n.old >= 0 ? mixc(STRAW, OLD, n.old) : mixc(P.fresh, STRAW, sm((P.t - (n.age[j] ?? P.t)) / 45))).map((v, c) => v * TT[c]); + } + const lit = (lx, ly) => 0.82 + 0.2 * cl((lx * sd) / 9 - (ly - S.by) / 14, -1, 1), at = (lx, ly) => [round(nx + sw * (1 + (ly * s) / 20) + lx * s * face), round(ny + ly * s)]; + n.low = -99; + // The wall behind the strands, dark where it is deep. + for (let i = 0; i < F.length; i += 3) { + const k = F[i + 2]; + if (k > lim || (k === nb && rev < 0.7)) continue; + const [xi, yi] = at(F[i], F[i + 1]), c = C[k], q = light * (0.3 + 0.2 * hash(i, 5)) * lit(F[i], F[i + 1]); + A(xi, yi, c[0] * q, c[1] * q, c[2] * q); + if (yi > n.low) [n.low, n.lowx] = [yi, xi]; + } + for (let j = 0; j <= lim; j++) { + const p = S[j].p, m = j < nb ? p.length : floor((rev * p.length) / 2) * 2, k0 = light * S[j].sh, [cr, cg, cb] = C[j]; + for (let i = 0; i < m; i += 2) { + const [xi, yi] = at(p[i], p[i + 1]), k = k0 * (lit(p[i], p[i + 1]) + ((i >> 2) & 1 ? 0.1 : -0.08)); + A(xi, yi + 1, cr * 0.3, cg * 0.3, cb * 0.3, 0.5); + A(xi, yi, cr * k, cg * k, cb * k); + } + } +} + +function twig(n) { + const [tx, ty] = nxy(n, 0, 0); + for (let i = 0, L = ceil(hypot(tx - n.rx, ty - n.ry) * 1.3); i <= L; i++) AL(round(mix(n.rx, tx, i / L) + 1.5 * sin((PI * i) / L) * n.face), round(mix(n.ry, ty, i / L)), [96, 80, 64]); +} + +function sky(P) { + const { buf, mat, wet, t, sun } = P, off = t * 1.6, CT = P.cloud, cc = P.hor.map((v) => mix(v, 255, 0.55)); + // Clouds drift behind the tree; the crown flickers in the wind. + for (let p = 15 * W; p < 86 * W; p++) + if (!mat[p]) { + const x = (p & 63) + off, x0 = floor(x), f = x - x0, r = ((p >> 7) - 7) * 128; + Ao(p * 3, cc[0], cc[1], cc[2], (CT[r + (x0 & 127)] * (1 - f) + CT[r + ((x0 + 1) & 127)] * f) * 0.8); + } + for (let p = 0; p < 22 * W; p++) if (mat[p] === 2) for (let c = 0, s = 0.8 + 0.45 * CT[((p >> 6) + 8) * 128 + (((p & 63) + floor(t * 6)) & 127)]; c < 3; c++) buf[p * 3 + c] *= s; + // Ripples on the water, and the sun's road across it. + for (let p = WTOP * W; p < (NEAR + 2) * W; p++) { + if (!wet[p]) continue; + const x = p & 63, y = p >> 6, z = (y - WTOP) / 26, rp = sin(y * 1.9 + 2.4 * sin(x * 0.2 + t * 0.7 + y * 0.3) - t * 1.6), o = p * 3; + const g = rp > 0.75 ? cl((0.4 + 1.6 * sun * cl(1 - abs(x - P.sx) / (3 + 9 * z), 0, 1)) * wet[p], 0, 1) * 0.7 : 0; + for (let c = 0; c < 3; c++) buf[o + c] = mix(buf[o + c] * (1 + 0.1 * rp * wet[p]), [255, 236, 190][c], g); + } + for (const [cx, r, a] of P.ri) for (let x = floor(cx - r); x <= cx + r; x++) A(x, round(WTOP + 2 + 0.2 * r * (1 - ((x - cx) / r) ** 2)), 240, 244, 240, a * 0.8); +} + +function reeds(P) { + for (let i = 0; i < P.reedN; i++) { + const [rx, ry, h, ph, plume] = P.reeds[i], lean = sin(P.t * 1.4 + ph) * 0.8 + (i & 1 ? 1 : -1); + for (let j = 0; j < h; j++) { + const x = round(rx + lean * (j / h) ** 2), k = 0.7 + (0.5 * j) / h; + AL(x, ry - j, [84, 140, 50], k); + if (j === round(h * 0.4) || j === round(h * 0.7)) for (let e = 1, sd = (j + i) & 1 ? 1 : -1; e < 4; e++) AL(x + sd * e, ry - j - (e >> 1), [96, 156, 56], k); + } + if (plume) for (let j = 0; j < 4; j++) AL(round(rx + lean) + (j & 1), ry - h - j, [160, 112, 74]); + } + for (let k = 0; k < 2; k++) { + const [bx, by, h, lean] = PICK[k], [tx, ty] = bladeTip(P, k); + for (let j = 0; j <= h; j++) { + const f = j / h, x = round(mix(bx, tx, f * f)), y = round(mix(by, ty, f)); + AL(x, y, [96, 168, 56], 0.7 + 0.5 * f); + if (j % 4 === 2) AL(x - sign(lean) * (1 + (j & 4 ? 1 : 0)), y + 1, [80, 150, 50], 0.8); + } + } +} + +export function draw(display, params) { + const P = params, { buf, t, pal: pl } = P, M = P.main; + [BUF, TT] = [buf, P.tint]; + buf.set(P.bg); + sky(P); + reeds(P); + // The colony; its males hang under their nests and flutter, sit on top, or are away. + for (let i = 0; i < P.nn; i++) { + const n = P.col[i], c = (t * 0.06 + n.ph * 0.3) % 1, [x, y] = nxy(n, 2 * n.face, n.S.by + 3); + twig(n); + drawNest(P, n, n.fresh ? 10 : n.S.length, 0, 0.85); + if (n.fresh) drawBird(floor(t / 0.5) & 1 ? POKE : PERCH, pl, x + 5 * n.face, y - 3, -n.face); + else if (i % 3 !== 2 && c < 0.35) drawBird(flap(t), pl, n.lowx, n.low + 7, n.face, false, true); + else if (i % 3 !== 2 && c < 0.55) drawBird(PERCH, pl, ...nxy(n, -3, -3 / n.s), -n.face); + } + twig(M); + drawNest(P, M, M.nb, P.bs === WEAVE ? P.rev : 0, 1); + if (P.fs && !P.fin) drawBird(P.fp || FA, FPAL, P.fx, P.fy, P.ff); + if (P.show) drawBird(flap(t), pl, M.lowx, M.low + 7, 1, false, true); + else { + const tip = drawBird(P.pose, pl, P.bx, P.by, P.face, P.hang); + if (P.strip > 0.5) strip(P, tip, round(P.strip), P.face, P.bs === OUT || P.bs === BACK); + for (let i = 0; P.tear && i <= 8; i++) AL(round(mix(tip[0], P.tear[0], i / 8)), round(mix(tip[1], P.tear[1], i / 8)), P.fresh, 1.1); + } + for (let p = 0, o = 0; p < N; p++, o += 3) display.setPixel(p & 63, p >> 6, cl(buf[o], 0, 255), cl(buf[o + 1], 0, 255), cl(buf[o + 2], 0, 255)); +} From 4be7cd884eff5fa3866ad656fc62fb098c32ef12 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 22:41:46 +0000 Subject: [PATCH 10/17] pattern: Weaver Co-Authored-By: Claude Opus 5.5 --- incoming/pattern-study/patterns/weaver.js | 88 +++++++++++------------ 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/incoming/pattern-study/patterns/weaver.js b/incoming/pattern-study/patterns/weaver.js index 62690066c..590d6fb27 100644 --- a/incoming/pattern-study/patterns/weaver.js +++ b/incoming/pattern-study/patterns/weaver.js @@ -1,7 +1,7 @@ // ===== Patternflow pattern ===== // Title: Weaver // Family: bird -// Idea: A male village weaver builds his hanging nest at the tip of an acacia twig over a river: he tears a green strip off a reed, flies back and threads it through, and the nest grows strand by strand from a ring to a round chamber with a downward entrance while the old strips fade to straw. +// Idea: A male village weaver builds his nest at the tip of an acacia twig over a river: he tears green strips off the reeds and threads them in, and the nest grows strand by strand from a ring to a round chamber with a downward entrance while old strips fade to straw. // Author: patternflow-lab // Date: 2026-10-23 // SPDX-License-Identifier: CC-BY-SA-4.0 @@ -19,7 +19,7 @@ const [W, H] = [64, 128]; const N = W * H; -// The far bank, the water, the near bank; his twig tip and where the twig leaves the limb. +// The far bank, the water, the near bank; his twig's tip and root. const [HOR, WTOP, NEAR, AX, AY, RX, RY] = [89, 95, 121, 37, 38, 40, 19]; const { min, max, floor, ceil, round, sin, cos, abs, hypot, pow, atan2, sign, PI } = Math; const rnd = () => Math.random(); @@ -38,11 +38,10 @@ function vn(x, y, per = 4096) { const x0 = ((xi % per) + per) % per, x1 = (x0 + 1) % per, a = hash(x0, yi), b = hash(x1, yi), c = hash(x0, yi + 1); return a + (b - a) * su + (c - a) * sv + (a - b - c + hash(x1, yi + 1)) * su * sv; } -const lcg = (s) => () => ((s = (Math.imul(s, 1664525) + 1013904223) >>> 0) / 4294967296); const hx = (s) => s.split(" ").map((h) => [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16))); -// Village, golden, baya: crown, mask, eye, bill, nape, breast, back, streak, wing, wing edge, tail, -// fresh strip; then the nest's tube, neck and chamber radii. +// Village, golden, baya: plumage in KEYS order (crown, mask, eye, bill, nape, breast, back, streak, +// wing, wing edge, tail), the fresh strip, and the nest's tube, neck and chamber radii. const KEYS = "CKEBNYMmWyT"; const SP = [ [hx("18140e 100c0a f02418 221c18 b25c1a fcd61c f0ca20 28220c 2e2816 e8ca38 706426 5cb234"), [5, 0, 9, 8.5]], @@ -58,20 +57,21 @@ const pose = (s, ax, ay, tx, ty) => ({ rows: s.split(" "), ax, ay, tx, ty }); const PERCH = pose("......CCC .....NCCCC .....NKEKKB ....MMKKKBBB ...MmMYKK ..WWWMYYY .yWWWYYYY TyWWyYYY TT..YYY T...F.F", 6, 5, 11, 3); const POKE = pose(". ...MMM ..WWMNCCC .yWWMNCCCC TyWWYMKEKKB TT.YYYKKKBBB T...YYYKK ....F.F", 5, 4, 11, 5); const FA = pose("...yW ...yWW ....yWW..CC TTMMMWWNCCKB ..TYYYYYKEKBB .....YYYYKK", 6, 4, 12, 4); +const flap = (t) => (floor(t / 0.065) & 1 ? FB : FA); const FB = pose(".........CC TTMMMMMNCCKB ..TYYWWWYKEKBB .....yWWWKK ......yWW .......yW", 6, 2, 12, 2); // Morning, noon, evening: sky overhead, at the horizon, the light, the sun. const SKY = [ - [[84, 128, 198], [252, 194, 142], [1.08, 0.9, 0.74], 50, 79], - [[44, 114, 224], [172, 214, 246], [1, 1, 1], 40, -40], - [[62, 54, 134], [255, 132, 66], [1.12, 0.74, 0.56], 16, 84], + [...hx("5480c6 fcc28e"), [1.08, 0.9, 0.74], 50, 79], + [...hx("2c72e0 acd6f6"), [1, 1, 1], 40, -40], + [...hx("3e3686 ff8442"), [1.12, 0.74, 0.56], 16, 84], ]; // The acacia: limbs as x0, y0, x1, y1, w0, w1, and the flat plates of its crown. -const LIMBS = [[6, 25, 24, 18, 3.6, 2.6], [24, 18, 44, 19, 2.6, 2], [44, 19, 63, 14, 2, 1.3], [40, 19, 57, 26, 1.7, 1.2], [57, 26, 64, 31, 1.2, 1], [7, 50, 20, 45, 2.8, 1.7], [20, 45, 28, 49, 1.7, 1], [29, 18, 25, 12, 1.4, 1], [50, 17, 54, 11, 1.2, 1]]; +const LIMBS = [[6, 25, 24, 18, 3.6, 2.6], [24, 18, 44, 19, 2.6, 2], [44, 19, 63, 14, 2, 1.3], [40, 19, 57, 26, 1.7, 1.2], [57, 26, 64, 31, 1.2, 1], [7, 50, 20, 45, 2.8, 1.7], [20, 45, 28, 49, 1.7, 1]]; const PLATES = [[32, 3, 42, 6.5], [6, 11, 13, 5], [48, 10, 20, 4.5], [22, 12, 10, 3], [62, 16, 8, 3]]; // The rest of the colony: twig tip, twig root, size, which way it faces. const COLONY = [[13, 31, 14, 21, 0.72, -1], [55, 56, 57, 26, 0.8, 1], [19, 64, 19, 47, 0.72, 1], [52, 31, 50, 19, 0.62, -1], [29, 82, 26, 48, 0.62, 1], [61, 84, 62, 30, 0.6, -1], [26, 27, 27, 19, 0.52, 1], [14, 85, 12, 51, 0.58, -1]]; -// The two tall blades he strips, by the trunk and on the right: foot, height, lean. +// The two tall blades he strips: foot, height, lean. const PICK = [[15, 123, 20, 3], [50, 122, 21, -2.5]]; const [WEAVE, OUT, TEAR, BACK, SHOW, CUT] = [0, 1, 2, 3, 4, 5]; const DUR = [3.2, 1.5, 1.4, 1.7, 1, 1.6]; @@ -80,7 +80,8 @@ const DUR = [3.2, 1.5, 1.4, 1.7, 1, 1.6]; // round the twig, the ring he stands in, the egg chamber from the roof round the back, the front // wall down to the entrance, the entrance tube. F says which strand closes each LED of the wall. function gen([tube, nk, rx, ry], seed, s) { - const r = lcg(seed), S = [], cy = nk + 8, bx = -3, by = cy + 1.5, F = []; + let c = 0; + const r = () => hash(seed, c++), S = [], cy = nk + 8, bx = -3, by = cy + 1.5, F = []; const add = (p) => S.push({ p, sh: 0.85 + 0.3 * r() }); const arcs = (n, cx, cy, ax, ay, th, f0, keep) => { for (let j = 0; j < n; j++) { @@ -116,13 +117,13 @@ function gen([tube, nk, rx, ry], seed, s) { } for (let y = 0; y < t0 + tube + 4; y += 1 / s) for (let x = -13; x < 8; x += 1 / s) { - const g = ring(x, y), tk = floor((y - t0) / dy), w = ((-PI / 2 - atan2((y - by) / ry, (x - bx) / rx)) % (2 * PI) + 2 * PI) % (2 * PI); + const g = ring(x, y), tk = floor((y - t0) / dy), w = (1.5 * PI - atan2((y - by) / ry, (x - bx) / rx)) % (2 * PI); let k = -1; if (y < nk) k = abs(x) < 1 + y * 0.22 ? floor(y / 1.5) : -1; else if (g < 1 && !door(x, y)) k = g > 0.6 ? pre - 6 : ch + cl(round((atan2((y - cy) / 8, x / 6) + 1.2) / 0.42), 0, 6); else if (ball(x, y) < 1 && !door(x, y)) k = pre + min(15, round(w / (PI * 0.0733))); else if (y >= t0 && tk < nt && abs(x - 2.6 - (y - t0) * 0.08) < (tk < 2 ? 4.4 - tk : 2.6)) k = fr + tk; - if (k >= 0) F.push(x, y, k); + if (k >= 0) F.push(x, y, k + (y > t0 + tube + 1.8) * 1000); } return Object.assign(S, { pre, fr, by, F }); } @@ -162,6 +163,7 @@ function build(P) { // Reeds at the water's edge, and more out in a reed bed. P.reeds = Array.from({ length: 40 }, (_, i) => i < 12 ? [(i & 1 ? 44 : 10) + (i >> 1) * 2.4 + rnd() * 2, NEAR + 1, 12 + floor(rnd() * 9), rnd() * 7, i % 3 === 0] : [3 + rnd() * 60, WTOP + 4 + floor(rnd() * 20), 7 + floor(rnd() * 9), rnd() * 7, i % 2 === 0]); + P.shad = Float32Array.from({ length: 128 }, (_, i) => sm(1 - abs(i - 34) / 26) + 0.7 * sm(1 - abs(i - 96) / 16)); P.cloud = Float32Array.from({ length: 128 * 36 }, (_, i, x = i & 127, y = i >> 7) => sm((vn(x / 16, y / 4, 8) * 0.65 + vn(x / 7, y / 2.5 + 9, 128 / 7) * 0.35 - 0.5) * 3) * sm(1 - abs(y - 16) / 18)); } @@ -195,13 +197,13 @@ const nest = (x, y, rx, ry, s, face, seed) => ({ x, y, rx, ry, s, face, seed, nb export function setup(params) { const P = params, F = (n) => new Float32Array(n); - Object.assign(P, { alb: F(N * 3), mat: new Uint8Array(N), bg: F(N * 3), buf: F(N * 3), wet: F(N), sp: -1, sk: -1, t: 0, fs: 0, side: 0, ri: [] }); + Object.assign(P, { alb: F(N * 3), mat: new Uint8Array(N), bg: F(N * 3), buf: F(N * 3), wet: F(N), sp: -1, sk: -1, t: 0, fs: 0, side: 0, spt: 9 }); P.main = nest(AX, AY, RX, RY, 1.3, 1, 7); P.col = COLONY.map((c, i) => Object.assign(nest(...c, 101 + i * 37), { old: (i * 0.37) % 1, fresh: i === 2 })); build(P); } -// Where he clings to weave strand j at reveal u: outside the wall at that point, facing it. +// Where he clings to weave strand j at reveal u: outside the wall, facing it. function wpos(P, j, u) { const M = P.main, p = M.S[min(j, M.S.length - 1)].p, i = min(p.length - 2, floor((u * p.length) / 2) * 2); const [x, y] = nxy(M, p[i], p[i + 1]), [cx, cy] = nxy(M, -2, M.S.by), l = hypot(x - cx, y - cy) || 1, dx = (x - cx) / l, dy = (y - cy) / l; @@ -216,7 +218,7 @@ function stepMale(P, d) { const M = P.main, held = P.fs && P.fs < 4; let u = (P.bt += d) / DUR[P.bs]; const go = (s) => ((P.bs = s), (P.bt = 0), (P.x0 = P.bx), (P.y0 = P.by)); - const fly = ([tx, ty], arc) => ([P.face, P.bx, P.by, P.pose, P.hang] = [tx >= P.x0 ? 1 : -1, mix(P.x0, tx, sm(u)), mix(P.y0, ty, sm(u)) - arc * sin(PI * u), floor(P.bt / 0.065) & 1 ? FB : FA, false]); + const fly = ([tx, ty], arc) => ([P.face, P.bx, P.by, P.pose, P.hang] = [tx >= P.x0 ? 1 : -1, mix(P.x0, tx, sm(u)), mix(P.y0, ty, sm(u)) - arc * sin(PI * u), flap(P.bt), false]); P.show = false; if (held && !P.bs) (P.bt -= d), (P.show = true), (u = -1); switch (P.bs) { @@ -237,7 +239,7 @@ function stepMale(P, d) { if (u >= 1) go(TEAR); break; case TEAR: { - // Bite a leaf low on the blade and back off up the stem to tear a strip off it. + // Bite low on the blade and back off up it, tearing a strip. const t = bladeTip(P, P.side), e = sm((u - 0.25) / 0.6), fc = PICK[P.side][3] > 0 ? -1 : 1; P.bend[P.side] = min(3, P.bend[P.side] + d * 12); [P.face, P.pose, P.hang, P.bx, P.by] = [fc, u < 0.25 || (u > 0.5 && u < 0.6) ? POKE : PERCH, false, t[0] - fc * 3 * e, t[1] - 4 - 2 * e]; @@ -267,31 +269,27 @@ function callFemale(P) { if (!P.fs) Object.assign(P, { fs: 1, ft: 0, fx0: 70, fy0: 30 + rnd() * 20 }); } function stepFemale(P, d) { - const M = P.main, done = M.nb >= M.S.length, [rx, ry] = nxy(M, -3, M.S.by - 8.5).map((v, i) => v - 1 - 4 * i), u = (P.ft += d) / [1, 1.8, 2.6, 2.4, 1.8][P.fs]; + const M = P.main, done = M.nb >= M.S.length, [rx, ry] = nxy(M, -3, M.S.by - 8.5).map((v, i) => v - 1 - 4 * i), u = (P.ft += d) / [1, 1.8, 3.4, 1, 1.8][P.fs]; const go = (s) => ((P.fs = s), (P.ft = 0), (P.fx0 = P.fx), (P.fy0 = P.fy)); if (P.fs === 1 || P.fs === 4) { const [tx, ty] = P.fs === 1 ? [rx, ry] : [-12, 24]; - [P.fx, P.fy, P.ff, P.fp] = [mix(P.fx0, tx, sm(u)), mix(P.fy0, ty, sm(u)) - 5 * sin(PI * u), tx > P.fx0 ? 1 : -1, floor(P.ft / 0.07) & 1 ? FB : FA]; + [P.fx, P.fy, P.ff, P.fp] = [mix(P.fx0, tx, sm(u)), mix(P.fy0, ty, sm(u)) - 5 * sin(PI * u), tx > P.fx0 ? 1 : -1, flap(P.ft)]; if (P.fs === 1 && u >= 1) (M.dv += 10), go(2); if (P.fs === 4 && u >= 1) (P.fs = 0), (P.reject = done); - } else if (P.fs === 2) { - [P.fx, P.fy, P.ff, P.fp] = [rx, ry, floor(P.ft / 0.9) & 1 ? 1 : -1, floor(P.ft / 0.45) % 3 === 1 ? POKE : PERCH]; - if (u >= 1) go(done ? 3 : 4); } else { - // She goes in by the entrance and comes out again. - P.fin = u < 1; - if (u >= 1) ([P.fx, P.fy] = nxy(M, 3, M.S.by + 12)), go(4); + [P.fx, P.fy, P.ff, P.fp] = [rx, ry, floor(P.ft / 0.9) & 1 ? 1 : -1, floor(P.ft / 0.45) % 3 === 1 ? POKE : PERCH]; + if (u >= 1) go(4); } } export function update(dt, input, params) { - const P = params, k = input.knobValues || [0, 2, 3, 0.06], sp = cl(k[0], 0, 2), sk = cl(k[3], 0, 1), M = P.main; + const P = params, k = input.knobValues || [0, 2, 3, 0.06], sp = cl(k[0], 0, 2), sk = cl(k[3], 0, 1), nn = cl(k[2], 3, 9), M = P.main; if (abs(sp - P.sp) > 0.004) (P.sp = sp), species(P, sp), (P.sk = -1); if (abs(sk - P.sk) > 0.002) (P.sk = sk), paint(P); - P.nn = round(cl(k[2], 3, 9)) - 1; + P.nn = round(nn) - 1; if (!P.bend) { // Start halfway through a strand. - M.nb = min(M.S.length - 1, M.S.pre + 3 + round(((cl(k[2], 3, 9) - 3) / 6) * (M.S.fr - M.S.pre - 3))); + M.nb = min(M.S.length - 1, M.S.pre + 3 + round(((nn - 3) / 6) * (M.S.fr - M.S.pre - 3))); for (let j = 0; j < M.nb; j++) M.age[j] = -(M.nb - j) * 7.8; Object.assign(P, { bs: WEAVE, bt: 1.4, rev: 0, strip: 0, bend: [0, 0] }); } @@ -305,11 +303,11 @@ export function update(dt, input, params) { } if (M.fall) { M.fall += (M.fv += 90 * d) * d; - if (M.y + M.fall > WTOP + 2) P.ri.push([M.x - 2, 0, 1]), (M.fall = M.nb = 0), (M.seed += 11), species(P, P.sp); + if (M.y + M.fall > WTOP + 2) (P.spx = M.x - 2), (P.spt = 0), (M.fall = M.nb = 0), (M.seed += 11), species(P, P.sp); } stepMale(P, d); if (P.fs) stepFemale(P, d); - P.ri = P.ri.filter((r) => ((r[1] += 9 * d), (r[2] -= 0.4 * d) > 0)); + P.spt += d; } let BUF, TT; @@ -321,7 +319,7 @@ function Ao(o, r, g, b, a = 1) { const A = (x, y, r, g, b, a) => x >= 0 && x < W && y >= 0 && y < H && Ao((y * W + x) * 3, r, g, b, a); const AL = (x, y, c, k = 1) => A(x, y, c[0] * k * TT[0], c[1] * k * TT[1], c[2] * k * TT[2]); -// Flipped, he hangs upside down; turned, he hangs head down under a nest with his wings going. +// Flipped, he hangs upside down; turned, he hangs head down with his wings going. function drawBird(S, pl, x0, y0, face, flip, turn) { const ox = round(x0), oy = round(y0); S.rows.forEach((row, r) => @@ -329,7 +327,6 @@ function drawBird(S, pl, x0, y0, face, flip, turn) { ); return [ox + (S.tx - S.ax) * face, oy + (flip ? S.ay - S.ty : S.ty - S.ay)]; } -const flap = (t) => (floor(t / 0.06) & 1 ? FB : FA); // A strip of grass from the bill: trailing in flight, hanging while he weaves. function strip(P, [x, y], len, face, flying) { @@ -346,13 +343,13 @@ function drawNest(P, n, nb, rev, light) { for (let j = 0; j <= lim; j++) { C[j] = (n.fresh ? mixc(P.fresh, STRAW, (0.7 * (nb - j)) / 12) : n.old >= 0 ? mixc(STRAW, OLD, n.old) : mixc(P.fresh, STRAW, sm((P.t - (n.age[j] ?? P.t)) / 45))).map((v, c) => v * TT[c]); } - const lit = (lx, ly) => 0.82 + 0.2 * cl((lx * sd) / 9 - (ly - S.by) / 14, -1, 1), at = (lx, ly) => [round(nx + sw * (1 + (ly * s) / 20) + lx * s * face), round(ny + ly * s)]; + const lit = (lx, ly) => 0.84 + 0.16 * cl((lx * sd) / 9 - (ly - S.by) / 20, -1, 1), at = (lx, ly) => [round(nx + sw * (1 + (ly * s) / 20) + lx * s * face), round(ny + ly * s)]; n.low = -99; - // The wall behind the strands, dark where it is deep. for (let i = 0; i < F.length; i += 3) { - const k = F[i + 2]; + const k = F[i + 2] % 1000, lx = F[i], ly = F[i + 1]; if (k > lim || (k === nb && rev < 0.7)) continue; - const [xi, yi] = at(F[i], F[i + 1]), c = C[k], q = light * (0.3 + 0.2 * hash(i, 5)) * lit(F[i], F[i + 1]); + // The wall behind the strands: straw in slanting runs, the entrance mouth dark. + const [xi, yi] = at(lx, ly), c = C[k], q = F[i + 2] > 999 ? 0.15 : light * lit(lx, ly) * (0.68 + 0.1 * hash(i, 5) + 0.12 * sin((lx * 0.8 + ly) * 2.2 + 1.5 * sin(lx * 0.5 - ly * 0.3))); A(xi, yi, c[0] * q, c[1] * q, c[2] * q); if (yi > n.low) [n.low, n.lowx] = [yi, xi]; } @@ -360,7 +357,7 @@ function drawNest(P, n, nb, rev, light) { const p = S[j].p, m = j < nb ? p.length : floor((rev * p.length) / 2) * 2, k0 = light * S[j].sh, [cr, cg, cb] = C[j]; for (let i = 0; i < m; i += 2) { const [xi, yi] = at(p[i], p[i + 1]), k = k0 * (lit(p[i], p[i + 1]) + ((i >> 2) & 1 ? 0.1 : -0.08)); - A(xi, yi + 1, cr * 0.3, cg * 0.3, cb * 0.3, 0.5); + A(xi, yi + 1, cr * 0.3, cg * 0.3, cb * 0.3, 0.3); A(xi, yi, cr * k, cg * k, cb * k); } } @@ -372,8 +369,8 @@ function twig(n) { } function sky(P) { - const { buf, mat, wet, t, sun } = P, off = t * 1.6, CT = P.cloud, cc = P.hor.map((v) => mix(v, 255, 0.55)); // Clouds drift behind the tree; the crown flickers in the wind. + const { buf, mat, wet, t, sun } = P, off = t * 3, CT = P.cloud, cc = P.hor.map((v) => mix(v, 255, 0.55)); for (let p = 15 * W; p < 86 * W; p++) if (!mat[p]) { const x = (p & 63) + off, x0 = floor(x), f = x - x0, r = ((p >> 7) - 7) * 128; @@ -387,7 +384,7 @@ function sky(P) { const g = rp > 0.75 ? cl((0.4 + 1.6 * sun * cl(1 - abs(x - P.sx) / (3 + 9 * z), 0, 1)) * wet[p], 0, 1) * 0.7 : 0; for (let c = 0; c < 3; c++) buf[o + c] = mix(buf[o + c] * (1 + 0.1 * rp * wet[p]), [255, 236, 190][c], g); } - for (const [cx, r, a] of P.ri) for (let x = floor(cx - r); x <= cx + r; x++) A(x, round(WTOP + 2 + 0.2 * r * (1 - ((x - cx) / r) ** 2)), 240, 244, 240, a * 0.8); + for (let r = P.spt * 9, x = floor(P.spx - r); P.spt < 2.5 && x <= P.spx + r; x++) A(x, round(WTOP + 2 + 0.2 * r * (1 - ((x - P.spx) / r) ** 2)), 240, 244, 240, 0.8 - P.spt * 0.3); } function reeds(P) { @@ -411,7 +408,7 @@ function reeds(P) { } export function draw(display, params) { - const P = params, { buf, t, pal: pl } = P, M = P.main; + const P = params, { buf, t, pal: pl, mat, shad } = P, M = P.main; [BUF, TT] = [buf, P.tint]; buf.set(P.bg); sky(P); @@ -422,17 +419,20 @@ export function draw(display, params) { twig(n); drawNest(P, n, n.fresh ? 10 : n.S.length, 0, 0.85); if (n.fresh) drawBird(floor(t / 0.5) & 1 ? POKE : PERCH, pl, x + 5 * n.face, y - 3, -n.face); - else if (i % 3 !== 2 && c < 0.35) drawBird(flap(t), pl, n.lowx, n.low + 7, n.face, false, true); - else if (i % 3 !== 2 && c < 0.55) drawBird(PERCH, pl, ...nxy(n, -3, -3 / n.s), -n.face); + else if (i % 3 !== 2) c < 0.35 ? drawBird(flap(t), pl, n.lowx, n.low + 7, n.face, false, true) : c < 0.55 && drawBird(PERCH, pl, ...nxy(n, -3, -3 / n.s), -n.face); } twig(M); drawNest(P, M, M.nb, P.bs === WEAVE ? P.rev : 0, 1); - if (P.fs && !P.fin) drawBird(P.fp || FA, FPAL, P.fx, P.fy, P.ff); + if (P.fs) drawBird(P.fp || FA, FPAL, P.fx, P.fy, P.ff); if (P.show) drawBird(flap(t), pl, M.lowx, M.low + 7, 1, false, true); else { const tip = drawBird(P.pose, pl, P.bx, P.by, P.face, P.hang); if (P.strip > 0.5) strip(P, tip, round(P.strip), P.face, P.bs === OUT || P.bs === BACK); for (let i = 0; P.tear && i <= 8; i++) AL(round(mix(tip[0], P.tear[0], i / 8)), round(mix(tip[1], P.tear[1], i / 8)), P.fresh, 1.1); } - for (let p = 0, o = 0; p < N; p++, o += 3) display.setPixel(p & 63, p >> 6, cl(buf[o], 0, 255), cl(buf[o + 1], 0, 255), cl(buf[o + 2], 0, 255)); + // Out to the panel, under the shadows of the passing clouds. + for (let p = 0, o = 0, so = floor(t * 3); p < N; p++, o += 3) { + const s = mat[p] ? 1 - 0.4 * shad[((p & 63) + so - (p >> 7)) & 127] : 1; + display.setPixel(p & 63, p >> 6, cl(buf[o] * s, 0, 255), cl(buf[o + 1] * s, 0, 255), cl(buf[o + 2] * s, 0, 255)); + } } From de330bab5079219ab6d6f87b2b0d58ab0f3489f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 22:41:56 +0000 Subject: [PATCH 11/17] pattern: Bowling Lane Co-Authored-By: Claude Opus 5.5 --- .../pattern-study/patterns/bowling-lane.js | 596 ++++++++++++++++++ 1 file changed, 596 insertions(+) create mode 100644 incoming/pattern-study/patterns/bowling-lane.js diff --git a/incoming/pattern-study/patterns/bowling-lane.js b/incoming/pattern-study/patterns/bowling-lane.js new file mode 100644 index 000000000..5af2f29ff --- /dev/null +++ b/incoming/pattern-study/patterns/bowling-lane.js @@ -0,0 +1,596 @@ +// ===== Patternflow pattern ===== +// Title: Bowling Lane +// Family: sport +// Idea: A ten-pin lane seen from behind the bowler, foul line to pinsetter, where a hooking ball rolls up through the oil over its reflection, scatters the pins and the sweep clears them for the next rack, under house lights or blacklight. +// Author: patternflow-lab +// Date: 2026-10-23 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 22, after cosmic bowling on ten-pin lanes with ball-tracking lane lights +// Round: 22 +// =============================== +// @matrix 64x128 +// @knobs Ball=0..1, Speed=0.2..3, Hook=0..1, Lights=0..1 +// +// Knob 1: Ball. The colour of the ball, which the lane lights and the projected trail follow: cyan at 0, round through blue, pink, red, orange and green. +// Knob 2: Speed. How fast everything goes: at 2 the ball takes about 2.5 s from the foul line to the pins. +// Knob 3: Hook. How far the oil runs and how much the ball curves: at 0 the oil runs 46 ft and a straight ball hits the head pin on the nose and leaves pins. Near 0.6 it hooks into the pocket and strikes; at 1 the oil stops at 30 ft and it crosses to the Brooklyn side. +// Knob 4: Lights. Cosmic bowling at 0, the pins, arrows and oil glowing under blacklight, up to the full house lights on the wood at 1. +// Buttons: K1 tap throws the next ball now. + +const W = 64; +const H = 128; +const N = W * H; +const CX = 31.5; +const { min, max, floor, ceil, round, sin, cos, abs, sqrt, exp, atan2, sign, hypot, PI } = Math; +const rnd = () => Math.random(); +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sm = (a, b, v) => { + const t = cl((v - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; +function hash(i, j) { + let h = (Math.imul(i, 374761393) + Math.imul(j, 668265263)) | 0; + h = Math.imul(h ^ (h >>> 13), 1274126177); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +} +function hsv(h, s, v) { + h = (((h % 1) + 1) % 1) * 6; + const k = (n) => v * (1 - s * cl(min((n + h) % 6, 4 - ((n + h) % 6)), 0, 1)); + return [k(5), k(3), k(1)]; +} + +// d is feet past the foul line, x inches from the middle board. The lane +// narrows in perspective; the pin deck is drawn nearer, 4.7 rows a foot. +const Z0 = 255.8; +const sc = (d) => 246.6 / (d + Z0); +const RD = 4.7; +const D0 = -16; +const NT = 1312; +const YT = new Float32Array(NT + 1); +{ + const ramp = (d) => sm(57, 59.5, d); + let i1 = 0; + let i2 = 0; + for (let k = 0; k < 960; k++) { + const d = (k + 0.5) / 16; + i1 += (1 - ramp(d)) / (d + Z0) ** 2 / 16; + i2 += ramp(d) / 16; + } + // Foul line on row 106, head pin on row 36. + const G = (70 - RD * i2) / i1; + const rate = (d) => (G / (d + Z0) ** 2) * (1 - ramp(d)) + RD * ramp(d); + YT[1216] = 36; + for (let i = 1216; i < NT; i++) YT[i + 1] = YT[i] - rate(D0 + (i + 0.5) / 16) / 16; + for (let i = 1216; i > 0; i--) YT[i - 1] = YT[i] + rate(D0 + (i - 0.5) / 16) / 16; +} +const rowOf = (d) => { + const f = cl((d - D0) * 16, 0, NT - 0.001); + const i = f | 0; + return YT[i] + (YT[i + 1] - YT[i]) * (f - i); +}; +const DR = new Float32Array(H); +for (let y = 0, i = NT; y < H; y++) { + while (i > 0 && YT[i] < y) i--; + DR[y] = i >= NT ? 66 : D0 + (i + (YT[i] - y) / (YT[i] - YT[i + 1])) / 16; +} +// The pit edge and the row under the masking unit. +const PIT = round(rowOf(62.85)); +const MB = 11; +const proj = (x, d, h) => { + const s = sc(d); + return [CX + x * s, rowOf(d) - h * 0.85 * s, s]; +}; +const SPOT = []; +for (let r = 0; r < 4; r++) for (let i = 0; i <= r; i++) SPOT.push([(i - r / 2) * 12, r * 10.392]); +const bx = (b) => 20.75 - (b - 0.5) * 1.064; +const DOTS = [3, 5, 8, 11, 14, 26, 29, 32, 35, 37]; +// Pin radius in inches, base to crown. +const PROF = [1.0, 1.7, 2.38, 2.15, 1.6, 1.0, 0.92, 1.2, 1.15, 0.6]; +const HOLES = [[-0.3, 0.5, 0.81], [0.3, 0.5, 0.81], [0, -0.4, 0.92]]; +// Maple approach, maple heads, pine, maple pin deck. +const WOOD = [[234, 178, 112], [228, 168, 100], [212, 134, 68], [238, 188, 124]]; +const OILC = [0.4, 0.7, 1.4]; +const SEG = [0.45, 0.55, 0.8, 0.6, 0.6, 0.45]; + +// The still picture: house light colour A, blacklight glow F, wash share WM. +function paint(P) { + const A = (P.A = new Float32Array(N * 3)), F = (P.F = new Float32Array(N * 3)), WM = (P.WM = new Float32Array(N)); + const put = (x, y, a, f, wm = WM[y * W + x]) => { + if (x < 0 || x >= W || y < MB || y >= H) return; + const o = (y * W + x) * 3; + for (let c = 0; c < 3; c++) { + A[o + c] = a[c]; + F[o + c] = f[c]; + } + WM[y * W + x] = wm; + }; + for (let y = MB; y < H; y++) { + const d = DR[y], s = sc(d); + const lit = d < 57 ? 0.9 + 0.08 * cos((2 * PI * (d + 3)) / 15) : 1.12; + const oil = sm(44, 36, d) * (d > 0 ? 1 : 0); + for (let x = 0; x < W; x++) { + const X = (x - CX) / s, ax = abs(X); + if (y < PIT || (d > 57 && ax > 30.5)) { + // Kickbacks and the pit curtain. + const k = (x * 2 + y) % 11; + if (ax > 30.5) put(x, y, k < 2 ? [70, 70, 80] : [28, 28, 34], k < 1 ? [200, 30, 150] : k < 2 ? [60, 20, 140] : [14, 6, 34], 0); + else put(x, y, [10, 10, 12], [6, 3, 16], 0); + } else if (ax <= 20.75 || ax > 43.75 || (d < 0 && (ax <= 30 || ax > 34.5))) { + const far = ax > 43.75, lx = far ? ax - 64.5 : X, a = [0, 0, 0]; + let tone = 0; + for (const e of [-0.25, 0.25]) { + const k = floor((20.75 - lx - e / s) / 1.064); + const kind = d < 0 ? 0 : d < 15.2 + (k & 3) * 0.7 ? 1 : d < 56 + (k % 3) * 0.6 ? 2 : 3; + const t = 0.88 + 0.14 * hash(k + 40, kind) + 0.06 * (hash(k + 40, floor(d * 2.5) + 99) - 0.5); + for (let c = 0; c < 3; c++) a[c] += WOOD[kind][c] * t * (far ? 0.7 : 1) * lit * 0.5; + tone += t / 2; + } + // Ceiling lights mirrored in the oil. + const glare = far ? 0 : 70 * oil * (exp(-(((X - 9) / 3.5) ** 2)) + exp(-(((X + 9) / 3.5) ** 2))) * (0.5 + 0.5 * cos((2 * PI * d) / 15)) ** 2; + const u = (far ? 0.7 : 1) * (d < 0 ? 0.8 : 1) * tone; + let f = [22 * u, 8 * u, 46 * u]; + if (!far && d > 2 && d < 55 && hash(x, y * 7 + 3) < 0.014) f = hsv(hash(y, x), 0.7, 180); + put(x, y, a.map((v) => v + glare), f, far ? 0.4 : 1); + } else if (ax <= 30 || ax > 34.5) { + const g = ((ax <= 30 ? ax - 25.375 : ax - 39.125) / 4.625) ** 2, v = (0.4 + 0.6 * g) * lit; + put(x, y, [70 * v, 72 * v, 84 * v], g > 0.75 ? [30, 12, 80] : [8, 4, 22], 0.5); + } else put(x, y, [50 * lit, 38 * lit, 32 * lit], [10, 5, 20], 0); + } + } + for (let x = 0; x < W; x++) { + const ax = abs((x - CX) / sc(0)); + if (ax <= 30 || ax > 34.5) put(x, round(rowOf(0)), [16, 16, 18], [255, 30, 90]); + } + // Foul line, arrows, dots, range finders and pin spots. + for (let b = 5; b <= 35; b += 5) { + const dt = 16 - abs(20 - b) * 0.267, ty = round(rowOf(dt)), ax = round(CX + bx(b) * sc(dt)); + for (let j = 0; j < 4; j++) for (let i = -(j >> 1); i <= j >> 1; i++) put(ax + i, ty + j, [96, 34, 22], [255, 120, 24]); + } + for (const b of DOTS) put(round(CX + bx(b) * sc(7)), round(rowOf(7)), [96, 40, 26], [80, 255, 110]); + for (const b of [...DOTS, 20]) put(round(CX + bx(b) * sc(-12)), round(rowOf(-12)), [90, 50, 30], [70, 220, 100]); + for (let y = round(rowOf(40)); y <= round(rowOf(34)); y++) for (const b of [10, 30]) put(round(CX + bx(b) * sc(DR[y])), y, [100, 46, 30], [40, 190, 255]); + for (const [X, Z] of SPOT) put(round(CX + X * sc(60 + Z / 12)), round(rowOf(60 + Z / 12)), [70, 46, 30], [60, 40, 130]); +} + +const rack = (h) => SPOT.map(([X, Z]) => ({ X, Z, vx: 0, vz: 0, th: 0, om: 0, ph: 0, w: 0, h, vh: 0, st: h ? 4 : 0 })); + +export function setup(params) { + paint(params); + Object.assign(params, { + buf: new Float32Array(N * 3), + tx: new Float32Array(H), + tt: new Float32Array(H).fill(-99), + t: 0, ph: 0, tm: 1.5, first: true, ball: 1, full: true, fl: 0, mark: 0, seg: -1, pins: rack(0), B: null, + ra: 0, ax: [-1, 0, 0], hs: 46, zs: -9, pt: 0, path: [0, 0, 0, 0, 40], bd: -13, bh: 0, bt: 0, + m: 1, hook: 0, oil: 46, Hh: 0.1, U: 1, c1: [0, 1, 1], c2: [0, 0, 1], + }); +} + +const pathX = (P, d) => { + const [x0, xb, sb, a, db] = P.path; + return d < db ? x0 + sb * d : xb + sb * (d - db) - (a * (d - db) ** 2) / 2; +}; +const pathS = (P, d) => (d < P.path[4] ? P.path[2] : P.path[2] - P.path[3] * (d - P.path[4])); + +// Out toward the gutter until the oil thins, then the hook back. A spare ball +// moves the whole line onto the standing pins. +function throwBall(P) { + const h = P.hook, db = P.oil - 6, e = 60 - db; + let x60 = (h < 0.55 ? 0.4 + (2.3 * h) / 0.55 : 2.7 - (4.7 * (h - 0.55)) / 0.45) + (rnd() - 0.5) * 1.6; + let x0 = 12 - 24 * h; + const xs = x0 + ((x60 - x0) * db) / 60; + let xb = xs + (16 - xs) * min(1, h * 1.4); + const line = () => { + const sb = (xb - x0) / db; + return [sb, ((xb + e * sb - x60) * 2) / (e * e)]; + }; + const up = P.pins.filter((p) => p.st === 0); + if (P.ball === 2 && up.length) { + const z = min(...up.map((p) => p.Z)), front = up.filter((p) => p.Z < z + 11), [sb, a] = line(); + const shift = front.reduce((s, p) => s + p.X, 0) / front.length - x60 - ((sb - a * e) * z) / 12 + (rnd() - 0.5) * 2; + x0 += shift; + xb += shift; + x60 += shift; + } + x0 = cl(x0, -17, 17); + xb = cl(xb, -17, 17); + P.path = [x0, xb, ...line(), db]; + Object.assign(P, { ph: 1, bt: 0, bd: -13, ra: 0 }); + const b = 0.9 * h; + const k = [-cos(b), 0.3 * sin(b), sin(b)]; + P.ax = k.map((v) => v / hypot(...k)); +} + +// The swing brings the ball in from below the panel, then it rolls. +function roll(P, h) { + P.bt += h; + const y0 = rowOf(P.bd), v = 25 - 0.07 * P.bd; + if (P.bt < 0.45) { + P.bd = -13 + 30 * P.bt; + P.bh = 10 * (1 - P.bt / 0.45) ** 2; + } else { + P.bd += v * h; + P.bh = 0; + P.ra += v * 0.36 * h; + } + const d = P.bd, x = CX + pathX(P, max(0, d)) * sc(d); + for (let y = max(0, round(rowOf(d))); y <= round(y0) && y < H; y++) { + P.tx[y] = x; + P.tt[y] = P.t; + } + if (d >= 58.6) { + P.B = { X: pathX(P, d), Z: (d - 60) * 12, vx: pathS(P, d) * v, vz: 12 * v, h: 0, vh: 0, st: -1 }; + P.ph = 2; + P.pt = 0; + } +} + +function pts(p) { + if (p.th < 0.35) return [[p.X, p.Z, 0.5]]; + const L = 15 * sin(p.th); + return [0.12, 0.5, 0.88].map((f) => [p.X + cos(p.ph) * L * f, p.Z + sin(p.ph) * L * f, f]); +} + +function kick(p, dvx, dvz, f) { + p.vx += dvx; + p.vz += dvz; + if (p.st < 0) return; + const dv = sqrt(dvx * dvx + dvz * dvz); + if (p.st === 0) { + if (p.th < 0.05) p.ph = atan2(dvz, dvx); + p.om += dv / 7; + if (dv > 130) Object.assign(p, { vh: dv * 0.28, om: dv / 7 + 5, st: 1, th: max(p.th, 0.05) }); + } else p.w += (15 * sin(p.th) * (f - 0.5) * (cos(p.ph) * dvz - sin(p.ph) * dvx)) / 70; +} + +// Ball and pins are discs on the deck; pins rock, topple past 11 degrees, fly +// when hit hard, spin, and bounce off the kickbacks. +function collide(P) { + const bodies = []; + if (P.B && P.B.h > -3) bodies.push([P.B, 15, 4.3, [[P.B.X, P.B.Z, 0.5]]]); + for (const p of P.pins) if (p.st < 2 && p.h < 14) bodies.push([p, 3.5, 2.4, pts(p)]); + for (let i = 0; i < bodies.length; i++) { + const [a, ma, ra, pa] = bodies[i]; + for (const [qx] of pa) { + const sg = sign(qx), lim = 30.5 - ra; + if (abs(qx) > lim) { + a.X -= qx - sg * lim; + if (a.vx * sg > 0) a.vx *= -0.5; + } + } + for (let j = i + 1; j < bodies.length; j++) { + const [b, mb, rb, pb] = bodies[j]; + const R = ra + rb, ia = 1 / ma, ib = 1 / mb; + for (const qa of pa) + for (const qb of pb) { + const dx = qb[0] - qa[0], dz = qb[1] - qa[1], dd = dx * dx + dz * dz; + if (dd >= R * R || dd < 1e-6) continue; + const D = sqrt(dd), nx = dx / D, nz = dz / D, pen = (R - D) / (ia + ib); + a.X -= nx * pen * ia; + a.Z -= nz * pen * ia; + b.X += nx * pen * ib; + b.Z += nz * pen * ib; + const vn = (b.vx - a.vx) * nx + (b.vz - a.vz) * nz; + if (vn >= 0) continue; + const J = (-1.5 * vn) / (ia + ib); + kick(a, -J * nx * ia, -J * nz * ia, qa[2]); + kick(b, J * nx * ib, J * nz * ib, qb[2]); + } + } + } +} + +function move(P, dt) { + const B = P.B; + if (B) { + B.X += B.vx * dt; + B.Z += B.vz * dt; + P.ra += B.vz * 0.03 * dt; + if (B.Z > 34.2) { + B.vh -= 386 * dt; + B.h += B.vh * dt; + if (B.Z > 44) B.vz = 0; + } + } + for (const p of P.pins) { + if (p.st >= 3) continue; + if (p.st === 2 || p.h > 0 || p.vh > 0) { + p.vh -= 386 * dt; + p.h += p.vh * dt; + p.th += p.om * dt; + if (p.st === 2) { + if (p.Z > 46) p.vz = 0; + if (p.h < -40) p.st = 3; + } else if (p.h <= 0) { + if (sin(p.th) < 0) p.ph += PI; + Object.assign(p, { h: 0, vh: p.vh < -80 ? -p.vh * 0.3 : 0, th: PI / 2, om: 0, st: 1 }); + } + } else if (p.st === 0) { + if (p.th > 0 || p.om > 0) { + p.om += 60 * sin(p.th - 0.2) * dt; + p.th += p.om * dt; + if (p.th < 0) { + p.th = -p.th; + p.om *= -0.4; + p.ph += PI; + if (abs(p.om) < 0.3) p.th = p.om = 0; + } + if (p.th > 0.2) p.st = 1; + } + } else if (p.th < PI / 2) { + p.om += 64 * sin(p.th) * dt; + p.th += p.om * dt; + if (p.th >= PI / 2) { + p.th = PI / 2; + p.om = 0; + } + } + if (p.st < 2 && p.h <= 0) { + const v = hypot(p.vx, p.vz), dec = (p.st ? 110 : 400) * dt, k = v > dec ? (v - dec) / v : 0; + p.vx *= k; + p.vz *= k; + p.w *= exp(-2.5 * dt); + } + p.X += p.vx * dt; + p.Z += p.vz * dt; + p.ph += p.w * dt; + if (p.st < 2 && p.h < 8 && p.Z + 7.5 * sin(p.th) * sin(p.ph) > 34.2) p.st = 2; + } +} + +function physics(P, h) { + for (let s = 0, n = ceil(h / 0.004); s < n; s++) { + move(P, h / n); + collide(P); + } +} + +function endPins(P) { + const up = P.pins.filter((p) => p.st === 0).length; + if (!up) { + P.fl = P.ball === 1 ? 1 : 0.8; + P.mark = P.ball; + } + Object.assign(P, { full: !up || P.ball === 2, ph: 3, tm: 0, seg: -1, B: null }); +} + +// Sweep drops, table lifts standing pins, sweep rakes the deadwood into the +// pit and returns, table sets the pins or a new rack, sweep lifts. +function sweep(P, h) { + let u = (P.tm += h), k = 0; + while (k < 6 && u > SEG[k]) u -= SEG[k++]; + if (k >= 6) return Object.assign(P, { ph: 0, tm: 0.7, ball: P.full ? 1 : 2, hs: 46 }); + const e = sm(0, 1, u / SEG[k]); + if (k !== P.seg) { + P.seg = k; + if (k === 4 && P.full) P.pins = rack(46); + if (k === 5) for (const p of P.pins) if (p.st === 4) p.st = p.h = 0; + } + if (k === 0) P.hs = 46 - 45 * e; + else if (k === 1) { + if (!P.full) for (const p of P.pins) if (p.st === 0 || p.st === 4) Object.assign(p, { st: 4, h: 46 * e, th: 0, om: 0, vx: 0, vz: 0 }); + } else if (k === 2) { + P.zs = -9 + 47 * e; + for (const p of P.pins) + if (p.st < 2 && p.h < 12 && p.Z < P.zs + 3) { + p.Z = P.zs + 3; + p.vz = max(p.vz, 0); + if (p.st === 0) Object.assign(p, { st: 1, th: 1.3, ph: PI / 2 }); + } + } else if (k === 3) P.zs = 38 - 47 * e; + else if (k === 4) { + for (const p of P.pins) if (p.st === 4) p.h = 46 * (1 - e); + } else P.hs = 1 + 45 * e; +} + +function throwNow(P) { + if (P.ph === 1) return; + if (P.ph >= 2) { + if (P.ph === 2) endPins(P); + P.pins = P.full ? rack(0) : P.pins.filter((p) => p.st === 0 || p.st === 4).map((p) => Object.assign(p, { st: 0, h: 0, th: 0, om: 0 })); + Object.assign(P, { ball: P.full ? 1 : 2, hs: 46, B: null }); + } + throwBall(P); +} + +function step(P, h) { + P.t += h; + P.fl = max(0, P.fl - h * 0.5); + let ph = h; + if (P.ph === 0) { + // The first ball goes at 1.5 s at any speed. + if ((P.tm -= P.first ? h / P.m : h) <= 0) { + P.first = false; + throwBall(P); + } + } else if (P.ph === 1) roll(P, h); + else if (P.ph === 2) { + // Pin action in slow motion at first. + ph = h * (0.45 + 0.55 * sm(0.5, 1, P.pt)); + P.pt += ph; + if (P.pt > 1.6) endPins(P); + } else sweep(P, h); + physics(P, ph); +} + +export function update(dt, input, params) { + const P = params; + const k = input.knobValues || [0, 2, 0, 0.06]; + P.c1 = hsv(0.52 + k[0], 0.9, 1); + P.c2 = hsv(0.66 + k[0], 0.7, 1); + P.m = cl(k[1], 0.2, 3) / 2; + P.hook = cl(k[2], 0, 1); + P.oil = 46 - 16 * P.hook; + const L = cl(k[3], 0, 1); + P.Hh = 0.1 + 0.9 * L * L; + P.U = 1 - L; + if (input.btnPressed && input.btnPressed[0]) throwNow(P); + step(P, cl(dt, 0, 0.1) * P.m); +} + +function add(buf, x, y, c, v) { + if (x < 0 || x >= W || y < MB || y >= H) return; + const o = (y * W + x) * 3; + for (let i = 0; i < 3; i++) buf[o + i] += c[i] * v; +} +function blend(buf, x, y, r, g, b, a) { + if (x < 0 || x >= W || y < MB || y >= H) return; + const o = (y * W + x) * 3; + [r, g, b].forEach((v, i) => (buf[o + i] += (v - buf[o + i]) * a)); +} + +// Capping lights that chase, follow the ball and light the gutters, and the +// projected trail. +function lights(P) { + const { buf, c1, t, U, fl, tx, tt } = P; + const db = P.ph === 1 ? P.bd : P.ph === 2 ? 70 : -99; + for (let y = round(rowOf(57)); y < H; y++) { + const d = DR[y], s = sc(d); + let v = 0.16 + 0.34 * max(0, sin(2 * PI * (d / 9 - t * 0.55))) ** 6; + if (db > -50) v += d < db ? 0.9 * exp((d - db) / 7) : exp(-(d - db) * 1.5); + v = (v + fl * (0.5 + 0.5 * sin(d * 0.7 - t * 14))) * 200 * (0.45 + 0.55 * U); + for (const sg of [-1, 1]) { + const xs = CX + sg * 32.25 * s, x = floor(xs), f = xs - x; + [0.2, 1 - f * 0.8, 0.2 + f * 0.8, 0.2].forEach((k, i) => add(buf, x + i - 1, y, c1, v * k)); + if (d > 0) for (let X = 21; X < 30; X += 1 / s) add(buf, round(CX + sg * X * s), y, c1, v * 0.4 * ((X - 20.75) / 9.25) ** 1.5); + } + const age = t - tt[y]; + if (age < 4) { + const I = min(1, age * 8) * exp(-age / 1.2) * 140 * U, w = 4.3 * s + 0.8; + for (let x = max(0, floor(tx[y] - w)); x <= min(W - 1, tx[y] + w); x++) { + const f = 1 - ((x - tx[y]) / w) ** 2; + if (f > 0) add(buf, x, y, c1, I * f); + } + } + } +} + +// Marbled shell and holes turning with the roll. +function drawBall(P, cx, cy, r, alpha, mirror, clip) { + const { buf, c1, c2, Hh, U } = P; + const [k0, k1, k2] = P.ax, ca = cos(P.ra), sa = sin(P.ra); + for (let y = floor(cy - r - 1); y <= cy + r + 1 && y < clip; y++) + for (let x = floor(cx - r - 1); x <= cx + r + 1; x++) { + const u = (x - cx) / r, v = (y - cy) / r, q = u * u + v * v; + const cov = cl((1 - sqrt(q)) * r + 0.5, 0, 1); + if (cov <= 0) continue; + const nz = sqrt(max(0, 1 - q)), ny = mirror ? v : -v; + const kd = (k0 * u + k1 * ny + k2 * nz) * (1 - ca); + const X = u * ca + (k1 * nz - k2 * ny) * sa + k0 * kd; + const Y = ny * ca + (k2 * u - k0 * nz) * sa + k1 * kd; + const Z = nz * ca + (k0 * ny - k1 * u) * sa + k2 * kd; + const sw = (0.5 + 0.5 * sin(3.2 * X + 2.4 * sin(2.6 * Y + 1.8 * Z) + 2 * Z * X)) ** 2; + let hole = 1; + for (const [hx, hy, hz] of HOLES) if (hx * X + hy * Y + hz * Z > 0.972) hole = 0.12; + const lit = (0.25 + 0.75 * max(0, -0.35 * u + 0.6 * ny + 0.72 * nz)) * Hh * 1.15 + U * (0.45 + 0.6 * nz); + const sp = mirror ? 0 : 230 * Hh * max(0, -0.2 * u + 0.5 * ny + 0.84 * nz) ** 24; + const col = [0, 1, 2].map((c) => (c1[c] * (1 - sw) * 0.9 + c2[c] * sw) * 255 * lit * hole + sp); + blend(buf, x, y, col[0], col[1], col[2], cov * (mirror ? alpha * max(0, 1 - (y - cy + r) / (2 * r)) : alpha)); + } +} + +// A pin's profile swept from base to crown, tilted by th toward ph. +function drawPin(P, p) { + const { buf, Hh, U } = P; + const [x0, y0, s] = proj(p.X, 60 + p.Z / 12, p.h - (abs(p.X) > 21 && p.st === 1 ? 2.5 : 0)); + const hx = cos(p.ph) * 15 * sin(p.th) * s; + const hy = (-sin(p.ph) * 15 * sin(p.th) * RD) / 12 - 15 * cos(p.th) * 0.85 * s; + const L2 = hx * hx + hy * hy, clip = p.Z > 34.2 ? PIT : H; + for (let y = floor(min(y0, y0 + hy) - 3); y <= max(y0, y0 + hy) + 3 && y < clip; y++) + for (let x = floor(min(x0, x0 + hx) - 3); x <= max(x0, x0 + hx) + 3; x++) { + const px = x - x0, py = y - y0; + const t = L2 > 0.01 ? cl((px * hx + py * hy) / L2, 0, 1) : 0; + const ex = px - t * hx, ey = py - t * hy, f = t * 9, i = min(8, f | 0); + const rw = (PROF[i] + (PROF[i + 1] - PROF[i]) * (f - i)) * s; + const cov = cl(rw - sqrt(ex * ex + ey * ey) + 0.5, 0, 1); + if (cov <= 0) continue; + const lit = (0.82 - (0.18 * ex) / (rw + 0.3)) * Hh; + if (t > 0.6 && t < 0.73) blend(buf, x, y, 215 * Hh + 255 * U, 30 * Hh + 40 * U, 40 * Hh + 150 * U, cov); + else blend(buf, x, y, 240 * lit + 180 * U, 240 * lit + 192 * U, 234 * lit + 255 * U, cov); + } +} + +function drawSweep(P) { + const { buf, Hh, U, hs, zs } = P; + const [xl, yb, s] = proj(-31, 60 + zs / 12, hs); + const th = 3.4 * s; + for (let y = floor(yb - th); y <= yb; y++) + for (let x = round(xl); x <= round(2 * CX - xl); x++) { + const top = y < yb - th + 1 ? 60 : 0; + const hz = ((x + 64) >> 1) % 3 === 0; + blend(buf, x, y, (140 + top) * Hh + (hz ? 255 : 40) * U, (142 + top) * Hh + (hz ? 210 : 20) * U, (152 + top) * Hh + (hz ? 40 : 70) * U, 1); + } + for (const x of [round(CX - 29.5 * s), round(CX + 29.5 * s)]) for (let y = MB; y < yb - th; y++) blend(buf, x, y, 80 * Hh + 50 * U, 80 * Hh + 30 * U, 92 * Hh + 120 * U, 1); +} + +// The masking unit, with the blacklight tube along its lip. +function hood(P) { + const { buf, Hh, U, c1, c2, fl, t } = P; + for (let y = 0; y < MB; y++) + for (let x = 0; x < W; x++) { + const o = (y * W + x) * 3, wy = 4.4 + 2.4 * sin(x * 0.11 + 0.8), b1 = max(0, 1 - abs(y - wy)); + const b2 = max(0, 1 - abs(y - wy - 2.5) * 1.3) * sm(4, 14, x), pulse = 0.55 + 0.45 * sin(x * 0.35 - t * 5); + const tw = hash(x, y) < 0.07 ? (0.5 + 0.5 * sin(t * 4 + hash(y, x) * 40)) * 150 * U : 0; + const fx = fl > 0 ? hsv(x / 40 - t * 2, 1, fl * 200) : [0, 0, 0]; + for (let c = 0; c < 3; c++) + buf[o + c] = + y === MB - 1 + ? 150 * Hh + [110, 50, 255][c] * U + : [20, 30, 88][c] * Hh * (0.85 + y * 0.03) + [12, 4, 30][c] * U + b1 * ([190, 220, 255][c] * Hh + c1[c] * 255 * U * pulse) + b2 * ([255, 140, 40][c] * Hh + c2[c] * 230 * U) + tw + fx[c]; + } + // X for a strike, a slash for a spare. + if (fl > 0.05) + for (let j = -3; j <= 3; j++) + for (const i of P.mark === 1 ? [j, -j] : [-j]) { + const o = ((5 + j) * W + 32 + i) * 3; + for (let c = 0; c < 3; c++) buf[o + c] = 255 * min(1, fl * 2); + } +} + +export function draw(display, params) { + const P = params; + const { buf, A, F, WM, Hh, U, c1 } = P; + // The lane lights wash the boards in slow waves; the oil glows. + for (let y = MB; y < H; y++) { + const d = DR[y], s = sc(d), wave = 16 + 9 * sin(2 * PI * (d / 14 - P.t * 0.5)); + for (let x = 0; x < W; x++) { + const i = y * W + x, o = i * 3, wm = WM[i] * wave * U; + const ax = abs(x - CX) / s; + const oil = WM[i] === 1 && d > 0 ? sm(P.oil + 3, P.oil - 4, d + ax * 0.2) * (64 - ax * ax * 0.08) * U : 0; + for (let c = 0; c < 3; c++) buf[o + c] = A[o + c] * Hh + F[o + c] * U + c1[c] * wm + oil * OILC[c]; + } + } + lights(P); + let ball = null; + if (P.ph === 1) ball = [pathX(P, max(0, P.bd)), P.bd, P.bh, H]; + else if (P.B && P.B.h > -14) ball = [P.B.X, 60 + P.B.Z / 12, P.B.h, P.B.Z > 34.2 ? PIT : H]; + let bs = null; + if (ball) { + const [x, d, h, clip] = ball; + const [cx, cy, s] = proj(x, d, 4.3 + h), fy = rowOf(d), r = 4.3 * s, R = r * 3.4; + bs = [cx, cy, r, clip, d]; + // Halo, shadow, reflection. + for (let y = floor(cy - R); y <= cy + R && y < clip; y++) + for (let x2 = floor(cx - R); x2 <= cx + R; x2++) { + const q = 1 - hypot(x2 - cx, (y - cy) * 1.2) / R; + if (q > 0) add(buf, x2, y, c1, q * q * 110 * U); + const e = ((x2 - cx) / (r * 1.1)) ** 2 + ((y - fy) / (r * 0.45)) ** 2; + if (e < 1 && clip === H && y >= MB && y < H && x2 >= 0 && x2 < W) + for (let c = 0; c < 3; c++) buf[(y * W + x2) * 3 + c] *= 1 - 0.55 * Hh * (1 - e) * exp(-h / 6); + } + if (d > 0 && d < P.oil + 2) drawBall(P, cx, 2 * fy - cy + h * 1.7 * s, r, 0.15 + 0.4 * sm(P.oil + 2, P.oil - 8, d), true, H); + } + // Deck objects far to near. + const items = P.pins.filter((p) => p.st < 3).map((p) => [p.Z + (p.st === 1 ? 7 * sin(p.ph) : 0), p]); + if (P.hs < 45) items.push([P.zs - 0.5, 0]); + if (bs && bs[4] > 57) items.push([(bs[4] - 60) * 12, 1]); + items.sort((a, b) => b[0] - a[0]); + for (const [, it] of items) { + if (it === 0) drawSweep(P); + else if (it === 1) drawBall(P, bs[0], bs[1], bs[2], 1, false, bs[3]); + else drawPin(P, it); + } + if (bs && bs[4] <= 57) drawBall(P, bs[0], bs[1], bs[2], 1, false, bs[3]); + hood(P); + for (let p = 0, o = 0; p < N; p++, o += 3) display.setPixel(p % W, (p / W) | 0, min(255, buf[o]), min(255, buf[o + 1]), min(255, buf[o + 2])); +} From 487c1ed310c968f91b39a9bb2e545423abf23516 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 22:45:34 +0000 Subject: [PATCH 12/17] pattern: Liftoff Co-Authored-By: Claude Opus 5.5 --- incoming/pattern-study/patterns/liftoff.js | 413 +++++++++++++++++++++ 1 file changed, 413 insertions(+) create mode 100644 incoming/pattern-study/patterns/liftoff.js diff --git a/incoming/pattern-study/patterns/liftoff.js b/incoming/pattern-study/patterns/liftoff.js new file mode 100644 index 000000000..1ac231a59 --- /dev/null +++ b/incoming/pattern-study/patterns/liftoff.js @@ -0,0 +1,413 @@ +// ===== Patternflow pattern ===== +// Title: Liftoff +// Family: flight +// Idea: A rocket vents on its pad across the water, lights up in rolling steam and climbs while the camera tilts after it from blue sky into black and stars, through Max-Q and staging, until it is a bright dot and the next rocket stands on the pad. +// Author: patternflow-lab +// Date: 2026-10-23 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 22, after long-lens tracking shots of launches filmed across the water from the press site +// Round: 22 +// =============================== +// @matrix 64x128 +// @knobs Rocket=0..3, Speed=0.2..3, Stages=1..3, Time=0..1 +// +// Knob 1: Rocket. A white moon rocket by its tower at 0, a chequered V-2 at 1, a grey-green cluster whose boosters fall away in a cross at 2, a sounding rocket on a rail at 3. +// Knob 2: Speed. At 2 the countdown, the flight and the next rocket take 35 s; at 0.2 ten times longer. +// Knob 3: Stages. 1 to 3 stages. A taller stack gets a taller tower and more steam, and each extra stage drops away on the climb. +// Knob 4: Time. Dawn at 0, then day, dusk and a floodlit night with searchlights. At dawn and dusk the high plume catches the sun. +// Buttons: K1 tap skips the countdown to ignition, or after liftoff rolls out the next rocket. + +const W = 64; +const H = 128; +const N = W * H; +// Shoreline row with the camera level, pad column, deck height. +const GY = 112; +const X0 = 36; +const DECK = 4; +const MAXP = 900; +// A puff: x, height, vx, vz, radius, growth, age, life, opacity, heat, +// whiteness, drag, sprite. +const PF = 13; +const SP = 24; +const { min, max, abs, sin, cos, exp, sqrt, floor, round, ceil, atan2, hypot } = Math; +const rnd = () => Math.random(); +const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); +const ss = (a, b, v) => { + const t = clamp((v - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; +// Dawn, day, dusk, night: four sky colours, ambient, sun colour; sun on the +// rocket, on the high plume, floodlights, stars, sun column, height, radius. +const PAL = [ + [255, 150, 90, 200, 135, 150, 80, 105, 190, 22, 36, 110, 80, 88, 125, 255, 175, 120, 0.3, 1, 0.75, 0.15, 56, 0, 3.5], + [200, 222, 245, 140, 190, 240, 55, 135, 230, 15, 55, 165, 175, 185, 200, 255, 250, 235, 1, 0, 0, 0, 12, 190, 3.5], + [255, 115, 55, 185, 85, 115, 70, 55, 140, 18, 18, 70, 95, 72, 100, 255, 135, 75, 0.35, 1, 0.6, 0.3, 8, 3, 4], + [22, 34, 62, 14, 22, 50, 7, 12, 34, 3, 5, 16, 30, 34, 55, 0, 0, 0, 0, 0.1, 1, 1, 30, -40, 0], +]; +// Kerosene, hydrogen, solid: flame core, middle, edge, glow; trail +// whiteness and thickness. +const FUEL = [ + [[255, 246, 215], [255, 196, 80], [235, 105, 30], [255, 140, 55], 0.62, 0.75], + [[235, 240, 255], [170, 180, 255], [120, 110, 230], [130, 150, 255], 0.95, 0.22], + [[255, 252, 240], [255, 225, 150], [250, 160, 70], [255, 190, 120], 0.95, 0.95], +]; +const ACC = [1, 1.15, 1.05, 1.7]; +const LEAN = [0, 0, 0, 0.12]; +const STEAM = [1, 0.55, 0.9, 0.35]; +const IGN = [-2, -1.6, -2.2, -0.12]; +// White, black, engine bells, bare metal, grey-green, orange. +const PAINT = [[0.9, 0.9, 0.88], [0.08, 0.08, 0.08], [0.24, 0.21, 0.2], [0.66, 0.69, 0.74], [0.62, 0.67, 0.58], [0.95, 0.42, 0.12]]; +// 3x5 letters: 0-9, T, minus, plus. +const FONT = [31599, 11415, 29671, 29647, 23497, 31183, 31215, 29330, 31727, 31695, 29842, 448, 1488]; +const BEAMS = [[3, 0.32], [62, -0.3], [22, 0.06]].map(([b, a]) => [b, sin(a), cos(a)]); +const BOOST = [[-1.4, 0, 1.9, 1.4, 2, 0], [0, 1, 2.1, 2.05, 1, 0], [1, 13, 2.05, 1.1, 10, 0], [13, 18, 1.1, 0, 10, 0]]; + +// Stages from the bottom: length, segments [u0, u1, half widths, paint], +// bell [length, half widths], nozzle, fuel, fins [u0, u1, span, paint]. +function build(sty, n) { + const S = (len, segs, bell, wn, fuel, fins = []) => ({ len, segs, bell, wn, fuel, fins }); + const a = 20 + (3 - n) * 2.5, b = n > 1 ? 26 : 29; + const st = [ + [S(a, [[0, a, 4.5, 4.5, 6]], [2.5, 4.4, 3.4], 4.3, 0, [[0, 5, 2.4, 1]]), S(13, [[0, 1, 4.5, 4.5, 1], [1, 13, 4.5, 4.5, 0]], [1.5, 3, 2.2], 3, 1), S(10, [[0, 3, 4.5, 3.3, 0], [3, 10, 3.3, 3.3, 7]], [1.2, 1.8, 1.2], 1.6, 1)], + [S(b, [[0, 20, 3.2, 3.4, 8], [20, b, 3.4, n > 1 ? 1.4 : 0, 8]], [1.5, 2.4, 1.8], 2.2, 0, [[0, 7, 3.4, 8]]), S(12, [[0, 12, 1.4, 1.4, 0]], [0.8, 1.1, 0.8], 1, 2, [[0, 3, 1.7, 1]]), S(8, [[0, 8, 1.1, 1.1, 9]], [0.7, 0.9, 0.6], 0.8, 2, [[0, 2.5, 1.2, 5]])], + [S(28, [[0, 26, 2.2, 2.8, 10], [26, 28, 2.8, 2.5, 10]], [1.5, 2.2, 1.6], 2.2, 0), S(10, [[0, 2, 2.4, 2.4, 11], [2, 10, 2.6, 2.6, 10]], [1.2, 1.8, 1.2], 1.6, 0), S(4, [[0, 4, 2.6, 2.6, 3]], [0.8, 1.2, 0.8], 1, 1)], + [S(15, [[0, 15, 1.7, 1.7, 9]], [1, 1.4, 1.1], 1.4, 2, [[0, 4.5, 2.6, 5]]), S(12, [[0, 1.5, 1.7, 1.3, 1], [1.5, 12, 1.3, 1.3, 9]], [0.8, 1.1, 0.8], 1.1, 2, [[1.5, 4, 1.7, 5]]), S(9, [[0, 9, 1.1, 1.1, 9]], [0.6, 0.9, 0.6], 0.8, 2, [[0, 2.5, 1.2, 5]])], + ][sty].slice(0, n); + const last = st[n - 1], top = last.segs.at(-1)[3]; + last.nose = sty === 0 ? [[0, 4, top, 2.6, 3], [4, 7, 2.6, 0.7, 0], [7, 11, 0.45, 0.45, 5], [11, 13, 0.9, 0.9, 5], [13, 14, 0.9, 0.1, 5]] : sty === 2 ? [[0, 5, 3.2, 3.2, 0], [5, 9, 3.2, 1.4, 0], [9, 10, 1.4, 0.5, 0], [10, 14, 0.4, 0.4, 5], [14, 16, 0.8, 0.8, 5], [16, 17, 0.8, 0, 5]] : top > 0 ? [[0, sty === 3 ? 6 : 4, top, 0, 0]] : []; + let u = 0; + for (const s of st) [s.u, u] = [u, u + s.len]; + return { st, n, L: u + (last.nose.length && last.nose.at(-1)[1]), sep: st.slice(1).map((_, i) => (sty === 2 ? 11.5 + i * 4.5 : 9.5 + i * 5)), boost: sty === 2 }; +} + +// Stages a..b-1 end to end from 0, with stage a's bell below. +function stack(V, a, b) { + const segs = [], fins = [], u0 = V.st[a].u, bl = V.st[a].bell; + segs.push([-bl[0], 0, bl[1], bl[2], 2, 0]); + for (let i = a; i < b; i++) { + const s = V.st[i], o = s.u - u0, t = o + s.len; + for (const g of s.segs) segs.push([g[0] + o, g[1] + o, g[2], g[3], g[4], o]); + for (const f of s.fins) fins.push([f[0] + o, f[1] + o, f[2], f[3], o]); + if (s.nose) for (const g of s.nose) segs.push([g[0] + t, g[1] + t, g[2], g[3], g[4], t]); + } + return { segs, fins }; +} + +const vehicle = (P) => P.vc[P.style * 4 + P.n] || (P.vc[P.style * 4 + P.n] = build(P.style, P.n)); + +// The stack's foot at flight time T, and its engines. +function fly(P, V, T) { + const sty = P.style, t = max(0, T), g = ACC[sty], E = g * 512 * (1 - exp((-t * t) / 128)); + let ai = 0, f = ss(IGN[sty], IGN[sty] + 0.6, T); + for (const ts of V.sep) { + if (T >= ts) ai++; + f *= 1 - ss(ts - 0.3, ts, T) + ss(ts + 0.7, ts + 1.1, T); + } + const bo = V.boost && T < 7.5, S = V.st[ai], wn = bo ? 6.2 : S.wn; + return { E, ai, f, wn, s: 1 / (1 + 0.001 * g * t * t * t), a: LEAN[sty] + 0.2 * ss(3, 16, t), x: X0 + 6 * ss(3, 20, t) + (sty === 3 ? 0.12 * min(E, 70) : 0), e: DECK + V.st[0].bell[0] + E, Lf: wn * (S.fuel === 2 ? 4.2 : 3.2) * (1 + E / 260) * f, k: 0.12 + min(0.9, E / 500) * (S.fuel === 1 ? 1.4 : 1), fuel: bo ? 0 : S.fuel }; +} + +// The camera's tilt in rows keeps the nose near the top, then lets the +// shrinking rocket climb. +function camera(V, T, F) { + const d = F.E - (GY - DECK - V.st[0].bell[0] - V.L * F.s - 15 + 9 * ss(12, 23, T)), h = max(14 - abs(d), 0) / 14; + return max(0, d) + h * h * 3.5; +} + +export function setup(params) { + const P = params, F = (n) => new Float32Array(n); + Object.assign(P, { buf: F(N * 3), G: F(N), D: F(N * 5), pa: F(MAXP * PF), spD: F(4 * SP * SP), spS: F(4 * SP * SP), vc: {}, started: false, T: -4.3, np: 0, clk: 0, pt: null }); + // Lumpy puffs lit from above. + for (let q = 0; q < 4 * SP * SP; q++) { + const k = floor(q / (SP * SP)), dx = (((q % SP) + 0.5) / SP) * 2 - 1, dy = (((floor(q / SP) % SP) + 0.5) / SP) * 2 - 1; + const d = hypot(dx, dy), an = atan2(dy, dx), edge = 0.8 + 0.1 * sin(5 * an + k * 1.7) + 0.07 * sin(9 * an + k * 2.9) + 0.04 * sin(13 * an + k); + P.spD[q] = ss(edge, edge - 0.45, d) * (0.78 + 0.22 * sin(dx * 7 + k) * sin(dy * 6 + k * 2)); + P.spS[q] = clamp(0.55 - 0.55 * dy + 0.12 * dx + (d > edge - 0.2 && dy < 0 ? 0.25 : 0), 0, 1); + } + P.stars = Array.from({ length: 240 }, () => [floor(rnd() * W), 30 + rnd() * 620, 0.25 + 0.75 * rnd() ** 3]); + P.shore = Array.from({ length: W }, (_, x) => 3 + 1.2 * sin(x * 0.7) * sin(x * 0.23 + 1) + (x % 11 < 3 ? 1.8 : 0) + (x > 52 ? 1.2 : 0)); +} + +function newRocket(P, T) { + [P.T, P.np, P.pt] = [T, 0, null]; +} + +function emit(P, ...v) { + if (P.np < MAXP) P.pa.set([...v.slice(0, 6), 0, ...v.slice(6), floor(rnd() * 4)], P.np++ * PF); +} + +function times(n, fn) { + for (let c = n; c > 0 && (c >= 1 || rnd() < c); c--) fn(); +} + +function step(P, h) { + P.T += h; + if (P.T > 25) return newRocket(P, -10); + const T = P.T, V = vehicle(P), sty = P.style, F = fly(P, V, T), C = camera(V, T, F), s = F.s, sa = sin(F.a), ca = cos(F.a); + // Before ignition liquid oxygen boils off and falls down the tanks. + if (T < IGN[sty] + 0.4 && sty !== 3) + for (const S of V.st) + times(S.fuel === 2 ? 0 : h * 34, () => { + const sd = rnd() < 0.6 ? 1 : -1; + emit(P, F.x + sd * (S.segs.at(-1)[3] - 0.3), F.e + S.u + S.len * (0.35 + 0.6 * rnd()), sd * (0.8 + 2.5 * rnd()), -3 - 5 * rnd(), 0.6, 1.5, 1.4 + rnd(), 0.2, 0, 0.95, 0.4); + }); + if (F.f > 0.02) { + const uN = V.st[F.ai].u - V.st[F.ai].bell[0], tl = F.Lf * s * 0.85, tx = F.x + sa * (uN * s - tl), tz = F.e + ca * (uN * s - tl), fu = FUEL[F.fuel]; + // Steam rolls out of the trench both ways and up round the rocket. + if (tz < 24 && F.E < 40) + times(55 * STEAM[sty] * (0.25 + 0.35 * P.n) * F.f * (1 - F.E / 40) * h, () => { + const sd = rnd() < 0.5 ? -1 : 1; + if (rnd() < 0.72) emit(P, X0 + sd * (8 + 2 * rnd()), 1 + rnd() * 2, sd * (18 + 34 * rnd()) * (0.6 + 0.4 * F.f), 3 + 9 * rnd(), 2.2, 3.6 + 2 * rnd(), 16, 0.5, 0.7, 0.95, 0.55); + else emit(P, X0 + sd * rnd() * 7, DECK + rnd() * 3, sd * 4 * rnd(), 10 + 16 * rnd(), 2, 2.8, 12, 0.45, 0.9, 0.95, 0.9); + }); + // The trail, laid along the path since the last step. + if (T > 0 && tz > DECK + 6) { + const [px, pz] = P.pt || [tx, tz], r0 = max(0.6, F.wn * s * 1.05), hi = P.tw * ss(140, 300, tz); + const cnt = min(14, ceil(max(hypot(tx - px, tz - pz) / max(0.6, r0 * 0.6), h * 24))); + for (let j = 1; j <= cnt; j++) emit(P, px + ((tx - px) * j) / cnt + (rnd() - 0.5) * r0, pz + ((tz - pz) * j) / cnt, (rnd() - 0.5) * 2 * s, -1.5 * s, r0, (1.1 + 1.8 * hi) * sqrt(s + 0.08) * 2, 30, fu[5] * (0.75 + 0.25 * rnd()) * (1 - 0.4 * hi), 1, fu[4], 0.05); + P.pt = [tx, tz]; + } + } else P.pt = null; + // Puffs spread, rise and thin, and go once below the panel. + const pa = P.pa; + for (let i = 0; i < P.np; i++) { + const o = i * PF, dr = exp(-pa[o + 11] * h); + pa[o + 6] += h; + pa[o + 2] *= dr; + pa[o + 3] = pa[o + 3] * dr + (pa[o + 11] > 0.3 ? 2 * h : 0); + pa[o] += (pa[o + 2] + 1.2 + pa[o + 1] * 0.012) * h; + pa[o + 1] += pa[o + 3] * h; + if (pa[o + 6] > pa[o + 7] || GY + C - pa[o + 1] - pa[o + 4] - pa[o + 5] * sqrt(pa[o + 6]) > 130 || abs(pa[o] - 32) > 62) { + pa.copyWithin(o, --P.np * PF, P.np * PF + PF); + i--; + } + } +} + +export function update(dt, input, params) { + const P = params, k = input.knobValues || [0, 2, 1, 0.06]; + dt = clamp(dt, 0, 0.1); + P.style = clamp(round(k[0]), 0, 3); + P.n = clamp(round(k[2]), 1, 3); + P.tod = clamp(k[3], 0, 1); + // The hour's colours; the sun lingers low at dawn and dusk. + const f = P.tod * 3, i = min(2, floor(f)), q = f - i, A = PAL[i], B = PAL[i + 1]; + P.pal = A.map((v, j) => v + (B[j] - v) * q); + P.se = A[23] + (B[23] - A[23]) * (i === 0 ? q * q : i === 1 ? 1 - (1 - q) ** 2 : q); + [P.sunI, P.tw, P.lights, P.night] = [P.pal[18], P.pal[19], P.pal[20], ss(0.67, 1, P.tod)]; + if (!P.started) { + P.started = true; + // Open 2.3 s before liftoff, with the tanks already venting. + for (let j = 0; j < 20; j++) step(P, 0.1); + } + if (input.btnPressed && input.btnPressed[0]) { + if (P.T < IGN[P.style] - 0.3) P.T = IGN[P.style] - 0.3; + else if (P.T > 2) newRocket(P, -3); + } + P.clk += dt; + step(P, (dt * clamp(k[1], 0.2, 3)) / 2); +} + +// Lights a surface facing vn across a cylinder, laid over the LED. +function shade(P, p, vn, fl, r, g, b, a) { + const c = P.pal, o = p * 3, buf = P.buf, sn = P.sunK * max(0, 0.25 + 0.75 * vn * P.side), am = 1 - 0.28 * vn * vn, fr = 235 * fl * (1 - 0.35 * vn * vn); + [r, g, b].forEach((k, j) => (buf[o + j] += (k * (c[12 + j] * am + c[15 + j] * sn + fr + P.G[p] * P.gc[j]) - buf[o + j]) * a)); +} + +// Patterned paints: moon rocket stages 1 and 3, V-2 quarters, bands, +// grey-green, truss. +function albedo(c, u, vn) { + if (c === 6) c = (u > 0.5 && u < 7 && vn > -0.1 && vn < 0.5) || (u > 15.5 && u < 17) ? 1 : 0; + else if (c === 7) c = u > 5 && u < 8.5 && vn > 0.05 ? 1 : 0; + else if (c === 8) c = (floor(u / 6.5) + (vn > 0)) & 1; + else if (c === 9) c = u % 7 < 1.4 ? 5 : 0; + else if (c > 9) c = c === 10 ? 4 : (floor(u * 1.5) + floor((vn + 1) * 2.5)) & 1 ? 1 : 3; + return PAINT[c]; +} + +// One body from its foot at (b.x, b.y), leaning b.ang, at scale b.sc. +function body(P, b) { + const sa = sin(b.ang), ca = cos(b.ang), sc = b.sc, fl = b.flame, buf = P.buf; + let u0 = 0, u1 = 0, hm = 0; + for (const g of b.segs) [u0, u1, hm] = [min(u0, g[0]), max(u1, g[1]), max(hm, g[2], g[3])]; + hm += b.fins.length ? 3.5 : 0; + const fu = u0; + if (fl) [u0, hm] = [u0 - fl.Lf * 3.2, max(hm + 5, fl.wn + (fl.k + 0.12) * fl.Lf * 3.2 + 1)]; + const m = (u0 + u1) / 2, cx = b.x + m * sa * sc, cy = b.y - m * ca * sc, R = hypot((u1 - u0) / 2, hm) * sc + 2; + const fc = fl && FUEL[fl.fuel], flood = P.lights * 0.9 * exp(-b.E / 45); + for (let y = max(0, floor(cy - R)); y <= min(H - 1, ceil(cy + R)); y++) + for (let x = max(0, floor(cx - R)); x <= min(W - 1, ceil(cx + R)); x++) { + const dx = x + 0.5 - b.x, dy = b.y - (y + 0.5), p = y * W + x; + const u = (dx * sa + dy * ca) / sc, v = (dx * ca - dy * sa) / sc, av = abs(v); + let seg = null; + for (const g of b.segs) if (u >= g[0] && u < g[1]) seg = g; + if (seg) { + const hw = seg[2] + ((seg[3] - seg[2]) * (u - seg[0])) / (seg[1] - seg[0]); + let cov = clamp((hw - av) * sc + 0.5, 0, 1), vn = clamp(v / max(0.3, hw), -1, 1), c = seg[4], uu = u - seg[5]; + if (cov <= 0) + for (const f of b.fins) + if (u >= f[0] && u < f[1]) { + const cf = clamp((hw + f[2] * (1 - (u - f[0]) / (f[1] - f[0])) - av) * sc + 0.5, 0, 1); + if (cf > cov) [cov, vn, c, uu] = [cf, v > 0 ? 0.6 : -0.6, f[3], u - f[4]]; + } + const A = albedo(c, uu, vn), hc = hw + 0.6 + (b.uq + 0.5 - u) * 0.6; + if (cov > 0) shade(P, p, vn, flood, A[0], A[1], A[2], cov * b.al); + // Max-Q: a collar of condensation round the body. + else if (b.q > 0 && u > b.uq - 7 && u < b.uq + 0.5 && av < hc) shade(P, p, v / hc, 0.4, 1, 1, 1, b.q * 0.95 * ss(0, 1.2, b.uq + 0.5 - u) * (1 - (av / hc) ** 3) * (0.75 + 0.25 * sin(u * 3 + P.clk * 40))); + continue; + } + // The flame, clipped where it meets the deck. + if (!fl || u >= fu || GY + P.Ci - y - 0.5 < DECK + 0.5) continue; + const ue = fu - u, q = av / (fl.wn * 0.85 + (fl.k + 0.12) * ue); + if (q >= 1.2) continue; + const I = (exp(-ue / fl.Lf) * max(0, 1 - q * q) * (fl.fuel === 0 && b.E < 140 ? 0.8 + 0.3 * cos((ue * 2.4) / fl.wn) : 1) * fl.f) / (1 + fl.k * 1.2); + const core = exp(-ue / (fl.Lf * 0.35)) * max(0, 1 - q * 1.7) * fl.f; + const tt = min(1, I * 0.9 + core) * 2, br = min(1.2, I * 1.7 + core), A = fc[tt < 1 ? 2 : 1], B = fc[tt < 1 ? 1 : 0], w = tt < 1 ? tt : tt - 1; + for (let j = 0; j < 3; j++) buf[p * 3 + j] += (A[j] + (B[j] - A[j]) * w) * br; + } +} + +// The cluster's boosters; at 7.5 s the side ones pivot out and fall. +function boosters(P, V, T, F, sides) { + const dt = T - 7.5, sep = dt > 0, S0 = sep ? fly(P, V, 7.5) : F; + if (dt > 5) return; + const drop = sep ? ((fly(P, V, 7.55).E - S0.E) / 0.05) * 1.1 * (1 - exp(-dt / 1.1)) : 0; + for (const sd of sides) { + const off = sd * 3.7 * S0.s; + body(P, { segs: BOOST, fins: [], x: S0.x + cos(S0.a) * off + (sep ? sd * 2 * dt : 0), y: GY + P.Ci - S0.e - drop + sin(S0.a) * off, ang: S0.a + sd * (sep ? 0.35 + 0.9 * dt : -0.05), sc: S0.s / (1 + (sep ? 0.06 * dt : 0)), al: 1 - ss(3, 5, max(0, dt)), E: sep ? 300 : F.E }); + } +} + +function sky(P) { + const c = P.pal, buf = P.buf, Ci = P.Ci, sx = c[22], se = P.se, sr = c[24], sunA = 1 - P.night, sk = [0, 0, 0], bi = P.lights * (0.25 + 0.75 * P.night) * 55; + for (let y = 0; y < H; y++) { + const a = GY + Ci - y - 6, e = a + 6, gl = sunA * 0.55 * exp(-abs(e - se) / 26); + const [A, B, q] = a < 22 ? [0, 3, max(0, a) / 22] : a < 100 ? [3, 6, (a - 22) / 78] : a < 240 ? [6, 9, (a - 100) / 140] : [9, -1, min(1, (a - 240) / 180)]; + for (let j = 0; j < 3; j++) sk[j] = c[A + j] + ((B < 0 ? 0 : c[B + j]) - c[A + j]) * q; + // The sun's glow and disc, and searchlights from the pad. + for (let x = 0; x < W; x++) { + const o = (y * W + x) * 3, dx = x + 0.5 - sx, g = gl * exp(-(dx * dx) / 420), disc = sr > 0 ? sunA * clamp(sr - hypot(dx, e - se) + 0.5, 0, 1) : 0; + let v = 0; + if (bi > 1 && e > 0) + for (const [bx, sn, cs] of BEAMS) { + const d = x + 0.5 - bx, al = d * sn + e * cs, pp = d * cs - e * sn, w = 1.2 + al * 0.035; + v += (bi * exp(-(pp * pp) / (w * w))) / (1 + al / 160); + } + for (let j = 0; j < 3; j++) buf[o + j] = sk[j] + c[15 + j] * g + (c[15 + j] * 0.8 + 50) * disc + v * [0.75, 0.82, 1][j]; + } + } + // Stars come out as the sky darkens. + P.stars.forEach(([x, e, m], i) => { + const y = GY + Ci - floor(e), v = m * (c[21] + (1 - c[21]) * ss(250, 420, e)) * (0.8 + 0.2 * sin(P.clk * 3 + i)) * 235, o = (y * W + x) * 3; + if (y >= 0 && y < H) for (let j = 0; j < 3; j++) buf[o + j] += v * (j > 1 ? 1 : 0.9); + }); +} + +// A lit beam between two points, in rows above the shoreline. +function line(P, x0, e0, x1, e1, w, r, g, b, fl) { + const Ci = P.Ci, dx = x1 - x0, de = e1 - e0, l2 = dx * dx + de * de || 1; + for (let y = max(0, floor(GY + Ci - max(e0, e1) - w)); y <= min(H - 1, ceil(GY + Ci - min(e0, e1) + w)); y++) + for (let x = max(0, floor(min(x0, x1) - w)); x <= min(W - 1, ceil(max(x0, x1) + w)); x++) { + const px = x + 0.5 - x0, pe = GY + Ci - y - 0.5 - e0, t = clamp((px * dx + pe * de) / l2, 0, 1), a = clamp(w * 0.5 - hypot(px - dx * t, pe - de * t) + 0.5, 0, 1); + if (a > 0) shade(P, y * W + x, 0, fl, r, g, b, a); + } +} + +// Shore, pad, the moon rocket's tower and arms, the sounding rocket's rail. +function pad(P, V, T) { + const Ci = P.Ci, sty = P.style, tl = P.lights * 0.5, th = DECK + V.L + 6, tx0 = X0 - 19, tx1 = X0 - 10, buf = P.buf; + for (let y = max(0, GY + Ci - 90); y <= min(H - 1, GY + Ci); y++) { + const e = GY + Ci - y; + for (let x = 0; x < W; x++) { + const p = y * W + x, k = e === 4 ? 0.62 : 0.45, q = 0.2 + ((x * 7 + e * 3) % 5) * 0.012, i = x - tx0, j = e % 9; + if (e <= 4 && abs(x + 0.5 - X0) < 11 - e * 1.2) shade(P, p, (x + 0.5 - X0) / 14, tl, k, k, k, 1); + else if (e <= P.shore[x]) shade(P, p, 0, 0, q * 0.9, q, q * 0.8, 1); + else if (e <= 6) for (let j = 0; j < 3; j++) buf[p * 3 + j] = P.pal[j] * 0.55 + buf[p * 3 + j] * 0.2; + // The tower: an X-braced lattice with a hammerhead crane on top. + if (sty || e <= 0) continue; + if ((i >= 0 && x <= tx1 && e <= th && (i === 0 || i === 9 || j === 0 || i === j || i === 9 - j)) || (e >= th && e <= th + 1 && i >= -2 && x <= X0 + 2) || (e > th + 1 && e <= th + 3 && i >= 2 && i <= 5)) shade(P, p, i / 9 - 0.5, tl, 0.52, 0.54, 0.6, 1); + if (e === th + 4 && (i === 3 || x === X0 + 2) && sin(P.clk * 5) > 0) buf.set([255, 40, 30], p * 3); + } + } + // Swing arms to the stage joints and the capsule; the crew arm leaves first. + if (sty === 0) + V.st.map((s) => s.u + s.len - 1.5).concat([V.L - 11]).forEach((u, i, lv) => { + const t0 = i === lv.length - 1 ? -6 : 0.1 + i * 0.25, reach = (X0 - 4.3 - tx1) * (1 - ss(t0, t0 + 1.4, T)); + if (reach > 0.3) line(P, tx1 + 0.5, DECK + 2.5 + u, tx1 + 0.5 + reach, DECK + 2.5 + u, 1.8, 0.5, 0.52, 0.58, tl); + }); + if (sty === 3) line(P, X0 - 2.2, DECK, X0 - 2.2 + sin(LEAN[3]) * V.L * 0.8, DECK + cos(LEAN[3]) * V.L * 0.8, 1.8, 0.55, 0.55, 0.6, tl); +} + +// Puffs add density, shading, heat, sunlight and whiteness; then the smoke +// is lit. +function smoke(P) { + const { D, spD, spS, buf, G, gc, pa, Ci, pal: c } = P; + D.fill(0); + for (let i = 0; i < P.np; i++) { + const o = i * PF, ag = pa[o + 6], r = max(0.9, min(16, pa[o + 4] + pa[o + 5] * sqrt(ag))), cx = pa[o], cy = GY + Ci - pa[o + 1], life = pa[o + 7]; + const a = (pa[o + 8] * min(1, ag * 12) * (1 - ss(life * 0.4, life, ag)) * 2) / (1 + r * 0.08); + const L = [0, pa[o + 9] * exp(-ag / 0.3), min(1, P.sunI + P.tw * ss(140, 300, pa[o + 1])), pa[o + 10]], k = pa[o + 12] * SP * SP; + for (let y = max(0, ceil(cy - r - 0.5)); y <= min(H - 1, floor(cy + r - 0.5)); y++) + for (let x = max(0, ceil(cx - r - 0.5)); x <= min(W - 1, floor(cx + r - 0.5)); x++) { + const sx = floor(((x + 0.5 - cx) / r) * 0.5 * SP + SP / 2), sy = floor(((y + 0.5 - cy) / r) * 0.5 * SP + SP / 2), q = k + sy * SP + sx, d = spD[q] * a; + if (sx < 0 || sx >= SP || sy < 0 || sy >= SP || d <= 0.001) continue; + const p = (y * W + x) * 5; + D[p] += d; + D[p + 1] += d * spS[q]; + for (let j = 1; j < 4; j++) D[p + j + 1] += d * L[j]; + } + } + for (let p = 0; p < N; p++) { + const d = D[p * 5]; + if (d < 0.004) continue; + const sh = D[p * 5 + 1] / d, he = min(1.4, D[p * 5 + 2] / d), sn = (D[p * 5 + 3] / d) * (0.3 + 0.8 * sh) * (0.6 + 0.4 * P.sunI), ab = D[p * 5 + 4] / d; + const al = 1 - exp(-1.7 * d), am = 0.5 + 0.55 * sh, fl = P.lights * 75 * exp(-max(0, GY + Ci - ((p / W) | 0)) / 60), g = G[p] * 1.25; + for (let j = 0; j < 3; j++) buf[p * 3 + j] += (ab * (c[12 + j] * am + c[15 + j] * sn + fl + g * gc[j]) + he * gc[j] * 0.9 - buf[p * 3 + j]) * al; + } +} + +export function draw(display, params) { + const P = params, V = vehicle(P), T = P.T, buf = P.buf, c = P.pal, G = P.G; + const F = fly(P, V, T), Ci = (P.Ci = round(camera(V, T, F))), s = F.s, sa = sin(F.a), ca = cos(F.a), fu = FUEL[F.fuel]; + P.side = clamp((c[22] - X0) / 25, -1, 1); + P.sunK = P.sunI + P.tw * 0.7 * ss(60, 260, F.E); + // The engines' glow. + const uo = V.st[F.ai].u, uN = uo - V.st[F.ai].bell[0], nx = F.x + sa * uN * s, ny = GY + Ci - F.e - ca * uN * s, gx = nx - sa * F.Lf * s * 0.4, gy = ny + ca * F.Lf * s * 0.4; + const flick = 0.85 + 0.15 * sin(P.clk * 31) * sin(P.clk * 17.3 + 1), near = 1 - ss(10, 60, F.E); + const gr = ((6 + 8 * F.wn * s) * (1 + 1.6 * near)) ** 2, gi = F.f * flick * (F.fuel === 1 ? 0.35 : 1) * (0.55 + 0.6 * near) * (F.ai ? 1 : 0.5 + 0.25 * P.n) * (0.7 + 0.5 * P.night); + P.gc = fu[3]; + for (let p = 0; p < N; p++) G[p] = gi > 0.01 ? (gi * gr) / (gr + ((p % W) + 0.5 - gx) ** 2 + (((p / W) | 0) + 0.5 - gy) ** 2) : 0; + sky(P); + pad(P, V, T); + // Spent stages fall behind and tumble. + V.sep.forEach((ts, i) => { + const dt = T - ts, S0 = fly(P, V, ts), u = V.st[i].u * S0.s; + if (dt > 0 && dt < 5) body(P, { ...stack(V, i, i + 1), x: S0.x + sin(S0.a) * u + 2 * dt, y: GY + Ci - S0.e - cos(S0.a) * u - ((fly(P, V, ts + 0.05).E - S0.E) / 0.05) * 1.1 * (1 - exp(-dt / 1.1)), ang: S0.a + 0.45 * dt, sc: S0.s / (1 + 0.06 * dt), al: 1 - ss(3, 5, dt), E: 300 }); + }); + if (V.boost) boosters(P, V, T, F, [-1, 1]); + // The rocket from its lowest burning stage up. + body(P, { ...stack(V, F.ai, V.n), x: F.x + sa * uo * s, y: GY + Ci - F.e - ca * uo * s, ang: F.a, sc: s, al: 1, E: F.E, q: ss(6.2, 6.7, T) * (1 - ss(7.6, 8.3, T)), uq: (V.L - uo) * 0.5, flame: { wn: F.wn, Lf: max(0.01, F.Lf * flick), k: F.k, f: F.f, fuel: F.fuel } }); + if (V.boost) boosters(P, V, T, F, [0]); + // Far off, the flame is a bright point. + if (s * F.wn < 1.4 && F.f > 0.05) + for (let p = 0; p < N; p++) { + const a = 1.1 * F.f * exp(-(((p % W) + 0.5 - nx) ** 2 + (((p / W) | 0) + 0.5 - ny) ** 2) / (0.9 + 0.5 * s * F.wn) ** 2); + for (let j = 0; j < 3; j++) buf[p * 3 + j] += fu[1][j] * a; + } + smoke(P); + // The water in front mirrors everything above the shoreline. + for (let y = max(0, GY + Ci); y < H; y++) { + const sy = clamp(2 * (GY + Ci) - 1 - y, 0, H - 1); + for (let x = 0; x < W; x++) { + const o = (y * W + x) * 3, so = (sy * W + x) * 3, rp = 0.5 + 0.1 * sin(y * 2.1 + x * 0.3 - P.clk * 3); + for (let k = 0; k < 3; k++) buf[o + k] = buf[so + k] * rp + c[k] * 0.12 + G[y * W + x] * P.gc[k] * 0.25; + } + } + // The clock. + const tc = T < 0 ? ceil(-T) : floor(T); + [10, T < 0 ? 11 : 12, floor(tc / 10) % 10, tc % 10].forEach((ch, i) => { + for (let r = 0; r < 15; r++) if ((FONT[ch] >> (14 - r)) & 1) buf.set([255, 196, 70], ((2 + floor(r / 3)) * W + 2 + i * 4 + (r % 3)) * 3); + }); + const fade = ss(-10, -9.3, T) * (1 - ss(23.8, 25, T)); + for (let p = 0, o = 0; p < N; p++, o += 3) display.setPixel(p % W, (p / W) | 0, min(255, buf[o] * fade), min(255, buf[o + 1] * fade), min(255, buf[o + 2] * fade)); +} From 848f6c5cc9a96a39a99199dfa8d9a75459e62226 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 22:47:18 +0000 Subject: [PATCH 13/17] pattern: Circulation Co-Authored-By: Claude Opus 5.5 --- .../pattern-study/patterns/circulation.js | 480 ++++++++++++++++++ 1 file changed, 480 insertions(+) create mode 100644 incoming/pattern-study/patterns/circulation.js diff --git a/incoming/pattern-study/patterns/circulation.js b/incoming/pattern-study/patterns/circulation.js new file mode 100644 index 000000000..f0c45faf8 --- /dev/null +++ b/incoming/pattern-study/patterns/circulation.js @@ -0,0 +1,480 @@ +// ===== Patternflow pattern ===== +// Title: Circulation +// Family: body +// Idea: A standing figure drawn head to feet like an anatomical chart, whose heart beats lub-dub and sends each pulse of red blood out along the arteries to the head, hands and feet while blue blood flows back up the veins and the lungs flush pink. +// Author: patternflow-lab +// Date: 2026-10-23 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 22, after the anatomical wall charts of the human circulatory system +// Round: 22 +// =============================== +// @matrix 64x128 +// @knobs Chart=0..1, Rate=1..5, Detail=1..4, Effort=0..1 +// +// Knob 1: Chart. The red and blue anatomical chart at 0, an angiogram with the skeleton showing at 0.5, a thermal camera at 1. +// Knob 2: Rate. The resting heart rate, 36 a minute at 1, 72 at 2, 180 at 5; the faster, the closer the pulses follow each other. +// Knob 3: Detail. How far the vessels branch, from the named arteries and veins drawn bold at 1 to the capillaries at 4. +// Knob 4: Effort. From rest to a hard run: the heart beats faster and harder, the breath deepens and the arms and legs flush with blood. +// Buttons: K1 tap startles the figure: one hard beat at once and a rush of adrenaline that races the heart for a few seconds. + +const W = 64, H = 128, N = W * H; +const { min, max, abs, exp, sin, cos, atan2, floor, ceil, round, hypot, PI } = Math; +const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); +const smooth = (a, b, v) => ((v = clamp((v - a) / (b - a), 0, 1)), v * v * (3 - 2 * v)); +const g2 = (z) => exp(-z * z); +const rnd = () => Math.random(); +const F32 = (n) => new Float32Array(n); + +// Pulse tables, 100 steps a second; the arterial pulse and the venous return +// run at CA and CV LEDs a second. +const KS = 100, KN = 520, CA = 80, CV = 30; +// Vessel kinds: 0 artery, 1 vein, 2 and 3 the lungs' arteries and veins. +// Branch levels 1..4: spacing, length, radius; the turns a blocked branch tries. +const GAP = [0, 7, 3, 2.2, 1.8], LEN = [0, 9, 7, 5, 3.5], RAD = [0, 0.52, 0.43, 0.37, 0.33]; +const TURN = [0, 0.5, -0.5, 1, -1]; + +// The trunk's half-width row by row, from the base of the neck to the crotch. +const TY = [22.5, 24, 25.5, 27.5, 31, 36, 44, 51, 57, 63, 67, 70.5]; +const TW = [4.6, 9.5, 13, 14.3, 13.8, 13, 11.6, 10.3, 11, 12.4, 11.2, 6]; +// Arm and leg axes on the viewer's left, x y radius per joint. +const ARM = [18.3, 28.4, 3.4, 12.8, 47, 2.6, 8.6, 64.5, 2.2, 7.6, 70.5, 2.1, 6.6, 76, 1.3]; +const LEG = [26.4, 66, 5.9, 26.2, 94, 3.7, 25.8, 119.5, 2, 24, 124.8, 2.2]; +// Capsules x1 y1 r1 x2 y2 r2 of the neck, an ear, a thumb, a calf and the +// limbs; the body is folded at the middle, so they stand for both sides. +const CAPS = [[31.5, 14, 4, 31.5, 24, 4.2], [25.1, 8.8, 1.2, 25.3, 11.6, 1], [7, 66.6, 1, 5.3, 70.6, 0.8], [25.6, 98, 3.5, 25.6, 110, 2.6]]; +for (const L of [ARM, LEG]) for (let i = 0; i < L.length - 3; i += 3) CAPS.push([0, 1, 2, 3, 4, 5].map((j) => L[i + j])); +// The lungs: centre, radii, hilum. The heart's centre. +const LUNG = [[25, 38.6, 5.4, 11.6, 27.6, 34], [38.2, 39.2, 5.1, 11, 35.6, 34.5]]; +const HX = 34.4, HY = 38.4; +// Artery and vein colours from the big trunks to the capillaries. +const RED = [[238, 30, 40], [238, 30, 40], [232, 38, 70], [215, 46, 110], [190, 56, 150]]; +const BLUE = [[46, 70, 240], [46, 70, 240], [66, 68, 236], [96, 66, 226], [128, 66, 214]]; +// The readout's 3x5 letters, five rows of three bits, and its heart. +const GLYPH = { + 0: 0x7b6f, 1: 0x2c97, 2: 0x73e7, 3: 0x73cf, 4: 0x5bc9, 5: 0x79cf, 6: 0x79ef, 7: 0x7292, 8: 0x7bef, 9: 0x7bcf, + B: 0x6bae, P: 0x6ba4, M: 0x5fed, +}; +const HEART = [10, 31, 31, 14, 4]; + +// A colour table from stops of position and r g b. +const lut = (s) => + Uint8Array.from({ length: 768 }, (_, n) => { + const v = ((n / 3) | 0) / 255, c = n % 3; + let j = 0; + while (j < s.length - 8 && v > s[j + 4]) j += 4; + return s[j + 1 + c] + (s[j + 5 + c] - s[j + 1 + c]) * clamp((v - s[j]) / (s[j + 4] - s[j]), 0, 1); + }); +// The angiogram and the thermal chart are one value through a colour table; +// the weights are in the order pal() reads them. +const CHARTS = [ + null, + [lut([0, 0, 0, 0, 0.15, 8, 20, 38, 0.4, 42, 80, 112, 0.7, 150, 196, 220, 1, 240, 252, 255]), 0.04, 0.1, 0.07, 0.14, 0, 0.06, 0, 0, 0.74, 0.05, 0.07, 0.5, 0.5, 0.36, 0.2, 0.5, 0.3, 0.95], + [lut([0, 0, 0, 10, 0.2, 34, 0, 86, 0.4, 145, 0, 140, 0.6, 232, 64, 36, 0.8, 255, 172, 0, 1, 255, 255, 210]), 0.07, 0.2, -0.05, 0.08, 0.3, 0.2, 0.18, 0.12, 0, 0.5, 0.16, 0.7, 0.3, 0.56, 0.12, 0.8, 0.2, 1], +]; + +// Distance to a segment, less a radius running r0..r1 along it. +function seg(x, y, x0, y0, x1, y1, r0, r1) { + const dx = x1 - x0, dy = y1 - y0; + const t = clamp(((x - x0) * dx + (y - y0) * dy) / (dx * dx + dy * dy || 1e-6), 0, 1); + return hypot(x - x0 - dx * t, y - y0 - dy * t) - r0 - (r1 - r0) * t; +} + +// Signed distance to the skin, negative inside. +function sdBody(x, y) { + let d = (hypot((x - 31.5) / 6.4, (y - 9.8) / 8.6) - 1) * 6.4, i = 0; + while (i < TY.length - 2 && y > TY[i + 1]) i++; + const u = clamp((y - TY[i]) / (TY[i + 1] - TY[i]), 0, 1); + d = min(d, max(abs(x - 31.5) - TW[i] - (TW[i + 1] - TW[i]) * u, TY[0] - y, y - TY[TY.length - 1])); + const xr = x < 31.5 ? x : 63 - x; + for (const c of CAPS) d = min(d, seg(xr, y, c[0], c[1], c[3], c[4], c[2], c[5])); + return d; +} + +function lung(x, y, j, br) { + const L = LUNG[j], ry = L[3] * (1 + 0.07 * br); + const dy = (y - L[1] - (ry - L[3]) * 0.8) / ry; + return hypot(((x - L[0]) / (L[2] * (1 + 0.04 * br))) * (1 + 0.35 * max(0, -dy)), dy); +} + +// The heart in its own frame: v runs from the base to the apex, u across. +function heart(P, x, y, s) { + const dx = x - HX, dy = y - HY, k = s * 1.12; + const v = (P.hv = (dx * 0.55 + dy * 0.835) / k), u = (P.hu = (dx * 0.835 - dy * 0.55) / k); + return v < 0 ? hypot(u / 5, v / 4.6) : hypot(u / (5 * (1 - 0.8 * (v / 7) ** 1.5) + 0.05), v / 7); +} + +// Points along a limb axis: t counts joints, f is the share of the radius off +// the axis, toward the outside of the limb when positive. +function limb(L, list) { + const out = []; + for (let i = 0; i < list.length; i += 2) { + const t = list[i], k = min(floor(t), L.length / 3 - 2), u = t - k; + const [x0, y0, r0, x1, y1, r1] = L.slice(k * 3, k * 3 + 6); + const dx = x1 - x0, dy = y1 - y0, l = hypot(dx, dy), r = (r0 + (r1 - r0) * u) * list[i + 1]; + out.push(x0 + dx * u - (dy / l) * r, y0 + dy * u + (dx / l) * r); + } + return out; +} + +export function setup(params) { + const P = params; + Object.assign(P, { t: 0, started: false, beats: [], ph: 0, br: 0, brPh: 0, adr: 0, det: -1, eh: 0, last: -9, f: {}, TA: F32(KN) }); + Object.assign(P, { TV: F32(KN), TP: F32(KN), hud: F32(N), c0: F32(3), c1: F32(3) }); + body(P), bones(P), vessels(P); +} + +// Skin mask, rim, depth, muscle, and the way through the body from the heart. +function body(P) { + const [m, rim, dep, mus] = ["m", "rim", "dep", "mus"].map((k) => (P[k] = F32(N))); + const geo = (P.geo = F32(N).fill(1e4)), room = (P.room = new Uint8Array(N)); + P.tex = F32(N).map(() => 0.75 + 0.35 * rnd()); + for (let p = 0; p < N; p++) { + const x = p % W, y = (p / W) | 0, sd = sdBody(x, y), lu = min(lung(x, y, 0, 0), lung(x, y, 1, 0)); + // Room to grow: 1 in the body but not the lungs or heart, 2 in the lungs. + room[p] = lu < 0.9 ? 2 : sd < -0.6 && lu > 1.1 && hypot(x - HX, y - HY) > 6.5 ? 1 : 0; + [m[p], rim[p], dep[p]] = [clamp(0.5 - sd, 0, 1), clamp(1 - abs(sd + 0.7) / 1.1, 0, 1), clamp(-sd / 5, 0, 1)]; + mus[p] = m[p] * (y > 68 ? 1 : smooth(12.5, 14.5, abs(x - 31.5))); + } + geo[38 * W + 34] = 0; + const NB = [1, 1, W, 1, W + 1, 1.414, W - 1, 1.414]; + for (let pass = 0; pass < 4; pass++) + for (let q = 0, sg = pass & 1 ? 1 : -1; q < N; q++) { + const p = sg > 0 ? N - 1 - q : q; + if (m[p] > 0.2) + for (let j = 0; j < 8; j += 2) if (geo[p + sg * NB[j]] + NB[j + 1] < geo[p]) geo[p] = geo[p + sg * NB[j]] + NB[j + 1]; + } + P.core = Float32Array.from(geo, (g) => exp(-g / 55)); +} + +// The skeleton for the angiogram: radius, brightness and a polyline for each +// bone of the left half, which is mirrored. +const BONES = [ + [0.5, 0.8, 26.5, 12.6, 26.9, 15.4, 28.6, 17.3, 31.6, 17.8], [0.55, 0.8, 31.6, 25.4, 25, 24.6, 19.5, 26.4], [0.7, 0.7, 31.6, 26.6, 31.6, 41], + [0.6, 0.75, 31.6, 63.5, 27.5, 62.4, 23.2, 59.2, 21, 60.5, 21.6, 64.5, 25, 68.8, 29.5, 69.8, 31.6, 69.6], + [1, 0.9, 26.2, 66.8, 26.4, 70, 26.2, 92], [0.85, 0.9, 26.6, 96, 26.4, 118.5], [0.45, 0.7, 24, 97, 24.4, 118.5], + [0.7, 0.75, 25.8, 120.5, 24.6, 124.5, 23.2, 126.2], [0.95, 0.9, 18.3, 28.4, 12.9, 46.6], + [0.55, 0.8, 12.2, 47.8, 7.9, 64.2], [0.55, 0.8, 13.6, 47.6, 9.5, 64.4], [0.45, 0.7, 8.6, 65.6, 6.4, 76], +]; +function bones(P) { + const bone = (P.bone = F32(N)), skull = [0.5, 0.9], all = [skull, ...BONES]; + for (let a = 0; a < 2; a += 0.2) skull.push(31.5 - 5.6 * sin(a), 9.6 - 7.6 * cos(a)); + for (let y = 18.5; y < 64; y += 2.7) all.push([0.8, 0.85, 30.8, y, 32.2, y]); + for (let i = 0; i < 8; i++) { + const y = 27.6 + i * 2.5, w = 21.7 + i * 0.3; + all.push([0.42, 0.65, 30.6, y, 26, y - 0.9, w, y + 1.2, w + 0.2, y + 3.4, w + 3 + i * 0.4, y + 5.2, 29.8 - i * 0.2, y + 6.2 - i * 0.2]); + } + for (const [r, v, ...q] of all) + for (let i = 2; i < q.length; i += 2) { + const [x0, y0, x1, y1] = q.slice(i - 2, i + 2); + for (let y = max(0, floor(min(y0, y1) - r - 1)); y <= min(H - 1, ceil(max(y0, y1) + r + 1)); y++) + for (let x = max(0, floor(min(x0, x1) - r - 1)); x <= min(31, ceil(max(x0, x1) + r + 1)); x++) { + const a = clamp(0.5 - seg(x, y, x0, y0, x1, y1, r, r), 0, 1) * v, p = y * W + x; + if (a > bone[p]) bone[p] = bone[p + 63 - 2 * x] = a; + } + } +} + +// The named vessels; four levels of branches grow between them. +function vessels(P) { + const ves = (P.ves = []); + const add = (kind, parent, r, pts) => (ves.push({ kind, parent, r, pts, lv: 0 }), ves[ves.length - 1]); + const pair = (kind, pr, pl, r, pts) => [add(kind, pr, r, pts), add(kind, pl, r, pts.map((v, i) => (i & 1 ? v : 63 - v)))]; + // Arteries, from the aorta out. + const ao = add(0, null, 1.3, [33.4, 35.5, 32.4, 31.6, 32.7, 29, 34, 27.6, 35.8, 28.4, 36.1, 31, 34.9, 35, 33.9, 40, 33.4, 47, 33, 55, 31.5, 62.5]); + const bc = add(0, ao, 1, [32.9, 28.3, 30.8, 26.9, 29.6, 25.6]); + const carR = add(0, bc, 0.8, [29.6, 25.6, 30, 21.5, 30, 17.5, 29.6, 15]); + const carL = add(0, ao, 0.8, [34.4, 27.8, 33.4, 24.5, 33, 21.5, 33, 17.5, 33.4, 15]); + pair(0, carR, carL, 0.6, [29.6, 15, 29.1, 12.2, 29.6, 9.6, 30.8, 8]); + pair(0, carR, carL, 0.55, [29.6, 15.4, 27.6, 13.6, 26.3, 10.2, 26.6, 6, 28.4, 3]); + const subR = add(0, bc, 0.9, [29.6, 25.6, 26.2, 26, 22.6, 26.7, 19.8, 28]); + const subL = add(0, ao, 0.9, [35.8, 28.4, 37.8, 26.6, 40.6, 26.8, 43.2, 28]); + const [brR, brL] = pair(0, subR, subL, 0.8, [19.8, 28, ...limb(ARM, [0.15, -0.3, 0.5, -0.28, 0.85, -0.22, 1, -0.1])]); + pair(0, brR, brL, 0.55, limb(ARM, [1, -0.1, 1.2, 0.35, 1.6, 0.42, 2, 0.42, 2.3, 0.15, 2.5, -0.2])); + const [ilR, ilL] = pair(0, ao, ao, 1, [31.5, 62.5, 30, 64.2, 28.6, 66.3]); + const [feR, feL] = pair(0, ilR, ilL, 0.85, [28.6, 66.3, ...limb(LEG, [0.08, -0.32, 0.4, -0.3, 0.75, -0.24, 1, -0.12])]); + pair(0, feR, feL, 0.55, limb(LEG, [1, -0.12, 1.25, -0.32, 1.6, -0.36, 1.95, -0.4, 2.3, -0.15, 2.75, 0.1])); + pair(0, feR, feL, 0.55, limb(LEG, [1.02, 0, 1.2, 0.36, 1.6, 0.42, 2, 0.32, 2.45, 0.2, 2.95, 0.35])); + // Veins, from the venae cavae out; the sinus over the brain joins the jugulars. + const svc = add(1, null, 1.2, [30.3, 35.6, 29.9, 31, 29.9, 27.4]); + const bvR = add(1, svc, 1, [29.9, 27.4, 28.2, 26, 26.6, 25.4]); + const bvL = add(1, svc, 0.9, [29.9, 27.4, 32.4, 26.3, 34.8, 25.6, 36.4, 25.4]); + pair(1, bvR, bvL, 0.8, [28.4, 25.8, 28.4, 21.5, 28.5, 17.5, 28.3, 14.4, 27.6, 11.6, 27.9, 7.2, 29.4, 4.6, 31.5, 3.9]); + const [axR, axL] = pair(1, bvR, bvL, 0.9, [26.6, 25.4, 23.2, 26.1, 20.4, 27.8]); + pair(1, axR, axL, 0.55, [20.4, 27.8, ...limb(ARM, [0.2, -0.68, 0.6, -0.64, 1, -0.68, 1.5, -0.78, 2, -0.78, 2.25, -0.45])]); + pair(1, axR, axL, 0.5, [21.2, 27, ...limb(ARM, [0.08, 0.5, 0.5, 0.64, 1, 0.7, 1.5, 0.78, 2, 0.78, 2.25, 0.5])]); + const ivc = add(1, null, 1.2, [30.5, 42.5, 30.8, 48, 31, 56, 31.4, 62.8]); + const [icR, icL] = pair(1, ivc, ivc, 1, [31.4, 62.8, 30, 65.4, 29.8, 67.2]); + pair(1, icR, icL, 0.8, [29.8, 67.2, ...limb(LEG, [0.1, -0.54, 0.4, -0.54, 0.75, -0.46, 1, -0.3, 1.3, -0.05, 1.7, 0, 2, 0.02, 2.4, -0.1])]); + pair(1, icR, icL, 0.5, [30, 68, ...limb(LEG, [0.15, -0.8, 0.5, -0.82, 1, -0.82, 1.5, -0.86, 2, -0.85, 2.4, -0.5, 2.8, -0.3])]); + // The lungs: blue blood out along the pulmonary arteries, red back. + const pt = add(2, null, 1.1, [32.4, 37, 31.6, 33.4, 31.8, 31.4]); + pair(2, pt, pt, 0.8, [31.8, 31.4, 29, 31.8, 26.4, 33.4, 25, 35]); + add(3, null, 0.8, [35, 36.2, 31.4, 37, 28, 37.4, 25.6, 38.6]); + add(3, null, 0.8, [35.6, 35.8, 37.6, 36.6, 39.2, 37.8]); + for (const v of ves) samples(v); + grow(P); +} + +// Resample a vessel every 0.4 LED with its distance from the heart. +function samples(v) { + const { pts, parent: q } = v; + let d = v.d0 || 0, best = 1e9; + if (q && v.d0 === undefined) + for (let i = 0; i < q.x.length; i++) { + const e = hypot(q.x[i] - pts[0], q.y[i] - pts[1]); + if (e < best) [best, d] = [e, q.d[i] + e]; + } + [v.x, v.y, v.d] = [[pts[0]], [pts[1]], [d]]; + for (let i = 2; i < pts.length; i += 2) { + const x0 = pts[i - 2], y0 = pts[i - 1], dx = pts[i] - x0, dy = pts[i + 1] - y0, l = hypot(dx, dy), n = max(1, ceil(l / 0.4)); + for (let k = 1; k <= n; k++) v.x.push(x0 + (dx * k) / n), v.y.push(y0 + (dy * k) / n), v.d.push(d + (l * k) / n); + d += l; + } +} + +// Branches grow level by level, shuffled so arteries and veins take their room +// in turn; a branch bends away from the skin or another vessel, or stops. +function grow(P) { + const occ = new Uint8Array(N); + const mark = (v) => v.x.forEach((x, i) => (occ[round(v.y[i]) * W + round(x)] = 1)); + P.ves.forEach(mark); + let front = P.ves.slice(); + for (let lv = 1; lv <= 4; lv++) { + const next = []; + front.sort(() => rnd() - 0.5); + for (const v of front) { + const n = v.x.length, want = v.kind >= 2 ? 2 : 1; + for (let s = GAP[lv] * rnd(); s < n * 0.4 - 0.8; s += GAP[lv] * (0.6 + 0.8 * rnd())) { + const i = min(n - 2, round(s / 0.4)), len = LEN[lv] * (0.5 + rnd()); + let a = atan2(v.y[i + 1] - v.y[i], v.x[i + 1] - v.x[i]) + (rnd() < 0.5 ? -1 : 1) * (0.35 + 0.6 * rnd()); + let x = v.x[i], y = v.y[i]; + const pts = [x, y]; + for (let st = 1; st <= len; st++) { + a += (rnd() - 0.5) * 0.7; + const turn = TURN.find((tu) => { + const nx = x + cos(a + tu), ny = y + sin(a + tu); + const q = round(ny) * W + round(nx); + return P.room[q] === want && (st < 2 + v.r || !occ[q]); + }); + if (turn === undefined) break; + a += turn; + pts.push((x += cos(a)), (y += sin(a))); + } + if (pts.length < 6) continue; + const b = { kind: v.kind, parent: v, r: RAD[lv], pts, lv, d0: v.d[i] }; + samples(b), mark(b), next.push(b); + } + } + P.ves.push(...(front = next)); + } +} + +// The vessels Detail shows in a red and a blue layer: weight, key (seconds +// after a beat), level, pulse or flow. Low detail draws the trunks bold. +function raster(P, det) { + for (const k of ["wR", "kR", "lR", "mR", "wB", "kB", "lB", "mB"]) (P[k] = P[k] || F32(N)).fill(0); + for (const v of P.ves) { + const vis = clamp(((det - 1) * 7) / 6 + 1.5 - v.lv, 0, 1); + if (vis <= 0) continue; + const red = v.kind === 0 || v.kind === 3, flow = v.kind & 1; + const [w, kk, l, mo] = red ? [P.wR, P.kR, P.lR, P.mR] : [P.wB, P.kB, P.lB, P.mB]; + const r = v.r * (v.lv ? 1 : 1.25 - 0.15 * (det - 1)), g = v.lv ? 0 : 0.9, e0 = r + g + 0.5; + for (let i = 0; i < v.x.length; i++) { + const x = v.x[i], y = v.y[i], key = flow ? max(0, 0.3 + (125 - v.d[i]) / CV) : v.d[i] / CA; + for (let py = max(0, ceil(y - e0)); py <= min(H - 1, floor(y + e0)); py++) + for (let px = max(0, ceil(x - e0)); px <= min(W - 1, floor(x + e0)); px++) { + const e = hypot(px - x, py - y), p = py * W + px; + let a = clamp(r + 0.5 - e, 0, 1); + if (g) a = max(a, 0.16 * clamp(e0 - e, 0, 1)); + if ((a *= vis) > w[p]) [w[p], kk[p], l[p], mo[p]] = [a, key, v.lv, flow]; + } + } + } +} + +export function update(dt, input, params) { + const P = params; + if (!P.ves) setup(P); + const k = input.knobValues || [0, 2, 1, 0.06]; + dt = clamp(dt || 0, 0, 0.1); + P.chart = clamp(k[0], 0, 1); + const rate = clamp(k[1], 1, 5), det = clamp(k[2], 1, 4), E = (P.E = clamp(k[3], 0, 1)); + if (abs(det - P.det) > 0.02) raster(P, (P.det = det)); + let kick = false; + if (input.btnPressed && input.btnPressed[0]) { + P.adr = 1; + if (P.t - P.last > 0.28) [P.ph, kick] = [1, true]; + } + P.adr *= exp(-dt / 5); + P.bpm = min(200, 36 * rate * (1 + 0.6 * E * E) + 75 * P.adr); + const T = (P.T = 60 / P.bpm); + P.tsys = clamp(0.16 + 0.17 * T, 0.2, 0.36); + P.stroke = 1 + 0.45 * E + 0.5 * P.adr; + if (!P.started) { + // Open as if the heart had been beating for a while. + P.started = true; + for (let i = 12; i >= 1; i--) P.beats.push([-(i - 0.6) * T, P.stroke]); + [P.ph, P.last, P.brPh] = [0.6, -0.6 * T, 0.15]; + } + P.t += dt; + if ((P.ph += dt / T) >= 1) { + P.last = P.t - (P.ph = kick ? 0 : P.ph % 1) * T; + P.beats.push([P.last, P.stroke * (kick ? 1.5 : 1)]); + } + while (P.beats.length && P.t - P.beats[0][0] > 5.6) P.beats.shift(); + P.brPh += dt / max(1, 4.4 - 2.6 * E - 1.2 * P.adr); + P.br = (0.5 - 0.5 * cos(2 * PI * P.brPh)) * (0.55 + 0.45 * E + 0.4 * P.adr); + P.eh = (P.eh + dt * 16) % 18; + tables(P), readout(P); +} + +// Each beat's waves by the time since it: TA the sharp arterial pulse with its +// dicrotic notch, TP the softer flush of the tissue, TV the venous swell. +function tables(P) { + const { TA, TV, TP, t } = P, td = 0.13 * (1 + 0.4 * P.E); + TA.fill(0), TV.fill(0), TP.fill(0); + const span = (age, a, b, fn) => { + for (let k = max(0, ceil((age - a) * KS)); k <= min(KN - 1, floor((age + b) * KS)); k++) fn(k, age - k / KS); + }; + for (const [tb, a] of P.beats) { + const age = t - tb; + span(age, 0.9, 0, (k, s) => (TA[k] += a * ((1 - exp(-s / 0.02)) * exp(-s / td) + 0.22 * g2((s - 0.3) / 0.05)))); + span(age, 1.4, 0, (k, s) => (TP[k] += a * (1 - exp(-s / 0.06)) * exp(-s / 0.26))); + span(age, 0.4, 0.4, (k, s) => (TV[k] += a * g2(s / 0.13))); + } +} + +const look = (T, key) => { + const f = key * KS, i = f | 0; + return i < KN - 1 ? T[i] + (T[i + 1] - T[i]) * (f - i) : 0; +}; + +function ecgAt(P, tt) { + let v = 0; + for (const [tb] of P.beats) { + const s = tt - tb; + if (s > -0.3 && s < 0.6) + v += 0.13 * g2((s + 0.16) / 0.03) - 0.12 * g2((s + 0.022) / 0.01) + g2(s / 0.016) - 0.28 * g2((s - 0.03) / 0.012) + 0.3 * g2((s - P.tsys + 0.07) / 0.055); + } + return v; +} + +// The monitor beside the feet: an ECG sweep on the left, the rate on the right. +function readout(P) { + const { hud } = P; + hud.fill(0, 100 * W, 120 * W); + for (let y = 104; y < 117; y += 3) for (let x = 2; x < 19; x += 3) hud[y * W + x] = 0.12; + for (let c = 0; c < 18; c++) { + const a = (P.eh - c + 18) % 18; + if (a > 16.4) continue; + let lo = 999, hi = 0; + for (let s = 0; s <= 6; s++) { + const yy = round(112 - 8 * ecgAt(P, P.t - (a + s / 6) / 16)); + [lo, hi] = [min(lo, yy), max(hi, yy)]; + } + for (let y = max(101, lo); y <= min(118, hi); y++) hud[y * W + c + 1] = a < 1 ? 1 : 0.95 - (0.55 * a) / 18; + } + const fl = 0.45 + 0.55 * g2((P.t - P.last - 0.05) / 0.08); + HEART.forEach((row, j) => { + for (let i = 0; i < 5; i++) if ((row >> (4 - i)) & 1) hud[(105 + j) * W + 46 + i] = fl; + }); + const put = (s, x0, y0, b) => { + for (let n = 0; n < s.length; n++) + for (let q = 0; q < 15; q++) if ((GLYPH[s[n]] >> (14 - q)) & 1) hud[(y0 + ((q / 3) | 0)) * W + x0 + n * 4 + (q % 3)] = b; + }; + const txt = String(round(P.bpm)); + put(txt, 63 - txt.length * 4, 105, 0.95); + put("BPM", 51, 112, 0.55); +} + +export function draw(display, params) { + const P = params; + if (!P.wR) return; + const { m, rim, dep, core, geo, bone, tex, mus, hud, TA, TV, TP, wR, kR, lR, mR, wB, kB, lB, mB, f, c0, c1, E, br } = P; + const fc = P.chart * 2, i0 = min(1, floor(fc)), u = smooth(0.3, 0.7, fc - i0); + // The heart squeezes in systole and flashes on the first sound, less on the second. + const since = P.t - P.last, sys = since < P.tsys ? sin((PI * since) / P.tsys) ** 0.7 : 0; + const s = 1 - (0.1 + 0.08 * E + 0.05 * P.adr) * sys; + const flash = g2((since - 0.03) / 0.06) + 0.6 * g2((since - P.tsys) / 0.045); + // The more capillaries drawn, the more the skin flushes as a pulse passes. + const bla = 0.3 + 0.24 * (P.det - 1); + for (let y = 0, p = 0; y < H; y++) + for (let x = 0; x < W; x++, p++) { + Object.assign(f, { y, m: m[p], rim: rim[p], dep: dep[p], core: core[p], mus: mus[p], bone: bone[p], hud: hud[p], lw: 0, hw: 0, ho: 0 }); + f.bl = m[p] > 0 ? look(TP, geo[p] / CA + 0.04) * (bla + 0.3 * E + 0.7 * E * mus[p]) : 0; + if (x > 17 && x < 46 && y > 24 && y < 54) { + const e0 = lung(x, y, 0, br), e1 = lung(x, y, 1, br), e = min(e0, e1), L = LUNG[e0 < e1 ? 0 : 1]; + if (e < 1.05) { + f.lw = clamp((1.03 - e) * 7, 0, 1) * m[p]; + f.lrim = clamp(1 - abs(e - 0.92) / 0.1, 0, 1); + f.ox = clamp(0.2 + 0.4 * br + 0.5 * look(TP, hypot(x - L[4], y - L[5]) / 40 + 0.06), 0, 1.2); + f.lt = tex[p]; + } + } + if ((f.wb = wB[p]) > 0) [f.bm, f.bp, f.lb] = [mB[p], look(mB[p] ? TV : TA, kB[p]), lB[p]]; + if ((f.wr = wR[p]) > 0) [f.rm, f.rp, f.lr] = [mR[p], look(mR[p] ? TV : TA, kR[p]), lR[p]]; + if (x > 24 && x < 45 && y > 27 && y < 49) { + const e = heart(P, x, y, s); + f.ho = clamp((1.3 - e) * 5, 0, 1); + if (e < 1.1) { + f.hw = clamp((1.02 - e) * 5, 0, 1); + f.hs = smooth(-0.9, 0.3, P.hu); + f.hr = clamp(1 - abs(e - 0.86) / 0.16, 0, 1); + f.hb = 0.62 + 0.45 * flash + 0.2 * sys - (abs(P.hu + 0.3) < 0.4 && P.hv > -2 ? 0.25 : 0); + } + } + if (i0 === 0) anat(f, c0); + else pal(f, CHARTS[1], c0, E); + if (u > 0) { + pal(f, CHARTS[i0 + 1], c1, E); + for (let c = 0; c < 3; c++) c0[c] += (c1[c] - c0[c]) * u; + } + display.setPixel(x, y, min(255, c0[0]), min(255, c0[1]), min(255, c0[2])); + } +} + +// Lerp o toward the colour c at brightness b by w; past full brightness it +// runs toward a hot pale core. +function tint(o, c, b, w) { + const k = min(b, 1), h = max(0, b - 1) * 0.9; + o[0] += (c[0] * k + 255 * h - o[0]) * w; + o[1] += (c[1] * k + 190 * h - o[1]) * w; + o[2] += (c[2] * k + 170 * h - o[2]) * w; +} + +// The anatomical chart: a pale ghost of a body on a slate ground. +function anat(f, o) { + const top = 1 - f.y / H, t = f.rim, bl = f.bl; + [o[0], o[1], o[2]] = [2 + 3 * top, 5 + 5 * top, 12 + 9 * top]; + if (f.m > 0) tint(o, [18 + 34 * t + 8 * f.dep + 80 * bl, 26 + 44 * t + 10 * f.dep + 14 * bl, 42 + 50 * t + 16 * f.dep + 40 * bl], 1, f.m); + if (f.lw > 0) { + const ox = f.ox; + tint(o, [70 + 170 * ox, 60 + 30 * ox, 160 - 40 * ox], f.lt * (0.55 + 0.35 * ox) + 0.35 * f.lrim, f.lw); + } + const vb = () => f.wb > 0 && tint(o, BLUE[f.lb], (f.bm ? 0.36 + 0.8 * f.bp : 0.4 + 1.05 * f.bp) + 0.04 * f.lb, f.wb); + const vr = () => f.wr > 0 && tint(o, RED[f.lr], (f.rm ? 0.36 + 0.8 * f.rp : 0.32 + 1.05 * f.rp) + 0.05 * f.lr, f.wr); + // The vessel that owns the LED is drawn last. + if (f.wb > f.wr) vr(), vb(); + else vb(), vr(); + for (let c = 0; c < 3; c++) o[c] *= 1 - 0.55 * f.ho; + if (f.hw > 0) { + const hs = f.hs, b = f.hb, k = 0.45 * f.hr; + tint(o, [(70 + 170 * hs) * b + 255 * k, (80 - 45 * hs) * b + 170 * k, (230 - 185 * hs) * b + 180 * k], 1, f.hw); + } + [40, 235, 140].forEach((c, i) => (o[i] += c * f.hud)); +} + +// The angiogram and the thermal chart: one value, then its colour table. +function pal(f, c, o, E) { + const [lt, bg, t0, rim, dep, core, bl, mus, heat, bone, lung, ox, a0, a1, v0, v1, h0, h1, hud] = c; + let v = bg * (1.2 - (0.6 * f.y) / H); + if (f.m > 0) v += (t0 + rim * f.rim + dep * f.dep + core * f.core + bl * f.bl + (heat + mus * f.mus) * E - v) * f.m; + if (f.lw > 0) v += (lung + ox * f.ox * f.lt - v) * f.lw; + v += (bone - v) * f.bone * f.m * (bone > 0); + if (f.wb > 0) v += ((f.bm ? v0 + v1 * f.bp : a0 * 0.9 + a1 * f.bp) * (1 - 0.08 * f.lb) - v) * f.wb; + if (f.wr > 0) v += ((f.rm ? v0 + v1 * f.rp : a0 + a1 * f.rp) * (1 - 0.08 * f.lr) - v) * f.wr; + v *= 1 - 0.35 * f.ho; + if (f.hw > 0) v += (h0 + h1 * (f.hb - 0.62) + 0.15 * f.hr - v) * f.hw; + const i = clamp(round(max(v, f.hud * hud) * 255), 0, 255) * 3; + [o[0], o[1], o[2]] = [lt[i], lt[i + 1], lt[i + 2]]; +} From d2e0df8489248d285b354169b40df9160092b22e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 22:51:29 +0000 Subject: [PATCH 14/17] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2022=ED=8C=90=20=E2=80=94=20README=20=EC=97=90=2022=ED=8C=90?= =?UTF-8?q?=20=ED=91=9C=EB=A5=BC=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ABfNAXnhF5FB3o17FyZ3oS --- incoming/pattern-study/README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/incoming/pattern-study/README.md b/incoming/pattern-study/README.md index 5b34027a4..abf69938d 100644 --- a/incoming/pattern-study/README.md +++ b/incoming/pattern-study/README.md @@ -1,6 +1,6 @@ # 패턴 연구 2026-10 -새 패턴 118개를 마흔 갈래로 나눠 만들었어요. 1판에서 열세 갈래로 23개를 만들고, 2판에서 1판에 없던 아홉 갈래로 16개를, 3판에서 하나를, 4판에서 새 갈래인 직물로 하나를 더했어요. 5판은 새 패턴 없이 1판의 Euclid Pulse 를 다시 짰고, 6판은 세운 패널의 128줄을 위아래로 다 쓰는 패턴 하나를 더했어요. 7판은 또렷한 모양이 멀리 흘러가는 지도 칸에 패턴 하나를 더했어요. 8판은 새 갈래인 기계로 하나를 더했어요. 9판은 새 패턴 없이 1판의 Sprout 을 다시 짰어요. 10판은 세운 패널을 떨어지는 블록의 우물로 쓰는 패턴 하나를 더했어요. 11판은 여러 색이 꽤 멀리 흘러가는 지도 칸에 패턴 하나를 더했어요. 12판은 새 갈래인 천체로 하나를 더했어요. 13판은 새 패턴 없이 1판의 Glyph Rain 을 다시 짰어요. 14판은 절차가 바뀌기 전에 띄운 바퀴라서, 세운 패널의 높이를 다 떨어지는 폭포 하나를 더했어요. 15판은 바뀐 절차의 첫 판이라 열 갈래에 새 패턴을 하나씩, 모두 열 개를 더했어요. 도시와 음악 갈래는 그때 새로 열었어요. 16판도 열 갈래에 하나씩 열 개를 더했고, 바다와 곤충 갈래를 새로 열었어요. 17판도 열 갈래에 하나씩 열 개를 더했고, 전자 회로와 장난감 갈래를 새로 열었어요. 18판도 열 갈래에 하나씩 열 개를 더했고, 유리와 광물·결정, 놀이공원 갈래를 새로 열었어요. 19판도 열 갈래에 하나씩 열 개를 더했고, 부엌과 버섯·균사 갈래를 새로 열었어요. 20판도 열 갈래에 하나씩 열 개를 더했고, 새와 화학·실험 갈래를 새로 열었어요. 21판도 열 갈래에 하나씩 열 개를 더했고, 종이와 운동·경기 갈래를 새로 열었어요. [index.html](index.html) 을 브라우저로 열면 전부 한 장에서 돌아가요. 맨 위 지도에서 점을 누르면 그 패턴으로 가고, 카드의 슬라이더가 노브 넷이에요. 마음에 드는 것은 「코드 복사」로 가져가 Pattern Lab 의 CODE 칸에 붙여 넣으면 같은 그림이 나와요. 페이지는 파일 하나라서 바깥에서 받아 오는 것이 없어요. +새 패턴 128개를 마흔두 갈래로 나눠 만들었어요. 1판에서 열세 갈래로 23개를 만들고, 2판에서 1판에 없던 아홉 갈래로 16개를, 3판에서 하나를, 4판에서 새 갈래인 직물로 하나를 더했어요. 5판은 새 패턴 없이 1판의 Euclid Pulse 를 다시 짰고, 6판은 세운 패널의 128줄을 위아래로 다 쓰는 패턴 하나를 더했어요. 7판은 또렷한 모양이 멀리 흘러가는 지도 칸에 패턴 하나를 더했어요. 8판은 새 갈래인 기계로 하나를 더했어요. 9판은 새 패턴 없이 1판의 Sprout 을 다시 짰어요. 10판은 세운 패널을 떨어지는 블록의 우물로 쓰는 패턴 하나를 더했어요. 11판은 여러 색이 꽤 멀리 흘러가는 지도 칸에 패턴 하나를 더했어요. 12판은 새 갈래인 천체로 하나를 더했어요. 13판은 새 패턴 없이 1판의 Glyph Rain 을 다시 짰어요. 14판은 절차가 바뀌기 전에 띄운 바퀴라서, 세운 패널의 높이를 다 떨어지는 폭포 하나를 더했어요. 15판은 바뀐 절차의 첫 판이라 열 갈래에 새 패턴을 하나씩, 모두 열 개를 더했어요. 도시와 음악 갈래는 그때 새로 열었어요. 16판도 열 갈래에 하나씩 열 개를 더했고, 바다와 곤충 갈래를 새로 열었어요. 17판도 열 갈래에 하나씩 열 개를 더했고, 전자 회로와 장난감 갈래를 새로 열었어요. 18판도 열 갈래에 하나씩 열 개를 더했고, 유리와 광물·결정, 놀이공원 갈래를 새로 열었어요. 19판도 열 갈래에 하나씩 열 개를 더했고, 부엌과 버섯·균사 갈래를 새로 열었어요. 20판도 열 갈래에 하나씩 열 개를 더했고, 새와 화학·실험 갈래를 새로 열었어요. 21판도 열 갈래에 하나씩 열 개를 더했고, 종이와 운동·경기 갈래를 새로 열었어요. 22판도 열 갈래에 하나씩 열 개를 더했고, 몸과 비행 갈래를 새로 열었어요. [index.html](index.html) 을 브라우저로 열면 전부 한 장에서 돌아가요. 맨 위 지도에서 점을 누르면 그 패턴으로 가고, 카드의 슬라이더가 노브 넷이에요. 마음에 드는 것은 「코드 복사」로 가져가 Pattern Lab 의 CODE 칸에 붙여 넣으면 같은 그림이 나와요. 페이지는 파일 하나라서 바깥에서 받아 오는 것이 없어요. 패널은 세로로 서요. 64 LED 너비에 128 LED 높이가 기본이라서 모든 패턴의 머리에 `@matrix 64x128` 이 있어요. 레포의 패널 설정은 그대로예요. `shared/constants.json` 의 `PANEL_GEOM_DEFAULT` 는 `128x64_SINGLE` 이고, 패턴이 가로세로를 바꾼 판을 적으면 펌웨어의 `firmware/patternflow/src/core_canvas.h` 가 90도 돌려 패널을 꽉 채워요. 1판 23개는 처음에 가로 128×64 로 만들었다가 2판에서 세로로 다시 짰어요. @@ -219,6 +219,21 @@ | [Slinky](patterns/slinky.js) | 장난감 | 슬링키가 패널 높이만큼 지그재그로 꺾인 계단을 한 칸씩 뒤집히며 내려가요. 코일이 활처럼 휘어 아래 칸으로 쏟아지고, 내려앉은 칸마다 계단등이 켜져요. | | [Woodpecker](patterns/woodpecker.js) | 새 | 새벽 숲의 자작나무 줄기가 128줄을 다 쓰고, 오색딱따구리가 뻣뻣한 꽁지로 버티며 껑충껑충 오르다 멈춰 머리가 흐려지도록 두드려 부스러기를 튀기고 구멍을 내요. 꼭대기에 닿으면 물결치듯 날아 밑동으로 내려가 다시 오르고, K1 을 누르면 다음 걸음 뒤에 바로 두드려요. | +### 22판 + +| 패턴 | 갈래 | 한 줄 | +| --- | --- | --- | +| [Bowling Lane](patterns/bowling-lane.js) | 운동·경기 | 볼러 뒤에서 본 10핀 레인이에요. 공이 오일 위로 제 반사를 끌며 휘어 올라가 핀을 흩고, 스위프 바가 데드우드를 쓸어 낸 뒤 핀이 다시 서요. 처음에는 블랙라이트 아래 핀과 화살표, 오일이 빛나고, K4 를 올리면 나무 레인에 하우스 조명이 켜져요. | +| [Circulation](patterns/circulation.js) | 몸 | 해부도처럼 머리부터 발끝까지 선 사람이에요. 심장이 뛸 때마다 붉은 피의 맥이 동맥을 타고 머리와 손발 끝까지 번져 가고, 푸른 정맥혈은 심장 쪽으로 거슬러 올라오며 폐가 박동과 숨에 분홍빛으로 물들어요. 발치의 모니터에는 심전도와 맥박 수가 떠요. | +| [Flame Test](patterns/flame-test.js) | 화학·실험 | 분젠 버너의 파란 불꽃에 손이 니크롬 고리를 넣으면 불꽃이 그 염의 색으로 터져요. 구리는 청록, 나트륨은 짙은 노랑으로 타고 알갱이가 튀다가 파랗게 돌아오면, 고리를 염산에 씻어 이름표 붙은 다음 시계접시로 가요. | +| [Flipdot](patterns/flipdot.js) | 글자 | 버스 앞에 다는 형광 노랑 플립 디스크 행선판을 세워 놓았어요. 위 칸에 노선 번호, 아래 칸에 행선지가 7줄 점 글꼴로 서고, 쪽이 바뀔 때마다 디스크가 한 줄씩 넘어가며 판 전체를 노랗게 뒤집었다가 반전된 경유지 쪽을 써요. 넘어가는 동안의 디스크는 가는 모서리로 보여요. | +| [Galilean Moons](patterns/galilean-moons.js) | 천체 | 목성의 네 위성을 관측 도표로 그려요. 위쪽 접안렌즈에서는 줄무늬 목성이 돌고 이오의 그림자가 원반을 지나가며, 아래 도표에서는 위성마다 제 사인 곡선을 그리며 날짜가 위로 올라와요. | +| [Liftoff](patterns/liftoff.js) | 비행 | 새벽 물 건너 발사대에서 로켓이 하얀 산소 김을 흘리며 서 있다가 김구름 속에 불을 붙이고 솟아올라요. 카메라가 따라 고개를 들면 하늘이 파랑에서 별 뜬 검정으로 바뀌고, Max-Q 와 단 분리를 지나 밝은 점이 되면 새 로켓이 발사대에 서요. | +| [Night Flight](patterns/night-flight.js) | 도시 | 여객기 창으로 내려다본 밤 도시예요. 1 LED 가로등이 바둑판 길과 대로, 강 위 다리를 따라 레이스처럼 이어지고, 고속도로에는 흰 전조등과 빨간 미등이 흘러요. 도시는 초록 항법등과 흰 섬광등이 달린 날개 끝을 지나 왼쪽 아래로 천천히 미끄러져요. | +| [Pin Art](patterns/pin-art.js) | 장난감 | 핀 아트 장난감을 앞에서 봐요. 엇갈린 격자의 강철 핀 4096개가 패널을 채우고, 뒤에서 펼친 손이 눌러 들어오면 밀려 나온 핀만 비스듬한 등빛을 받아 빛나요. 장난감을 기울이면 핀이 물결치며 평평해지고 열쇠가 다음으로 눌러 들어와요. | +| [Senbazuru](patterns/senbazuru.js) | 종이 | 붉은 옻칠 고리에 실로 꿴 종이학이 줄줄이 매달려 금박 학 아래로 주황에서 초록, 파랑을 지나 보라와 분홍까지 띠를 이루고, 실 끝에는 금 구슬과 비단 술이 달려요. 어두운 절 방에 외풍이 불면 줄이 흔들리고 학이 실을 축으로 돌면서 접힌 면이 하나씩 등불을 받아 번쩍여요. K1 을 누르면 돌풍이 불어 학 하나가 떨어져 나뭇잎처럼 바닥으로 내려앉아요. | +| [Weaver](patterns/weaver.js) | 새 | 강 위로 뻗은 아카시아 가지 끝에서 수컷 마을베짜기새가 둥지를 지어요. 갈대 잎을 찢어 문 초록 띠를 벽에 꿰어 당기면 둥지가 고리에서 둥근 방으로, 다시 아래로 열린 입구로 한 가닥씩 자라고, 묵은 띠는 짚 색으로 바래요. K1 을 누르면 암컷이 날아와 둥지를 살피고, 다 지은 둥지가 퇴짜를 맞으면 수컷이 끊어 강에 떨어뜨리고 새 고리부터 다시 지어요. | + ## 1판 갈래마다 노브 넷의 자리는 갈래가 달라도 거의 같아요. K1 은 색이고 K2 는 빠르기예요. K3 은 모양을 바꾸고, K4 는 그 갈래만의 손잡이예요. 그래서 처음 보는 패턴도 K1 과 K2 는 손이 먼저 알아요. From 63cd9f75a112c81216d4bee1577fec0cd02be7ce Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 22:56:23 +0000 Subject: [PATCH 15/17] =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=2022=ED=8C=90=20=E2=80=94=20index.html=20=EC=9D=84=20128?= =?UTF-8?q?=EA=B0=9C=EB=A1=9C=20=EB=8B=A4=EC=8B=9C=20=EC=A7=93=EB=8A=94?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ABfNAXnhF5FB3o17FyZ3oS --- incoming/pattern-study/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/incoming/pattern-study/index.html b/incoming/pattern-study/index.html index 32770cba9..d2a5c2e23 100644 --- a/incoming/pattern-study/index.html +++ b/incoming/pattern-study/index.html @@ -141,7 +141,7 @@

패턴 연구

- + +