0xa2c3bdc9…c101sent to0x3a6e049b…7741·#25,827,689·view on Etherscan
/* PXL BITS — on-chain generative pixel-monster artwork + game. VANILLA CANVAS (no libraries). Deterministic from tokenData.hash. Paste into the Artsunami editor. */
/* =====================================================================
PXL GRID - CREATURE GRID
---------------------------------------------------------------------
A 256 x 512 (portrait 1:2) native canvas, divided into cases (cells).
Each case holds one generative abstract pixel-art character that plays
a seamless looping animation.
Determinism contract (unchanged from the base engine)
- (hash) -> grid. Same hash, same 32/8/128 creatures, same colours.
- Every creature is frozen in buildCreature(); paintCreature() reads
only the frozen spec + a time value, so animation never touches the
RNG. Playback is stable and reproducible.
- Seamless global loop: every creature period divides MASTER_SECONDS,
so the whole board returns to its exact start every loop.
- Backend agnostic: writes into the same abstract FB used by the base
engine, so a Node verifier can rasterise identical frames to PNG.
Native resolution: 256 x 512. Art block: 4 native px (chunky pixels).
===================================================================== */
const E = (function () {
"use strict";
/* ---------- native canvas + chunk size ---------- */
const CW = 256, CH = 512; // native pixels
const BLK = 4; // one "art pixel" = 4x4 native px
const MASTER_SECONDS = 4; // global loop length
/* ---------- neon palette (locked to the reference sheets) ---------- */
const NEON = {
blue: "#1f2bff",
green: "#00ff2f",
pink: "#ff17c6",
yellow: "#ffe600",
white: "#ffffff",
cyan: "#00e5ff",
orange: "#ff7a00",
red: "#ff2740",
dblue: "#0b1280",
bg: "#05060f",
};
const BODY_POOL = [
["green", 5], ["pink", 5], ["cyan", 4], ["yellow", 4],
["orange", 3], ["red", 3], ["blue", 3], ["white", 4],
];
/* =====================================================================
1. DETERMINISTIC RNG (xmur3 -> sfc32) [reused, proven]
===================================================================== */
function xmur3(str) {
let h = 1779033703 ^ str.length;
for (let i = 0; i < str.length; i++) {
h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
return function () {
h = Math.imul(h ^ (h >>> 16), 2246822507);
h = Math.imul(h ^ (h >>> 13), 3266489909);
h ^= h >>> 16;
return h >>> 0;
};
}
function sfc32(a, b, c, d) {
return function () {
a >>>= 0; b >>>= 0; c >>>= 0; d >>>= 0;
let t = (a + b) | 0;
a = b ^ (b >>> 9);
b = (c + (c << 3)) | 0;
c = (c << 21) | (c >>> 11);
d = (d + 1) | 0;
t = (t + d) | 0;
c = (c + t) | 0;
return (t >>> 0) / 4294967296;
};
}
function makeRng(hash) {
const seed = xmur3(String(hash));
const rand = sfc32(seed(), seed(), seed(), seed());
for (let i = 0; i < 15; i++) rand();
return {
f: rand,
int: (a, b) => a + Math.floor(rand() * (b - a + 1)),
range: (a, b) => a + rand() * (b - a),
bool: (p = 0.5) => rand() < p,
pick: (arr) => arr[Math.floor(rand() * arr.length)],
weighted: (pairs) => {
let tot = 0; for (const p of pairs) tot += p[1];
let x = rand() * tot;
for (const p of pairs) { if ((x -= p[1]) < 0) return p[0]; }
return pairs[pairs.length - 1][0];
},
shuffle: (arr) => {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
},
};
}
function makeHash(seed) {
const alphabet = "123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";
let base;
if (seed == null) {
base = (typeof crypto !== "undefined" && crypto.getRandomValues)
? Array.from(crypto.getRandomValues(new Uint32Array(8))).join("")
: (Date.now() + "" + Math.random());
} else base = String(seed);
const gen = xmur3(base + "|fx");
let out = "oo";
for (let i = 0; i < 49; i++) out += alphabet[gen() % alphabet.length];
return out;
}
/* =====================================================================
2. FRAMEBUFFER (abstract raster) [reused, proven]
===================================================================== */
class FB {
constructor(w = CW, h = CH) { this.w = w; this.h = h; this.d = new Array(w * h).fill(null); }
clear(c = null) { this.d.fill(c); }
inside(x, y) { return x >= 0 && x < this.w && y >= 0 && y < this.h; }
set(x, y, c) { x |= 0; y |= 0; if (this.inside(x, y) && c) this.d[y * this.w + x] = c; }
get(x, y) { x |= 0; y |= 0; return this.inside(x, y) ? this.d[y * this.w + x] : null; }
hline(x, y, len, c) { for (let i = 0; i < len; i++) this.set(x + i, y, c); }
vline(x, y, len, c) { for (let i = 0; i < len; i++) this.set(x, y + i, c); }
fill(x, y, w, h, c) { for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) this.set(x + i, y + j, c); }
// one art pixel = BLK x BLK native block, snapped to the art grid
blk(ax, ay, c) { this.fill(ax * BLK, ay * BLK, BLK, BLK, c); }
}
/* =====================================================================
3. MASK HELPERS (build a creature silhouette on a small art grid)
===================================================================== */
function Mask(w, h) { return { w, h, d: new Uint8Array(w * h) }; }
function mset(m, x, y, v = 1) { if (x >= 0 && x < m.w && y >= 0 && y < m.h) m.d[y * m.w + x] = v; }
function mget(m, x, y) { return (x >= 0 && x < m.w && y >= 0 && y < m.h) ? m.d[y * m.w + x] : 0; }
function ellipseMask(m, cx, cy, rx, ry) {
for (let y = 0; y < m.h; y++) for (let x = 0; x < m.w; x++) {
const dx = (x - cx) / (rx + 0.0001), dy = (y - cy) / (ry + 0.0001);
if (dx * dx + dy * dy <= 1) mset(m, x, y, 1);
}
}
function rectMask(m, x0, y0, w, h) {
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) mset(m, x0 + x, y0 + y, 1);
}
function roundRectMask(m, x0, y0, w, h, r) {
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
let on = true;
// knock out the four corners with a quarter-circle test
const corners = [[x0 + r - 1, y0 + r - 1, x, y, -1, -1],
[x0 + w - r, y0 + r - 1, x, y, 1, -1],
[x0 + r - 1, y0 + h - r, x, y, -1, 1],
[x0 + w - r, y0 + h - r, x, y, 1, 1]];
const gx = x0 + x, gy = y0 + y;
if (x < r && y < r) { const dx = r - 1 - x, dy = r - 1 - y; if (dx * dx + dy * dy > r * r) on = false; }
else if (x >= w - r && y < r) { const dx = x - (w - r), dy = r - 1 - y; if (dx * dx + dy * dy > r * r) on = false; }
else if (x < r && y >= h - r) { const dx = r - 1 - x, dy = y - (h - r); if (dx * dx + dy * dy > r * r) on = false; }
else if (x >= w - r && y >= h - r) { const dx = x - (w - r), dy = y - (h - r); if (dx * dx + dy * dy > r * r) on = false; }
if (on) mset(m, gx, gy, 1);
void corners;
}
}
function diamondMask(m, cx, cy, rx, ry) {
for (let y = 0; y < m.h; y++) for (let x = 0; x < m.w; x++) {
if (Math.abs(x - cx) / (rx + 0.0001) + Math.abs(y - cy) / (ry + 0.0001) <= 1) mset(m, x, y, 1);
}
}
// per-row half widths for a bell / trapezoid, mirrored for symmetry
function columnMask(m, cx, y0, h, topHalf, botHalf) {
for (let j = 0; j < h; j++) {
const t = h <= 1 ? 0 : j / (h - 1);
const half = Math.round(topHalf + (botHalf - topHalf) * t);
for (let x = cx - half; x <= cx + half; x++) mset(m, x, y0 + j, 1);
}
}
function hexMask(m, cx, cy, rx, ry) {
for (let y = 0; y < m.h; y++) {
const t = Math.abs(y - cy) / (ry + 0.0001);
if (t > 1) continue;
const half = t < 0.5 ? rx : Math.round(rx * (1 - (t - 0.5) / 0.5 * 0.85));
for (let x = cx - half; x <= cx + half; x++) mset(m, x, y, 1);
}
}
function crossMask(m, cx, cy, rx, ry, arm) {
for (let y = cy - ry; y <= cy + ry; y++) for (let x = cx - arm; x <= cx + arm; x++) mset(m, x, y, 1);
for (let x = cx - rx; x <= cx + rx; x++) for (let y = cy - arm; y <= cy + arm; y++) mset(m, x, y, 1);
}
function sparkleMask(m, cx, cy, rx, ry) {
// 4-point star: tapered vertical + horizontal spikes + centre diamond
for (let y = cy - ry; y <= cy + ry; y++) { const half = Math.max(0, Math.round((1 - Math.abs(y - cy) / (ry + 0.0001)) * 1)); for (let x = cx - half; x <= cx + half; x++) mset(m, x, y, 1); }
for (let x = cx - rx; x <= cx + rx; x++) { const half = Math.max(0, Math.round((1 - Math.abs(x - cx) / (rx + 0.0001)) * 1)); for (let y = cy - half; y <= cy + half; y++) mset(m, x, y, 1); }
diamondMask(m, cx, cy, Math.max(1, rx * 0.4), Math.max(1, ry * 0.4));
}
// outline = filled cell that touches empty/edge on a 4-neighbour
function outlineOf(m) {
const out = [];
for (let y = 0; y < m.h; y++) for (let x = 0; x < m.w; x++) {
if (!mget(m, x, y)) continue;
if (!mget(m, x - 1, y) || !mget(m, x + 1, y) || !mget(m, x, y - 1) || !mget(m, x, y + 1)) out.push([x, y]);
}
return out;
}
function fillPixels(m) {
const px = [];
for (let y = 0; y < m.h; y++) for (let x = 0; x < m.w; x++) if (mget(m, x, y)) px.push([x, y]);
return px;
}
function bounds(m) {
let x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9;
for (let y = 0; y < m.h; y++) for (let x = 0; x < m.w; x++) if (mget(m, x, y)) {
if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y;
}
if (x1 < x0) return { x0: 0, y0: 0, w: 0, h: 0 };
return { x0, y0, w: x1 - x0 + 1, h: y1 - y0 + 1 };
}
/* =====================================================================
4. COLOUR UTILITIES
===================================================================== */
function hexToRgb(hx) { return [parseInt(hx.slice(1, 3), 16), parseInt(hx.slice(3, 5), 16), parseInt(hx.slice(5, 7), 16)]; }
function rgbToHex(r, g, b) {
const h = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0");
return "#" + h(r) + h(g) + h(b);
}
function shade(hx, f) { const [r, g, b] = hexToRgb(hx); return f >= 0 ? rgbToHex(r + (255 - r) * f, g + (255 - g) * f, b + (255 - b) * f) : rgbToHex(r * (1 + f), g * (1 + f), b * (1 + f)); }
function luminance(hx) { const [r, g, b] = hexToRgb(hx); return (0.299 * r + 0.587 * g + 0.114 * b) / 255; }
/* =====================================================================
5. CREATURE GENERATOR (abstract character, frozen form)
---------------------------------------------------------------------
cw, ch = art-grid size of the cell (e.g. 16x16 for a 64px cell).
Returns a spec whose paint cost is a few short pixel loops.
===================================================================== */
const PERIODS = [MASTER_SECONDS / 2, MASTER_SECONDS / 4, MASTER_SECONDS / 8, MASTER_SECONDS / 4];
const BODY_TEMPLATES = ["blob", "orb", "bell", "tower", "ghost", "diamond", "bug", "drop"];
// shared rhythmic hop: every creature pops on the 8th-note grid (8 per loop)
const HOP_SECONDS = MASTER_SECONDS / 8;
function hopEnv(t) { const p = (((t / HOP_SECONDS) % 1) + 1) % 1; return Math.exp(-p * 5); }
/* ---- gameplay bonuses (only ever assigned on respawn, never in the frozen art) ---- */
const BONUS = {
bomb: { col: "#ff7a00", icon: ["010", "111", "111"] }, // clears row + column
gold: { col: "#ffe600", icon: ["111", "101", "111"] }, // extra BITS
time: { col: "#00e5ff", icon: ["010", "111", "010"] }, // +time
x2: { col: "#ff17c6", icon: ["101", "010", "101"] }, // score x2 for a few seconds
rainbow: { col: "#ffffff", icon: ["111", "111", "111"] }, // clears every monster of its colour
};
const BONUS_KEYS = ["bomb", "gold", "time", "x2", "rainbow"];
function randomBonus() { return BONUS_KEYS[(Math.random() * BONUS_KEYS.length) | 0]; }
function buildCreature(R, cw, ch, theme) {
const margin = 1; // keep off the case border
const aw = cw - margin * 2; // available art width
const ah = ch - margin * 2;
// ----- template + optional face type -----
const tmpl = R.weighted([
["blob", 5], ["orb", 4], ["bell", 3], ["tower", 3],
["ghost", 4], ["diamond", 3], ["bug", 3], ["drop", 3],
["mushroom", 3], ["capsule", 3], ["hex", 3], ["cross", 2],
["stack", 2], ["crystal", 2], ["slime", 3], ["star", 2],
["skull", 4], ["alien", 4],
]);
const face = (tmpl === "skull" || tmpl === "alien") ? tmpl : null;
// ----- palette : OUTLINE style (1px neon stroke on black, like the sheets) -----
const bodyName = R.pick(theme.bodies);
let body = NEON[bodyName]; // 1px stroke colour (hollow body)
if (face === "skull") body = NEON.white; // white skull outline
else if (face === "alien") body = R.pick([NEON.white, NEON.green, NEON.cyan]);
let feat = NEON[R.pick(theme.accents)]; // eyes / mouth neon
if (feat === body) feat = (body === NEON.white) ? NEON.cyan : NEON.white;
const eyeCol = face === "skull" ? NEON.yellow : feat; // skull: yellow eye holes like the sheets
const mouthCol = feat;
const outline = body; // arms / feet / antennae render in the stroke colour
const glow = shade(body, -0.4); // dim halo for the pulse expression
const cellBg = NEON.bg; // cases are always black, never tinted
// ----- silhouette -----
const m = Mask(cw, ch);
// pick a body box that leaves head-room for antennae and feet
const bw = Math.max(3, Math.min(aw, R.int(Math.ceil(aw * 0.55), aw)));
const bh = Math.max(3, Math.min(ah - 2, R.int(Math.ceil(ah * 0.55), ah - 2)));
const cx = (cw - 1) / 2;
// centre the body vertically, with a small deterministic jitter
const slack = Math.max(0, ah - bh);
const topY = margin + Math.max(0, Math.min(slack, Math.round(slack / 2) + R.int(-1, 1)));
const cyBody = topY + bh / 2;
switch (tmpl) {
case "orb": ellipseMask(m, cx, cyBody, bw / 2, bh / 2); break;
case "diamond": diamondMask(m, cx, cyBody, bw / 2, bh / 2); break;
case "bell": columnMask(m, Math.round(cx), topY, bh, Math.max(1, Math.round(bw * 0.28)), Math.round(bw / 2)); break;
case "tower": roundRectMask(m, Math.round(cx - bw / 2), topY, bw, bh, Math.min(3, Math.floor(bw / 2))); break;
case "drop": { ellipseMask(m, cx, cyBody + bh * 0.12, bw / 2, bh * 0.42); columnMask(m, Math.round(cx), topY, Math.round(bh * 0.55), 1, Math.round(bw / 2)); break; }
case "ghost": {
ellipseMask(m, cx, topY + bw / 2, bw / 2, bw / 2);
rectMask(m, Math.round(cx - bw / 2), Math.round(topY + bw / 2), bw, Math.round(bh - bw / 2));
const by = topY + bh - 1;
for (let x = 0; x < bw; x++) if (x % 2 === 0) mset(m, Math.round(cx - bw / 2) + x, by, 0);
break;
}
case "bug": ellipseMask(m, cx, cyBody, bw / 2, bh / 2.4); break;
case "mushroom": {
const capH = Math.max(2, Math.round(bh * 0.5));
ellipseMask(m, cx, topY + capH, bw / 2, capH);
// keep only the dome (flat under the cap), then add a centred stem
for (let y = topY + capH; y < ch; y++) for (let x = 0; x < cw; x++) mset(m, x, y, 0);
const stemW = Math.max(1, Math.round(bw * 0.32));
rectMask(m, Math.round(cx - stemW / 2), topY + capH, stemW, Math.max(2, bh - capH));
break;
}
case "capsule": roundRectMask(m, Math.round(cx - bw / 2), topY, bw, bh, Math.floor(Math.min(bw, bh) / 2)); break;
case "hex": hexMask(m, cx, cyBody, bw / 2, bh / 2); break;
case "cross": crossMask(m, Math.round(cx), Math.round(cyBody), Math.round(bw / 2), Math.round(bh / 2), Math.max(1, Math.round(bw * 0.22))); break;
case "stack": {
const gap = 1, hEach = Math.max(2, Math.round((bh - gap) / 2));
const wTop = Math.max(3, Math.round(bw * 0.7));
roundRectMask(m, Math.round(cx - wTop / 2), topY, wTop, hEach, 1);
roundRectMask(m, Math.round(cx - bw / 2), topY + hEach + gap, bw, hEach, 1);
break;
}
case "crystal": diamondMask(m, cx, cyBody, Math.max(2, bw / 2.6), bh / 2); break;
case "slime": {
ellipseMask(m, cx, topY + bh, bw / 2, bh); // dome
for (let y = topY + bh; y < ch; y++) for (let x = 0; x < cw; x++) mset(m, x, y, 0); // flat bottom
for (let y = 0; y < topY; y++) for (let x = 0; x < cw; x++) mset(m, x, y, 0);
break;
}
case "star": sparkleMask(m, Math.round(cx), Math.round(cyBody), Math.round(bw / 2), Math.round(bh / 2)); break;
case "skull": {
const cranH = Math.max(3, Math.round(bh * 0.62));
roundRectMask(m, Math.round(cx - bw / 2), topY, bw, cranH, Math.min(4, Math.floor(bw / 3)));
const jawW = Math.max(3, Math.round(bw * 0.6)), jawH = Math.max(2, bh - cranH);
roundRectMask(m, Math.round(cx - jawW / 2), topY + cranH, jawW, jawH, 1);
break;
}
case "alien": {
const crown = Math.max(2, Math.round(bw * 0.42));
ellipseMask(m, cx, topY + crown, bw / 2, crown); // rounded crown
columnMask(m, Math.round(cx), topY + crown, Math.max(2, bh - crown), Math.round(bw / 2), 1); // taper to a chin point
break;
}
default: roundRectMask(m, Math.round(cx - bw / 2), topY, bw, bh, Math.min(3, Math.floor(Math.min(bw, bh) / 3)));
}
let bb = bounds(m);
let fillPx = fillPixels(m);
let outPx = outlineOf(m);
if (fillPx.length === 0) { // safety: never leave a case empty
roundRectMask(m, Math.round(cx - bw / 2), topY, bw, bh, 2);
bb = bounds(m); fillPx = fillPixels(m); outPx = outlineOf(m);
}
// ----- eyes + mouth (always present) -----
let eyes = [], mouth = null, bigEyes = false;
if (face === "skull") {
const s = Math.max(1, Math.round(bb.w * 0.22));
const ey = Math.round(bb.y0 + bb.h * 0.34);
eyes = [{ x: Math.round(cx - s), y: ey }, { x: Math.round(cx + s), y: ey }];
const mwid = Math.max(2, Math.round(bb.w * 0.32));
mouth = { y: Math.round(bb.y0 + bb.h * 0.76), x0: Math.round(cx - mwid), x1: Math.round(cx + mwid), smile: false };
} else if (face === "alien") {
const s = Math.max(2, Math.round(bb.w * 0.24));
const ey = Math.round(bb.y0 + bb.h * 0.32);
eyes = [{ x: Math.round(cx - s), y: ey }, { x: Math.round(cx + s), y: ey }];
const mwid = Math.max(1, Math.round(bb.w * 0.16));
mouth = { y: Math.round(bb.y0 + bb.h * 0.64), x0: Math.round(cx - mwid), x1: Math.round(cx + mwid), smile: false };
} else {
const nEyes = R.weighted([[2, 7], [1, 1], [3, 1]]);
const eyeRowY = Math.round(bb.y0 + bb.h * R.range(0.28, 0.44));
if (nEyes === 1) eyes.push({ x: Math.round(cx), y: eyeRowY });
else if (nEyes === 2) { const s = Math.max(1, Math.round(bb.w * 0.2)); eyes.push({ x: Math.round(cx - s), y: eyeRowY }, { x: Math.round(cx + s), y: eyeRowY }); }
else { const s = Math.max(1, Math.round(bb.w * 0.24)); eyes.push({ x: Math.round(cx - s), y: eyeRowY }, { x: Math.round(cx), y: eyeRowY }, { x: Math.round(cx + s), y: eyeRowY }); }
bigEyes = bb.w >= 12;
const my = Math.min(bb.y0 + bb.h - 2, eyeRowY + Math.max(2, Math.round(bb.h * 0.22)));
const mw = Math.max(1, Math.round(bb.w * R.range(0.22, 0.4)));
mouth = { y: my, x0: Math.round(cx - mw), x1: Math.round(cx + mw), smile: R.bool(0.7) };
}
// ----- antennae (top) -----
const antennae = [];
if (!face && R.bool(0.5)) {
const h = R.int(1, Math.max(1, Math.min(3, topY - margin + 1)));
if (h >= 1) {
const off = R.bool(0.6) ? Math.max(1, Math.round(bb.w * 0.22)) : 0;
const list = off ? [-off, off] : [0];
for (const o of list) antennae.push({ x: Math.round(cx + o), baseY: bb.y0, h, tip: feat });
}
}
// ----- feet (bottom) -----
const feet = [];
if (!face && R.bool(0.55) && bb.y0 + bb.h + 1 < ch) {
const s = Math.max(1, Math.round(bb.w * 0.22));
feet.push({ x: Math.round(cx - s), y: bb.y0 + bb.h }, { x: Math.round(cx + s), y: bb.y0 + bb.h });
}
// ----- arms (sides) -----
const arms = [];
if (!face && R.bool(0.4)) {
const ay = Math.round(bb.y0 + bb.h * 0.55);
arms.push({ x: bb.x0 - 1, y: ay, side: -1 }, { x: bb.x0 + bb.w, y: ay, side: 1 });
}
// ----- animation channels (all periods divide MASTER) -----
// drift = travels along a direction (up-down, down-up, left-right, right-left, diagonals)
const DIRS = [[0, -1], [0, 1], [-1, 0], [1, 0], [1, 1], [-1, 1], [1, -1], [-1, -1]];
const motion = R.weighted([
["drift", 8], ["float", 2], ["burst", 3], ["wiggle", antennae.length ? 3 : 0],
["march", feet.length ? 3 : 0], ["wave", arms.length ? 3 : 0],
]);
const anim = {
motion,
dir: R.pick(DIRS),
period: R.pick(PERIODS),
off: R.range(0, 1),
amp: R.int(1, 2),
};
// pixel explosion: 1px particles fly out from the centre, loop-safe (frozen here)
if (motion === "burst") {
const N = R.int(9, 15), reach = Math.max(4, Math.round(Math.max(bb.w, bb.h) * 0.95));
anim.burst = [];
for (let i = 0; i < N; i++) anim.burst.push({
ang: (i / N) * Math.PI * 2 + R.range(-0.35, 0.35),
maxR: R.int(3, reach),
off: R.range(0, 0.18),
col: R.bool(0.5) ? body : feat,
});
}
// ----- expression (eyes + mouth), loop-safe periods, features stay visible -----
const EXPR_PERIODS = [MASTER_SECONDS, MASTER_SECONDS / 2];
anim.eyeExpr = face === "skull" ? "still"
: face === "alien" ? R.pick(["blink", "look", "still"])
: R.pick(["blink", "look", "squint", "wink", "still"]);
anim.mouthExpr = face === "skull" ? "chatter"
: face === "alien" ? R.pick(["still", "open"])
: R.pick(["talk", "smileshift", "open", "still"]);
anim.exprPeriod = R.pick(EXPR_PERIODS);
anim.exprOff = R.range(0, 1);
const hopAmt = cw <= 8 ? 1 : 2; // dense cells hop less
// ----- background: always plain black (no patterns, no tint) -----
const back = { type: "plain", col: NEON.bg, items: [], anim: false, period: PERIODS[0], off: 0 };
const rarity = R.weighted([["common", 70], ["rare", 20], ["epic", 8], ["legendary", 2]]);
return {
cw, ch, bb, fillPx, outPx, eyes, bigEyes, mouth, antennae, feet, arms, back, tmpl, hopAmt, face, bonus: null, rarity,
colors: { body, outline, stroke: body, feat, eye: eyeCol, mouthCol, glow, bg: cellBg },
anim,
};
}
/* =====================================================================
6. GRID SPEC (choose layout + theme, build one creature per case)
===================================================================== */
/* Fixed grid for the whole collection: same cases for every piece. */
const FIXED = { cols: 4, rows: 8 };
function buildGrid(hash) {
const R = makeRng(hash);
const cols = FIXED.cols, rows = FIXED.rows;
const cellW = CW / cols, cellH = CH / rows; // native px per cell
const caw = Math.floor(cellW / BLK), cah = Math.floor(cellH / BLK); // art px per cell
// cohesive theme: a small shared palette for the whole board
const bodies = R.shuffle(BODY_POOL.map((p) => p[0])).slice(0, R.int(3, 5));
const accents = R.shuffle(["white", "green", "pink", "yellow", "cyan", "orange", "red", "blue"]).slice(0, 4);
const theme = { bodies, accents };
const border = R.weighted([["blu