0xa2c3bdc9…c101sent to0x3a6e049b…7741·#25,827,692·view on Etherscan
e", 6], ["dblue", 3]]); // always a visible frame
const frameNative = caw >= 12 ? BLK : 1;
const cells = [];
const n = cols * rows;
for (let i = 0; i < n; i++) {
const cr = makeRng(String(hash) + ":" + i); // independent, reproducible per case
const creature = buildCreature(cr, caw, cah, theme);
const col = i % cols, row = (i / cols) | 0;
cells.push({ i, col, row, ox: col * caw, oy: row * cah, creature });
}
return { hash, cols, rows, caw, cah, cells, border, frameNative, theme };
}
/* =====================================================================
7. PAINT (reads spec + time only; seamless loop)
===================================================================== */
function phase(t, period, off) { let p = (t / period + off) % 1; if (p < 0) p += 1; return p; }
const TAU = Math.PI * 2;
function paintBack(fb, ox, oy, cr, t) {
const b = cr.back; if (b.type === "plain") return;
const put = (x, y) => { if (x >= 1 && x < cr.cw - 1 && y >= 1 && y < cr.ch - 1) fb.blk(ox + x, oy + y, b.col); };
if (b.type === "scan") {
const shift = b.anim ? Math.floor(phase(t, b.period, b.off) * 3) : 0;
for (let y = 1 + ((shift) % 3); y < cr.ch - 1; y += 3) for (let x = 1; x < cr.cw - 1; x += 2) put(x, y);
} else if (b.type === "dots") {
for (let y = 2; y < cr.ch - 1; y += 3) for (let x = 2; x < cr.cw - 1; x += 3) put(x, y);
} else if (b.type === "bricks") {
for (let y = 2; y < cr.ch - 1; y += 3) { for (let x = 1; x < cr.cw - 1; x++) put(x, y); }
for (let y = 2; y < cr.ch - 1; y += 3) { const o = ((y / 3) | 0) % 2 ? 2 : 0; for (let x = 1 + o; x < cr.cw - 1; x += 4) for (let k = 1; k < 3 && y + k < cr.ch - 1; k++) put(x, y + k); }
} else if (b.type === "stars") {
for (const s of b.items) {
const on = !s.tw || phase(t, b.period, s.ph) > 0.5;
if (on) put(s.x, s.y);
}
}
}
function paintCreature(fb, ox, oy, cr, t) {
const C = cr.colors, A = cr.anim, bb = cr.bb;
paintBack(fb, ox, oy, cr, t);
// ----- motion -----
let dy = 0, dx = 0, shadow = 0;
const pm = phase(t, A.period, A.off);
const s = Math.sin(pm * TAU);
if (A.motion === "drift") { const d = A.dir || [0, -1]; dx = Math.round(A.amp * s * d[0]); dy = Math.round(A.amp * s * d[1]); }
else if (A.motion === "float") { dy = Math.round(A.amp * s); shadow = 1; }
// wiggle / march / wave / burst handled at their parts below
// shared rhythmic hop: lifts every creature on the beat
dy -= Math.round((cr.hopAmt || 1) * hopEnv(t));
const put = (x, y, c) => fb.blk(ox + x + dx, oy + y + dy, c);
// shadow under a floating creature (width shrinks as it rises)
if (shadow) {
const fy = bb.y0 + bb.h + 1;
const half = Math.max(1, Math.round(bb.w * 0.3) - (dy < 0 ? 1 : 0));
for (let x = -half; x <= half; x++) fb.blk(ox + Math.round((cr.cw - 1) / 2) + x, oy + fy, C.bg === NEON.bg ? "#0a0c1a" : shade(C.bg, 0.25));
}
// ----- feet (march alternates) -----
if (cr.feet.length) {
const marchP = phase(t, A.period, A.off);
const up = Math.sin(marchP * TAU) > 0 ? 0 : 1;
cr.feet.forEach((f, idx) => {
const lift = A.motion === "march" ? ((idx % 2 === 0) === (up === 0) ? -1 : 0) : 0;
put(f.x, f.y + lift, C.outline);
});
}
// ----- body: 1px outline only (hollow, like the sheets) -----
for (const p of cr.outPx) put(p[0], p[1], C.stroke);
// ----- pixel explosion: single 1px pixels fly out from the centre, loop-safe -----
if (A.motion === "burst" && A.burst) {
const cxp = (cr.cw - 1) / 2, cyp = bb.y0 + bb.h / 2;
for (const pt of A.burst) {
const p = phase(t, A.period, (A.off + pt.off) % 1);
const r = p * pt.maxR;
const x = Math.round(cxp + Math.cos(pt.ang) * r), y = Math.round(cyp + Math.sin(pt.ang) * r);
if (x >= 1 && x < cr.cw - 1 && y >= 1 && y < cr.ch - 1) put(x, y, pt.col); // 1px, clipped inside the case
}
}
// ----- arms (wave alternates) -----
if (cr.arms.length) {
const wp = phase(t, A.period, A.off);
cr.arms.forEach((a, idx) => {
const lift = A.motion === "wave" ? Math.round(Math.sin((wp + idx * 0.5) * TAU)) : 0;
put(a.x, a.y + lift, C.body);
put(a.x, a.y + lift, C.body);
});
}
// ----- antennae (wiggle sways the tip) -----
for (const an of cr.antennae) {
const wp = phase(t, A.period, A.off);
const sway = A.motion === "wiggle" ? Math.round(Math.sin(wp * TAU)) : 0;
for (let k = 1; k <= an.h; k++) put(an.x, an.baseY - k, C.outline);
put(an.x + sway, an.baseY - an.h - 1, an.tip);
}
// ----- face features (with expression, features always stay visible) -----
const ep = phase(t, A.exprPeriod || MASTER_SECONDS, A.exprOff || 0);
if (cr.face === "skull") {
for (const e of cr.eyes) for (let ax = -1; ax <= 0; ax++) for (let ay = 0; ay <= 1; ay++) put(e.x + ax, e.y + ay, C.eye); // yellow eye holes
put(Math.round((cr.cw - 1) / 2), cr.eyes[0].y + 2, C.stroke); // nose
const mo = cr.mouth, open = hopEnv(t) > 0.5 ? 1 : 0; // jaw chatters on the beat
for (let x = mo.x0; x <= mo.x1; x += 2) put(x, mo.y + open, C.stroke); // teeth marks
} else if (cr.face === "alien") {
const blink = A.eyeExpr === "blink" && ep > 0.9;
const lookX = A.eyeExpr === "look" ? Math.round(Math.cos(ep * TAU)) : 0;
for (const e of cr.eyes) {
const dir = e.x < (cr.cw - 1) / 2 ? 1 : -1;
if (blink) { for (let dx2 = -1; dx2 <= 1; dx2++) put(e.x + dx2, e.y, C.eye); } // lid line (still visible)
else {
const bx = e.x + lookX;
for (let ay = -1; ay <= 1; ay++) { put(bx, e.y + ay, C.eye); put(bx - dir, e.y + ay, C.eye); }
put(bx - dir, e.y - 1, C.eye);
put(bx, e.y - 1, "#ffffff"); // glint
}
}
const mo = cr.mouth, open = A.mouthExpr === "open" && Math.sin(ep * TAU) > 0.4 ? 1 : 0;
for (let x = mo.x0; x <= mo.x1; x++) { put(x, mo.y, C.mouthCol); if (open) put(x, mo.y + 1, C.mouthCol); }
} else {
// ----- generic eyes with expression -----
const blink = A.eyeExpr === "blink" && ep > 0.9;
const squint = A.eyeExpr === "squint" && ((ep % 0.5) > 0.32);
const lookX = A.eyeExpr === "look" ? Math.round(Math.cos(ep * TAU)) : 0;
const lookY = A.eyeExpr === "look" ? Math.round(Math.sin(ep * TAU) * 0.6) : 0;
cr.eyes.forEach((e, idx) => {
const wink = A.eyeExpr === "wink" && idx === cr.eyes.length - 1 && ep > 0.85;
if (blink || squint || wink) { put(e.x - 1, e.y, C.eye); put(e.x, e.y, C.eye); } // closed dash (visible)
else { put(e.x + lookX, e.y + lookY, C.eye); if (cr.bigEyes) put(e.x + lookX, e.y - 1 + lookY, "#ffffff"); }
});
// ----- generic mouth with expression -----
const mo = cr.mouth, mid = (mo.x0 + mo.x1) / 2, halfW = Math.max(1, (mo.x1 - mo.x0) / 2);
let cs = mo.smile ? 1 : -1, open = 0;
if (A.mouthExpr === "smileshift") cs = Math.sin(ep * TAU); // smile <-> frown
else if (A.mouthExpr === "talk") open = hopEnv(t) > 0.5 ? 1 : 0; // opens on the beat
else if (A.mouthExpr === "open") open = Math.sin(ep * TAU) > 0.4 ? 1 : 0;
for (let x = mo.x0; x <= mo.x1; x++) {
const md = Math.abs(x - mid) / halfW;
put(x, mo.y - Math.round((1 - md) * cs), C.mouthCol);
if (open) put(x, mo.y + 1, C.mouthCol); // open mouth (second row)
}
}
// bonus badge (static, top-left corner of the case)
if (cr.bonus && BONUS[cr.bonus]) {
const bc = BONUS[cr.bonus], ic = bc.icon;
for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) if (ic[r][c] === "1") fb.blk(ox + 1 + c, oy + 1 + r, bc.col);
}
// rarity pips (static, top-right corner) — common shows none, keeping the art clean
if (cr.rarity && cr.rarity !== "common") {
const pips = cr.rarity === "rare" ? 1 : cr.rarity === "epic" ? 2 : 3;
const rc = cr.rarity === "rare" ? "#00e5ff" : cr.rarity === "epic" ? "#ff17c6" : "#ffe600";
for (let p = 0; p < pips; p++) fb.blk(ox + cr.cw - 2 - p * 2, oy + 1, rc);
}
}
/* interactive explosion: shatter a touched monster into 1px pixels, then respawn */
const FX_DUR = 0.55; // seconds
function explode(grid, idx, now) {
const cell = grid.cells[idx]; if (!cell || cell.fx) return;
const cr = cell.creature;
const cxp = (cr.cw - 1) / 2, cyp = cr.bb.y0 + cr.bb.h / 2;
const parts = [];
for (const p of cr.outPx) {
const ang = Math.atan2(p[1] - cyp, p[0] - cxp) + (Math.random() - 0.5) * 0.7;
const spd = 6 + Math.random() * 12; // art px / second
parts.push({ x: p[0], y: p[1], vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd - 3, col: cr.colors.stroke });
}
cell.fx = { start: now, parts };
return true;
}
/* match-style chain: exploding a monster also clears a contiguous same-colour
run of 3+ on its row and/or its column, then each cell respawns at random.
Returns how many cells were triggered. No score, just a satisfying pop. */
function explodeMatch(grid, idx, now) {
const cell = grid.cells[idx]; if (!cell) return 0;
const cols = grid.cols, rows = grid.rows;
const color = cell.creature.colors.stroke;
const r0 = (idx / cols) | 0, c0 = idx % cols;
const same = (i) => grid.cells[i] && grid.cells[i].creature.colors.stroke === color;
const hit = new Set([idx]);
const runH = [idx];
for (let c = c0 - 1; c >= 0 && same(r0 * cols + c); c--) runH.unshift(r0 * cols + c);
for (let c = c0 + 1; c < cols && same(r0 * cols + c); c++) runH.push(r0 * cols + c);
if (runH.length >= 3) runH.forEach((i) => hit.add(i));
const runV = [idx];
for (let r = r0 - 1; r >= 0 && same(r * cols + c0); r--) runV.unshift(r * cols + c0);
for (let r = r0 + 1; r < rows && same(r * cols + c0); r++) runV.push(r * cols + c0);
if (runV.length >= 3) runV.forEach((i) => hit.add(i));
let out = [];
for (const i of hit) if (explode(grid, i, now)) out.push(i);
return out;
}
function paint(fb, grid, t, now) {
if (now == null) now = t;
fb.clear(NEON.bg);
for (const cell of grid.cells) {
if (cell.fx) {
const e = now - cell.fx.start;
if (e >= FX_DUR) { // explosion done -> respawn a fresh monster
cell._resp = (cell._resp || 0) + 1;
cell.creature = buildCreature(makeRng(String(grid.hash) + ":" + cell.i + ":r" + cell._resp + ":" + Math.floor(now * 1000)), grid.caw, grid.cah, grid.theme);
if (Math.random() < 0.16) cell.creature.bonus = randomBonus(); // bonuses only emerge during play
cell.fx = null;
paintCreature(fb, cell.ox, cell.oy, cell.creature, t);
} else { // draw flying 1px shards
const g = 22;
for (const pr of cell.fx.parts) {
const px = pr.x + pr.vx * e, py = pr.y + pr.vy * e + 0.5 * g * e * e;
if (px >= 1 && px < grid.caw - 1 && py >= 1 && py < grid.cah - 1)
fb.blk(cell.ox + Math.round(px), cell.oy + Math.round(py), pr.col);
}
}
} else {
paintCreature(fb, cell.ox, cell.oy, cell.creature, t);
}
}
// case grid: single 1px lines at every boundary (no doubling at shared edges)
if (grid.border !== "none") {
const col = NEON[grid.border];
const cwN = grid.caw * BLK, chN = grid.cah * BLK;
for (let c = 0; c <= grid.cols; c++) { const x = Math.min(CW - 1, c * cwN); for (let y = 0; y < CH; y++) fb.set(x, y, col); }
for (let r = 0; r <= grid.rows; r++) { const y = Math.min(CH - 1, r * chN); for (let x = 0; x < CW; x++) fb.set(x, y, col); }
}
// bonus cases blink a 2px-wide border in the bonus colour
if (Math.floor(now * 3) % 2 === 0) {
const cwN = grid.caw * BLK, chN = grid.cah * BLK;
for (const cell of grid.cells) {
const b = cell.creature.bonus; if (!b || !BONUS[b] || cell.fx) continue;
const x0 = cell.ox * BLK, y0 = cell.oy * BLK, c = BONUS[b].col;
for (let x = 0; x < cwN; x++) for (let d = 0; d < 2; d++) { fb.set(x0 + x, y0 + d, c); fb.set(x0 + x, Math.min(CH - 1, y0 + chN - 1 - d), c); }
for (let y = 0; y < chN; y++) for (let d = 0; d < 2; d++) { fb.set(x0 + d, y0 + y, c); fb.set(Math.min(CW - 1, x0 + cwN - 1 - d), y0 + y, c); }
}
}
}
/* =====================================================================
8. AUDIO (deterministic chiptune sequence, one MASTER-second loop)
---------------------------------------------------------------------
Pure data: notes as MIDI numbers (or null for a rest). The harness
turns this into sound with Web Audio. 16 steps across MASTER_SECONDS.
===================================================================== */
const SCALES = {
minPent: [0, 3, 5, 7, 10],
majPent: [0, 2, 4, 7, 9],
dorian: [0, 2, 3, 5, 7, 9, 10],
lydian: [0, 2, 4, 6, 7, 9, 11],
};
const STYLES = ["acid", "stab", "rolling", "arp", "pluck"];
const SONG_BARS = 36; // 8 steps/bar at 0.25s = 2s bar (120 BPM) -> 72s
const PROGS = [[0, 0, 4, 3], [0, 3, 4, 2], [0, 4, 2, 3], [0, 2, 4, 5], [0, 0, 3, 4], [0, 5, 3, 4]];
function buildAudio(hash) {
const R = makeRng(String(hash) + "|audio");
const SPB = 8; // steps per bar, kick every 2 steps = 120 BPM four-on-the-floor
const scaleName = R.pick(["minPent", "dorian", "minPent"]); // darker, driving scales
const scale = SCALES[scaleName], SL = scale.length;
const root = R.pick([43, 45, 47, 48, 50]);
const style = R.weighted([["acid", 4], ["stab", 3], ["rolling", 3], ["arp", 3], ["pluck", 3]]);
const prog = R.pick(PROGS);
const leadWave = style === "stab" || style === "pluck" ? "square" : "sawtooth";
const bassWave = R.pick(["square", "triangle"]);
const noteAt = (deg) => root + 12 + scale[((deg % SL) + SL) % SL] + 12 * Math.floor(deg / SL);
// an acid pattern of scale-degree offsets, lightly mutated per bar for variety
const basePat = []; for (let i = 0; i < SPB; i++) basePat.push(R.int(0, SL));
const lead = [], bass = [], perc = [], waves = [], vel = [];
for (let b = 0; b < SONG_BARS; b++) {
const phrase = (b / 4) | 0;
const barDeg = prog[phrase % prog.length];
const intense = Math.min(3, phrase);
// song structure -> energy dynamics + variation
const mode = b < 2 ? "intro" : (phrase % 4 === 3 ? "break" : (b % 8 < 1 ? "build" : "main"));
const pat = basePat.map((d) => (R.bool(0.18) ? R.int(0, SL) : d)); // per-bar mutation
for (let s = 0; s < SPB; s++) {
const beat = s % 2 === 0; // on-beat (quarter)
// ---- drums: driving four-on-the-floor ----
let v = 0;
if (mode !== "break" && beat) v |= 2; // kick every beat
else if (mode === "break" && s === 0) v |= 2; // sparse kick in the break
if (!beat) v |= 8; // offbeat open hat (the techno "tss")
if ((mode === "main" && intense >= 2)) v |= 1; // driving 16th closed hats when intense
if ((mode === "main" || mode === "build") && (s === 2 || s === 6)) v |= 4; // clap on 2 & 4
perc.push(v);
// ---- bass: syncopated, driving ----
let bn = null;
if (mode !== "intro") { if (!beat || intense >= 2) bn = root - 12 + scale[barDeg % SL]; }
bass.push(bn == null ? null : Math.max(24, Math.min(60, bn)));
// ---- lead ----
let ln = null, gv = 0.15;
if (mode === "main" || mode === "build") {
if (style === "stab") { if (!beat) ln = noteAt(barDeg + [0, 2, 4][s % 3]); }
else if (style === "rolling") { ln = noteAt(barDeg + basePat[0] + (s % 4 === 0 ? SL : 0)); }
else { ln = noteAt(barDeg + pat[s] + (R.bool(0.12) ? SL : 0)); } // acid / arp / pluck
if (beat) gv = 0.2; // accent on beats for punch
}
lead.push(ln == null ? null : Math.max(24, Math.min(96, ln)));
vel.push(gv);
waves.push(leadWave);
}
}
const STEPS = SONG_BARS * SPB;
if (perc.filter((p) => p & 2).length < SONG_BARS) for (let b = 0; b < SONG_BARS; b++) { perc[b * SPB] |= 2; perc[b * SPB + 4] |= 2; }
return {
steps: STEPS, stepSeconds: MASTER_SECONDS / 16, loopSeconds: STEPS * (MASTER_SECONDS / 16),
root, scaleName, style, leadWave, bassWave, waves, vel, lead, bass, perc,
};
}
/* =====================================================================
9. EXPORTS
===================================================================== */
return {
CW, CH, BLK, MASTER_SECONDS, NEON,
makeHash, makeRng, FB,
buildGrid, buildCreature, paint, paintCreature, buildAudio, explode, explodeMatch, BONUS,
hexToRgb,
};
})();
/* ===================== PXL BITS — vanilla harness =====================
Pure Canvas 2D + DOM + Web Audio. No libraries. The engine above (E)
is deterministic from the token hash; this drives display, input,
the contemplative demo, the 69s game, sound and sharing. */
/* ---- token hash (Art Blocks / Artsunami style) with a local fallback ---- */
var FX_ALPHABET = "123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";
function randomToken() { var s = "oo"; for (var i = 0; i < 49; i++) s += FX_ALPHABET[(Math.random() * FX_ALPHABET.length) | 0]; return s; }
var TOKEN_HASH =
(typeof tokenData !== "undefined" && tokenData && tokenData.hash) ? tokenData.hash :
(typeof fxhash !== "undefined" && fxhash) ? fxhash :
(typeof $fx !== "undefined" && $fx && $fx.hash) ? $fx.hash :
randomToken();
var CW = E.CW, CH = E.CH, HDR = 24; // header (title/score/timer) native height
/* ---- canvas: use the platform's canvas if present, else create one ---- */
var canvas =
(typeof window !== "undefined" && window.canvas && window.canvas.getContext) ? window.canvas :
(typeof document !== "undefined" && document.querySelector && document.querySelector("canvas")) ||
(function () { var c = document.createElement("canvas"); (document.body || document.documentElement).appendChild(c); return c; })();
var ctx = canvas.getContext("2d");
try {
canvas.style.position = "absolute";
canvas.style.left = "50%"; canvas.style.top = "50%";
canvas.style.transform = "translate(-50%,-50%)";
canvas.style.imageRendering = "pixelated";
} catch (e) {}
var off = document.createElement("canvas"); off.width = CW; off.height = CH;
var offctx = off.getContext("2d");
var imgData = offctx.createImageData(CW, CH);
var fb = new E.FB();
var curHash = TOKEN_HASH;
var grid = E.buildGrid(curHash);
/* ---- colour cache ---- */
var RGB = {}; function rgbOf(h) { return RGB[h] || (RGB[h] = E.hexToRgb(h)); }
var BG = E.hexToRgb(E.NEON.bg);
/* ---- clock (monotonic seconds) ---- */
var clock = (typeof performance !== "undefined" && performance.now) ? function () { return performance.now() / 1000; } : function () { return Date.now() / 1000; };
var T0 = clock(); function nowSec() { return clock() - T0; }
/* ---- game state ---- */
var score = 0, ROUND = 69, roundState = "demo", roundStart = 0;
var lastInteraction = 0, lastAuto = 0, DEMO_RETURN = 18, AUTO_EVERY = 0.5;
var multUntil = 0, flashText = "", flashUntil = 0, flashCol = "#ffffff";
var dispScale = 1, dispOX = 0, dispOY = 0;
/* ---- per-colour identity (destruction note + timbre + base points) ---- */
var COLOR_META = {
green: { note: 64, base: 5, wave: "triangle" }, cyan: { note: 67, base: 6, wave: "triangle" },
blue: { note: 69, base: 7, wave: "sawtooth" }, pink: { note: 72, base: 8, wave: "sawtooth" },
yellow: { note: 62, base: 6, wave: "square" }, orange: { note: 60, base: 7, wave: "square" },
red: { note: 57, base: 9, wave: "square" }, white: { note: 76, base: 10, wave: "square" }
};
var META_BY_HEX = {};
for (var _nm in COLOR_META) if (E.NEON[_nm]) META_BY_HEX[String(E.NEON[_nm]).toLowerCase()] = COLOR_META[_nm];
function metaFor(hex) { return (hex && META_BY_HEX[String(hex).toLowerCase()]) || { note: 60, base: 6, wave: "square" }; }
var RARITY_MULT = { common: 1, rare: 2, epic: 4, legendary: 8 };
function scoreForCells(idxs) { var s = 0; for (var k = 0; k < idxs.length; k++) { var c = grid.cells[idxs[k]].creature; s += metaFor(c.colors.stroke).base * (RARITY_MULT[c.rarity] || 1); } return s; }
/* ---- 3x5 pixel font ---- */
var PXFONT = {
"0": ["111", "101", "101", "101", "111"], "1": ["010", "110", "010", "010", "111"],
"2": ["111", "001", "111", "100", "111"], "3": ["111", "001", "111", "001", "111"],
"4": ["101", "101", "111", "001", "001"], "5": ["111", "100", "111", "001", "111"],
"6": ["111", "100", "111", "101", "111"], "7": ["111", "001", "010", "010", "010"],
"8": ["111", "101", "111", "101", "111"], "9": ["111", "101", "111", "001", "111"],
"A": ["010", "101", "111", "101", "101"], "B": ["110", "101", "110", "101", "110"],
"C": ["111", "100", "100", "100", "111"], "D": ["110", "101", "101", "101", "110"],
"E": ["111", "100", "111", "100", "111"], "G": ["111", "100", "101", "101", "111"],
"H": ["101", "101", "111", "101", "101"], "I": ["111", "010", "010", "010", "111"],
"L": ["100", "100", "100", "100", "111"], "M": ["101", "111", "111", "101", "101"],
"N": ["101", "111", "111", "111", "101"], "O": ["111", "101", "101", "101", "111"],
"P": ["111", "101", "111", "100", "100"], "R": ["110", "101", "110", "101", "101"],
"S": ["111", "100", "111", "001", "111"], "T": ["111", "010", "010", "010", "010"],
"U": ["101", "101", "101", "101", "111"], "V": ["101", "101", "101", "101", "010"],
"X": ["101", "101", "010", "101", "101"], ":": ["000", "010", "000", "010", "000"],
" ": ["000", "000", "000", "000", "000"]
};
function textW(str, ps) { return str.length * (4 * ps) - ps; }
function drawText(cx, str, x, cy, ps, col, align, outlineCol, ob) {
var total = textW(str, ps);
var sx0 = Math.round(align === "left" ? x : align === "right" ? x - total : x - total / 2);
var y0 = Math.round(cy - (5 * ps) / 2);
var cells = [], sx = sx0;
for (var ci = 0; ci < str.length; ci++) {
var g = PXFONT[str[ci]] || PXFONT[" "];
for (var r = 0; r < 5; r++) for (var c = 0; c < 3; c++) if (g[r][c] === "1") cells.push([sx + c * ps, y0 + r * ps]);
sx += 4 * ps;
}
if (outlineCol) { var o = ob || 2; cx.fillStyle = outlineCol; for (var p = 0; p < cells.length; p++) cx.fillRect(cells[p][0] - o, cells[p][1] - o, ps + 2 * o, ps + 2 * o); }
cx.fillStyle = col; for (var q = 0; q < cells.length; q++) cx.fillRect(cells[q][0], cells[q][1], ps, ps);
}
/* ---- sizing: fill the frame at the game's portrait aspect ---- */
function fitSize() {
var NW = CW, NH = CH + HDR;
var W = (typeof innerWidth === "number" && innerWidth) || 0;
var H = (typeof innerHeight === "number" && innerHeight) || 0;
if (W < 2 || H < 2) { W = 540; H = 1080; }
var s = Math.min(W / NW, H / NH);
return { w: Math.max(1, Math.round(NW * s)), h: Math.max(1, Math.round(NH * s)) };
}
function resize() { var sz = fitSize(); canvas.width = sz.w; canvas.height = sz.h; ctx.imageSmoothingEnabled = false; }
resize();
if (typeof addEventListener !== "undefined") addEventListener("resize", resize);
/* ============================ AUDIO (Web Audio) ============================ */
var AC = null, master = null, muted = false, audioStarted = false;
var seq = null, nextStepTime = 0, stepIdx = 0, visT0 = 0, schedTimer = null;
var LOOKAHEAD = 0.10, TICK_MS = 25;
function midiToFreq(m) { return 440 * Math.pow(2, (m - 69) / 12); }
function buildSeq() { seq = E.buildAudio(curHash); }
function startAudio() {