memoscan
← all memos

Memo 0x1cb4394a…d97e4c on Ethereum

/* 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