0x46cd289b…3484sent to0xa184990f…4d6d·#25,288,728·view on Etherscan
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