0xa184…4d6d

All memos sent from and to 0xa184…4d6d.

type){ case 'flower': m = buildFlower(seed); break; case 'field': m = buildField(seed, spec.fieldType); break; case 'mirror': m = buildMirror(seed, spec.mode); break; case 'weave': m = buildWeave(seed); break; case 'bands': m = buildC64Token(seed); break; case 'bloom-breathe': m = buildBloom(seed, 'breathe'); break; case 'bloom-exhale': m = buildBloom(seed, 'exhale'); break; case 'rain': m = buildRain(seed); break; case 'cellular': m = buildCellular(seed); break; case 'fireworks': m = buildFireworks(seed); break; case 'snake': m = buildSnake(seed); break; case 'julia': m = buildJulia(seed); break; case 'plasma': m = buildPlasma(seed); break; case 'spiral': m = buildSpiral(seed); break; case 'starfield': m = buildStarfield(seed); break; case 'lissajous': m = buildLissajous(seed); break; case 'joystick': m = buildJoystick(seed); break; case 'frog': m = buildFrog(seed); break; default: m = buildC64Token(seed); } return rollCharSetForModel(m); } function startLoop(canvas, spec, salt, opts){ const model = buildFromSpec(spec, salt); const cell = Math.max(1, Math.floor((canvas.width || 512) / model.N)); canvas.width = canvas.height = model.N * cell; const ctx = canvas.getContext('2d'); drawC64Frame(ctx, model, 0, opts || {}); // synchronous first frame (snapshot-safe) function frame(now){ const t = (now % LOOP_MS) / LOOP_MS; drawC64Frame(ctx, model, t, opts || {}); requestAnimationFrame(frame); } requestAnimationFrame(frame); return model; } /* ====== seed-driven rendering (mint-time randomness) ====== The contract assigns each token a 256-bit seed at mint time. EVERYTHING about the token — family AND all within-family traits — derives from that single seed, so nothing is predictable before the mint tx lands in a block. 23 families, weight-picked. The four newest (Plasma/Spiral/Starfield/ Lissajous) carry w<1 so they land slightly rarer than the rest. */ const FAMILY_SPECS = [ { type:'flower' }, { type:'field', fieldType:'hbars' }, { type:'field', fieldType:'tunnel' }, { type:'field', fieldType:'rays' }, { type:'field', fieldType:'pulse' }, { type:'field', fieldType:'diag' }, { type:'mirror', mode:'h' }, { type:'mirror', mode:'v' }, { type:'mirror', mode:'d' }, { type:'mirror', mode:'quad' }, { type:'weave' }, { type:'bands' }, { type:'bloom-breathe' }, { type:'bloom-exhale' }, { type:'rain' }, { type:'cellular' }, { type:'fireworks' }, { type:'snake' }, { type:'julia' }, { type:'plasma', w:0.75 }, { type:'spiral', w:0.75 }, { type:'starfield', w:0.75 }, { type:'lissajous', w:0.75 }, { type:'joystick', w:0.75 }, { type:'frog', w:0.05 }, // ultra-rare easter egg (~5 of 2048) ]; function pickFamilyIndex(rf){ let total = 0; for(const f of FAMILY_SPECS) total += (f.w || 1); let x = rf() * total; for(let i = 0; i < FAMILY_SPECS.length; i++){ x -= (FAMILY_SPECS[i].w || 1); if(x <= 0) return i; } return FAMILY_SPECS.length - 1; } function buildFromSeed(seedStr){ // family chosen from an independent sub-stream so it doesn't bias the // within-family trait rolls (which run off the raw seedStr) const rf = makeRng(seedStr + ':family'); const fam = FAMILY_SPECS[pickFamilyIndex(rf)]; return buildFromSpec({ ...fam, seed: seedStr }, ''); } const FAMILY_LABELS = ['Flowers','Stripes','Tunnel','Rays','Pulse','Diagonal','Mirror H','Mirror V','Mirror D','Kaleidoscope','Weave','Bands','Bloom Breathe','Bloom Exhale','Rain','Cellular','Fireworks','Snake','Julia','Plasma','Spiral','Starfield','Lissajous','Joystick','Frog']; // Canonical seed -> labeled traits. Same family pick + within-family rolls the // renderer uses, surfaced as the published trait names. function traitsFromSeed(seedStr){ const rf = makeRng(seedStr + ':family'); const idx = pickFamilyIndex(rf); const family = FAMILY_LABELS[idx] || 'Unknown'; const m = buildFromSeed(seedStr); const colors = (m.colorCount != null) ? m.colorCount : (m.palette ? m.palette.length : null); const t = []; t.push(['Family', family]); t.push(['Density', String(m.N)]); if (colors != null) t.push(['Colors', String(colors)]); t.push(['Palette', m.palMode === 'colodore' ? 'Colodore' : 'Pepto']); t.push(['Char Set', m.charSet || 'Blocks']); if (m.base) t.push(['Mirror Base', ({diag:'Chevrons',bars:'Bars',rings:'Rings',wave:'Waves'})[m.base] || m.base]); // (Bloom Mode trait dropped: the Bloom Breathe / Bloom Exhale family already says it.) if (m.pattern) t.push(['Pattern', m.pattern]); if (m.form) t.push(['Form', m.form]); if (m.variant) t.push(['Variant', ({triangle:'Triangle',carpet:'Carpet',xor:'XOR'})[m.variant] || m.variant]); if (m.eye) { const g = m.eye.gaze; t.push(['Gaze', g <= 3 ? 'Calm' : g <= 5 ? 'Darting' : 'Frantic']); t.push(['Blink', (['Unblinking','Steady','Twitchy','Spasmodic'])[m.eye.blink] || 'Steady']); t.push(['Eye Fill', ({solid:'Solid',grid:'Grid',pattern:'Pattern'})[m.eye.fill] || 'Solid']); t.push(['Eye Color', ({0:'Void',1:'White',2:'Red',3:'Cyan',4:'Purple',5:'Green',6:'Blue',7:'Yellow',8:'Orange',9:'Brown',10:'High AF',11:'Dark Grey',12:'Grey',13:'Light Green',14:'Light Blue',15:'Light Grey'})[m.eye.light] || 'White']); t.push(['Eye Animation', m.eye.anim === 'sclera' ? 'Strobe' : m.eye.anim === 'pupil' ? 'Void Strobe' : 'None']); } t.push(['Tears', (m.eye && m.eye.tears) ? 'On' : 'Off']); t.push(['CRT Bend', (m.fx && m.fx.crt > 0) ? 'On' : 'Off']); t.push(['Chroma Split', (m.fx && m.fx.chroma > 0) ? 'On' : 'Off']); return { family: family, traits: t }; } function startLoopFromSeed(canvas, seedStr, opts){ const model = buildFromSeed(seedStr); const cell = Math.max(1, Math.floor((canvas.width || 512) / model.N)); canvas.width = canvas.height = model.N * cell; const ctx = canvas.getContext('2d'); drawC64Frame(ctx, model, 0, opts || {}); // synchronous first frame (snapshot-safe) function frame(now){ const t = (now % LOOP_MS) / LOOP_MS; drawC64Frame(ctx, model, t, opts || {}); requestAnimationFrame(frame); } requestAnimationFrame(frame); return model; } global.Nervous = { version: '1.1.0', build: buildFromSpec, buildFromSeed, traitsFromSeed, drawFrame: drawC64Frame, applyPostFx, startLoop, startLoopFromSeed, LOOP_MS, }; })(typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : this));
[((i - offset + 1) % n + n) % n]; ctx.fillStyle = css(paper); ctx.fillRect(e[0]*cs, e[1]*cs, cs, cs); ctx.fillStyle = css(ink); drawGlyph(ctx, applyCharSet(model, e[0], e[1], e[2]), e[0]*cs, e[1]*cs, cs, 0); }; if(model.mode === 'breathe'){ // grow to full, then shrink back to seed (palindrome -> clean loop) const k = t < 0.5 ? Math.floor(t * 2 * P) : Math.floor((1 - t) * 2 * P); const limit = Math.min(k, P); for(let i = 0; i < limit; i++) drawCell(i); } else { // exhale: a ring expands outward, clearing behind it. order[] is BFS-from- // center so an index window == a ring at a radius. It wraps, so a new ring // enters the center as the old exits the edge -> perfectly loop-clean. const head = Math.floor(t * P); const w = Math.max(3, Math.floor(P * 0.30)); for(let i = 0; i < P; i++){ if((((head - i) % P) + P) % P < w) drawCell(i); } } } /* ---- Rain: per-column vertical streams, deterministic glyph per cell, head bright, trail dims through the palette. Each column's head cycles N rows per loop (at speed 1) or 2N (at speed 2), so every column closes the loop seamlessly. */ const RAIN_GLYPHS = ['.',':','X','O','I','L','M','BALL','DIAG_FWD','DIAG_BACK']; function buildRain(seedStr){ const r = makeRng(seedStr); const N = pickN(r); const numColors = bellColors(r); const palette = pickColors(r, numColors); const cols = new Array(N); const glyphs = new Array(N*N); for(let x = 0; x < N; x++){ cols[x] = { phase: Math.floor(r() * N), speed: r() < 0.7 ? 1 : 2, trail: Math.max(3, 3 + Math.floor(r() * Math.min(N - 2, 8))) }; for(let y = 0; y < N; y++) glyphs[y*N+x] = pick(RAIN_GLYPHS, r); } return { kind:'rain', seed: seedStr, N, palette, cols, glyphs, palMode: rollPal(r), eye: makeEye(N, r), marquee: null, hasMarquee: false, static: false, fx: rollFx(r) }; } function drawRain(ctx, model, t, cs){ const N = model.N, pal = model.palette, n = pal.length; ctx.fillStyle = css(0); ctx.fillRect(0, 0, N*cs, N*cs); for(let x = 0; x < N; x++){ const col = model.cols[x]; const head = ((Math.floor(t * col.speed * N) + col.phase) % N + N) % N; for(let i = 0; i < col.trail; i++){ const y = (head - i + N) % N; const ci = Math.max(0, n - 1 - Math.floor(i / col.trail * n)); ctx.fillStyle = css(pal[ci]); drawGlyph(ctx, applyCharSet(model, x, y, model.glyphs[y*N+x]), x*cs, y*cs, cs, 0); } } } /* ---- Cellular: one row evolves via a Wolfram rule, becomes the next row. The resulting fractal triangle is rendered as field; color sweeps over it. */ /* the seven rules are sorted numerically and assigned letter labels A..G so the published trait is opaque (no math citation to argue about). The internal "rule" integer is what the renderer actually uses. */ const CA_RULES = [22, 30, 73, 90, 105, 110, 150]; // published by their real Wolfram rule number (accurate + recognizable) const CA_LABELS = ['Rule 22','Rule 30','Rule 73','Rule 90','Rule 105','Rule 110','Rule 150']; function buildCellular(seedStr){ const r = makeRng(seedStr); const N = pickN(r); const numColors = bellColors(r); const palette = pickColors(r, numColors); const ruleIdx = Math.floor(r() * CA_RULES.length); const rule = CA_RULES[ruleIdx]; const pattern = CA_LABELS[ruleIdx]; let row = new Uint8Array(N); if(r() < 0.6){ row[Math.floor(N/2)] = 1; } else { for(let i = 0; i < N; i++) row[i] = r() < 0.5 ? 1 : 0; } const cellGlyphs = new Array(N*N); const field = new Int16Array(N*N); for(let y = 0; y < N; y++){ for(let x = 0; x < N; x++){ const i = y*N + x; cellGlyphs[i] = row[x] ? 'BLOCK' : '.'; field[i] = x + y; } const next = new Uint8Array(N); for(let x = 0; x < N; x++){ const l = row[(x - 1 + N) % N], c = row[x], rr = row[(x + 1) % N]; const idx = (l << 2) | (c << 1) | rr; next[x] = (rule >> idx) & 1; } row = next; } const laps = Math.max(2, Math.round(10 / numColors)); return { kind:'field', seed: seedStr, N, palette, field, cellGlyphs, rule, pattern, laps, dir: 1, palMode: rollPal(r), eye: makeEye(N, r), marquee: null, hasMarquee: false, static: false, fx: rollFx(r) }; } /* ---- Fireworks: 2-4 staggered bursts per loop. Each burst rises then explodes as an 8-point ring. Loop-clean because each burst t-window has fixed t_start and duration; outside the window nothing renders. */ function buildFireworks(seedStr){ const r = makeRng(seedStr); const N = pickN(r); const numColors = bellColors(r); const palette = pickColors(r, numColors); const count = 2 + Math.floor(r() * 3); const dur = (1 / count) * 0.95; const bursts = []; for(let i = 0; i < count; i++){ bursts.push({ xc: 2 + Math.floor(r() * (N - 4)), yp: 2 + Math.floor(r() * (N/2)), t0: (i / count + r() * 0.04) % 1, dur, color: i % numColors, }); } return { kind:'fireworks', seed: seedStr, N, palette, bursts, palMode: rollPal(r), eye: makeEye(N, r), marquee: null, hasMarquee: false, static: false, fx: rollFx(r) }; } function drawFireworks(ctx, model, t, cs){ const N = model.N, pal = model.palette, n = pal.length; ctx.fillStyle = css(0); ctx.fillRect(0, 0, N*cs, N*cs); for(const b of model.bursts){ const p = (t - b.t0 + 1) % 1; if(p > b.dur) continue; const phase = p / b.dur; if(phase < 0.3){ const yNow = Math.floor(N - 1 - (phase / 0.3) * (N - 1 - b.yp)); ctx.fillStyle = css(pal[b.color % n]); drawGlyph(ctx, '.', b.xc * cs, yNow * cs, cs, 0); } else { const burstPhase = (phase - 0.3) / 0.7; const maxR = Math.max(2, Math.floor(Math.min(N/3, 10))); const radius = Math.max(1, Math.floor(burstPhase * maxR)); for(let a = 0; a < 8; a++){ const ang = a / 8 * Math.PI * 2; const x = b.xc + Math.round(Math.cos(ang) * radius); const y = b.yp + Math.round(Math.sin(ang) * radius); if(x < 0 || y < 0 || x >= N || y >= N) continue; const ci = (b.color + a) % n; ctx.fillStyle = css(pal[ci]); drawGlyph(ctx, applyCharSet(model, x, y, 'X_DIAG'), x * cs, y * cs, cs, 0); } } } } /* ---- Snake: a colored worm traces a deterministic path that wraps the grid. Head bright, tail dims through the palette. Path is pre-computed so the traversal is perfectly periodic. */ function buildSnake(seedStr){ const r = makeRng(seedStr); const N = pickN(r); const numColors = bellColors(r); const palette = pickColors(r, numColors); const trail = Math.max(4, Math.floor(N * 0.6)); const path = []; const seen = new Uint8Array(N*N); let x = Math.floor(r() * N), y = Math.floor(r() * N); const target = N * N; while(path.length < target){ if(!seen[y*N + x]){ path.push([x, y]); seen[y*N + x] = 1; } const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; const ord = dirs.map(d => [d, r()]).sort((a, b) => a[1] - b[1]).map(p => p[0]); let moved = false; for(const [dx, dy] of ord){ const nx = (x + dx + N) % N, ny = (y + dy + N) % N; if(!seen[ny*N + nx]){ x = nx; y = ny; moved = true; break; } } if(!moved){ let next = -1; for(let i = 0; i < N*N; i++) if(!seen[i]){ next = i; break; } if(next < 0) break; x = next % N; y = Math.floor(next / N); } } return { kind:'snake', seed: seedStr, N, palette, path, trail, palMode: rollPal(r), eye: makeEye(N, r), marquee: null, hasMarquee: false, static: false, fx: rollFx(r) }; } function drawSnake(ctx, model, t, cs){ const N = model.N, pal = model.palette, n = pal.length; const path = model.path, P = path.length, T = model.trail; ctx.fillStyle = css(0); ctx.fillRect(0, 0, N*cs, N*cs); const head = Math.floor(t * P); for(let i = 0; i < T; i++){ const idx = ((head - i) % P + P) % P; const cell = path[idx]; const ci = Math.max(0, n - 1 - Math.floor(i / T * n)); ctx.fillStyle = css(pal[ci]); drawGlyph(ctx, applyCharSet(model, cell[0], cell[1], i === 0 ? 'BLOCK' : 'BALL'), cell[0] * cs, cell[1] * cs, cs, 0); } } /* ---- Julia: classic complex-plane fractal. Each cell maps to a point in [-1.5, 1.5] x [-1.5, 1.5]; we iterate z = z² + c and color by escape time. c morphs around a small circle in the complex plane over the loop, so the shape continuously breathes and returns exactly to its start at t = 1. Loop-clean because c(t) is periodic and the iteration is pure math. */ const JULIA_GLYPHS = ['.', ':', 'DIAG_FWD', 'X', 'X_DIAG', 'CHECKER', 'BLOCK']; // ten distinct fractal forms. Internally these are complex-plane c values that // drive the iteration math, but the published trait is just an opaque letter // (A through J) so the name doesn't invite math debates. const JULIA_SEEDS = [ [-0.7, 0.27015], [-0.8, 0.156], [0.285, 0.01], [-0.4, 0.6], [0.355, 0.355], [-0.835, -0.2321], [-0.7269, 0.1889], [-0.835, 0.232], [-0.74543, 0.11301], [0.37, 0.1], ]; const JULIA_LABELS = ['A','B','C','D','E','F','G','H','I','J']; function buildJulia(seedStr){ const r = makeRng(seedStr); const N = pickN(r); const numColors = bellColors(r); const palette = pickColors(r, numColors); const seedIdx = Math.floor(r() * JULIA_SEEDS.length); const seed = JULIA_SEEDS[seedIdx]; const form = JULIA_LABELS[seedIdx]; const c0r = seed[0], c0i = seed[1]; const morphR = 0.02 + r() * 0.04; // gentle morph: stays in interesting territory const maxIter = 24 + Math.floor(r() * 16); // 24..40 return { kind:'julia', seed: seedStr, N, palette, c0r, c0i, morphR, maxIter, form, palMode: rollPal(r), eye: makeEye(N, r), marquee: null, hasMarquee: false, static: false, fx: rollFx(r) }; } function drawJulia(ctx, model, t, cs){ const N = model.N, pal = model.palette, n = pal.length; const { c0r, c0i, morphR, maxIter } = model; const ang = t * Math.PI * 2; const cr = c0r + morphR * Math.cos(ang); const ci = c0i + morphR * Math.sin(ang); const scale = 3 / N; ctx.fillStyle = css(0); ctx.fillRect(0, 0, N*cs, N*cs); const gLen = JULIA_GLYPHS.length; for(let y = 0; y < N; y++){ const zy0 = -1.5 + y * scale; for(let x = 0; x < N; x++){ const zx0 = -1.5 + x * scale; let zx = zx0, zy = zy0, i = 0; while(i < maxIter && zx*zx + zy*zy < 4){ const t2 = zx*zx - zy*zy + cr; zy = 2*zx*zy + ci; zx = t2; i++; } if(i >= maxIter) continue; // inside the set: leave black const colorIdx = i % n; const g = JULIA_GLYPHS[Math.min(gLen - 1, Math.floor(i / maxIter * gLen))]; ctx.fillStyle = css(pal[colorIdx]); drawGlyph(ctx, applyCharSet(model, x, y, g), x*cs, y*cs, cs, 0); } } } const TAU = Math.PI * 2; /* ---- Plasma: layered sine fields morph + drift; loop-clean because every sine phase advances an integer number of cycles over t in [0,1). ---- */ function buildPlasma(seedStr){ const r = makeRng(seedStr); const N = pickN(r); const numColors = bellColors(r); const palette = pickColors(r, numColors); const fx1 = 2 + Math.floor(r() * 4), fy1 = 2 + Math.floor(r() * 4); const fd = 2 + Math.floor(r() * 4), fr = 4 + Math.floor(r() * 5); const drift = (r() < 0.5 ? 1 : -1) * (1 + Math.floor(r() * 2)); return { kind:'plasma', seed: seedStr, N, palette, fx1, fy1, fd, fr, drift, palMode: rollPal(r), eye: makeEye(N, r), marquee:null, hasMarquee:false, static:false, fx: rollFx(r) }; } function drawPlasma(ctx, model, t, cs){ const N = model.N, pal = model.palette, n = pal.length; const { fx1, fy1, fd, fr, drift } = model, cx = (N-1)/2, cy = (N-1)/2; for(let y = 0; y < N; y++){ const yPx = y * cs; for(let x = 0; x < N; x++){ const v = Math.sin(x/N*Math.PI*fx1 + t*TAU) + Math.sin(y/N*Math.PI*fy1 + t*TAU) + Math.sin((x+y)/N*Math.PI*fd + t*TAU*2) + Math.sin(Math.hypot(x-cx,y-cy)/N*Math.PI*fr - drift*t*TAU); const lvl = Math.floor((v + 4) / 8 * n); ctx.fillStyle = css(pal[((lvl)%n+n)%n]); ctx.fillRect(x*cs, yPx, cs, cs); ctx.fillStyle = css(pal[((lvl+1)%n+n)%n]); drawGlyph(ctx, applyCharSet(model, x, y, 'BLOCK'), x*cs, yPx, cs, 0); } } } /* ---- Spiral: rotating arms; loop-clean (integer rotations per loop). ---- */ function buildSpiral(seedStr){ const r = makeRng(seedStr); const N = pickN(r); const numColors = bellColors(r); const palette = pickColors(r, numColors); const arms = 2 + Math.floor(r() * 5), twist = 3 + Math.floor(r() * 6); const rot = 1 + Math.floor(r() * 2), dir = r() < 0.5 ? 1 : -1; return { kind:'spiral', seed: seedStr, N, palette, arms, twist, rot, dir, palMode: rollPal(r), eye: makeEye(N, r), marquee:null, hasMarquee:false, static:false, fx: rollFx(r) }; } function drawSpiral(ctx, model, t, cs){ const N = model.N, pal = model.palette, n = pal.length; const { arms, twist, rot, dir } = model, cx = (N-1)/2, cy = (N-1)/2; for(let y = 0; y < N; y++){ const yPx = y * cs; for(let x = 0; x < N; x++){ const ang = Math.atan2(y-cy, x-cx), rad = Math.hypot(x-cx, y-cy); const lvl = Math.floor((ang*arms/TAU + rad*twist/N - dir*t*rot) * n); ctx.fillStyle = css(pal[((lvl)%n+n)%n]); ctx.fillRect(x*cs, yPx, cs, cs); ctx.fillStyle = css(pal[((lvl+1)%n+n)%n]); drawGlyph(ctx, applyCharSet(model, x, y, 'BLOCK'), x*cs, yPx, cs, 0); } } } /* ---- Starfield: parallax warp; loop-clean (depth wraps mod 1 per loop). ---- */ function buildStarfield(seedStr){ const r = makeRng(seedStr); const N = pickN(r); const numColors = bellColors(r); const palette = pickColors(r, numColors); const count = 20 + Math.round(N * N * 0.06); const speed = 1 + Math.floor(r() * 2); const stars = []; for(let i = 0; i < count; i++) stars.push({ ang: r() * TAU, depth: r() }); return { kind:'starfield', seed: seedStr, N, palette, stars, speed, palMode: rollPal(r), eye: makeEye(N, r), marquee:null, hasMarquee:false, static:false, fx: rollFx(r) }; } function drawStarfield(ctx, model, t, cs){ const N = model.N, pal = model.palette, n = pal.length; const { stars, speed } = model, cx = (N-1)/2, cy = (N-1)/2, maxR = N * 0.62; ctx.fillStyle = css(0); ctx.fillRect(0, 0, N*cs, N*cs); for(const s of stars){ const rr = ((s.depth + t*speed) % 1 + 1) % 1; const sr = rr * rr * maxR; const px = Math.round(cx + Math.cos(s.ang)*sr), py = Math.round(cy + Math.sin(s.ang)*sr); if(px < 0 || py < 0 || px >= N || py >= N) continue; ctx.fillStyle = css(pal[Math.min(n-1, Math.floor(rr * n))]); drawGlyph(ctx, applyCharSet(model, px, py, 'BALL'), px*cs, py*cs, cs, 0); } } /* ---- Lissajous: a harmonic curve traces + morphs; loop-clean (phase TAU). ---- */ function buildLissajous(seedStr){ const r = makeRng(seedStr); const N = pickN(r); const numColors = bellColors(r); const palette = pickColors(r, numColors); let a = 2 + Math.floor(r() * 3), b = 2 + Math.floor(r() * 3); if(a === b) b = (b % 3) + 2; const dir = r() < 0.5 ? 1 : -1; return { kind:'lissajous', eyesBehind: true, seed: seedStr, N, palette, a, b, dir, palMode: rollPal(r), eye: makeEye(N, r), marquee:null, hasMarquee:false, static:false, fx: rollFx(r) }; } function drawLissajous(ctx, model, t, cs){ const N = model.N, pal = model.palette, n = pal.length; const { a, b, dir } = model, cx = (N-1)/2, cy = (N-1)/2, A = (N-1)/2*0.92, B = (N-1)/2*0.92; const steps = N * 28; let ppx = null, ppy = null; for(let k = 0; k <= steps; k++){ // <= closes the loop seam const phi = (k % steps) / steps * TAU; const px = Math.round(cx + A*Math.sin(a*phi + dir*t*TAU)), py = Math.round(cy + B*Math.sin(b*phi)); if(px < 0 || py < 0 || px >= N || py >= N){ ppx = ppy = null; continue; } ctx.fillStyle = css(pal[Math.floor(phi/TAU*n)%n]); drawGlyph(ctx, applyCharSet(model, px, py, 'BALL'), px*cs, py*cs, cs, 0); // bridge diagonal hops so the curve stays solidly connected for any char // set (Dots especially): fill the corner cell between diagonal steps. if(ppx !== null && Math.abs(px - ppx) === 1 && Math.abs(py - ppy) === 1){ drawGlyph(ctx, applyCharSet(model, px, ppy, 'BALL'), px*cs, ppy*cs, cs, 0); } ppx = px; ppy = py; } } /* ---- Joystick: a crypto wink. An abstracted 8-bit "joystick" — the two signature eyes form the base, a glyph shaft + rounded tip extend out, and color radiates from the tip like the Flower's bloom. Reads as a wide-eyed little character, not anatomy. Loop-clean (radial color cycle). ---- */ function segDistJ(px, py, x0, y0, x1, y1){ const dx = x1 - x0, dy = y1 - y0, L2 = dx*dx + dy*dy || 1; let u = ((px - x0) * dx + (py - y0) * dy) / L2; u = Math.max(0, Math.min(1, u)); return Math.hypot(px - (x0 + u*dx), py - (y0 + u*dy)); } function buildJoystick(seedStr){ const r = makeRng(seedStr); const N = pickN(r); const numColors = bellColors(r); const palette = pickColors(r, numColors); const HEADINGS = [[0,1],[0,1],[0,-1]]; // vertical; biased eyes-on-top (reads best as a face) const [hx, hy] = HEADINGS[Math.floor(r() * HEADINGS.length)]; const px = -hy, py = hx; // perpendicular const ballR = N * (0.11 + r() * 0.05); const shaftW = N * (0.07 + r() * 0.04); const tipR = shaftW * (1.45 + r() * 0.5); // bulbous tip — keeps the radial bloom prominent const shaftLen = N * (0.34 + r() * 0.16); const gap = ballR * (0.95 + r() * 0.3); // center the whole silhouette (back-of-eyes .. tip) on the grid so nothing clips const baseX = N/2 - hx * ((shaftLen + tipR) / 2), baseY = N/2 - hy * ((shaftLen + tipR) / 2); const balls = [[baseX + px*gap, baseY + py*gap], [baseX - px*gap, baseY - py*gap]]; const sx0 = baseX + hx*ballR, sy0 = baseY + hy*ballR; const tipX = baseX + hx*(ballR + shaftLen), tipY = baseY + hy*(ballR + shaftLen); const N2 = N*N, shaftMask = new Uint8Array(N2), dist = new Int16Array(N2); const scale = 0.7 + r() * 0.5; for(let y=0;y<N;y++) for(let x=0;x<N;x++){ const i = y*N+x, fx = x+0.5, fy = y+0.5; if(segDistJ(fx, fy, sx0, sy0, tipX, tipY) <= shaftW || Math.hypot(fx-tipX, fy-tipY) <= tipR){ shaftMask[i] = 1; dist[i] = Math.round(Math.hypot(fx-tipX, fy-tipY) * scale); } } const cellGlyphs = Array.from({length:N2}, () => FIELD_POOL[Math.floor(r()*FIELD_POOL.length)]); return { kind:'joystick', seed: seedStr, N, palette, shaftMask, dist, cellGlyphs, laps: Math.max(2, Math.round(10 / numColors)), dir: r() < 0.5 ? 1 : -1, balls, ballR, eye: makeEye(N, r), eyesBehind: true, palMode: rollPal(r), marquee:null, hasMarquee:false, static:false, fx: rollFx(r) }; } function drawBallEyes(ctx, model, t, cs, balls, R){ const e = model.eye, N = model.N; const sclera = (e.light != null && e.light !== 1) ? e.light : 1; const pupilCol = (e.light === 0) ? 1 : 0; // Void => light pupil const blink = e.blink ? (((t * (e.blink + 1)) % 1) > 0.94) : false; const ang = t * TAU, frantic = (e.gaze || 0) > 4 ? 1 : 0.4; const ox = Math.cos(ang) * R * 0.28 * frantic, oy = Math.sin(ang*1.3) * R * 0.28 * frantic; for(const [bx, by] of balls){ for(let dy=-Math.ceil(R); dy<=Math.ceil(R); dy++) for(let dx=-Math.ceil(R); dx<=Math.ceil(R); dx++){ if(dx*dx+dy*dy > R*R) continue; const x = Math.round(bx+dx), y = Math.round(by+dy); if(x<0||y<0||x>=N||y>=N) continue; ctx.fillStyle = css(sclera); ctx.fillRect(x*cs, y*cs, cs, cs); } if(blink) continue; const pr = Math.max(1, R*0.42); for(let dy=-Math.ceil(pr); dy<=Math.ceil(pr); dy++) for(let dx=-Math.ceil(pr); dx<=Math.ceil(pr); dx++){ if(dx*dx+dy*dy > pr*pr) continue; const x = Math.round(bx+ox+dx), y = Math.round(by+oy+dy); if(x<0||y<0||x>=N||y>=N) continue; ctx.fillStyle = css(pupilCol); ctx.fillRect(x*cs, y*cs, cs, cs); } } } function drawJoystick(ctx, model, t, cs){ const N = model.N, n = model.palette.length; ctx.fillStyle = css(0); ctx.fillRect(0, 0, N*cs, N*cs); const off = Math.floor(t * model.laps * n) * model.dir; // radial bloom from the tip for(let y=0;y<N;y++) for(let x=0;x<N;x++){ const i = y*N+x; if(!model.shaftMask[i]) continue; const ci = ((model.dist[i] + off) % n + n) % n; ctx.fillStyle = css(model.palette[ci]); drawGlyph(ctx, applyCharSet(model, x, y, 'BLOCK'), x*cs, y*cs, cs, 0); } drawBallEyes(ctx, model, t, cs, model.balls, model.ballR); } /* ---- Frog: ultra-rare crypto wink. A centered, symmetric little frog — round body + bulging signature eyes on top + a wide grin + tiny feet — with color radiating from the center like the Flower. Styled, not literal. Loop-clean. ---- */ function buildFrog(seedStr){ const r = makeRng(seedStr); let N = pickN(r); if(N < 24) N = 24; // frogs need room to read // green-biased palette: a dark->light green ramp (+ occasional accent) so every // frog reads froggy while still varying piece to piece (Pepto/Colodore shift too). const palette = [r() < 0.5 ? 9 : 11, 5, 13]; // shadow (brown/dk grey), green, light green if(r() < 0.45) palette.push(3); // cyan highlight sometimes if(r() < 0.22) palette.push(r() < 0.5 ? 8 : 14); // rare warm/cool accent spot const cx = N/2, cy = N*0.54; const rx = N*0.34, ry = N*0.30; // body ellipse const eyeR = N * (0.12 + r()*0.03); const eyeGap = N*0.18, eyeY = cy - ry*0.80; // eyes bulge up off the top const eyes = [[cx - eyeGap, eyeY], [cx + eyeGap, eyeY]]; const footR = N*0.10, footY = cy + ry*0.92, footX = N*0.20; const N2 = N*N, mask = new Uint8Array(N2), dist = new Int16Array(N2); const scale = 0.6 + r()*0.4; for(let y=0;y<N;y++) for(let x=0;x<N;x++){ const i=y*N+x, fx=x+0.5, fy=y+0.5; const inBody = ((fx-cx)/rx)**2 + ((fy-cy)/ry)**2 <= 1; const inFoot = Math.hypot(fx-(cx-footX), fy-footY) <= footR || Math.hypot(fx-(cx+footX), fy-footY) <= footR; if(inBody || inFoot){ mask[i]=1; dist[i]=Math.round(Math.hypot(fx-cx, fy-cy)*scale); } } // wide grin (gentle upturned smile) + two nostrils above it const mouthCells=[], mY = cy + ry*0.34, mW = rx*0.6; for(let x=Math.round(cx-mW); x<=Math.round(cx+mW); x++){ const dxn = Math.abs((x-cx)/mW); mouthCells.push([x, Math.round(mY - dxn*dxn*N*0.07)]); // ends curve up => smile } const nostrils = [[Math.round(cx - N*0.05), Math.round(cy + ry*0.06)], [Math.round(cx + N*0.05), Math.round(cy + ry*0.06)]]; const cellGlyphs = Array.from({length:N2}, () => FIELD_POOL[Math.floor(r()*FIELD_POOL.length)]); return { kind:'frog', seed:seedStr, N, palette, mask, dist, cellGlyphs, laps: Math.max(2, Math.round(10/palette.length)), dir: r()<0.5?1:-1, eyes, eyeR, mouthCells, nostrils, eye: makeEye(N, r), eyesBehind:true, palMode: rollPal(r), marquee:null, hasMarquee:false, static:false, fx: rollFx(r) }; } function drawFrog(ctx, model, t, cs){ const N = model.N, n = model.palette.length; ctx.fillStyle = css(0); ctx.fillRect(0, 0, N*cs, N*cs); const off = Math.floor(t * model.laps * n) * model.dir; for(let y=0;y<N;y++) for(let x=0;x<N;x++){ const i=y*N+x; if(!model.mask[i]) continue; const ci = ((model.dist[i] + off) % n + n) % n; ctx.fillStyle = css(model.palette[ci]); drawGlyph(ctx, applyCharSet(model, x, y, 'BLOCK'), x*cs, y*cs, cs, 0); } ctx.fillStyle = css(0); for(const [x,y] of model.mouthCells) if(x>=0&&y>=0&&x<N&&y<N) ctx.fillRect(x*cs, y*cs, cs, cs); for(const [x,y] of model.nostrils) if(x>=0&&y>=0&&x<N&&y<N) ctx.fillRect(x*cs, y*cs, cs, cs); drawBallEyes(ctx, model, t, cs, model.eyes, model.eyeR); } /* ====== public bootstrap ====== */ const LOOP_MS = 3400; function buildFromSpec(spec, salt){ const sfx = salt ? '-' + salt : ''; const seed = (spec.seed || spec.id || 'token') + sfx; let m; switch(spec.
st chase = r() < 0.35; // ~1/3 of bands get a chase sweep bands.push({ y0: y, y1: y + rows[b], bg, fg, fgBright: BRIGHTER[fg] ?? 1, motif, motion, dir, speed: N * speedMul, chase, chaseAx: r() < 0.5 ? 1 : 0, chaseAy: r() < 0.5 ? 1 : 0, chaseSpeed: N * (r() < 0.5 ? 1 : 2), chaseThick: 1 + Math.floor(r() * 2), }); y += rows[b]; } clampBandColors(bands); return { seed: seedStr, N, bands, colorCount: countBandColors(bands), eye: makeEye(N, r), marquee: null, hasMarquee: false, palMode: rollPal(r), static: false, fx: rollFx(r) }; } /* eye geometry + per-token gaze behavior (the old shiftRate / blinkRate, now seeded per token so every face darts and blinks differently). All loop-clean: the dart sequence wraps at t=1 and blink windows live strictly inside (0,1). */ const TEARS_PCT = 0.30; // ultra-rare "pattern" sclera fills the eye with symbol glyphs, one symbol per // row (a random combo down the eye). Genuine C64 ROM chars. const EYE_PATTERN_SYMS = ['#', 'V', '%', '&', '@', '*']; const EYE_PATTERN_PCT = 0.02; // ~2% of tokens (ultra rare) const EYE_HIGH_COLOR = 10; // C64 light red (pink) = the "High AF" eye const EYE_COLOR_PCT = 0.10; // ~10% of the collection have a non-white eye const EYE_HIGH_OF_COLORED = 0.10; // of colored eyes, ~10% are High AF (~1% overall) // every other C64 color, randomly mixed. black (0) = "Void", which gets a white // pupil instead of black so the dot still reads against the black sclera. const EYE_COLOR_POOL = [0, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13, 14, 15]; // ultra-rare animated ("strobe") eyes: the color cycles every few frames. non- // void eyes strobe the sclera/field; void eyes strobe the pupil. probabilities // (a rate, so counts scale with supply) bumped ~50% over the prior pass. At the // 2048 supply the expected counts are roughly ~21 solid, ~21 grid, ~27 pattern, // ~12 void. void is clamped at 0.90 so a couple of static voids still exist. const EYE_ANIM_PROB = { solid: 0.02175, grid: 0.02175, pattern: 0.648, void: 0.90 }; const EYE_ANIM_COLORS = [2, 3, 4, 5, 7, 8, 10, 13, 14]; // vivid C64 strobe cycle function makeEye(N, rng){ const r = rng || Math.random; const w = Math.max(3, Math.round(0.18 * N)); // a touch bigger so a pupil fits const h = Math.max(2, Math.round(0.13 * N)); const gap = Math.max(1, Math.round(0.06 * N)); const top = Math.max(1, Math.round(0.16 * N)); const left = Math.min(N - (w * 2 + gap) - 1, Math.round(0.48 * N)); const mid = Math.floor(w / 2); const looks = [mid, mid, 0, w - 1]; // center-biased gaze targets const steps = 3 + Math.floor(r() * 5); // 3..7 darts per loop (shift rate) const dartSeq = Array.from({ length: steps }, () => looks[Math.floor(r() * looks.length)]); dartSeq[0] = mid; // rest at center at loop start/end const blinkCount = [0, 1, 1, 1, 2, 2, 3][Math.floor(r() * 7)]; // blink rate const blinks = []; for(let i = 0; i < blinkCount; i++) blinks.push([0.05 + r() * 0.8, 0.045]); // tears: ~30% of tokens cry. one cell-sized drop falls below each eye, // advancing one row per loop-tick and wrapping at t=1 (loop-clean). const tears = r() < TEARS_PCT; // sclera fill, now a deliberate seeded trait (was an accidental density // artifact): solid block / grid of squares / ultra-rare symbol pattern. const fr = r(); let fill, fillRows = null; if(fr < EYE_PATTERN_PCT){ fill = 'pattern'; fillRows = Array.from({ length: h }, () => EYE_PATTERN_SYMS[Math.floor(r() * EYE_PATTERN_SYMS.length)]); } else if(fr < EYE_PATTERN_PCT + (1 - EYE_PATTERN_PCT) / 2){ fill = 'solid'; } else { fill = 'grid'; } // eye color: ~90% white. ~10% get a non-white sclera; of those, ~10% are the // pink "High AF" eye (~1% overall) and the rest are any other C64 color. let light = 1; // white sclera (default) if(r() < EYE_COLOR_PCT){ light = (r() < EYE_HIGH_OF_COLORED) ? EYE_HIGH_COLOR : EYE_COLOR_POOL[Math.floor(r() * EYE_COLOR_POOL.length)]; } const pupil = (light === 0) ? 1 : 0; // void (black) eye gets a white dot // ultra-rare strobe: non-void eyes animate the sclera, void eyes the pupil let anim = null; if(light === 0){ if(r() < EYE_ANIM_PROB.void) anim = 'pupil'; } else if(r() < (EYE_ANIM_PROB[fill] || 0)){ anim = 'sclera'; } return { w, h, gap, top, left, light, dark: 0, pupil, dartSeq, blinks, tears, tearColor: 14, gaze: steps, blink: blinkCount, fill, fillRows, anim }; } /* ---- generic "field" renderer ---- Per-cell color cycles along a scalar field (distance, axis, angle, ...) with a temporal offset, over a fixed field of random symbols. One renderer covers flowers (radial), tunnels (square), stripes (axis), rays (angle), checker, pulse and diagonals. Loop-clean: offset advances whole color-cycles per loop. */ const FIELD_POOL = ['X', '.', 'DIAG_FWD', 'DIAG_BACK', 'X_DIAG', 'CHECKER', 'CROSS', 'HBAR', 'VBAR', 'DIAG_QUAD', 'TRI_LOW', 'TRI_UP', 'HALF_L', 'HALF_B', 'QUAD_TL', 'QUAD_BL', 'BLOCK']; function pickColors(r, count){ const all = Array.from({ length: 16 }, (_, i) => i); for(let i = all.length - 1; i > 0; i--){ const j = (r() * (i + 1)) | 0; [all[i], all[j]] = [all[j], all[i]]; } return all.slice(0, count); } const FIELD_FNS = { radial: (x, y, c0) => Math.round(Math.hypot(x - c0, y - c0)), tunnel: (x, y, c0) => Math.max(Math.abs(x - c0), Math.abs(y - c0)), hbars: (x, y) => y, vbars: (x, y) => x, diag: (x, y) => x + y, checker: (x, y, c0, sc) => Math.floor(x / sc) + Math.floor(y / sc), rays: (x, y, c0, sc, sectors) => Math.floor(((Math.atan2(y - c0, x - c0) + Math.PI) / (2 * Math.PI)) * sectors), pulse: () => 0, }; function buildField(seedStr, type){ const r = makeRng(seedStr); const N = pickN(r); const numColors = bellColors(r); const palette = pickColors(r, numColors); const c0 = (N - 1) / 2; const sc = 1 + Math.floor(r() * 3); // checker scale const sectors = numColors * (2 + Math.floor(r() * 3)); // rays: multiple of colors const fn = FIELD_FNS[type] || FIELD_FNS.radial; const field = new Int16Array(N * N); for(let y = 0; y < N; y++) for(let x = 0; x < N; x++) field[y * N + x] = fn(x, y, c0, sc, sectors); const cellGlyphs = Array.from({ length: N * N }, () => FIELD_POOL[Math.floor(r() * FIELD_POOL.length)]); const laps = Math.max(2, Math.round(12 / numColors)); const dir = r() < 0.5 ? 1 : -1; return { kind: 'field', seed: seedStr, N, palette, field, cellGlyphs, laps, dir, palMode: rollPal(r), eye: makeEye(N, r), marquee: null, hasMarquee: false, static: false, fx: rollFx(r) }; } /* ---- Flowers: a real petalled bloom, not concentric rings. The color bands follow a rose curve (radius modulated by cos(petals*angle)) so they bulge into petals at the tips and pinch into seams between them, around a randomized center point. Petal count + center are internal (NOT traits). ---- */ function buildFlower(seedStr){ const r = makeRng(seedStr); const N = pickN(r); const numColors = bellColors(r); const palette = pickColors(r, numColors); const cx = (N - 1) / 2 + (r() - 0.5) * N * 0.3; // randomized center point const cy = (N - 1) / 2 + (r() - 0.5) * N * 0.3; const petals = 5 + Math.floor(r() * 4); // 5..8 petals (internal) const amp = 0.55 + r() * 0.25; // petal depth const phase = r() * Math.PI * 2; // flower rotation const scale = 0.5 + r() * 0.4; // band density const field = new Int16Array(N * N); for(let y = 0; y < N; y++) for(let x = 0; x < N; x++){ const dx = x - cx, dy = y - cy; const ang = Math.atan2(dy, dx) + phase; const rad = Math.hypot(dx, dy); const petalR = 1 + amp * Math.cos(petals * ang); // >0 since amp<=0.8 field[y * N + x] = Math.round(rad / petalR * scale); } const cellGlyphs = Array.from({ length: N * N }, () => FIELD_POOL[Math.floor(r() * FIELD_POOL.length)]); const laps = Math.max(2, Math.round(12 / numColors)); const dir = r() < 0.5 ? 1 : -1; return { kind: 'field', seed: seedStr, N, palette, field, cellGlyphs, laps, dir, palMode: rollPal(r), eye: makeEye(N, r), marquee: null, hasMarquee: false, static: false, fx: rollFx(r) }; } /* mirror / kaleidoscope: a coherent MOVING pattern (chevrons / bars / waves / rings) folded across symmetry axes, so the motion itself reflects. The pattern is a level field evaluated on the FOLDED coordinate, then color-cycled by the loop phase (drawField), so bands sweep and meet symmetrically at the axes. Directional glyphs are flipped per fold so the texture reflects too. modes: 'h' (vertical axis), 'v' (horizontal axis), 'd' (diagonal), 'quad' (4-fold). */ const MIRROR_BASES = ['diag', 'bars', 'rings', 'wave']; const MIRROR_GLYPH = { diag: 'DIAG_FWD', bars: 'VBAR', rings: 'BLOCK', wave: 'HBAR' }; function buildMirror(seedStr, mode){ const r = makeRng(seedStr); const N = pickN(r); const numColors = bellColors(r); const palette = pickColors(r, numColors); const base = pick(MIRROR_BASES, r); const glyph = MIRROR_GLYPH[base]; const freq = 1 + Math.floor(r() * 3), amp = 1 + Math.floor(r() * 3); const cx = (N - 1) / 2, cy = (N - 1) / 2; const lvl = (sx, sy) => { switch(base){ case 'bars': return Math.round(sx); case 'rings': return Math.round(Math.hypot(sx, sy)); case 'wave': return Math.round(sy + amp * Math.sin(sx * freq * 0.4)); default: return Math.round(sx + sy); // diag -> chevrons when folded } }; const field = new Int16Array(N * N); const cellGlyphs = new Array(N * N); const fh = new Uint8Array(N * N), fv = new Uint8Array(N * N); for(let y = 0; y < N; y++) for(let x = 0; x < N; x++){ let sx, sy, h = 0, v = 0; if(mode === 'h'){ sx = Math.abs(x - cx); sy = y; h = x < cx ? 1 : 0; } else if(mode === 'v'){ sx = x; sy = Math.abs(y - cy); v = y < cy ? 1 : 0; } else if(mode === 'quad'){ sx = Math.abs(x - cx); sy = Math.abs(y - cy); h = x < cx ? 1 : 0; v = y < cy ? 1 : 0; } else { if(x >= y){ sx = x; sy = y; } else { sx = y; sy = x; } } // 'd' diagonal fold const i = y * N + x; field[i] = lvl(sx, sy); cellGlyphs[i] = glyph; fh[i] = h; fv[i] = v; } const laps = Math.max(2, Math.round(12 / numColors)); const dir = r() < 0.5 ? 1 : -1; return { kind: 'field', seed: seedStr, N, palette, field, cellGlyphs, cellFlipH: fh, cellFlipV: fv, base, mirrorMode: mode, laps, dir, palMode: rollPal(r), eye: makeEye(N, r), marquee: null, hasMarquee: false, static: false, fx: rollFx(r) }; } function drawField(ctx, model, t, cs){ const N = model.N, pal = model.palette, n = pal.length; const offset = (model.dir || 1) * Math.floor(t * n * (model.laps || 1)); const f = model.field, g = model.cellGlyphs, fh = model.cellFlipH, fv = model.cellFlipV; for(let y = 0; y < N; y++){ const yPx = y * cs; for(let x = 0; x < N; x++){ const i = y * N + x; const lvl = f[i]; const paper = pal[((lvl - offset) % n + n) % n]; const ink = pal[((lvl - offset + 1) % n + n) % n]; ctx.fillStyle = css(paper); ctx.fillRect(x * cs, yPx, cs, cs); ctx.fillStyle = css(ink); drawGlyph(ctx, applyCharSet(model, x, y, g[i]), x * cs, yPx, cs, 0, fh ? !!fh[i] : false, fv ? !!fv[i] : false); } } } /* ---- band-based idea builders ---- */ function splitHeights(N, count, r){ const w = Array.from({ length: count }, () => 0.5 + r()); const s = w.reduce((a, b) => a + b, 0); const rows = w.map(x => Math.max(1, Math.round(x / s * N))); let diff = N - rows.reduce((a, b) => a + b, 0); for(let i = 0; diff !== 0; i = (i + 1) % count){ if(diff > 0){ rows[i]++; diff--; } else if(rows[i] > 1){ rows[i]--; diff++; } } return rows; } function buildWeave(seed){ const r = makeRng(seed); const N = pickN(r); const numColors = bellColors(r); const palette = pickColors(r, numColors); const T = 2 + Math.floor(r() * 3); // thread width 2..4 cells const bandThreads = 1 + Math.floor(r() * 2); // threads per color stripe const woff = 1 + Math.floor(r() * Math.max(1, numColors - 1)); // warp vs weft stripe offset const field = new Int16Array(N * N); const cellGlyphs = new Array(N * N).fill('BLOCK'); for(let y = 0; y < N; y++) for(let x = 0; x < N; x++){ const tx = Math.floor(x / T), ty = Math.floor(y / T); const over = ((tx + ty) & 1) === 0; // plain weave: alternate thread on top const warp = Math.floor(tx / bandThreads) % numColors; // vertical thread colour const weft = (Math.floor(ty / bandThreads) + woff) % numColors; // horizontal thread colour field[y * N + x] = over ? warp : weft; } const laps = Math.max(2, Math.round(12 / numColors)); const dir = r() < 0.5 ? 1 : -1; return { kind:'field', seed, N, palette, field, cellGlyphs, laps, dir, palMode: rollPal(r), eye: makeEye(N, r), marquee: null, hasMarquee: false, static: false, fx: rollFx(r) }; } function bandAt(model, y){ const b = model.bands; for(let i = 0; i < b.length; i++) if(y >= b[i].y0 && y < b[i].y1) return b[i]; return b[b.length - 1]; } /* count the unique color indices actually used across every band in a token (bg / fg / fgBright, plus chase color = 1 if any band has chase on). Band-engine tokens don't carry a model.palette, so this is how the traits panel surfaces "colors" for Bands / Glitch / Weave / Single tokens. */ function countBandColors(bands){ const s = new Set(); for(const b of bands){ s.add(b.bg); s.add(b.fg); if(b.fgBright != null) s.add(b.fgBright); if(b.chase) s.add(1); // chase sweep is hardcoded white } return s.size; } /* quietly cap band-engine tokens to the published 2-12 colors range. Collapses fgBright shimmer onto its fg first (least visible change), then if still over, remaps a band's fg to the previous band's fg. Internal constraint; not surfaced as a public trait. */ const BAND_COLOR_MAX = 12; function clampBandColors(bands){ while(countBandColors(bands) > BAND_COLOR_MAX){ let collapsed = false; for(const b of bands){ if(b.fgBright !== b.fg){ b.fgBright = b.fg; collapsed = true; break; } } if(!collapsed) break; } while(countBandColors(bands) > BAND_COLOR_MAX){ let collapsed = false; for(let i = 1; i < bands.length; i++){ if(bands[i].fg !== bands[0].fg){ bands[i].fg = bands[0].fg; collapsed = true; break; } } if(!collapsed) break; } // final safety net: collapse extra bgs onto bands[0].bg if still over while(countBandColors(bands) > BAND_COLOR_MAX){ let collapsed = false; for(let i = 1; i < bands.length; i++){ if(bands[i].bg !== bands[0].bg){ bands[i].bg = bands[0].bg; collapsed = true; break; } } if(!collapsed) break; } } /* per-cell motif index for a band's motion mode (off = whole-cell phase offset) */ function motifIndex(band, x, y, off){ const L = band.motif.length; let v; switch(band.motion){ case 'scrollV': v = y - band.dir * off; break; case 'diag': v = x + y - band.dir * off; break; // along the x+y axis case 'diagB': v = x - y - band.dir * off; break; // opposite diagonal (TR<->BL) case 'altRows': v = x - (y % 2 ? -1 : 1) * off; break; case 'altCols': v = y - (x % 2 ? -1 : 1) * off; break; case 'scrollH': default: v = x - band.dir * off; break; } return ((v % L) + L) % L; } /* draw one frame. t in [0,1). opts: { crt:0-100, chroma:0-100, shimmer:bool } */ function drawC64Frame(ctx, model, t, opts = {}){ setPaletteMode(opts.palette || model.palMode || 'pepto'); // opts override > per-token > default const N = model.N; const D = ctx.canvas.width; const cs = Math.floor(D / N); if(model.kind === 'field'){ drawField(ctx, model, t, cs); } else if(model.kind === 'bloom'){ drawBloom(ctx, model, t, cs); } else if(model.kind === 'rain'){ drawRain(ctx, model, t, cs); } else if(model.kind === 'fireworks'){ drawFireworks(ctx, model, t, cs); } else if(model.kind === 'snake'){ drawSnake(ctx, model, t, cs); } else if(model.kind === 'julia'){ drawJulia(ctx, model, t, cs); } else if(model.kind === 'plasma'){ drawPlasma(ctx, model, t, cs); } else if(model.kind === 'spiral'){ drawSpiral(ctx, model, t, cs); } else if(model.kind === 'starfield'){ drawStarfield(ctx, model, t, cs); } else if(model.kind === 'lissajous'){ // eyes BEHIND the curve so the harmonic line weaves in front of the face // (the one family with a 3D/depth feel). Clear, draw eyes, then the curve. ctx.fillStyle = css(0); ctx.fillRect(0, 0, N * cs, N * cs); if(!opts.eyesOff) drawEyes(ctx, model, t, cs); drawLissajous(ctx, model, t, cs); } else if(model.kind === 'joystick'){ drawJoystick(ctx, model, t, cs); } else if(model.kind === 'frog'){ drawFrog(ctx, model, t, cs); } else if(model.kind === 'honorary'){ if(typeof drawHonorary === 'function') drawHonorary(ctx, model, t, cs); // marketing-only; absent in the lean on-chain bundle } else { const stat = !!model.static; const shimmer = opts.shimmer !== false && !stat; for(let y = 0; y < N; y++){ const band = bandAt(model, y); const off = stat ? 0 : Math.floor(t * band.speed); const chaseOff = (band.chase && !stat) ? Math.floor(t * band.chaseSpeed) : 0; const yPx = y * cs; for(let x = 0; x < N; x++){ ctx.fillStyle = css(band.bg); ctx.fillRect(x * cs, yPx, cs, cs); let inkIdx = band.fg; // periodic, loop-safe "nervous" shimmer if(shimmer){ const phase = t + ((x * 5 + y * 9) % 16) / 16; if(Math.sin(phase * Math.PI * 2) > 0.72) inkIdx = band.fgBright; } // chase sweep: a bright diagonal/line stripe running across the band if(band.chase){ const cv = ((band.chaseAx * x + band.chaseAy * y - chaseOff) % N + N) % N; if(cv < band.chaseThick) inkIdx = 1; // white sweep } ctx.fillStyle = css(inkIdx); drawGlyph(ctx, applyCharSet(model, x, y, band.motif[motifIndex(band, x, y, off)]), x * cs, yPx, cs, 0); } } } if(!opts.eyesOff && !model.eyesBehind && model.eye) drawEyes(ctx, model, t, cs); const bakedCrt = model.fx?.crt ?? model.crt ?? 0; const bakedChroma = model.fx?.chroma ?? model.chroma ?? 0; const crt = (opts.crt != null ? opts.crt : bakedCrt) | 0; const chroma = (opts.chroma != null ? opts.chroma : bakedChroma) | 0; if(crt > 0 || chroma > 0){ applyPostFx(ctx, { rgbShift: chroma, crtEffect: crt }); } } function drawEyes(ctx, model, t, cs){ const e = model.eye; const tt = t % 1; // per-token blink windows + dart sequence (the nervous identity, always alive) const blinks = e.blinks || []; const seq = (e.dartSeq && e.dartSeq.length) ? e.dartSeq : [Math.floor(e.w / 2)]; const blink = blinks.some(([s, d]) => tt >= s && tt < s + d); const cell = (cx, cy, ch, colorIdx) => { ctx.fillStyle = css(colorIdx); drawGlyph(ctx, ch, cx * cs, cy * cs, cs, 0); }; const pupilCol = seq[Math.floor(tt * seq.length) % seq.length]; const pupilRow = Math.min(e.h - 1, Math.floor(e.h / 2)); const fill = e.fill || 'solid'; // ultra-rare strobe: cycle a vivid color several times per loop (loop-clean, // wraps to the start color at t=1). sclera-strobe recolors the field, pupil- // strobe recolors the dot. const strobe = EYE_ANIM_COLORS[Math.floor(tt * EYE_ANIM_COLORS.length * 12) % EYE_ANIM_COLORS.length]; const scleraCol = (e.anim === 'sclera') ? strobe : e.light; const pupilColor = (e.anim === 'pupil') ? strobe : (e.pupil != null ? e.pupil : e.dark); const drawOne = (exCell) => { if(blink){ // solid eyelid line, regardless of fill style const yMid = e.top + Math.floor(e.h / 2); ctx.fillStyle = css(e.dark); for(let i = 0; i < e.w; i++) ctx.fillRect((exCell + i) * cs, yMid * cs, cs, cs); return; } // sclera for(let row = 0; row < e.h; row++){ for(let i = 0; i < e.w; i++){ const cx = exCell + i, cy = e.top + row; if(fill === 'pattern'){ // ultra-rare: a symbol per row (random combo down the eye) cell(cx, cy, (e.fillRows && e.fillRows[row]) || 'BLOCK', scleraCol); } else if(fill === 'grid'){ // deliberate grid: white square inset by a gutter so gaps always read const g = Math.max(1, Math.floor(cs * 0.14)); ctx.fillStyle = css(scleraCol); ctx.fillRect(cx * cs + g, cy * cs + g, cs - 2 * g, cs - 2 * g); } else { // solid: full cell, no gaps at any density ctx.fillStyle = css(scleraCol); ctx.fillRect(cx * cs, cy * cs, cs, cs); } } } // ball pupil sitting on the sclera (strobe / white on void / else black) cell(exCell + pupilCol, e.top + pupilRow, 'BALL', pupilColor); }; drawOne(e.left); drawOne(e.left + e.w + e.gap); // tears: a cell-sized drop below each eye, advancing one row per loop and // wrapping at t=1 so the loop stays seamless. Drawn after the eyes so the // drop reads on top of any field underneath. if(e.tears){ const N = model.N; const fallStart = e.top + e.h; const fallDist = N - fallStart; if(fallDist > 0){ const row = fallStart + Math.floor(tt * fallDist); const cx1 = e.left + Math.floor(e.w / 2); const cx2 = e.left + e.w + e.gap + Math.floor(e.w / 2); ctx.fillStyle = css(e.tearColor); drawGlyph(ctx, 'BALL', cx1 * cs, row * cs, cs, 0); drawGlyph(ctx, 'BALL', cx2 * cs, row * cs, cs, 0); } } } /* ---- Bloom: tree-of-life expansion from 4 center cells. modes: 'breathe' — expand to fill, contract back to seed, returns at t=1 (smooth loop) 'exhale' — expand to fill, snap reset to seed at loop end Both are cell-step and deterministic. */ const BLOOM_LEAVES = ['HEART','DIAMOND','SPADE','CLUB','BALL']; const BLOOM_BRANCH = ['DIAG_FWD','DIAG_BACK','BLOCK','X']; function buildBloom(seedStr, mode){ const r = makeRng(seedStr); const N = pickN(r); const numColors = bellColors(r); const palette = pickColors(r, numColors); const cx = Math.floor(N/2), cy = Math.floor(N/2); const seedCells = [[cx-1,cy-1],[cx,cy-1],[cx-1,cy],[cx,cy]]; const visited = new Uint8Array(N*N); const order = []; const frontier = []; for(const [x,y] of seedCells){ visited[y*N+x] = 1; order.push([x,y,'BLOCK']); frontier.push([x,y]); } const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; while(frontier.length){ const i = Math.floor(r() * frontier.length); const [fx,fy] = frontier.splice(i, 1)[0]; for(const [dx,dy] of dirs){ if(r() < 0.28) continue; // pruning rate keeps branches organic const nx = fx + dx, ny = fy + dy; if(nx<0||ny<0||nx>=N||ny>=N) continue; if(visited[ny*N+nx]) continue; visited[ny*N+nx] = 1; const g = (r() < 0.22) ? pick(BLOOM_LEAVES, r) : pick(BLOOM_BRANCH, r); order.push([nx,ny,g]); frontier.push([nx,ny]); } } const laps = Math.max(2, Math.round(12 / numColors)); const dir = r() < 0.5 ? 1 : -1; return { kind:'bloom', mode: mode||'breathe', seed: seedStr, N, palette, order, laps, dir, palMode: rollPal(r), eye: makeEye(N, r), marquee: null, hasMarquee: false, static: false, fx: rollFx(r) }; } function drawBloom(ctx, model, t, cs){ const N = model.N, pal = model.palette, n = pal.length, order = model.order, P = order.length; ctx.fillStyle = css(0); ctx.fillRect(0, 0, N*cs, N*cs); // void const offset = (model.dir || 1) * Math.floor(t * n * (model.laps || 1)); const drawCell = (i) => { const e = order[i]; const paper = pal[((i - offset) % n + n) % n]; const ink = pal
/* NERVOUS renderer bundle, v1.1.0 — on-chain SSTORE2 storage */ (function(global){ "use strict"; /* ====== src/glyphfont.js ====== */ /* glyphfont.js — authentic Commodore 64 glyphs, 8x8. These are the EXACT bytes from the C64 character ROM (characters.901225-01), uppercase/graphics set. Each glyph is 8 rows of 8 bits (bit 7 = leftmost pixel), i.e. 8 bytes per glyph. Not a redraw, the real letterforms and the real PETSCII pattern tiles, so the look is genuine C64. Drawn with fillRect so it scales crisply to any cell size and inherits the current ctx.fillStyle. The graphics tiles are the pattern-makers (dither, diagonals, quadrants, lines, suits); the letters cover OXPEQ + MALLRIOT. */ const GLYPH_W = 8; const GLYPH_H = 8; const GLYPHS = { /* full uppercase alphabet, genuine C64 ROM 901225-01 bytes */ 'A': [24,60,102,126,102,102,102,0], 'B': [124,102,102,124,102,102,124,0], 'C': [60,102,96,96,96,102,60,0], 'D': [120,108,102,102,102,108,120,0], 'E': [126,96,96,120,96,96,126,0], 'F': [126,96,96,120,96,96,96,0], 'G': [60,102,96,96,110,102,60,0], 'H': [102,102,102,126,102,102,102,0], 'I': [60,24,24,24,24,24,60,0], 'J': [30,12,12,12,12,108,56,0], 'K': [102,108,120,112,120,108,102,0], 'L': [96,96,96,96,96,96,126,0], 'M': [99,119,127,107,99,99,99,0], 'N': [102,118,126,126,110,102,102,0], 'O': [60,102,102,102,102,102,60,0], 'P': [124,102,102,124,96,96,96,0], 'Q': [60,102,102,102,102,60,14,0], 'R': [124,102,102,124,120,108,102,0], 'S': [60,102,96,60,6,102,60,0], 'T': [126,24,24,24,24,24,24,0], 'U': [102,102,102,102,102,102,60,0], 'V': [102,102,102,102,102,60,24,0], 'W': [99,99,99,107,127,119,99,0], 'X': [102,102,60,24,60,102,102,0], 'Y': [102,102,102,60,24,24,24,0], 'Z': [126,6,12,24,48,96,126,0], /* full lowercase alphabet, genuine C64 set-2 forms */ 'a': [0,0,60,6,62,102,62,0], 'b': [0,96,96,124,102,102,124,0], 'c': [0,0,60,96,96,96,60,0], 'd': [0,6,6,62,102,102,62,0], 'e': [0,0,60,102,126,96,60,0], 'f': [0,14,24,60,24,24,24,0], 'g': [0,0,62,102,102,62,6,124], 'h': [0,96,96,124,102,102,102,0], 'i': [0,24,0,56,24,24,60,0], 'j': [0,6,0,6,6,6,6,60], 'k': [0,96,96,108,120,108,102,0], 'l': [0,56,24,24,24,24,60,0], 'm': [0,0,102,127,127,107,99,0], 'n': [0,0,124,102,102,102,102,0], 'o': [0,0,60,102,102,102,60,0], 'p': [0,0,124,102,102,124,96,96], 'q': [0,0,62,102,102,62,6,6], 'r': [0,0,124,102,96,96,96,0], 's': [0,0,62,96,60,6,124,0], 't': [0,24,126,24,24,24,14,0], 'u': [0,0,102,102,102,102,62,0], 'v': [0,0,102,102,102,60,24,0], 'w': [0,0,99,107,127,62,54,0], 'x': [0,0,102,60,24,60,102,0], 'y': [0,0,102,102,102,62,6,124], 'z': [0,0,126,12,24,48,126,0], /* full digits 0-9, authentic C64 ROM bytes */ '0': [60,102,110,118,102,102,60,0], '1': [24,56,24,24,24,24,126,0], '2': [60,102,6,12,48,96,126,0], '3': [60,102,6,28,6,102,60,0], '4': [6,14,30,102,127,6,6,0], '5': [126,96,124,6,6,102,60,0], '6': [60,96,96,124,102,102,60,0], '7': [126,102,12,24,24,24,24,0], '8': [60,102,102,60,102,102,60,0], '9': [60,102,102,62,6,102,60,0], /* a few extra punctuation marks for marketing copy */ ',': [0,0,0,0,0,24,24,48], '?': [60,102,6,12,24,0,24,0], '-': [0,0,0,126,0,0,0,0], '+': [0,24,24,126,24,24,0,0], '=': [0,0,126,0,0,126,0,0], '/': [3,7,14,28,56,112,224,192], ':': [0,0,24,24,0,24,24,0], ';': [0,0,24,24,0,24,24,48], "'": [24,24,24,0,0,0,0,0], '"': [102,102,102,0,0,0,0,0], /* number-row symbols, genuine C64 forms. '^' has no C64 caret; the key in that position is the up-arrow, so we map '^' to the authentic up-arrow. */ '!': [24,24,24,24,0,0,24,0], '@': [60,102,110,110,96,98,60,0], '#': [102,102,255,102,255,102,102,0], '$': [24,62,96,60,6,124,24,0], '%': [98,102,12,24,48,102,70,0], '^': [0,24,60,126,24,24,24,24], // C64 up-arrow '&': [60,102,60,56,103,102,63,0], '*': [0,102,60,255,60,102,0,0], '(': [12,24,48,48,48,24,12,0], ')': [48,24,12,12,12,24,48,0], '.': [0,0,0,0,0,24,24,0], /* PETSCII graphic / pattern tiles (the real pattern vocabulary) */ 'CHECKER': [204,204,51,51,204,204,51,51], // dither / hatch 'DIAG_BACK':[192,224,112,56,28,14,7,3], // \ 'DIAG_FWD': [3,7,14,28,56,112,224,192], // / 'DIAG_QUAD':[240,240,240,240,15,15,15,15], // ▚ opposed quadrants 'TRI_LOW': [255,127,63,31,15,7,3,1], // lower-left solid triangle 'TRI_UP': [255,254,252,248,240,224,192,128], // upper-left solid triangle 'HBAR': [0,0,0,255,255,0,0,0], // ─ 'VBAR': [24,24,24,24,24,24,24,24], // │ 'CROSS': [24,24,24,255,255,24,24,24], // ┼ 'X_DIAG': [195,231,126,60,60,126,231,195], // ✕ 'HALF_L': [240,240,240,240,240,240,240,240], // ▌ left half 'HALF_B': [0,0,0,0,255,255,255,255], // ▄ bottom half 'QUAD_TL': [240,240,240,240,0,0,0,0], // ▘ top-left quadrant 'QUAD_BL': [0,0,0,0,240,240,240,240], // ▖ bottom-left quadrant 'BLOCK': [255,255,255,255,255,255,255,255], // full block (reverse space) 'SPADE': [8,28,62,127,127,28,62,0], 'HEART': [54,127,127,127,62,28,8,0], 'DIAMOND': [8,28,62,127,62,28,8,0], 'CLUB': [24,24,102,102,24,24,60,0], 'BALL': [0,60,126,126,126,126,60,0], // C64-style pi: a top bar with two descending legs, drawn in the 8x8 ROM grid. 'PI': [0,0,126,102,102,102,102,0], }; /* draw glyph `ch` centered in a `cell`-px square at pixel origin (px, py), using the current ctx.fillStyle. `pad` (0..0.4) shrinks it within the cell. Returns true if the glyph exists. */ function drawGlyph(ctx, ch, px, py, cell, pad = 0, flipH = false, flipV = false){ const g = GLYPHS[ch]; if(!g) return false; const inner = cell * (1 - pad * 2); const ps = Math.max(1, Math.floor(Math.min(inner / GLYPH_W, inner / GLYPH_H))); const gw = GLYPH_W * ps, gh = GLYPH_H * ps; const ox = Math.round(px + (cell - gw) / 2); const oy = Math.round(py + (cell - gh) / 2); for(let r = 0; r < GLYPH_H; r++){ const sr = flipV ? GLYPH_H - 1 - r : r; // mirror rows const bits = g[sr]; for(let c = 0; c < GLYPH_W; c++){ const sc = flipH ? GLYPH_W - 1 - c : c; // mirror columns if(bits & (1 << (GLYPH_W - 1 - sc))) ctx.fillRect(ox + c * ps, oy + r * ps, ps, ps); } } return true; } const GLYPH_KEYS = Object.keys(GLYPHS); /* ====== src/c64palette.js ====== */ /* c64palette.js — the genuine Commodore 64 (VIC-II) 16-color hardware palette, "Pepto" values, the widely-accepted accurate set. This is THE palette for the reworked art (native-C64 default). Index order matches the C64's color codes. */ const C64 = [ [0x00, 0x00, 0x00], // 0 black [0xFF, 0xFF, 0xFF], // 1 white [0x68, 0x37, 0x2B], // 2 red [0x70, 0xA4, 0xB2], // 3 cyan [0x6F, 0x3D, 0x86], // 4 purple [0x58, 0x8D, 0x43], // 5 green [0x35, 0x28, 0x79], // 6 blue [0xB8, 0xC7, 0x6F], // 7 yellow [0x6F, 0x4F, 0x25], // 8 orange [0x43, 0x39, 0x00], // 9 brown [0x9A, 0x67, 0x59], // 10 light red [0x44, 0x44, 0x44], // 11 dark grey [0x6C, 0x6C, 0x6C], // 12 grey [0x9A, 0xD2, 0x84], // 13 light green [0x6C, 0x5E, 0xB5], // 14 light blue [0x95, 0x95, 0x95], // 15 light grey ]; /* Colodore palette — the other widely-used C64 set, calibrated to the 1084 monitor (warmer / softer than Pepto). Toggle at render time via setPaletteMode. */ const COLODORE = [ [0x00, 0x00, 0x00], [0xFF, 0xFF, 0xFF], [0x81, 0x33, 0x38], [0x75, 0xCE, 0xC8], [0x8E, 0x3C, 0x97], [0x56, 0xAC, 0x4D], [0x2E, 0x2C, 0x9B], [0xED, 0xF1, 0x71], [0x8E, 0x50, 0x29], [0x55, 0x38, 0x00], [0xC4, 0x6C, 0x71], [0x4A, 0x4A, 0x4A], [0x7B, 0x7B, 0x7B], [0xA9, 0xFF, 0x9F], [0x70, 0x6D, 0xEB], [0xB2, 0xB2, 0xB2], ]; /* darker codes good for backgrounds, brighter codes good for foreground/ink, so band pairings always have contrast */ const DARKS = [0, 2, 4, 6, 8, 9, 11]; const BRIGHTS = [1, 3, 5, 7, 10, 12, 13, 14, 15]; let CURRENT = C64; function setPaletteMode(mode){ CURRENT = (mode === 'colodore') ? COLODORE : C64; } const css = (i) => { const c = CURRENT[i] || CURRENT[0]; return `rgb(${c[0]},${c[1]},${c[2]})`; }; /* nearest palette index to an arbitrary RGB (used to quantize uploaded images into the C64 palette for "honorary" pieces). Uses the active palette mode. */ function nearestIndex(r, g, b){ let best = 0, bd = Infinity; for(let i = 0; i < CURRENT.length; i++){ const c = CURRENT[i]; const dr = c[0] - r, dg = c[1] - g, db = c[2] - b; const d = dr * dr + dg * dg + db * db; if(d < bd){ bd = d; best = i; } } return best; } /* a slightly brighter relative for the "nervous" shimmer of a given ink color */ const BRIGHTER = { 2: 10, 6: 14, 5: 13, 4: 14, 8: 7, 9: 8, 11: 12, 12: 15, 3: 1, 7: 1, 10: 1, 13: 1, 14: 1, 15: 1, 1: 1, 0: 11, }; /* ====== src/post-fx.js ====== */ /* post-fx.js — whole-canvas post-process passes ported from mallriot's glitch-o-matic 5000. Run after eyes/border/tears so the effects apply to the final composite (true VHS/CRT vibe). Effects (each applied only if its intensity is > 0): 1. rgbShift — R/B horizontal channel shift 2. crtEffect — barrel distortion + scanlines + vignette Takes effect intensities as explicit parameters so we don't depend on any module-level state object (keeps the IIFE bundle self-contained). */ function applyPostFx(ctx, opts = {}){ const rgb = (opts.rgbShift | 0) || 0; const crt = (opts.crtEffect | 0) || 0; if(rgb === 0 && crt === 0) return; if(rgb > 0) applyRgbShift(ctx, rgb); if(crt > 0) applyCrtEffect(ctx, crt); } /* ---------- C64 monitor look (baked into canvas; mirrors the live CSS) ---------- composite: horizontal color bleed (px radius), the "on a TV" merge of dithers. sat/con/bri: saturation / contrast / brightness multipliers (like CSS filters). scan: scanline darkening (0..1) on alternate rows. Used at export time so a saved GIF matches the live CSS monitor overlay. */ function applyMonitor(ctx, { composite = 0, sat = 1, con = 1, bri = 1, scan = 0 } = {}){ const W = ctx.canvas.width, H = ctx.canvas.height; const img = ctx.getImageData(0, 0, W, H); const d = img.data; if(composite > 0){ const src = new Uint8ClampedArray(d); const r = composite | 0, div = r * 2 + 1; for(let y = 0; y < H; y++){ const rb = y * W; for(let x = 0; x < W; x++){ let R = 0, G = 0, B = 0; for(let k = -r; k <= r; k++){ const xx = x + k < 0 ? 0 : x + k >= W ? W - 1 : x + k; const i = (rb + xx) * 4; R += src[i]; G += src[i+1]; B += src[i+2]; } const di = (rb + x) * 4; d[di] = R / div; d[di+1] = G / div; d[di+2] = B / div; } } } if(sat !== 1 || con !== 1 || bri !== 1 || scan > 0){ for(let y = 0; y < H; y++){ const sl = (scan > 0 && (y % 2 === 0)) ? (1 - scan) : 1; for(let x = 0; x < W; x++){ const i = (y * W + x) * 4; let R = d[i], G = d[i+1], B = d[i+2]; if(sat !== 1){ const g = 0.299*R + 0.587*G + 0.114*B; R = g + (R-g)*sat; G = g + (G-g)*sat; B = g + (B-g)*sat; } if(con !== 1){ R = (R-128)*con + 128; G = (G-128)*con + 128; B = (B-128)*con + 128; } if(bri !== 1){ R *= bri; G *= bri; B *= bri; } if(sl !== 1){ R *= sl; G *= sl; B *= sl; } d[i] = R < 0 ? 0 : R > 255 ? 255 : R; d[i+1] = G < 0 ? 0 : G > 255 ? 255 : G; d[i+2] = B < 0 ? 0 : B > 255 ? 255 : B; } } } ctx.putImageData(img, 0, 0); } /* ---------- rgb shift / channel separation (R right, B left) ---------- */ function applyRgbShift(ctx, shift){ const W = ctx.canvas.width; const H = ctx.canvas.height; const img = ctx.getImageData(0, 0, W, H); const data = img.data; const src = new Uint8ClampedArray(data); for(let y = 0; y < H; y++){ const rowBase = y * W; for(let x = 0; x < W; x++){ const dIdx = (rowBase + x) * 4; const rX = Math.max(0, Math.min(W - 1, x + shift)); const bX = Math.max(0, Math.min(W - 1, x - shift)); data[dIdx] = src[(rowBase + rX) * 4]; data[dIdx + 2] = src[(rowBase + bX) * 4 + 2]; } } ctx.putImageData(img, 0, 0); } /* ---------- noise: per-pixel random brightness offset ---------- */ function applyNoise(ctx, intensity){ const W = ctx.canvas.width; const H = ctx.canvas.height; const img = ctx.getImageData(0, 0, W, H); const data = img.data; const prob = intensity / 100; for(let i = 0; i < data.length; i += 4){ if(Math.random() < prob){ const n = (Math.random() - 0.5) * 100; data[i] = Math.max(0, Math.min(255, data[i] + n)); data[i + 1] = Math.max(0, Math.min(255, data[i + 1] + n)); data[i + 2] = Math.max(0, Math.min(255, data[i + 2] + n)); } } ctx.putImageData(img, 0, 0); } /* ---------- CRT: barrel distortion + scanlines + vignette ---------- The (x,y) → (sx,sy) source map for the barrel warp depends only on the canvas size + intensity, never on the frame. Same for the vignette strength per pixel. We cache both, keyed by W:H:intensity, so the per- frame cost drops to a tight memory-bound loop. Critical for marketplace iframes where browsers throttle synchronous canvas work. */ const _crtCache = new Map(); function getCrtCache(W, H, intensity){ const key = W + ':' + H + ':' + intensity; let e = _crtCache.get(key); if(e) return e; const f = intensity / 100; const cx = W / 2, cy = H / 2; const curvature = 0.15 * f; const vignetteStrength = 0.4 * f; const Wm1 = W - 1, Hm1 = H - 1; const n = W * H; const sxArr = new Float32Array(n); const syArr = new Float32Array(n); const vigArr = new Float32Array(n); for(let y = 0; y < H; y++){ const dy = (y - cy) / cy; const rowBase = y * W; for(let x = 0; x < W; x++){ const dx = (x - cx) / cx; const rSq = dx * dx + dy * dy; const distortion = 1 + curvature * rSq; let sx = cx + (x - cx) * distortion; let sy = cy + (y - cy) * distortion; if(sx < 0) sx = 0; else if(sx > Wm1) sx = Wm1; if(sy < 0) sy = 0; else if(sy > Hm1) sy = Hm1; const i = rowBase + x; sxArr[i] = sx; syArr[i] = sy; vigArr[i] = 1 - rSq * vignetteStrength; } } e = { sxArr, syArr, vigArr }; _crtCache.set(key, e); return e; } function applyCrtEffect(ctx, intensity){ const W = ctx.canvas.width; const H = ctx.canvas.height; const img = ctx.getImageData(0, 0, W, H); const data = img.data; const src = new Uint8ClampedArray(data); const { sxArr, syArr, vigArr } = getCrtCache(W, H, intensity); const scanlineOpacity = 0.3 * (intensity / 100); const dim2 = 1 - scanlineOpacity; const Wm1 = W - 1, Hm1 = H - 1; for(let y = 0; y < H; y++){ const rowBase = y * W; const rowScan = (y % 2 === 0) ? dim2 : 1; for(let x = 0; x < W; x++){ const i = rowBase + x; const sx = sxArr[i]; const sy = syArr[i]; const x0 = sx | 0, y0 = sy | 0; const x1 = x0 < Wm1 ? x0 + 1 : x0; const y1 = y0 < Hm1 ? y0 + 1 : y0; const fx = sx - x0, fy = sy - y0; const ifx = 1 - fx, ify = 1 - fy; const w00 = ifx * ify; const w10 = fx * ify; const w01 = ifx * fy; const w11 = fx * fy; const i00 = (y0 * W + x0) * 4; const i10 = (y0 * W + x1) * 4; const i01 = (y1 * W + x0) * 4; const i11 = (y1 * W + x1) * 4; const dim = rowScan * vigArr[i]; let R = (src[i00] * w00 + src[i10] * w10 + src[i01] * w01 + src[i11] * w11) * dim; let G = (src[i00 + 1] * w00 + src[i10 + 1] * w10 + src[i01 + 1] * w01 + src[i11 + 1] * w11) * dim; let B = (src[i00 + 2] * w00 + src[i10 + 2] * w10 + src[i01 + 2] * w01 + src[i11 + 2] * w11) * dim; const dIdx = i * 4; data[dIdx] = R < 0 ? 0 : R > 255 ? 255 : R; data[dIdx + 1] = G < 0 ? 0 : G > 255 ? 255 : G; data[dIdx + 2] = B < 0 ? 0 : B > 255 ? 255 : B; } } ctx.putImageData(img, 0, 0); } /* ====== src/c64render.js ====== */ /* c64render.js — C64 art core (cell grid of glyph tiles). A token is a horizontal stack of bands. Each band has a C64 ink/paper pair, a small glyph alphabet, a motif, and its OWN motion mode so a single token mixes left/right, up/down, diagonal, and chasing motions instead of one repetitive scroll. Some bands also get a bright "chase" sweep on top. Everything is driven by a normalized loop phase t in [0,1) with whole-cell steps, and every per-band shift is an integer multiple of the grid width N, so at t=1 every offset wraps to 0 and the loop is perfect by construction. Eyes (the face) sit upper-right and blink periodically. CRT bend + chroma split are reused from post-fx.js. Does NOT use the old band path. */ /* weighted motif pool: the letter X is by far the most common tile; suits (spade/heart/diamond/club) left out for now. [glyphKey, weight] */ const MOTIF_WEIGHTED = [ ['X', 10], // most common ['CHECKER', 2], ['DIAG_FWD', 2], ['DIAG_BACK', 2], ['X_DIAG', 1], ['DIAG_QUAD', 1], ['HBAR', 1], ['VBAR', 1], ['CROSS', 1], ['TRI_LOW', 1], ['TRI_UP', 1], ['HALF_L', 1], ['HALF_B', 1], ['QUAD_TL', 1], ['QUAD_BL', 1], ['BLOCK', 1], ['BALL', 1], ]; const MOTIF_TOTAL = MOTIF_WEIGHTED.reduce((s, [, w]) => s + w, 0); function pickGlyph(r){ let n = r() * MOTIF_TOTAL; for(const [k, w] of MOTIF_WEIGHTED){ n -= w; if(n <= 0) return k; } return MOTIF_WEIGHTED[0][0]; } const MOTIONS = ['scrollH', 'scrollV', 'diag', 'diagB', 'altRows', 'altCols']; const GRID_CHOICES = [16, 24, 32, 48, 64]; // 64 = native 8px glyph cells (floor) const COLODORE_PCT = 0.08; // ~8% use the warm "Colodore" monitor palette const CRT_PCT = 0.12; // ~12% get CRT bend baked in (~123 of 1024) const CHROMA_PCT = 0.12; // ~12% get chroma split baked in (~123 of 1024) // the two rolls are INDEPENDENT, so both at 12% means about 1.5% of tokens // land with both effects (~15 of 1024) — the super-rare crossover tier. /* rollFx: independently roll CRT bend intensity and chroma split intensity for any token. Returns { crt, chroma } where each is either 0 (no effect) or a non-zero intensity in the same range the old Glitch family used. Order of r() calls is fixed so the rolls are deterministic from the seed. */ function rollFx(r){ const crtOn = r() < CRT_PCT; const crtAmt = crtOn ? 45 + Math.floor(r() * 40) : 0; // 45-85 when on const chromaOn = r() < CHROMA_PCT; const chromaAmt = chromaOn ? 3 + Math.floor(r() * 8) : 0; // 3-10 when on return { crt: crtAmt, chroma: chromaAmt }; } // color count 2..12 on a gentle bell (triangular, avg of 2 uniforms) so 2 and 12 // are rarest but still appear (~0.5% each) and the middle counts dominate function bellColors(r){ const t = (r() + r()) / 2; return Math.max(2, Math.min(12, Math.round(2 + t * 10))); } function rollPal(r){ return r() < COLODORE_PCT ? 'colodore' : null; } function makeRng(seedStr){ let h = 2166136261; const s = String(seedStr); for(let i = 0; i < s.length; i++){ h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } let a = h >>> 0; return () => { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; } const pick = (arr, r) => arr[Math.floor(r() * arr.length)]; // density (grid N) is weighted toward 32: a bell curve so 16 and 64 are rare and // 32 is the most common. Weights line up with GRID_CHOICES [16,24,32,48,64]. // Consumes exactly one r() call (same as pick) so downstream rolls are unchanged. const GRID_WEIGHTS = [6, 22, 44, 22, 6]; // -> ~6% / 22% / 44% / 22% / 6% const GRID_WEIGHT_TOTAL = GRID_WEIGHTS.reduce((a, b) => a + b, 0); function pickN(r){ let n = r() * GRID_WEIGHT_TOTAL; for(let i = 0; i < GRID_CHOICES.length; i++){ n -= GRID_WEIGHTS[i]; if(n <= 0) return GRID_CHOICES[i]; } return GRID_CHOICES[GRID_CHOICES.length - 1]; } /* ---- Char Set: what every cell is "made of". A global trait applied to ALL families, overriding the per-family fill glyph. Rarity decreasing, with two ultra-rares (Garble, Pi) at ~0.5% each. Off cells ('.'/':') are preserved so cellular/fractal patterns survive; maze keeps one diagonal as the tile and blanks the other so the maze still reads. Rolled on an independent rng sub-stream (':charset') so it doesn't shift any other trait. */ const CHARSET_GARBLE = ['A','B','X','Z','#','@','%','&','*','?','=','+','BLOCK','BALL', 'HEART','DIAMOND','SPADE','CLUB','DIAG_FWD','DIAG_BACK','CHECKER','CROSS','X_DIAG','HBAR','VBAR','PI']; const CHARSET_TIERS = [ { name:'Blocks', glyph:'BLOCK', w:400 }, { name:'X', glyph:'X', w:240 }, { name:'Dots', glyph:'BALL', w:150 }, { name:'Diagonals', glyph:'DIAG_FWD', w:100 }, { name:'Crosses', glyph:'CROSS', w:60 }, { name:'Spades', glyph:'SPADE', w:12 }, // suits split 4 ways, { name:'Clubs', glyph:'CLUB', w:12 }, // totaling the old Suits share; { name:'Hearts', glyph:'HEART', w:5.5 }, // hearts + diamonds rarer { name:'Diamonds', glyph:'DIAMOND', w:5.5 }, { name:'Garble', glyph:null, w:5 }, // random glyph per cell { name:'Pi', glyph:'PI', w:5 }, ]; const CHARSET_TOTAL = CHARSET_TIERS.reduce((s, t) => s + t.w, 0); function pickCharSet(r){ let n = r() * CHARSET_TOTAL; for(const t of CHARSET_TIERS){ n -= t.w; if(n <= 0) return t; } return CHARSET_TIERS[0]; } /* attach a char set to a finished model (idempotent). Uses its own rng stream. */ function rollCharSetForModel(model){ if(!model || model.charSet) return model; const r = makeRng((model.seed || 'token') + ':charset'); const tier = pickCharSet(r); model.charSet = tier.name; if(tier.name === 'Garble'){ model.charSetGlyph = null; model.charSetSalt = (Math.floor(r() * 0xffffffff)) >>> 0; } else model.charSetGlyph = tier.glyph; return model; } function csGlyph(model, x, y){ if(model.charSet === 'Garble'){ let h = ((x * 73856093) ^ (y * 19349663) ^ (model.charSetSalt || 0)) >>> 0; h = (h ^ (h >>> 13)) >>> 0; return CHARSET_GARBLE[h % CHARSET_GARBLE.length]; } return model.charSetGlyph; } /* swap a cell's fill glyph for the char-set glyph. keeps '.'/':'(off) cells, and for maze keeps one diagonal as the tile + blanks the other. */ function applyCharSet(model, x, y, orig){ if(!model.charSet) return orig; if(orig === '.' || orig === ':') return orig; return csGlyph(model, x, y); } /* divisors of N (>=4 preferred) so motifs are longer / less obviously tiled */ function motifLength(N, r){ const all = []; for(let d = 1; d <= N; d++) if(N % d === 0) all.push(d); const longish = all.filter(d => d >= 4); return pick(longish.length ? longish : all, r); } function buildC64Token(seedStr){ const r = makeRng(seedStr); const N = pickN(r); const bandCount = 3 + Math.floor(r() * 4); // 3..6 const weights = Array.from({ length: bandCount }, () => 0.4 + r()); const wsum = weights.reduce((a, b) => a + b, 0); let rows = weights.map(w => Math.max(1, Math.round(w / wsum * N))); let diff = N - rows.reduce((a, b) => a + b, 0); for(let i = 0; diff !== 0; i = (i + 1) % bandCount){ if(diff > 0){ rows[i]++; diff--; } else if(rows[i] > 1){ rows[i]--; diff++; } } const bands = []; let y = 0; for(let b = 0; b < bandCount; b++){ const bg = pick(DARKS, r); let fg = pick(BRIGHTS, r); if(fg === bg) fg = 1; // a small alphabet (2-3 glyphs) arranged into a longer motif = less tiling const alpha = Array.from({ length: 2 + Math.floor(r() * 2) }, () => pickGlyph(r)); const L = motifLength(N, r); const motif = Array.from({ length: L }, () => pick(alpha, r)); const motion = pick(MOTIONS, r); const dir = r() < 0.5 ? 1 : -1; const speedMul = r() < 0.7 ? 1 : 2; // mostly slow (1 lap) con