diff --git a/docs/dannom-feature-requests.md b/docs/dannom-feature-requests.md new file mode 100644 index 0000000000..f759bd06b4 --- /dev/null +++ b/docs/dannom-feature-requests.md @@ -0,0 +1,27 @@ +# Dannom feature requests + +Captured from 6,478 public `laer-klokken` messages between June 1 and August 8, +2026. Evidence timestamps are UTC. Implementation was checked against +`system/public/aesthetic.computer/lib/nom.mjs` on August 7, 2026. + +| ID | Status | Request | Evidence | Code / next decision | +| --- | --- | --- | --- | --- | +| DAN-001 | Implemented locally | Show the current level. | `2026-06-30 17:17` — `@theamerican`: “perhaps one could request a level counter in dannom…”; `2026-07-03 06:12` — `@chigichigi`: “I think a counter and a leaderboard in dannom would be gr8.” | The always-visible Comic Relief HUD now reads `L#` beside the run score. | +| DAN-002 | Implemented locally | Add a persistent leaderboard / high scores. | `2026-07-03 06:12` — `@chigichigi`: “a counter and a leaderboard”; followed by wanting everyone to see “how evil I am at that game.” World-record claims recur on June 30 and July 6. | Correct munches reward combos; clears reward level and remaining beats. `/api/nom-scores` keeps one best run per handled user and edition; score, level, correct answers, and first-achieved time resolve order. The game-over board shows the top five. Client-authored scores are not yet server-verified replays. | +| DAN-003 | Open | Let players replay collected Danish words and inspect their meanings. | `2026-07-09 17:49` — `@q13R608tx`: “when you clicked the words on the left you could hear them and if you hover over them you see their meanings.” | Collected words are painted as inert left-side chips. Add click/tap pronunciation, hover/focus translation, and a touch-accessible equivalent. | +| DAN-004 | Editorial decision | Review disputed Danish category answers. | `2026-07-06 19:16` — `@theamerican` questioned `bjerg` / mountain in Danish nature; `2026-07-09 17:23` — `@q13R608tx` asked whether rain is cozy; `2026-07-10 11:00–11:03` — `@theamerican` asked why rain and wind are not cozy while book is. | `NATUR.correct` contains `bjerg`; `HYGGE.correct` contains `bog`; `HYGGE.wrong` contains `regn` and `vind`. Treat these as authored judgments: retain, revise, or make the categories intentionally debatable. | +| DAN-005 | Resolved; test needed | Prevent unavoidable death when a new level starts beside a monster. | `2026-07-06 19:28` — `@theamerican`: “when you level up and the monster is right beside you so you can't avoid it.” | `spawnTroggles()` now keeps monsters at least three Manhattan tiles from the player. Add a deterministic regression test for every spawn path. | +| DAN-006 | Open content project | Make a recorded Dannom lecture / AC-only radio program. | `2026-07-09 20:27` — `@prutti`: “en Dannom lecture?”; `2026-07-09 21:01` — `@HendeFraHoeve`: “vi laver et dannom radioprogram der kun kan høres her på AC”; `2026-07-14 14:32` — `@theamerican`: “dannom i teori og praksis.” | Produce as a separate AC audio piece; invite community statements about Dannom theory and practice. This can become a Klokkentales episode without changing the game. | +| DAN-007 | Parked | Add a cheat or secret transformation. | `2026-06-30 17:19` — `@theamerican`: “perhaps some sort of cheat would be in the spirit.” A June 19 rumor claimed level 10 becomes a Wolfenstein-like shooter. | Playful request, not a committed requirement. Preserve as secret-design material. | +| DAN-008 | Needs validation | Offer a relaxed or untimed mode. | On `2026-07-09`, players discussed whether the timer creates hurry and whether the point is to ignore both timer and level number. | Inferred opportunity, not a direct request. Timer pressure may be part of the intended philosophy; validate before changing it. | +| DAN-009 | Needs validation | Teach sentences or grammar as well as vocabulary. | `2026-07-14 19:39` — `@q13R608tx`: “dannom is only good for general vocabulary”; `@theamerican`: “you won't learn full sentenses.” | Product feedback rather than a direct feature request. Explore as a distinct mode so the word-munching game stays legible. | +| DAN-010 | Closed; no reproduction | Track transient reports that Dannom disappeared. | `2026-08-03 20:46` — “rip dannom”; `21:37` — “is dannom gone?”; `21:38` — “its still here.” | Self-resolved in chat. Reopen only with a reproducible load failure or piece-log evidence. | +| DAN-011 | Implemented locally | Brand Nom HUDs with Comic Relief and show cute handled identities. | Product direction, August 7. | Browser HD text loads the foundry Comic Relief face. The score HUD and leaderboard paint handles character-by-character with their saved colors, with a generated color fallback. Word tiles retain Unicode-safe text. | +| DAN-012 | Implemented locally; device verification pending | Run the shared Nom engine in the OSKIEWAR-generation Xbox stack. | Product direction, August 7. | `xbox/tools/bundle-nom.mjs` produces a QuickJS `xbox/live/dannom.js` from the real Nom, scoring, synth, and percussion sources. The adapter uses fixed-step pad edges, native drawing/audio, and packaged `comicWrite`; its command-frame probe and rendered 1280×720 frame pass locally. Deploy against the current OSKIEWAR native BIOS remains to be verified on Xbox hardware. | + +## Not promoted + +- One life was endorsed as fitting the game, not requested as a change. +- Removing or recategorizing `stol` / chair followed a playful conversation and + was disclaimed by its subject; reconsider only if the request recurs. +- “Music for Dannom” is a community artifact, not a game feature request. diff --git a/system/netlify.toml b/system/netlify.toml index 488be749db..5b2efd0809 100644 --- a/system/netlify.toml +++ b/system/netlify.toml @@ -336,6 +336,10 @@ included_env_vars = ["CONTEXT", "ADMIN_SUB", "AUTH0_M2M_CLIENT_ID", "AUTH0_M2M_S external_node_modules = ["mongodb", "mongodb-connection-string-url"] included_files = ["backend/**/*.mjs"] included_env_vars = ["CONTEXT", "ADMIN_SUB", "AUTH0_M2M_CLIENT_ID", "AUTH0_M2M_SECRET", "MONGODB_CONNECTION_STRING", "MONGODB_NAME"] +[functions.nom-scores] +external_node_modules = ["mongodb", "mongodb-connection-string-url"] +included_files = ["backend/**/*.mjs", "public/aesthetic.computer/lib/nom-score.mjs"] +included_env_vars = ["CONTEXT", "ADMIN_SUB", "AUTH0_M2M_CLIENT_ID", "AUTH0_M2M_SECRET", "MONGODB_CONNECTION_STRING", "MONGODB_NAME"] [functions.track-media] external_node_modules = ["sharp", "mongodb", "mongodb-connection-string-url", "adm-zip", "@atproto/api"] included_files = ["backend/**/*.mjs"] @@ -1819,6 +1823,10 @@ from = "/api/piece-fans" to = "/.netlify/functions/piece-fans" status = 200 [[redirects]] +from = "/api/nom-scores" +to = "/.netlify/functions/nom-scores" +status = 200 +[[redirects]] from = "/api/ticket/*" to = "/.netlify/functions/ticket" status = 200 diff --git a/system/netlify/functions/nom-scores.mjs b/system/netlify/functions/nom-scores.mjs new file mode 100644 index 0000000000..27c9c7ee50 --- /dev/null +++ b/system/netlify/functions/nom-scores.mjs @@ -0,0 +1,102 @@ +// nom-scores, 2026.08.07 +// Public top runs; authenticated writes only for people with an @handle. + +import { authorize, getHandleOrEmail } from "../../backend/authorization.mjs"; +import { connect } from "../../backend/database.mjs"; +import { respond } from "../../backend/http.mjs"; +import { compareNomRuns, normalizeNomRun } from "../../public/aesthetic.computer/lib/nom-score.mjs"; + +const GAMES = new Set([ + "numbnom", "engnom", "mexinom", "dannom", "rusnom", "catnom", "notenom", "artnom", +]); + +function gameFrom(event, body = {}) { + return String(body.game || event.queryStringParameters?.game || "dannom").toLowerCase(); +} + +function publicRow(row, rank) { + return { + rank, + handle: row.handle, + colors: Array.isArray(row.colors) ? row.colors : null, + score: row.score, + level: row.level, + correct: row.correct, + when: row.when, + }; +} + +export async function handler(event) { + let body = {}; + if (event.httpMethod === "POST") { + try { + body = JSON.parse(event.body || "{}"); + } catch { + return respond(400, { message: "Bad JSON." }); + } + } + + const game = gameFrom(event, body); + if (!GAMES.has(game)) return respond(400, { message: "Unknown Nom game." }); + + const database = await connect(); + const scores = database.db.collection("nom-scores"); + await scores.createIndex({ game: 1, user: 1 }, { unique: true }); + await scores.createIndex({ game: 1, score: -1, level: -1, correct: -1, when: 1 }); + + try { + if (event.httpMethod === "GET") { + const rows = await scores + .find({ game }) + .sort({ score: -1, level: -1, correct: -1, when: 1 }) + .limit(10) + .toArray(); + return respond(200, { game, scores: rows.map((row, index) => publicRow(row, index + 1)) }); + } + + if (event.httpMethod !== "POST") + return respond(405, { message: "Method Not Allowed" }); + + const run = normalizeNomRun(body); + if (!run) return respond(400, { message: "Need valid { score, level, correct }." }); + + let user; + try { + user = await authorize(event.headers); + } catch { + return respond(401, { message: "Log in to rank a run." }); + } + if (!user?.sub) return respond(401, { message: "Log in to rank a run." }); + + const handle = await getHandleOrEmail(user.sub); + if (!handle?.startsWith("@")) + return respond(403, { message: "Set an @handle to rank a run." }); + + const handleDoc = await database.db.collection("@handles").findOne({ + handle: { $regex: new RegExp(`^${handle.slice(1).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i") }, + }); + const next = { + game, + user: user.sub, + handle, + colors: Array.isArray(handleDoc?.colors) ? handleDoc.colors : null, + ...run, + when: new Date(), + }; + const prior = await scores.findOne({ game, user: user.sub }); + const improved = compareNomRuns(next, prior) > 0; + if (improved) + await scores.updateOne({ game, user: user.sub }, { $set: next }, { upsert: true }); + + const ranked = await scores + .find({ game }) + .sort({ score: -1, level: -1, correct: -1, when: 1 }) + .limit(1000) + .toArray(); + const rank = ranked.findIndex((row) => row.user === user.sub) + 1; + const saved = improved ? next : prior; + return respond(200, { ok: true, improved, rank: rank || null, run: publicRow(saved, rank || null) }); + } finally { + await database.disconnect(); + } +} diff --git a/system/public/aesthetic.computer/disks/dannom.mjs b/system/public/aesthetic.computer/disks/dannom.mjs index c5c9ca314e..2312d309ac 100644 --- a/system/public/aesthetic.computer/disks/dannom.mjs +++ b/system/public/aesthetic.computer/disks/dannom.mjs @@ -3,7 +3,7 @@ // hygge. Reuses lib/nom.mjs and forces Danish word mode at boot. Each munch // speaks the English translation aloud, so it doubles as a vocab drill. -import { boot as nomBoot, sim, paint, act, makeMeta } from "../lib/nom.mjs"; +import { boot as nomBoot, sim, paint, act, leave, makeMeta } from "../lib/nom.mjs"; function boot(api) { nomBoot({ ...api, params: ["danish"] }); @@ -15,4 +15,4 @@ function meta() { return makeMeta(["danish"]); } -export { boot, sim, paint, act, meta }; +export { boot, sim, paint, act, leave, meta }; diff --git a/system/public/aesthetic.computer/lib/nom-score.mjs b/system/public/aesthetic.computer/lib/nom-score.mjs new file mode 100644 index 0000000000..73682ab8d4 --- /dev/null +++ b/system/public/aesthetic.computer/lib/nom-score.mjs @@ -0,0 +1,37 @@ +// Shared scoring rules for the Nom games. + +export const NOM_SCORE_LIMIT = 1_000_000_000; + +export function munchPoints(combo) { + const streak = Math.max(1, Math.trunc(Number(combo) || 1)); + return 100 + (streak - 1) * 25; +} + +export function clearPoints(level, beatsLeft) { + const board = Math.max(1, Math.trunc(Number(level) || 1)); + const beats = Math.max(0, Math.trunc(Number(beatsLeft) || 0)); + return board * 250 + beats * 10; +} + +export function normalizeNomRun(value = {}) { + const score = Math.trunc(Number(value.score)); + const level = Math.trunc(Number(value.level)); + const correct = Math.trunc(Number(value.correct)); + if ( + !Number.isFinite(score) || score < 0 || score > NOM_SCORE_LIMIT || + !Number.isFinite(level) || level < 1 || level > 10_000 || + !Number.isFinite(correct) || correct < 0 || correct > 1_000_000 + ) return null; + return { score, level, correct }; +} + +// Positive means a is the stronger run. Earlier achievement wins an exact tie. +export function compareNomRuns(a, b) { + if (!b) return 1; + if (a.score !== b.score) return a.score - b.score; + if (a.level !== b.level) return a.level - b.level; + if (a.correct !== b.correct) return a.correct - b.correct; + const at = new Date(a.when || 0).getTime(); + const bt = new Date(b.when || 0).getTime(); + return bt - at; +} diff --git a/system/public/aesthetic.computer/lib/nom.mjs b/system/public/aesthetic.computer/lib/nom.mjs index 2a5520ce4f..1358a293ea 100644 --- a/system/public/aesthetic.computer/lib/nom.mjs +++ b/system/public/aesthetic.computer/lib/nom.mjs @@ -6,6 +6,7 @@ // `artnom` = museum thumbnails grouped by Getty AAT art-historical styles. import { Synth } from "./synth.mjs"; // shared virtual synth + perc kit +import { clearPoints, munchPoints } from "./nom-score.mjs"; /* #region 📚 README Controls: @@ -24,7 +25,7 @@ import { Synth } from "./synth.mjs"; // shared virtual synth + perc kit #endregion */ /* #region 🏁 TODO - - [] Add a leaderboard / score persistence. + - [x] Add a leaderboard / score persistence. - [] More Troggle personalities (some eat numbers, some teleport). - [] Sound for the Troggle stomp. #endregion */ @@ -52,7 +53,7 @@ let walkTarget = null; // 👆 tap-to-move destination cell — sim walks the mu let walkTick = 0; // frames until the walker's next step const WALK_STEP_FRAMES = 7; // tap-to-move cadence (~115ms per square) let troggles = []; -let level = 1; // internal progression counter (board #) — never displayed +let level = 1; // current board number, displayed and included in ranking let remaining = 0; // correct cells left to eat let foundValues = []; // distinct correct values eaten (shown in a bottom row) let boardCorrectTotal = 1; // correct cells the board started with (fatness gauge) @@ -76,6 +77,16 @@ let facing = { x: 1, y: 0 }; // direction the Muncher's mouth points let chompPhase = 0; // frames remaining in a chomp animation let combo = 0; // consecutive correct munches → rising pitch let melody = []; // queued notes: { at, type, tone, duration, volume } +let score = 0; +let correctMunches = 0; +let playerHandle = null; +let playerColors = null; +let authorizeFn = null; +let leaderboard = []; +let leaderboardRank = null; +let leaderboardStatus = ""; +let lastSubmittedKey = ""; +let scoreGeneration = 0; // 🎥 Dynamic camera (smoothed follow-lean + zoom punch + shake), à la dumduel. let cam = { x: 0, y: 0, zoom: 1, shake: 0 }; @@ -135,6 +146,9 @@ function reset() { invuln = 0; mouth = 0; troggleClock = null; message = ""; messageTimer = 0; snd = null; synth = null; speakFn = null; frames = 0; facing = { x: 1, y: 0 }; chompPhase = 0; combo = 0; melody = []; + score = 0; correctMunches = 0; playerHandle = null; playerColors = null; + authorizeFn = null; leaderboard = []; leaderboardRank = null; + leaderboardStatus = ""; lastSubmittedKey = ""; scoreGeneration += 1; cam = { x: 0, y: 0, zoom: 1, shake: 0 }; camTargetZoom = 1; screenH = 512; deathPhase = 0; deathPos = { col: 2, row: 2 }; pendingOver = false; clearPhase = 0; flashRed = 0; flashGold = 0; flashGreen = 0; confetti = []; @@ -147,7 +161,7 @@ function reset() { // 🥾 Boot — reset all state, then drop straight onto a live board (no title gate // / "press any key"); a brief intro card fades over the playing board. -function boot({ params, hud, clock, artnom, net, num: { randInt } }) { +function boot({ params, hud, clock, artnom, net, handle, authorize, num: { randInt } }) { reset(); clock?.resync?.(); // network-sync the wall clock; board beats ride this grid const m = resolveMode(params); @@ -155,6 +169,11 @@ function boot({ params, hud, clock, artnom, net, num: { randInt } }) { lang = m.lang; artCatalog = artnom || null; artPreload = net?.preload || null; + const rawHandle = handle?.(); + playerHandle = rawHandle + ? (String(rawHandle).startsWith("@") ? String(rawHandle) : `@${rawHandle}`) + : null; + authorizeFn = authorize || null; // The Canvas2D adapter intentionally has no bitmap-paste surface yet, so the // image edition stays on the pixel renderer. All text editions remain HD. hiRes = mode !== "art" && params?.includes?.("sd") !== true; @@ -165,6 +184,71 @@ function boot({ params, hud, clock, artnom, net, num: { randInt } }) { introTimer = INTRO_FRAMES; // title/edition card over the live board (beats frozen) flashGreen = 16; // green "go!" flash pendingStart = true; // announce the rule + chime once audio is available + loadComicFont(); + loadLeaderboard(scoreGeneration); + loadPlayerColors(scoreGeneration); +} + +function gameKey() { + if (mode === "art") return "artnom"; + if (mode === "note") return "notenom"; + if (mode !== "word") return "numbnom"; + return { en: "engnom", es: "mexinom", da: "dannom", ru: "rusnom", cat: "catnom" }[lang] || "engnom"; +} + +async function readScoreResponse(response) { + if (!response?.ok) throw new Error(`score api ${response?.status || "failed"}`); + return response.json(); +} + +async function loadLeaderboard(generation = scoreGeneration) { + if (typeof fetch !== "function") return; + try { + const data = await readScoreResponse(await fetch(`/api/nom-scores?game=${gameKey()}`)); + if (generation === scoreGeneration) + leaderboard = Array.isArray(data.scores) ? data.scores : []; + } catch { + if (generation === scoreGeneration) leaderboardStatus = "scores offline"; + } +} + +async function loadPlayerColors(generation = scoreGeneration) { + if (!playerHandle || typeof fetch !== "function") return; + try { + const bare = playerHandle.replace(/^@/, ""); + const response = await fetch(`/api/handle-colors?handle=${encodeURIComponent(bare)}`); + if (!response.ok) return; + const data = await response.json(); + if (generation === scoreGeneration && Array.isArray(data.colors)) playerColors = data.colors; + } catch { + // Custom colors are optional; the renderer supplies a lively fallback. + } +} + +async function submitRun() { + if (!playerHandle || !authorizeFn || typeof fetch !== "function") { + if (!playerHandle) leaderboardStatus = "set a handle to rank"; + return; + } + const key = `${score}:${level}:${correctMunches}`; + if (key === lastSubmittedKey) return; + lastSubmittedKey = key; + const generation = scoreGeneration; + try { + const token = await authorizeFn(); + if (!token) throw new Error("not authorized"); + const data = await readScoreResponse(await fetch("/api/nom-scores", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ game: gameKey(), score, level, correct: correctMunches }), + })); + if (generation !== scoreGeneration) return; + leaderboardRank = data.rank || null; + leaderboardStatus = data.improved ? `new best · #${data.rank}` : `best · #${data.rank}`; + await loadLeaderboard(generation); + } catch { + if (generation === scoreGeneration) leaderboardStatus = "score not saved"; + } } // Map the first colon param → { mode, lang }. Shared by boot() (sets the live @@ -1258,6 +1342,7 @@ function sim({ gizmo, seconds, sound, clock, num: { randInt } }) { pendingOver = false; state = "over"; jingleOver(); + submitRun(); } return; } @@ -1454,6 +1539,8 @@ function munch() { cell.eaten = true; cell.flash = 14; combo += 1; + score += munchPoints(combo); + correctMunches += 1; remaining -= 1; beatsLeft = min(beatsMax, beatsLeft + 1); // munching earns a beat back if (mode === "note") noteMunchGood(cell); @@ -1479,6 +1566,7 @@ function munch() { if (mode === "word" && lang !== "en") sayTranslation(v); else if (mode !== "note" && mode !== "art" && remaining === 1) say("last one"); if (remaining <= 0) { + score += clearPoints(level, beatsLeft); state = "clear"; clearPhase = 96; camTargetZoom = 1.16; // bigger celebratory push-in @@ -1486,6 +1574,7 @@ function munch() { spawnConfetti(); flash("LEVEL CLEAR!"); jingleClear(); + submitRun(); } } else { cell.flash = 14; @@ -1945,6 +2034,9 @@ function act({ event: e, sound, speak, cursor, net, num: { randInt } }) { function resetGame({ randInt }) { level = 1; + score = 0; + correctMunches = 0; + lastSubmittedKey = ""; troggleClock = null; message = ""; messageTimer = 0; @@ -2128,6 +2220,23 @@ function paintCaption({ ink, write, box, screen }) { ); } +function handleColor(colors, index, ch) { + const color = colors?.[index]; + if (color && [color.r, color.g, color.b].every(Number.isFinite)) + return [color.r, color.g, color.b]; + return charColor(String(ch).toLowerCase()); +} + +function paintHandle({ ink }, handle, colors, x, y, size = 1) { + const value = String(handle || ""); + const advance = 6 * size; + for (let index = 0; index < value.length; index += 1) + ink(...handleColor(colors, index, value[index])).write( + value[index], { x: x + index * advance, y, size }, + ); + return value.length * advance; +} + // 🖼️ Canvas2D adapter — mirrors the exact slice of the AC paint API this // engine uses (wipe / ink().box/.line/.write + bare box/line after ink) on // the hd layer's pre-scaled 2d context, so paintGame runs unchanged at @@ -2148,6 +2257,22 @@ let hdCached = null; // { ctx, width, height, api } const glyphW = new Map(); // "font|char" → advance at 100px const sprites = new Map(); // "font|size|r,g,b|text" → { c, w, h, pad } const SPRITE_CAP = 480; // hard bound; cleared wholesale when exceeded +let comicFontPromise = null; + +function loadComicFont() { + if (comicFontPromise || typeof FontFace === "undefined" || typeof document === "undefined") + return comicFontPromise; + comicFontPromise = new FontFace( + "Comic Relief", + "url(https://papers.aesthetic.computer/foundry/fonts/ComicRelief-Regular.ttf)", + ).load().then((font) => { + document.fonts.add(font); + glyphW.clear(); + sprites.clear(); + return font; + }).catch(() => null); + return comicFontPromise; +} // Font styling for the hd layer — unifont stays a neutral monospace (it // carries Cyrillic/accents for the word editions); everything else (numbers, @@ -2156,7 +2281,7 @@ const SPRITE_CAP = 480; // hard bound; cleared wholesale when exceeded function hdFont(font, px) { return font === "unifont" ? `500 ${px * 0.72}px ui-monospace, Menlo, Consolas, monospace` - : `800 ${px * 0.8}px "Arial Rounded MT Bold", ui-rounded, "SF Pro Rounded", ui-monospace, Menlo, monospace`; + : `400 ${px * 0.92}px "Comic Relief", "Comic Sans MS", ui-rounded, sans-serif`; } // Per-glyph advance from the cache, measuring on first sight only. @@ -2316,7 +2441,7 @@ function paintGame({ wipe, ink, screen, write, box, line, text, paste }) { const timeCol = lowTime ? (blink ? T.timerPanic : T.timerPanicAlt) : tcol; bigNum(ink, `${beatsLeft}`, 8, 8, timerSize, timeCol); - // Rule title, top-right (no levels, no score). Word editions render it in + // Rule title, top-right. Word editions render it in // unifont so Cyrillic / accented category labels (ЕДА, ФРУКТЫ, …) show real // glyphs. Sized to the room left of the big timer so the two never collide // on narrow phone screens. @@ -2541,6 +2666,17 @@ function paintGame({ wipe, ink, screen, write, box, line, text, paste }) { // 🔢 Items-left, bottom-right, big — riding just above the caption strip. const hudBottom = screen.height - captionBandH(screen); + const runLabel = `L${level} ${score}`; + let runSize = min(3, 1 + hudScale); + while (runSize > 1 && runLabel.length * 6 * runSize > screen.width * 0.38) runSize -= 1; + const runY = hudBottom - 8 * runSize - 4; + ink(...T.introName).write(runLabel, { x: 8, y: runY, size: runSize }); + if (playerHandle) { + let handleSize = max(1, min(2, hudScale)); + while (handleSize > 1 && playerHandle.length * 6 * handleSize > screen.width * 0.4) + handleSize -= 1; + paintHandle({ ink }, playerHandle, playerColors, 8, runY - 10 * handleSize - 2, handleSize); + } const leftN = `${remaining}`; const lsz = timerSize; const leftW = leftN.length * 6 * lsz; @@ -2594,7 +2730,7 @@ function paintGame({ wipe, ink, screen, write, box, line, text, paste }) { // 📣 Transient message banner — large, with a colored background. Stacks // above the caption band when one is showing (all instruction above the // board, none below). - if (message) { + if (message && state !== "over") { let ms = 1 + hudScale; while (ms > 1 && message.length * 6 * ms + 10 > screen.width) ms -= 1; const mw = message.length * 6 * ms; @@ -2622,12 +2758,14 @@ function paintGame({ wipe, ink, screen, write, box, line, text, paste }) { "LEVEL CLEAR", mode === "art" && lastArtwork ? "I: record · press to continue" : "press any key", ); - if (state === "over") + if (state === "over") { overlay( { ink, screen, write }, "GAME OVER", mode === "art" && lastArtwork ? "I: record · press to retry" : "press to retry", ); + paintLeaderboard({ ink, screen, write }); + } } // Cute monster: fatter the more it has eaten; shrinks + trembles when starving @@ -2849,7 +2987,50 @@ function overlay({ ink, screen, write }, title, sub) { ink(0, 0, 0, 150).box(tx - 10, ty - 6, tw + 20, 8 * ts + 12, "outline"); ink(255, 248, 230).write(title, { x: tx, y: ty, size: ts }); const ss = max(1, round(ts / 2)); - ink(...T.overlaySub).write(sub, { center: "x", y: round(screen.height / 2 + 8 * ts), size: ss }); + const sy = round(screen.height / 2 + 8 * ts); + const sw = sub.length * 6 * ss; + ink(...T.bg).box(round((screen.width - sw) / 2) - 5, sy - 3, sw + 10, 8 * ss + 6); + ink(...T.overlaySub).write(sub, { center: "x", y: sy, size: ss }); +} + +function paintLeaderboard({ ink, screen }) { + const rows = leaderboard.slice(0, screen.height < 430 ? 3 : 5); + const size = max(1, min(hudScale, 2)); + const lineH = 11 * size; + let y = round(screen.height / 2 + 78); + const maxY = screen.height - lineH * (rows.length + 1) - 6; + y = min(y, maxY); + for (const row of rows) { + const rank = `#${row.rank}`; + const handle = String(row.handle || "@player"); + const points = String(row.score || 0); + const gap = 6 * size; + const width = (rank.length + handle.length + points.length) * 6 * size + gap * 2; + let x = max(4, round((screen.width - width) / 2)); + const mine = playerHandle && handle.toLowerCase() === playerHandle.toLowerCase(); + ink(...T.bg).box(x - 5, y - 3, width + 10, 8 * size + 6); + ink(...(mine ? T.introName : T.overlaySub)).write(rank, { x, y, size }); + x += rank.length * 6 * size + gap; + x += paintHandle({ ink }, handle, row.colors, x, y, size) + gap; + ink(...(mine ? T.introName : T.overlaySub)).write(points, { x, y, size }); + y += lineH; + } + const status = leaderboardStatus || (rows.length ? "" : "no ranked runs yet"); + if (status) { + let statusSize = size; + while (statusSize > 1 && status.length * 6 * statusSize > screen.width - 8) + statusSize -= 1; + const statusW = status.length * 6 * statusSize; + ink(...T.bg).box(round((screen.width - statusW) / 2) - 5, y - 3, statusW + 10, 8 * statusSize + 6); + ink(...T.overlaySub).write(status, { center: "x", y, size: statusSize }); + } else if (leaderboardRank && leaderboardRank > rows.length) { + ink(...T.overlaySub).write(`#${leaderboardRank}`, { center: "x", y, size }); + } +} + +function leave() { + killChord(); + submitRun(); } // 📰 Meta — built from explicit params so the title is correct at load time, @@ -2877,4 +3058,4 @@ function meta() { ]); } -export { boot, sim, paint, act, meta, makeMeta }; +export { boot, sim, paint, act, leave, meta, makeMeta }; diff --git a/tests/nom-score.test.mjs b/tests/nom-score.test.mjs new file mode 100644 index 0000000000..f9b3d377da --- /dev/null +++ b/tests/nom-score.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + clearPoints, + compareNomRuns, + munchPoints, + normalizeNomRun, +} from "../system/public/aesthetic.computer/lib/nom-score.mjs"; + +test("Nom correct-munch points reward a combo", () => { + assert.equal(munchPoints(1), 100); + assert.equal(munchPoints(4), 175); +}); + +test("Nom level-clear points reward level and remaining beats", () => { + assert.equal(clearPoints(1, 0), 250); + assert.equal(clearPoints(3, 7), 820); +}); + +test("Nom run validation accepts bounded integers only", () => { + assert.deepEqual(normalizeNomRun({ score: "425", level: 2, correct: 3 }), { + score: 425, + level: 2, + correct: 3, + }); + assert.equal(normalizeNomRun({ score: -1, level: 2, correct: 3 }), null); + assert.equal(normalizeNomRun({ score: 10, level: 0, correct: 3 }), null); +}); + +test("Nom run ordering resolves score, level, correct, then first achievement", () => { + const base = { score: 1000, level: 3, correct: 12, when: "2026-08-07T01:00:00Z" }; + assert.ok(compareNomRuns({ ...base, score: 1001 }, base) > 0); + assert.ok(compareNomRuns({ ...base, level: 4 }, base) > 0); + assert.ok(compareNomRuns({ ...base, correct: 13 }, base) > 0); + assert.ok(compareNomRuns({ ...base, when: "2026-08-07T00:00:00Z" }, base) > 0); +}); diff --git a/xbox/live/dannom.js b/xbox/live/dannom.js new file mode 100644 index 0000000000..71bf6686bc --- /dev/null +++ b/xbox/live/dannom.js @@ -0,0 +1,3801 @@ +// Dannom for the OSKIEWAR-generation native Xbox host. GENERATED. +// Rebuild: node xbox/tools/bundle-nom.mjs +(function (xboxNativeSynth) { +// source: system/public/aesthetic.computer/lib/percussion.mjs +// Percussion synth kit — shared between web notepat and native notepat. +// Exports the 12-drum layout, display labels, pad colors, and a pure +// `playPercussion(sound, letter, opts)` that fires the layered voices +// for a single drum on the supplied AC sound API. +// +// Natural notes = 7 core drums (kick/snare/clap/snap/hat-c/hat-o/ride). +// Sharps = 5 accents (crash/splash/cowbell/block/tambo). +// +// The native kit layered a voice-inspector and hold-release machinery on +// top of this; both are kept callback-based here so the module itself has +// no persistent state and works identically on web + native. + +// TR-808 hi-hat 6-square inharmonic cluster (trimmed to 4 for clarity). +const HAT_FREQS = [800, 540, 522.7, 369.6]; + +const PERCUSSION_NAMES = { + c: "kick", d: "snare", e: "clap", f: "snap", + g: "hat-c", a: "hat-o", b: "ride", + "c#": "crash", "d#": "splash", + "f#": "cowbell", "g#": "block", "a#": "tambo", +}; + +// 3-char display labels shown on drum pads in place of note names. +const PERCUSSION_LABELS = { + c: "BAS", d: "SNR", e: "CLP", f: "SNP", + g: "HHC", a: "HHO", b: "RDE", + "c#": "CRS", "d#": "SPL", + "f#": "CBL", "g#": "BLK", "a#": "TMB", +}; + +// Metallic / earthy colors to visually distinguish drum pads from keys. +const PERCUSSION_COLORS = { + c: [220, 90, 40], // kick — deep orange + d: [220, 180, 110], // snare — tan + e: [240, 220, 130], // clap — pale yellow + f: [220, 240, 140], // snap — yellow-green + g: [120, 220, 180], // closed hat — mint + a: [120, 200, 240], // open hat — cyan + b: [180, 180, 230], // ride — silver-blue + "c#": [220, 150, 240], // crash — lavender + "d#": [240, 160, 220], // splash — pink + "f#": [200, 150, 80], // cowbell — brass + "g#": [190, 120, 70], // block — wood brown + "a#": [230, 210, 170], // tambourine — sandy +}; + +// Graphic-notation voice signatures for the pad-idle animation renderer. +// t: "s"=sine "t"=triangle "q"=square "w"=sawtooth "n"=noise +// Format per voice: [type, freq, volume]. Consumed by drawGrid() in each +// notepat implementation. +const PERCUSSION_NOTATION = { + c: [["t",1800,1.0],["n",4000,0.55],["q",180,1.2],["w",90,1.0],["s",44,1.9],["s",54,1.3],["q",120,0.85]], + d: [["n",2200,0.55],["t",220,0.4],["q",180,0.2]], + e: [["q",2500,0.9],["n",6000,0.6],["n",1600,0.75],["n",1700,0.6],["n",1500,0.5],["n",1800,0.45]], + f: [["n",3200,0.45],["q",1800,0.22],["t",2400,0.18]], + g: [["n",7000,0.35],["n",5000,0.2]], + a: [["n",6500,0.3],["n",4800,0.18]], + b: [["n",4200,0.28],["q",3100,0.1],["q",4600,0.08]], + "c#": [["n",3500,0.42],["n",6500,0.28],["q",4200,0.08]], + "d#": [["n",5500,0.38],["n",8500,0.25]], + "f#": [["q",810,0.22],["q",540,0.18]], + "g#": [["t",900,0.35],["q",1800,0.14]], + "a#": [["n",7000,0.3],["n",4500,0.18],["q",6500,0.1]], +}; + +// Wave type → accent color for the notation glyph. +const NOTATION_WAVE_RGB = { + s: [120, 200, 255], // sine — sky blue + t: [120, 255, 180], // triangle — mint + q: [255, 220, 120], // square — amber + w: [255, 140, 80], // sawtooth — orange + n: [220, 220, 230], // noise — pale grey +}; + +// Fire the layered voices for a single drum. Pure function — no +// persistent state and no module-level side effects. +// +// Params: +// sound AC sound API (needs .synth). +// letter "c" | "d" | ... | "a#". Unknown letters are ignored. +// opts: +// volume master volume scalar (0.1–2.2 clamped). Default 1.0. +// pan stereo pan (−1..1). Default 0. +// pitchFactor tone multiplier for transpose (0.25–4 clamped). Default 1. +// phase "down" (live key press, sustains go into holdVoices), +// "up" (legacy release — ignored), or +// "both" (self-contained one-shot, sustains play finite). +// Default "both". +// holdVoices array to receive live-sustain voice records. If absent +// or phase !== "down", sustains fire as finite durations. +// onVoice optional callback(voiceDesc) called once per voice +// fired, for inspector/debug overlays. `voiceDesc` has +// { type, tone, duration, volume, attack, decay, pan, kind } +// where kind is "hit" or "sustain". +function playPercussion(sound, letter, opts = {}) { + if (!sound?.synth) return; + const { + volume = 1.0, + pan = 0, + pitchFactor = 1.0, + phase = "both", + holdVoices = null, + onVoice = null, + } = opts; + + const v = Math.max(0.1, Math.min(2.2, volume)); + const pf = Math.max(0.25, Math.min(4, pitchFactor)); + + const fireDown = phase !== "up"; + const isLive = phase === "down" && Array.isArray(holdVoices); + if (!fireDown) return; + + // Per-hit random helpers (inline, stateless). `rj` jitters around a center + // by a ± fraction; `rn` returns a uniform range. + const rj = (center, frac) => center * (1 + (Math.random() - 0.5) * 2 * frac); + const rn = (min, max) => min + Math.random() * (max - min); + + const inspect = (params, kind) => { + if (!onVoice) return; + onVoice({ + type: params?.type, + tone: params?.tone, + duration: params?.duration, + volume: params?.volume, + attack: params?.attack, + decay: params?.decay, + pan: params?.pan, + kind, + }); + }; + + const addSustain = (params, bothDuration, releaseFade, releaseUpdate, onRelease) => { + inspect({ ...params, duration: bothDuration }, "sustain"); + if (isLive) { + const handle = sound.synth({ ...params, duration: Infinity }); + if (handle) { + const liveEntry = { handle, releaseFade, releaseUpdate, onRelease }; + if (params?.tone !== undefined) liveEntry.baseTone = params.tone / pf; + if (params?.volume !== undefined) liveEntry.baseVolumeUnit = params.volume / v; + if (params?.base !== undefined) liveEntry.sampleBase = params.base; + if (releaseUpdate?.tone !== undefined) liveEntry.releaseBaseTone = releaseUpdate.tone / pf; + if (releaseUpdate?.volume !== undefined) liveEntry.releaseBaseVolumeUnit = releaseUpdate.volume / v; + holdVoices.push(liveEntry); + } + return handle; + } + return sound.synth({ ...params, duration: bothDuration }); + }; + + const addHit = (params) => { + inspect(params, "hit"); + const handle = sound.synth(params); + if (isLive && handle) { + const liveEntry = { + handle, + ignoreRelease: true, + releaseFade: 0, + tailSeconds: Math.max( + Number(params?.duration) || 0, + (Number(params?.attack) || 0) + (Number(params?.decay) || 0), + ), + }; + if (params?.tone !== undefined) liveEntry.baseTone = params.tone / pf; + if (params?.volume !== undefined) liveEntry.baseVolumeUnit = params.volume / v; + if (params?.base !== undefined) liveEntry.sampleBase = params.base; + holdVoices.push(liveEntry); + } + return handle; + }; + + const addReleaseBurst = (onRelease) => { + if (isLive && onRelease) holdVoices.push({ handle: null, releaseFade: 0, onRelease }); + }; + + switch (letter) { + // === ONE-SHOT DRUMS (c/d/e/f/g) === + case "c": { // kick — tight TR-808: transient + short body + optional sub + const downPan = pan + rn(-0.02, 0.02); + addHit({ type: "noise", tone: 2500 * pf, duration: 0.0025, volume: rj(0.50, 0.12) * v, attack: 0.0002, decay: 0.0022, pan: downPan }); + addHit({ type: "sine", tone: 200 * pf, duration: 0.012, volume: rj(1.1, 0.10) * v, attack: 0.0005, decay: 0.011, pan: downPan }); + addHit({ type: "sine", tone: 150 * pf, duration: 0.045, volume: rj(1.3, 0.10) * v, attack: 0.001, decay: 0.044, pan: downPan }); + addHit({ type: "sine", tone: 90 * pf, duration: 0.080, volume: rj(0.85, 0.12) * v, attack: 0.002, decay: 0.078, pan: downPan }); + addHit({ type: "sine", tone: 55 * pf, duration: rj(0.35, 0.20), volume: rj(1.0, 0.12) * v, attack: 0.003, decay: 0.345, pan: downPan }); + break; + } + + case "d": { // snare — TR-909-leaning: big transient + short tone + dominant noise + const downPan = pan + rn(-0.02, 0.02); + addHit({ type: "noise", tone: 3500 * pf, duration: 0.004, volume: rj(0.95, 0.10) * v, attack: 0.0001, decay: 0.004, pan: downPan }); + addHit({ type: "sine", tone: 238 * pf, duration: 0.030, volume: rj(0.35, 0.12) * v, attack: 0.0003, decay: 0.029, pan: downPan }); + addHit({ type: "sine", tone: 476 * pf, duration: 0.030, volume: rj(0.28, 0.12) * v, attack: 0.0003, decay: 0.029, pan: downPan }); + addHit({ type: "noise", tone: 3500 * pf, duration: rj(0.11, 0.20), volume: rj(0.85, 0.10) * v, attack: 0.0005, decay: 0.108, pan: downPan + rn(-0.04, 0.04) }); + addHit({ type: "noise", tone: 1800 * pf, duration: rj(0.07, 0.20), volume: rj(0.38, 0.15) * v, attack: 0.0008, decay: 0.068, pan: downPan + rn(-0.04, 0.04) }); + addHit({ type: "triangle", tone: 180 * pf, duration: 0.025, volume: rj(0.22, 0.15) * v, attack: 0.001, decay: 0.024, pan: downPan }); + break; + } + + case "e": { // clap — TR-808 4-burst pattern via staggered attacks (one-shot) + const downPan = pan + rn(-0.06, 0.02); + addHit({ type: "noise", tone: 1000 * pf, duration: 0.025, volume: rj(0.90, 0.15) * v, attack: 0.005, decay: 0.020, pan: downPan }); + addHit({ type: "noise", tone: 1100 * pf, duration: 0.035, volume: rj(0.95, 0.15) * v, attack: 0.015, decay: 0.020, pan: downPan }); + addHit({ type: "noise", tone: 900 * pf, duration: 0.045, volume: rj(0.85, 0.15) * v, attack: 0.025, decay: 0.020, pan: downPan }); + addHit({ type: "noise", tone: 3000 * pf, duration: 0.008, volume: rj(0.55, 0.15) * v, attack: 0.001, decay: 0.007, pan: downPan }); + addHit({ type: "noise", tone: 1000 * pf, duration: rj(0.14, 0.25), volume: rj(0.85, 0.15) * v, attack: 0.045, decay: 0.135, pan: downPan + rn(-0.02, 0.10) }); + addHit({ type: "noise", tone: 2200 * pf, duration: rj(0.10, 0.25), volume: rj(0.35, 0.18) * v, attack: 0.050, decay: 0.095, pan: downPan + rn(-0.02, 0.10) }); + break; + } + + case "f": { // snap — finger snap physics (one-shot) + const downPan = pan + rn(-0.04, 0.04); + addHit({ type: "noise", tone: 6000 * pf, duration: 0.003, volume: rj(0.70, 0.15) * v, attack: 0.0001, decay: 0.0028, pan: downPan }); + addHit({ type: "sine", tone: 2100 * pf, duration: rj(0.045, 0.20), volume: rj(0.55, 0.12) * v, attack: 0.0005, decay: 0.044, pan: downPan }); + addHit({ type: "sine", tone: 3500 * pf, duration: rj(0.020, 0.25), volume: rj(0.28, 0.18) * v, attack: 0.0005, decay: 0.019, pan: downPan }); + break; + } + + case "g": { // closed hi-hat — 4-square cluster + subtle lift click on release + const downPan = pan + rn(-0.03, 0.03); + for (const f of HAT_FREQS) { + addHit({ type: "square", tone: f * pf, duration: rj(0.008, 0.20), volume: rj(0.18, 0.18) * v, attack: 0.0005, decay: 0.0075, pan: downPan }); + } + addHit({ type: "noise", tone: 8000 * pf, duration: rj(0.040, 0.20), volume: rj(0.38, 0.12) * v, attack: 0.0005, decay: 0.038, pan: downPan }); + addReleaseBurst(() => { + sound.synth({ type: "noise", tone: 9000 * pf, duration: 0.004, volume: rj(0.22, 0.20) * v, attack: 0.0002, decay: 0.0038, pan: downPan }); + sound.synth({ type: "square", tone: 6000 * pf, duration: 0.003, volume: rj(0.08, 0.25) * v, attack: 0.0003, decay: 0.0027, pan: downPan }); + }); + break; + } + + case "a": { // open hi-hat — TR-808 cluster, LONG sustain. Foot-pedal release = damp fast. + const downPan = pan + rn(-0.04, 0.04); + for (const f of HAT_FREQS) { + addHit({ type: "square", tone: f * pf, duration: 0.012, volume: rj(0.16, 0.18) * v, attack: 0.0005, decay: 0.011, pan: downPan }); + } + addHit({ type: "noise", tone: 8200 * pf, duration: 0.012, volume: rj(0.42, 0.12) * v, attack: 0.0003, decay: 0.011, pan: downPan }); + addSustain( + { type: "noise", tone: 7000 * pf, volume: rj(0.32, 0.15) * v, attack: 0.003, decay: 0, pan: downPan + rn(-0.02, 0.08) }, + rj(0.40, 0.25), + 0.12, + { tone: 3500 }, + ); + addSustain( + { type: "noise", tone: 5000 * pf, volume: rj(0.20, 0.18) * v, attack: 0.003, decay: 0, pan: downPan + rn(-0.02, 0.08) }, + rj(0.25, 0.25), + 0.10, + { tone: 2800 }, + ); + addSustain( + { type: "square", tone: 800 * pf, volume: rj(0.08, 0.20) * v, attack: 0.005, decay: 0, pan: downPan }, + rj(0.20, 0.25), + 0.08, + ); + break; + } + + case "b": { // ride — bell ping + long shimmer sustain + const downPan = pan + rn(-0.03, 0.03); + addHit({ type: "square", tone: 800 * pf, duration: 0.020, volume: rj(0.10, 0.18) * v, attack: 0.0005, decay: 0.019, pan: downPan }); + addHit({ type: "square", tone: 540 * pf, duration: 0.020, volume: rj(0.08, 0.18) * v, attack: 0.0005, decay: 0.019, pan: downPan }); + addSustain( + { type: "sine", tone: 440 * pf, volume: rj(0.24, 0.12) * v, attack: 0.0008, decay: 0, pan: downPan }, + rj(0.40, 0.20), + 0.25, + ); + addSustain( + { type: "sine", tone: 587 * pf, volume: rj(0.20, 0.12) * v, attack: 0.0008, decay: 0, pan: downPan }, + rj(0.40, 0.20), + 0.25, + ); + addSustain( + { type: "noise", tone: 4200 * pf, volume: rj(0.26, 0.12) * v, attack: 0.005, decay: 0, pan: downPan + rn(-0.03, 0.03) }, + rj(0.9, 0.20), + 0.30, + ); + break; + } + + case "c#": { // crash — explosive noise attack + LONG shimmer wash + const downPan = pan + rn(-0.05, 0.05); + addHit({ type: "noise", tone: 8000 * pf, duration: 0.030, volume: rj(0.75, 0.15) * v, attack: 0.0005, decay: 0.029, pan: downPan }); + for (const f of HAT_FREQS) { + addHit({ type: "square", tone: f * pf, duration: 0.030, volume: rj(0.12, 0.20) * v, attack: 0.0005, decay: 0.029, pan: downPan }); + } + addSustain( + { type: "noise", tone: 5000 * pf, volume: rj(0.45, 0.12) * v, attack: 0.008, decay: 0, pan: downPan + rn(-0.04, 0.04) }, + rj(1.4, 0.18), + 0.45, + ); + addSustain( + { type: "noise", tone: 7500 * pf, volume: rj(0.30, 0.15) * v, attack: 0.008, decay: 0, pan: downPan + rn(-0.04, 0.04) }, + rj(0.9, 0.18), + 0.35, + ); + addSustain( + { type: "square", tone: 800 * pf, volume: rj(0.08, 0.20) * v, attack: 0.008, decay: 0, pan: downPan }, + rj(0.5, 0.20), + 0.20, + ); + break; + } + + case "d#": { // splash — short bright cymbal burst (one-shot) + const downPan = pan + rn(-0.04, 0.04); + addHit({ type: "noise", tone: 9000 * pf, duration: 0.012, volume: rj(0.55, 0.15) * v, attack: 0.0003, decay: 0.011, pan: downPan }); + addHit({ type: "square", tone: 800 * pf, duration: 0.015, volume: rj(0.14, 0.20) * v, attack: 0.0005, decay: 0.014, pan: downPan }); + addHit({ type: "square", tone: 540 * pf, duration: 0.015, volume: rj(0.10, 0.20) * v, attack: 0.0005, decay: 0.014, pan: downPan }); + addHit({ type: "noise", tone: 6000 * pf, duration: rj(0.35, 0.20), volume: rj(0.42, 0.12) * v, attack: 0.004, decay: 0.345, pan: downPan + rn(-0.03, 0.03) }); + addHit({ type: "noise", tone: 8500 * pf, duration: rj(0.22, 0.20), volume: rj(0.25, 0.15) * v, attack: 0.004, decay: 0.215, pan: downPan + rn(-0.03, 0.03) }); + break; + } + + case "f#": { // cowbell — TR-808: two triangles 800/540 Hz (one-shot) + const downPan = pan + rn(-0.03, 0.03); + addHit({ type: "square", tone: 1800 * pf, duration: 0.004, volume: rj(0.35, 0.15) * v, attack: 0.0002, decay: 0.0038, pan: downPan }); + addHit({ type: "triangle", tone: 800 * pf, duration: rj(0.28, 0.20), volume: rj(0.42, 0.12) * v, attack: 0.0008, decay: 0.275, pan: downPan }); + addHit({ type: "triangle", tone: 540 * pf, duration: rj(0.28, 0.20), volume: rj(0.36, 0.12) * v, attack: 0.0008, decay: 0.275, pan: downPan }); + break; + } + + case "g#": { // wood block — single triangle @ 2500 Hz (one-shot) + const downPan = pan + rn(-0.03, 0.03); + addHit({ type: "noise", tone: 5000 * pf, duration: 0.002, volume: rj(0.35, 0.18) * v, attack: 0.0001, decay: 0.0018, pan: downPan }); + addHit({ type: "triangle", tone: 2500 * pf, duration: rj(0.050, 0.25), volume: rj(0.52, 0.12) * v, attack: 0.0003, decay: 0.048, pan: downPan }); + addHit({ type: "triangle", tone: 1250 * pf, duration: rj(0.050, 0.25), volume: rj(0.18, 0.18) * v, attack: 0.0005, decay: 0.048, pan: downPan }); + break; + } + + case "a#": { // tambourine — staggered jingle bursts (one-shot) + const downPan = pan + rn(-0.04, 0.04); + addHit({ type: "noise", tone: 7000 * pf, duration: 0.08, volume: rj(0.38, 0.18) * v, attack: 0.002, decay: 0.075, pan: downPan }); + addHit({ type: "noise", tone: 7500 * pf, duration: 0.09, volume: rj(0.30, 0.18) * v, attack: 0.015, decay: 0.075, pan: downPan }); + addHit({ type: "noise", tone: 6500 * pf, duration: 0.10, volume: rj(0.25, 0.18) * v, attack: 0.030, decay: 0.070, pan: downPan }); + addHit({ type: "square", tone: 6000 * pf, duration: 0.030, volume: rj(0.14, 0.20) * v, attack: 0.001, decay: 0.028, pan: downPan }); + addHit({ type: "noise", tone: 7000 * pf, duration: rj(0.20, 0.22), volume: rj(0.32, 0.18) * v, attack: 0.050, decay: 0.195, pan: downPan + rn(-0.04, 0.04) }); + addHit({ type: "noise", tone: 4500 * pf, duration: rj(0.15, 0.22), volume: rj(0.20, 0.18) * v, attack: 0.055, decay: 0.145, pan: downPan + rn(-0.04, 0.04) }); + break; + } + } +} + +// source: system/public/aesthetic.computer/lib/synth.mjs +// Virtual Synth Controller — a reusable instrument any AC disk piece can +// instantiate to play pitched notes, chords, sustained voices, and the shared +// notepat percussion kit, without re-deriving note frequencies or drum recipes. +// +// Usage inside a piece (sim/act both receive `sound`): +// +// import { Synth } from "../lib/synth.mjs"; +// let synth; +// function sim({ sound }) { synth ??= Synth(sound); } +// function act({ event: e, sound }) { +// synth ??= Synth(sound); +// if (e.is("keyboard:down:space")) synth.note("c4"); // pitched +// if (e.is("keyboard:down:k")) synth.kick(); // drum +// if (e.is("keyboard:down:c")) synth.chord(["c4","e4","g4"]); +// const held = synth.hold("a3"); // sustained (duration "🔁") +// // ...later: held.kill(0.1); +// } +// +// It wraps the piece's own `sound` API (sound.synth / sound.freq) plus the +// shared 12-drum kit in ./percussion.mjs. No persistent global state — each +// Synth() is an independent controller you can keep on a module variable. + + +// Drum display-name → note letter (reverse of PERCUSSION_NAMES), so you can +// say synth.perc("kick") instead of synth.perc("c"). +const NAME_TO_LETTER = Object.fromEntries( + Object.entries(PERCUSSION_NAMES).map(([letter, name]) => [name, letter]), +); + +// Friendly aliases for the convenience drum methods (hat → closed hat, etc.). +const DRUM_ALIASES = { + kick: "c", bass: "c", snare: "d", clap: "e", snap: "f", + hat: "g", hatClosed: "g", closedHat: "g", hatOpen: "a", openHat: "a", + ride: "b", crash: "c#", splash: "d#", cowbell: "f#", block: "g#", tambo: "a#", +}; + +// Equal-temperament fallback (A4 = 440) used only if sound.freq is absent. +const SEMITONE = { + c: 0, "c#": 1, db: 1, d: 2, "d#": 3, eb: 3, e: 4, f: 5, + "f#": 6, gb: 6, g: 7, "g#": 8, ab: 8, a: 9, "a#": 10, bb: 10, b: 11, +}; +function fallbackHz(str) { + const m = String(str).toLowerCase().match(/^([a-g](?:#|b)?)(-?\d)?$/); + if (!m) return parseFloat(str) || 440; + const semi = SEMITONE[m[1]] ?? 0; + const oct = m[2] != null ? parseInt(m[2], 10) : 4; + return 440 * Math.pow(2, (semi + (oct - 4) * 12 - 9) / 12); +} + +function Synth(sound, opts = {}) { + if (!sound || !sound.synth) { + // Return an inert controller so callers never crash before audio is ready. + const noop = () => null; + return new Proxy({}, { get: () => noop }); + } + + const state = { + wave: opts.wave || opts.voice || "sine", + volume: opts.volume ?? 0.4, + pan: opts.pan ?? 0, + }; + + // note name (or raw Hz number) → frequency in Hz. + const hz = (n) => + typeof n === "number" ? n : sound.freq?.(n) ?? fallbackHz(n); + + // Play a single pitched note. Returns the synth handle (has .kill / .update). + function note(n, o = {}) { + const duration = o.duration ?? o.dur ?? 0.25; + return sound.synth({ + type: o.wave || o.type || state.wave, + tone: hz(n), + attack: o.attack ?? 0.01, + decay: o.decay ?? duration * 0.5, + sustain: o.sustain ?? 0, + release: o.release ?? 0.06, + volume: o.volume ?? state.volume, + pan: o.pan ?? state.pan, + duration, + }); + } + + // Start a sustained note (loops until killed). Returns the handle — call + // handle.kill(fade) to release it. + function hold(n, o = {}) { + return sound.synth({ + type: o.wave || o.type || state.wave, + tone: hz(n), + attack: o.attack ?? 0.01, + decay: o.decay ?? 0.9, + sustain: o.sustain ?? 1, + volume: o.volume ?? state.volume, + pan: o.pan ?? state.pan, + duration: "🔁", + }); + } + + // Play several notes at once (root slightly louder). Returns an array. + function chord(notes, o = {}) { + const base = o.volume ?? state.volume; + return (notes || []).map((n, i) => + note(n, { ...o, volume: base * (i === 0 ? 1 : 0.7) }), + ); + } + + // Sustained chord — returns handles; kill them all with releaseAll(handles). + function holdChord(notes, o = {}) { + const base = o.volume ?? state.volume; + return (notes || []).map((n, i) => + hold(n, { ...o, volume: base * (i === 0 ? 1 : 0.7) }), + ); + } + + function releaseAll(handles, fade = 0.12) { + (handles || []).forEach((h) => h?.kill?.(fade)); + } + + // Fire a drum from the shared kit. `name` accepts a display name ("kick", + // "hat-c"), an alias ("openHat"), or a raw note letter ("c", "c#"). + function perc(name, o = {}) { + const letter = NAME_TO_LETTER[name] || DRUM_ALIASES[name] || name; + return playPercussion(sound, letter, o); + } + + const api = { + note, + hold, + chord, + holdChord, + releaseAll, + perc, + // Set the default voice/waveform for subsequent note()/chord() calls. + voice: (w) => ((state.wave = w), w), + // Tweak a default (volume / pan / wave). + set: (k, v) => ((state[k] = v), v), + hz, + state, + drumNames: PERCUSSION_NAMES, + }; + + // Convenience drum methods: synth.kick(), .snare(), .hat(), .openHat(), … + for (const [alias, letter] of Object.entries(DRUM_ALIASES)) { + api[alias] = (o) => perc(letter, o); + } + + return api; +} + +// source: system/public/aesthetic.computer/lib/nom-score.mjs +// Shared scoring rules for the Nom games. + +const NOM_SCORE_LIMIT = 1_000_000_000; + +function munchPoints(combo) { + const streak = Math.max(1, Math.trunc(Number(combo) || 1)); + return 100 + (streak - 1) * 25; +} + +function clearPoints(level, beatsLeft) { + const board = Math.max(1, Math.trunc(Number(level) || 1)); + const beats = Math.max(0, Math.trunc(Number(beatsLeft) || 0)); + return board * 250 + beats * 10; +} + +function normalizeNomRun(value = {}) { + const score = Math.trunc(Number(value.score)); + const level = Math.trunc(Number(value.level)); + const correct = Math.trunc(Number(value.correct)); + if ( + !Number.isFinite(score) || score < 0 || score > NOM_SCORE_LIMIT || + !Number.isFinite(level) || level < 1 || level > 10_000 || + !Number.isFinite(correct) || correct < 0 || correct > 1_000_000 + ) return null; + return { score, level, correct }; +} + +// Positive means a is the stronger run. Earlier achievement wins an exact tie. +function compareNomRuns(a, b) { + if (!b) return 1; + if (a.score !== b.score) return a.score - b.score; + if (a.level !== b.level) return a.level - b.level; + if (a.correct !== b.correct) return a.correct - b.correct; + const at = new Date(a.when || 0).getTime(); + const bt = new Date(b.when || 0).getTime(); + return bt - at; +} + +// source: system/public/aesthetic.computer/lib/nom.mjs +// nom — shared engine for the muncher games (numbnom / engnom / mexinom / dannom / rusnom / notenom / catnom / artnom), 2026.06.07 +// Move the muncher around a 5×5 grid and eat every square that satisfies the +// rule at the top — but dodge the Troggles. `numbnom` = numbers, `engnom` = words, +// `notenom` = musical notes (note mode voices each square + plays the scale), +// `catnom` = Categories-game rounds (AC pieces, code words, slang, vibes), +// `artnom` = museum thumbnails grouped by Getty AAT art-historical styles. + + +/* #region 📚 README + Controls: + Arrows / WASD — move the Muncher + Space / Enter — munch the current square + Tap a square — walk there and munch it on arrival + Tap own square — munch it right now + + Modes (pass after a colon): + munchers → numbers + munchers:numbers → numbers + munchers:words → words + munchers:notes → musical notes (notenom) + munchers:cat → categories (catnom) + munchers:art → art history thumbnails (artnom) +#endregion */ + +/* #region 🏁 TODO + - [x] Add a leaderboard / score persistence. + - [] More Troggle personalities (some eat numbers, some teleport). + - [] Sound for the Troggle stomp. +#endregion */ + +const COLS = 5; +const ROWS = 5; + +// 🌐 Game state +let mode = "number"; // "number" | "word" | "note" | "art" +let lang = "en"; // word language: "en" engnom | "es" mexinom | "da" dannom | "ru" rusnom | "cat" catnom +let hiRes = false; // 🖼️ render via the hd() native-resolution Canvas2D layer (opt-in per edition) +let noteScale = []; // current board's scale (note mode), played on board start +let state = "title"; // title | play | clear | over | win +let grid = []; // [{ value, correct, eaten, flash }] +let ruleLabel = ""; // short on-screen token (e.g. "X3") +let ruleSpeech = ""; // spoken phrase (e.g. "multiples of 3") +let caption = ""; // 💬 last spoken text — always shown along the bottom +let captionFrame = 0; // frame the caption was set (for a quick fade-in) +const warmed = new Set(); // utterances already sent to the TTS cache this session +let muncher = { col: 2, row: 2 }; // logical grid position +let muncherVis = { col: 2, row: 2 }; // smoothed display position (slides) +let hover = null; // { col, row } cell under the mouse, for hover highlight +let swallowTap = false; // 👆 set when a tap was consumed (e.g. advancing an overlay) +let walkTarget = null; // 👆 tap-to-move destination cell — sim walks the muncher there +let walkTick = 0; // frames until the walker's next step +const WALK_STEP_FRAMES = 7; // tap-to-move cadence (~115ms per square) +let troggles = []; +let level = 1; // current board number, displayed and included in ranking +let remaining = 0; // correct cells left to eat +let foundValues = []; // distinct correct values eaten (shown in a bottom row) +let boardCorrectTotal = 1; // correct cells the board started with (fatness gauge) +let invuln = 0; // frames of board-start immunity (troggle spawn grace) +let mouth = 0; // muncher mouth animation phase +let troggleClock = null; +let message = ""; // transient feedback line +let messageTimer = 0; +let artCatalog = null; // generated Getty-AAT/museum manifest (artnom only) +let artPreload = null; // net.preload captured at boot for remote IIIF thumbnails +let artLoadGeneration = 0; // ignores thumbnail promises from a prior boot +let artImages = new Map(); // remote image URL -> bitmap | "loading" | "failed" +let lastArtwork = null; // most recently munched object; I opens its museum page + +// 🔊 + ✨ Feel +let snd = null; // live sound handle (captured each frame) +let synth = null; // virtual synth controller (notes + perc kit), lazy-created +let speakFn = null; // live speak() handle (captured in act) +let frames = 0; // global frame counter (drives legs + melody scheduler) +let facing = { x: 1, y: 0 }; // direction the Muncher's mouth points +let chompPhase = 0; // frames remaining in a chomp animation +let combo = 0; // consecutive correct munches → rising pitch +let melody = []; // queued notes: { at, type, tone, duration, volume } +let score = 0; +let correctMunches = 0; +let playerHandle = null; +let playerColors = null; +let authorizeFn = null; +let leaderboard = []; +let leaderboardRank = null; +let leaderboardStatus = ""; +let lastSubmittedKey = ""; +let scoreGeneration = 0; + +// 🎥 Dynamic camera (smoothed follow-lean + zoom punch + shake), à la dumduel. +let cam = { x: 0, y: 0, zoom: 1, shake: 0 }; +let camTargetZoom = 1; +const MAX_ZOOM = 1.18; // peak zoom punch — layout reserves headroom so it fits +let screenH = 512; // last seen screen height (for confetti culling) + +// 💀 / 🎉 Sequenced animations. +let deathPhase = 0; // counts down during the death animation +let deathPos = { col: 2, row: 2 }; // where the Muncher died +let pendingOver = false; // game over fires after the death animation +let clearPhase = 0; // counts down during the level-clear celebration +let flashRed = 0; // red screen-flash intensity (death) +let flashGold = 0; // gold screen-flash intensity (clear) +let flashGreen = 0; // green screen-flash intensity (board start) +let confetti = []; // { x, y, vx, vy, s, c } celebration bits + +// ⏳ Per-board countdown — measured in musical beats, ticked off the *wall +// clock* (clock.time(), network-synced) rather than a free-running frame +// counter. Every board everywhere shares the same global beat grid, so two +// games started seconds apart still pulse in unison — like clocks naturally do. +const FPS = 60; +const BPM = 74; // slower, more relaxed groove +const BEAT_MS = (60 / BPM) * 1000; // milliseconds per beat (~810ms, BPM 74) +let beatMs = BEAT_MS; // active beat length (ms) — note mode overrides to BPM 92 +let nowMs = 0; // last seen synced wall-clock time (ms), captured each sim +let beatIndex = 0; // global beat number = floor(nowMs / beatMs) +let lastBeatIndex = null; // beat index at the previous tick (null until first) +let beatsLeft = 0; // beats of time remaining on this board +let beatsMax = 1; // beats the board started with +let beatPhase = 0; // 0..1 fraction into the current beat (drives sub-beat feel) +let beatPulse = 0; // brief visual pulse on each beat +let heldChord = null; // sustained synth voices while munch is held + +// 🪧 Intro — the board is live from frame one (no title gate / "press any key"). +// A brief name+edition card fades over the playing board; the beat budget is +// frozen until it clears so reading the title costs no time. +const INTRO_FRAMES = 132; // ~2.2s at 60fps +let introTimer = 0; // counts down while the intro card is showing +let pendingStart = false; // announce the rule + play the start chime once audio is up + +// ♻️ Full state reset. This engine module is a session singleton shared by every +// nom disk (numbnom/engnom/mexinom/dannom/rusnom/notenom), so its module-level +// state survives navigation. boot() runs fresh on every entry, so reinitializing +// everything here makes each piece a clean, self-contained AC piece — no bleed +// between games (mirrors world_boot / nopaint_boot). Keep in sync with the +// declarations at the top of the file. +function reset() { + mode = "number"; lang = "en"; hiRes = false; noteScale = []; + state = "title"; grid = []; ruleLabel = ""; ruleSpeech = ""; caption = ""; + muncher = { col: 2, row: 2 }; muncherVis = { col: 2, row: 2 }; + hover = null; swallowTap = false; walkTarget = null; walkTick = 0; + sprites.clear(); // drop last session's hd label sprites (fonts/theme may differ) + pixelWashDark = null; // the hidden pixel buffer may hold another piece's art + troggles = []; level = 1; remaining = 0; + foundValues = []; boardCorrectTotal = 1; + invuln = 0; mouth = 0; troggleClock = null; message = ""; messageTimer = 0; + snd = null; synth = null; speakFn = null; frames = 0; + facing = { x: 1, y: 0 }; chompPhase = 0; combo = 0; melody = []; + score = 0; correctMunches = 0; playerHandle = null; playerColors = null; + authorizeFn = null; leaderboard = []; leaderboardRank = null; + leaderboardStatus = ""; lastSubmittedKey = ""; scoreGeneration += 1; + cam = { x: 0, y: 0, zoom: 1, shake: 0 }; camTargetZoom = 1; screenH = 512; + deathPhase = 0; deathPos = { col: 2, row: 2 }; pendingOver = false; + clearPhase = 0; flashRed = 0; flashGold = 0; flashGreen = 0; confetti = []; + beatMs = BEAT_MS; nowMs = 0; beatIndex = 0; lastBeatIndex = null; + beatsLeft = 0; beatsMax = 1; beatPhase = 0; beatPulse = 0; heldChord = null; + introTimer = 0; pendingStart = false; + artCatalog = null; artPreload = null; artLoadGeneration += 1; + artImages.clear(); lastArtwork = null; +} + +// 🥾 Boot — reset all state, then drop straight onto a live board (no title gate +// / "press any key"); a brief intro card fades over the playing board. +function nomBoot({ params, hud, clock, artnom, net, handle, authorize, num: { randInt } }) { + reset(); + clock?.resync?.(); // network-sync the wall clock; board beats ride this grid + const m = resolveMode(params); + mode = m.mode; + lang = m.lang; + artCatalog = artnom || null; + artPreload = net?.preload || null; + const rawHandle = handle?.(); + playerHandle = rawHandle + ? (String(rawHandle).startsWith("@") ? String(rawHandle) : `@${rawHandle}`) + : null; + authorizeFn = authorize || null; + // The Canvas2D adapter intentionally has no bitmap-paste surface yet, so the + // image edition stays on the pixel renderer. All text editions remain HD. + hiRes = mode !== "art" && params?.includes?.("sd") !== true; + hud?.label?.(""); // hide the top-left corner label + resetGame({ randInt }); + newRound({ randInt }); + state = "play"; + introTimer = INTRO_FRAMES; // title/edition card over the live board (beats frozen) + flashGreen = 16; // green "go!" flash + pendingStart = true; // announce the rule + chime once audio is available + loadComicFont(); + loadLeaderboard(scoreGeneration); + loadPlayerColors(scoreGeneration); +} + +function gameKey() { + if (mode === "art") return "artnom"; + if (mode === "note") return "notenom"; + if (mode !== "word") return "numbnom"; + return { en: "engnom", es: "mexinom", da: "dannom", ru: "rusnom", cat: "catnom" }[lang] || "engnom"; +} + +async function readScoreResponse(response) { + if (!response?.ok) throw new Error(`score api ${response?.status || "failed"}`); + return response.json(); +} + +async function loadLeaderboard(generation = scoreGeneration) { + if (typeof fetch !== "function") return; + try { + const data = await readScoreResponse(await fetch(`/api/nom-scores?game=${gameKey()}`)); + if (generation === scoreGeneration) + leaderboard = Array.isArray(data.scores) ? data.scores : []; + } catch { + if (generation === scoreGeneration) leaderboardStatus = "scores offline"; + } +} + +async function loadPlayerColors(generation = scoreGeneration) { + if (!playerHandle || typeof fetch !== "function") return; + try { + const bare = playerHandle.replace(/^@/, ""); + const response = await fetch(`/api/handle-colors?handle=${encodeURIComponent(bare)}`); + if (!response.ok) return; + const data = await response.json(); + if (generation === scoreGeneration && Array.isArray(data.colors)) playerColors = data.colors; + } catch { + // Custom colors are optional; the renderer supplies a lively fallback. + } +} + +async function submitRun() { + if (!playerHandle || !authorizeFn || typeof fetch !== "function") { + if (!playerHandle) leaderboardStatus = "set a handle to rank"; + return; + } + const key = `${score}:${level}:${correctMunches}`; + if (key === lastSubmittedKey) return; + lastSubmittedKey = key; + const generation = scoreGeneration; + try { + const token = await authorizeFn(); + if (!token) throw new Error("not authorized"); + const data = await readScoreResponse(await fetch("/api/nom-scores", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ game: gameKey(), score, level, correct: correctMunches }), + })); + if (generation !== scoreGeneration) return; + leaderboardRank = data.rank || null; + leaderboardStatus = data.improved ? `new best · #${data.rank}` : `best · #${data.rank}`; + await loadLeaderboard(generation); + } catch { + if (generation === scoreGeneration) leaderboardStatus = "score not saved"; + } +} + +// Map the first colon param → { mode, lang }. Shared by boot() (sets the live +// globals) and makeMeta() so a piece's identity is computed from its params, +// not from leftover singleton state — the system calls meta() *before* boot(), +// and this engine module is reused across nom pieces, so reading the globals in +// meta() would otherwise mislabel (e.g. dannom showing the number edition). +function resolveMode(params) { + const p = params?.[0]; + if (p === "art" || p === "artnom" || p === "museum") return { mode: "art", lang: "en" }; + if (p === "spanish" || p === "es" || p === "mexi" || p === "mexinom") return { mode: "word", lang: "es" }; + if (p === "danish" || p === "da" || p === "dansk" || p === "dannom") return { mode: "word", lang: "da" }; + if (p === "russian" || p === "ru" || p === "russkiy" || p === "rusnom") return { mode: "word", lang: "ru" }; + if (p === "cat" || p === "cats" || p === "categories" || p === "catnom") return { mode: "word", lang: "cat" }; + if (p === "words" || p === "word" || p === "english" || p === "en") return { mode: "word", lang: "en" }; + if (p === "notes" || p === "note" || p === "music" || p === "notenom") return { mode: "note", lang: "en" }; + return { mode: "number", lang: "en" }; // default (incl. bare / numbers) +} + +// 🎲 Round generation ──────────────────────────────────────────────────────── + +const { abs, floor, min, max, round, sin, cos, pow } = Math; + +function isPrime(n) { + if (n < 2) return false; + for (let i = 2; i * i <= n; i += 1) if (n % i === 0) return false; + return true; +} + +// Every digit 0–9 has its own fixed color; numbers are drawn digit-by-digit. +const DIGIT_COLORS = [ + [255, 105, 97], // 0 coral + [255, 170, 80], // 1 orange + [255, 225, 90], // 2 yellow + [150, 225, 90], // 3 lime + [90, 220, 140], // 4 green + [90, 215, 210], // 5 teal + [95, 180, 255], // 6 blue + [150, 150, 255], // 7 indigo + [200, 140, 255], // 8 violet + [255, 140, 210], // 9 pink +]; +function charHue(ch) { + if (ch >= "0" && ch <= "9") return DIGIT_COLORS[ch.charCodeAt(0) - 48]; + if (ch >= "a" && ch <= "z") + return DIGIT_COLORS[(ch.charCodeAt(0) - 97) % DIGIT_COLORS.length]; + return null; // theme's plain text color +} +function charColor(ch) { + const c = charHue(ch); + return c ? themedText(c) : T.plainText; +} +// Theme-aware tint for a cell's background, keyed to its last character. +function tileColor(str) { + return tileTint(charHue(str[str.length - 1] || "0") || [140, 150, 180]); +} + +// Color-name words → their actual color (EN + ES), so COLORS/COLORES squares +// are literally that color. +const COLOR_WORDS = { + red: [230, 60, 60], rojo: [230, 60, 60], + blue: [70, 120, 240], azul: [70, 120, 240], + green: [70, 200, 90], verde: [70, 200, 90], + pink: [240, 120, 180], rosa: [240, 120, 180], + gold: [240, 200, 70], oro: [240, 200, 70], + gray: [150, 150, 160], gris: [150, 150, 160], + black: [35, 35, 44], negro: [35, 35, 44], + white: [235, 235, 245], blanco: [235, 235, 245], + brown: [150, 90, 50], cafe: [150, 90, 50], + purple: [160, 90, 220], morado: [160, 90, 220], + tan: [210, 180, 140], lime: [160, 230, 90], teal: [80, 200, 190], + navy: [60, 80, 170], cyan: [90, 220, 230], rose: [230, 120, 140], + // 🇩🇰 Danish color words (real spelling — board renders them in MatrixChunky8). + "rød": [230, 60, 60], "blå": [70, 120, 240], "grøn": [70, 200, 90], + gul: [240, 210, 70], sort: [35, 35, 44], hvid: [235, 235, 245], + "grå": [150, 150, 160], brun: [150, 90, 50], + // 🇷🇺 Russian color words (Cyrillic — board renders them in MatrixChunky8). + "красный": [230, 60, 60], "синий": [70, 120, 240], "зелёный": [70, 200, 90], + "жёлтый": [240, 210, 70], "чёрный": [35, 35, 44], "белый": [235, 235, 245], + "серый": [150, 150, 160], "розовый": [240, 120, 180], +}; +function luma(c) { + return 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]; +} + +// 🌗 Theme — auto light/dark, following the system setting. paint() re-reads +// `api.dark` every frame, so flipping the OS theme restyles the live board. +// T holds every structural color; the rainbow hue tables (digits, notes, +// color-words) stay fixed and run through themedText / tileTint instead. +let dark = true; +let T = null; + +function setTheme(isDark) { + if (T && dark === isDark) return; + dark = isDark; + T = dark ? { + bg: [8, 10, 26], + trackBg: [20, 24, 44], + timeHi: [120, 220, 120], timeMid: [240, 210, 90], timeLow: [240, 80, 80], + timerPanic: [255, 60, 60], timerPanicAlt: [255, 235, 120], + ruleLabel: [255, 230, 120], + captionText: [245, 248, 255], captionBg: [0, 0, 0, 160], + eaten: [15, 17, 34], + failedBg: [44, 22, 24], + missedBg: [72, 74, 82], missedText: [228, 230, 238], missedOutline: [210, 214, 222], + outline: [60, 70, 120], knownOutline: [150, 245, 170], + hoverFill: [255, 255, 255, 28], hoverOutline: [235, 242, 255], + wrapHint: [110, 215, 255, 110], + beckon: [170, 255, 195], + leftLabel: [140, 165, 195], leftWarn: [245, 130, 130], leftOk: [150, 210, 175], + introName: [255, 230, 120], introSub: [150, 220, 255], + overlaySub: [210, 220, 255], + plainText: [230, 230, 245], + } : { + bg: [236, 240, 247], + trackBg: [213, 219, 231], + timeHi: [40, 150, 70], timeMid: [190, 148, 16], timeLow: [198, 50, 50], + timerPanic: [205, 32, 32], timerPanicAlt: [170, 130, 12], + ruleLabel: [158, 116, 8], + captionText: [28, 32, 44], captionBg: [255, 255, 255, 180], + eaten: [224, 229, 238], + failedBg: [240, 219, 219], + missedBg: [203, 207, 216], missedText: [56, 60, 70], missedOutline: [128, 134, 148], + outline: [175, 185, 207], knownOutline: [26, 138, 66], + hoverFill: [20, 30, 50, 24], hoverOutline: [50, 70, 100], + wrapHint: [24, 104, 164, 150], + beckon: [20, 150, 80], + leftLabel: [96, 110, 134], leftWarn: [198, 54, 54], leftOk: [34, 124, 86], + introName: [150, 108, 6], introSub: [24, 98, 156], + overlaySub: [64, 74, 110], + plainText: [42, 46, 58], + }; +} +setTheme(true); + +// Text hue adapted to the theme — the rainbow is tuned for the dark board; +// on paper each hue drops to ~58% so it keeps contrast on pastel tiles. +function themedText(c) { + return dark ? c : [round(c[0] * 0.58), round(c[1] * 0.58), round(c[2] * 0.58)]; +} + +// Tile-background tint from a hue — deep tints on dark, pastels on light. +function tileTint(c) { + return dark + ? [round(c[0] * 0.24) + 6, round(c[1] * 0.24) + 6, round(c[2] * 0.24) + 6] + : [255 - round((255 - c[0]) * 0.22), 255 - round((255 - c[1]) * 0.22), 255 - round((255 - c[2]) * 0.22)]; +} + +// 🔤 Text fitting ───────────────────────────────────────────────────────────── +// Word cells render in GNU unifont — fixed 8×16, highly legible, with full +// coverage of every edition's glyphs (ASCII, Danish æ/ø/å, Cyrillic). Numbers/ +// notes stay on the default 6×10 font. Sizing is *measurement-free* on purpose: +// measuring proportional widths via text.box was unreliable because non-ASCII +// advances only arrive after the glyph BDF streams in, which scrambled early +// frames. unifont's fixed 8px advance makes size + centering exact and stable. +const CELL_MARGIN = 4; // px inset on each side of a cell +const FONT_METRICS = { + unifont: { adv: 8, h: 16 }, // fixed-width — exact sizing, no async surprises + MatrixChunky8: { adv: 5, h: 8 }, // proportional (kept for reference) + _default: { adv: 6, h: 10 }, // font_1 (monospace) +}; +// hd rasterizes unifont ~14% narrower than its bitmap advance, so the fixed +// grid reads gappy there. Tighter tracking → words fit bigger and closer +// (size / space / legibility). The pixel path keeps the true bitmap grid. +const FONT_METRICS_TIGHT = { unifont: { adv: 7, h: 16 } }; +const fontMetrics = (font) => + (hiRes && FONT_METRICS_TIGHT[font]) || FONT_METRICS[font] || FONT_METRICS._default; + +function cellFont() { + return mode === "word" ? "unifont" : null; // null → default font_1 +} + +// Fit a label inside a cell at the LARGEST integer scale the cell allows — +// readability first, on every display. Words are still sacred: always the +// whole word on ONE line, never split / hyphenated. A word that would +// overflow even at scale 1 refits on the tile's DIAGONAL instead — 45° buys +// √2 more run — and drops to fractional scale when even that is tight (the +// hd layer rasterizes any px size; long dannom words live here). +const CELL_TEXT_MAX = 6; // sanity cap on cell label scale +const fitScratch = { lines: [""], size: 1, lineH: 10, width: 0, height: 0, font: null, rot: 0 }; +function fitText(str, cell, font) { + const { adv, h } = fontMetrics(font); + const inner = max(8, cell - CELL_MARGIN * 2); + const across = inner / (str.length * adv); // scale that fits horizontally + let size, rot = 0; + if (across >= 1) { + size = max(1, min(CELL_TEXT_MAX, floor(min(across, inner / h)))); + } else { + // A w×h label rotated 45° spans (w+h)/√2 — solve that for scale. + rot = -45; // up-slope: reads bottom-left → top-right + size = min(CELL_TEXT_MAX, (inner * Math.SQRT2) / (str.length * adv + h)); + if (size >= 1) size = floor(size); + } + // One shared scratch object — callers consume it synchronously, so this + // avoids ~30 small allocations per frame. + fitScratch.lines[0] = str; + fitScratch.size = size; + fitScratch.lineH = h * size; + fitScratch.width = str.length * adv * size; + fitScratch.height = h * size; + fitScratch.font = font; + fitScratch.rot = rot; + return fitScratch; +} + +// Draw a (possibly two-line) cell label, centered in the cell with an optional +// jitter offset + alpha. Shared by the live board and the wrap-preview ghosts. +// The pixel path (previews/icons) can't rotate or fractional-scale bitmap +// glyphs, so a diagonal fit falls back to the old scale-1 seam bleed there. +function drawCellText(ink, txt, cx, cy, cell, font, col, alpha = 255, jx = 0, jy = 0) { + const fit = fitText(txt, cell, font); + const { adv } = fontMetrics(font); + const paint = alpha >= 255 ? ink(...col) : ink(col[0], col[1], col[2], alpha); + const rot = paint.hd ? fit.rot : 0; + const size = paint.hd ? fit.size : max(1, floor(fit.size)); + const lineH = (fit.lineH / fit.size) * size; + const baseY = cy + (cell - lineH * fit.lines.length) / 2 + jy; + fit.lines.forEach((ln, i) => { + const lw = ln.length * adv * size; + const lx = cx + (cell - lw) / 2 + jx; + const ly = baseY + i * lineH; + const pos = rot ? { x: lx, y: ly, size, rot } : { x: lx, y: ly, size }; + paint.write(ln, pos, undefined, undefined, false, font || undefined); + }); +} + +// 🎵 Note model (notenom — `mode === "note"`) ──────────────────────────────── +const LETTERS = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; +// Each pitch class gets a fixed hue so the board reads like a little keyboard. +const NOTE_COLORS = { + C: [255, 105, 97], "C#": [255, 150, 80], D: [255, 205, 90], "D#": [200, 225, 90], + E: [120, 220, 110], F: [90, 215, 175], "F#": [90, 205, 220], G: [95, 165, 255], + "G#": [150, 150, 255], A: [195, 130, 255], "A#": [235, 120, 220], B: [255, 120, 170], +}; +function noteColor(letter) { + return themedText(NOTE_COLORS[letter] || [220, 220, 235]); +} +function noteTile(letter) { + return tileTint(NOTE_COLORS[letter] || [220, 220, 235]); +} +function noteLabel(letter, oct) { + return letter + oct; // e.g. "C4", "F#5" +} +function cellNote(cell) { + return noteLabel(cell.letter, cell.oct).toLowerCase(); +} + +// 🎼 Note rules — one per board. `test(letter, oct)` decides correctness, `say` +// is spoken on board start, and `scale` is the ascending run played underneath. +const NOTE_ROUNDS = [ + { + label: "C MAJOR", say: "c major", + test: (l) => ["C", "D", "E", "F", "G", "A", "B"].includes(l), + scale: ["C4", "D4", "E4", "F4", "G4", "A4", "B4", "C5"], + }, + { + label: "A MINOR", say: "a minor", + test: (l) => ["A", "B", "C", "D", "E", "F", "G"].includes(l), + scale: ["A3", "B3", "C4", "D4", "E4", "F4", "G4", "A4"], + }, + { + label: "G MAJOR", say: "g major", + test: (l) => ["G", "A", "B", "C", "D", "E", "F#"].includes(l), + scale: ["G3", "A3", "B3", "C4", "D4", "E4", "F#4", "G4"], + }, + { + label: "SHARPS", say: "sharps", + test: (l) => l.includes("#"), + scale: ["C#4", "D#4", "F#4", "G#4", "A#4", "C#5"], + }, + { + label: "C CHORD", say: "c chord", + test: (l) => ["C", "E", "G"].includes(l), + scale: ["C4", "E4", "G4", "C5"], + }, + { + label: "HIGH", say: "high notes", + test: (l, o) => o >= 5, + scale: ["C5", "E5", "G5", "C6"], + }, + { + label: "LOW", say: "low notes", + test: (l, o) => o <= 3, + scale: ["C3", "E3", "G3", "C4"], + }, + { + label: "D MAJOR", say: "d major", + test: (l) => ["D", "E", "F#", "G", "A", "B", "C#"].includes(l), + scale: ["D4", "E4", "F#4", "G4", "A4", "B4", "C#5", "D5"], + }, + { + label: "E MINOR", say: "e minor", + test: (l) => ["E", "F#", "G", "A", "B", "C", "D"].includes(l), + scale: ["E4", "F#4", "G4", "A4", "B4", "C5", "D5", "E5"], + }, + { + label: "A MAJOR", say: "a major", + test: (l) => ["A", "B", "C#", "D", "E", "F#", "G#"].includes(l), + scale: ["A3", "B3", "C#4", "D4", "E4", "F#4", "G#4", "A4"], + }, + { + label: "G CHORD", say: "g chord", + test: (l) => ["G", "B", "D"].includes(l), + scale: ["G4", "B4", "D5", "G5"], + }, + { + label: "F CHORD", say: "f chord", + test: (l) => ["F", "A", "C"].includes(l), + scale: ["F4", "A4", "C5", "F5"], + }, + { + label: "AM CHORD", say: "a minor chord", + test: (l) => ["A", "C", "E"].includes(l), + scale: ["A3", "C4", "E4", "A4"], + }, + { + label: "D CHORD", say: "d chord", + test: (l) => ["D", "F#", "A"].includes(l), + scale: ["D4", "F#4", "A4", "D5"], + }, + { + label: "NATURALS", say: "natural notes", + test: (l) => !l.includes("#"), + scale: ["C4", "D4", "E4", "F4", "G4", "A4", "B4", "C5"], + }, +]; + +// Spanish → English (spoken on each munch in mexinom). +const TRANSLATE = { + taco: "taco", mole: "mole sauce", elote: "corn", chile: "chili", salsa: "salsa", + queso: "cheese", tamal: "tamale", atole: "atole", sopa: "soup", pan: "bread", frijol: "bean", + gato: "cat", perro: "dog", burro: "donkey", coyote: "coyote", puma: "puma", lobo: "wolf", + gallo: "rooster", toro: "bull", mono: "monkey", oso: "bear", pato: "duck", + rojo: "red", azul: "blue", gris: "gray", rosa: "pink", verde: "green", negro: "black", + blanco: "white", oro: "gold", cafe: "brown", morado: "purple", + mango: "mango", guayaba: "guava", lima: "lime", melon: "melon", papaya: "papaya", + mora: "berry", higo: "fig", coco: "coconut", pera: "pear", uva: "grape", + pinata: "piñata", globo: "balloon", dulce: "candy", musica: "music", baile: "dance", + feria: "fair", vela: "candle", regalo: "gift", fuego: "fire", + mesa: "table", silla: "chair", reloj: "clock", libro: "book", tren: "train", gorra: "cap", + clave: "key", vaso: "glass", foco: "bulb", muro: "wall", red: "net", taza: "cup", sol: "sun", + casa: "house", jugo: "juice", flor: "flower", mar: "sea", pie: "foot", ojo: "eye", mano: "hand", + dedo: "finger", luz: "light", rio: "river", sal: "salt", lapiz: "pencil", bota: "boot", + clavo: "nail", papel: "paper", caja: "box", + nopal: "cactus", pozole: "hominy stew", birria: "birria stew", carne: "meat", + pollo: "chicken", arroz: "rice", tortilla: "tortilla", + leon: "lion", tigre: "tiger", vaca: "cow", rana: "frog", pez: "fish", + aguila: "eagle", conejo: "rabbit", + amarillo: "yellow", naranja: "orange", violeta: "violet", plata: "silver", + turquesa: "turquoise", celeste: "sky blue", lila: "lilac", vino: "maroon", + fresa: "strawberry", limon: "lemon", platano: "banana", sandia: "watermelon", + durazno: "peach", ciruela: "plum", pina: "pineapple", manzana: "apple", + confeti: "confetti", banda: "band", mariachi: "mariachi", cohete: "firework", + premio: "prize", fiesta: "party", corona: "crown", canto: "singing", amigo: "friend", + boca: "mouth", nariz: "nose", oreja: "ear", cabeza: "head", pelo: "hair", + cara: "face", brazo: "arm", pierna: "leg", diente: "tooth", lengua: "tongue", + cuello: "neck", rodilla: "knee", codo: "elbow", corazon: "heart", + cama: "bed", puerta: "door", ventana: "window", plato: "plate", cuchara: "spoon", + tenedor: "fork", cuchillo: "knife", olla: "pot", sarten: "pan", escoba: "broom", + toalla: "towel", jabon: "soap", llave: "key", lampara: "lamp", espejo: "mirror", cobija: "blanket", + luna: "moon", estrella: "star", nube: "cloud", lluvia: "rain", viento: "wind", + montana: "mountain", arbol: "tree", hoja: "leaf", roca: "rock", arena: "sand", + lago: "lake", cielo: "sky", nieve: "snow", tierra: "earth", + mama: "mom", papa: "dad", hijo: "son", hija: "daughter", hermano: "brother", + hermana: "sister", abuelo: "grandfather", abuela: "grandmother", tio: "uncle", + tia: "aunt", primo: "cousin", prima: "cousin", nieto: "grandson", nieta: "granddaughter", + esposo: "husband", esposa: "wife", bebe: "baby", sobrino: "nephew", +}; + +// 🇩🇰 Danish → English (spoken on each munch in dannom). Real Danish spelling +// (æ ø å) — the board renders these with MatrixChunky8, which has the glyphs. +const TRANSLATE_DA = { + "brød": "bread", ost: "cheese", "pølse": "sausage", kage: "cake", suppe: "soup", + "smør": "butter", fisk: "fish", salt: "salt", sukker: "sugar", "grød": "porridge", "æg": "egg", + kat: "cat", hund: "dog", ko: "cow", gris: "pig", hest: "horse", and: "duck", + ravn: "raven", ulv: "wolf", "bjørn": "bear", "ræv": "fox", mus: "mouse", "får": "sheep", + "rød": "red", "blå": "blue", "grøn": "green", gul: "yellow", sort: "black", + hvid: "white", "grå": "gray", brun: "brown", + "æble": "apple", "pære": "pear", blomme: "plum", banan: "banana", drue: "grape", + fersken: "peach", citron: "lemon", melon: "melon", "bær": "berry", + lys: "candle", te: "tea", bog: "book", sofa: "sofa", "tæppe": "blanket", + ild: "fire", ven: "friend", kaffe: "coffee", hus: "house", sten: "stone", + tog: "train", stol: "chair", bord: "table", ur: "clock", glas: "glass", + sko: "shoe", "dør": "door", vej: "road", regn: "rain", vind: "wind", + mel: "flour", "mælk": "milk", sild: "herring", "kød": "meat", honning: "honey", + ged: "goat", "høne": "hen", fugl: "bird", "ørn": "eagle", + lilla: "purple", pink: "pink", orange: "orange", turkis: "turquoise", + guld: "gold", "sølv": "silver", beige: "beige", "lyserød": "light pink", + "jordbær": "strawberry", "hindbær": "raspberry", "kirsebær": "cherry", + appelsin: "orange", ananas: "pineapple", "blåbær": "blueberry", abrikos: "apricot", + pude: "pillow", kakao: "cocoa", pejs: "fireplace", "bål": "bonfire", + sok: "sock", hue: "beanie", kram: "hug", smil: "smile", + "hånd": "hand", fod: "foot", "øje": "eye", "øre": "ear", "næse": "nose", + mund: "mouth", arm: "arm", ben: "leg", "hår": "hair", hoved: "head", + finger: "finger", "tå": "toe", hals: "neck", "knæ": "knee", tand: "tooth", ryg: "back", + seng: "bed", lampe: "lamp", gulv: "floor", "væg": "wall", spejl: "mirror", + skab: "cupboard", kop: "cup", kniv: "knife", ske: "spoon", gryde: "pot", + gaffel: "fork", kande: "jug", vindue: "window", + sol: "sun", "måne": "moon", "sø": "lake", hav: "sea", skov: "forest", + "træ": "tree", blad: "leaf", flod: "river", bjerg: "mountain", himmel: "sky", + sky: "cloud", stjerne: "star", strand: "beach", mark: "field", "græs": "grass", blomst: "flower", + bluse: "blouse", bukser: "pants", "trøje": "sweater", kjole: "dress", hat: "hat", + frakke: "coat", jakke: "jacket", vest: "vest", slips: "tie", "bælte": "belt", + "støvle": "boot", skjorte: "shirt", handske: "glove", +}; + +// 🇷🇺 Russian → English (spoken on each munch in rusnom). Cyrillic — the board +// renders these with MatrixChunky8, which carries the full Russian alphabet. +const TRANSLATE_RU = { + "хлеб": "bread", "сыр": "cheese", "суп": "soup", "мясо": "meat", "рыба": "fish", + "сахар": "sugar", "соль": "salt", "каша": "porridge", "яйцо": "egg", "масло": "butter", "торт": "cake", + "кот": "cat", "собака": "dog", "корова": "cow", "конь": "horse", "волк": "wolf", + "лиса": "fox", "медведь": "bear", "заяц": "hare", "мышь": "mouse", "коза": "goat", "бык": "bull", "овца": "sheep", + "красный": "red", "синий": "blue", "зелёный": "green", "жёлтый": "yellow", "чёрный": "black", + "белый": "white", "серый": "gray", "розовый": "pink", + "яблоко": "apple", "груша": "pear", "слива": "plum", "банан": "banana", "виноград": "grape", + "лимон": "lemon", "персик": "peach", "дыня": "melon", "ягода": "berry", + "солнце": "sun", "луна": "moon", "река": "river", "море": "sea", "гора": "mountain", + "небо": "sky", "дождь": "rain", "снег": "snow", "ветер": "wind", "звезда": "star", + "стол": "table", "стул": "chair", "книга": "book", "окно": "window", "дверь": "door", + "ключ": "key", "часы": "clock", "лампа": "lamp", "пол": "floor", "стена": "wall", + "дом": "house", "лес": "forest", "сад": "garden", + "молоко": "milk", "борщ": "borscht", "блин": "pancake", "пирог": "pie", "мёд": "honey", + "лев": "lion", "тигр": "tiger", "слон": "elephant", "олень": "deer", + "оранжевый": "orange", "фиолетовый": "purple", "коричневый": "brown", "голубой": "light blue", + "золотой": "gold", "серебряный": "silver", "бежевый": "beige", "бирюзовый": "turquoise", + "апельсин": "orange", "мандарин": "tangerine", "ананас": "pineapple", "вишня": "cherry", + "клубника": "strawberry", "малина": "raspberry", "абрикос": "apricot", + "облако": "cloud", "трава": "grass", "цветок": "flower", "дерево": "tree", "озеро": "lake", + "голова": "head", "рука": "hand", "нога": "leg", "глаз": "eye", "нос": "nose", "рот": "mouth", + "ухо": "ear", "зуб": "tooth", "волосы": "hair", "палец": "finger", "живот": "belly", + "спина": "back", "шея": "neck", "губа": "lip", "плечо": "shoulder", "колено": "knee", + "кровать": "bed", "шкаф": "wardrobe", "диван": "sofa", "зеркало": "mirror", "ковёр": "carpet", + "полка": "shelf", "чашка": "cup", "тарелка": "plate", + "рубашка": "shirt", "футболка": "t-shirt", "платье": "dress", "юбка": "skirt", "шапка": "cap", + "шарф": "scarf", "куртка": "jacket", "пальто": "coat", "свитер": "sweater", "пояс": "belt", + "галстук": "tie", "носок": "sock", "перчатка": "glove", "кофта": "cardigan", "шляпа": "hat", "майка": "tank top", + "мама": "mom", "папа": "dad", "брат": "brother", "сестра": "sister", "сын": "son", "дочь": "daughter", + "бабушка": "grandmother", "дедушка": "grandfather", "дядя": "uncle", "тётя": "aunt", + "внук": "grandson", "внучка": "granddaughter", "муж": "husband", "жена": "wife", "мать": "mother", "отец": "father", +}; + +// 😼 Catnom glossary — spoken on each munch (slang → plain meaning, code → +// one-line gloss), so eating a word teaches it. Words without an entry are +// just echoed back. +const TRANSLATE_CAT = { + // slang + rizz: "charisma", sus: "suspicious", cap: "a lie", goat: "greatest of all time", + mid: "just okay", drip: "a stylish look", slay: "did amazing", bet: "okay, deal", + yeet: "throw it hard", vibe: "a feeling", fam: "your people", bussin: "really good", + // code + bug: "a mistake in code", loop: "code that repeats", git: "saves code history", + byte: "eight bits", bool: "true or false", null: "nothing at all", + array: "a list of things", stack: "last in, first out", repo: "where code lives", + pixel: "one screen dot", code: "instructions", crash: "when code dies", + // aesthetic computer pieces + prompt: "where you type", paint: "draw pictures", chat: "talk to everyone", + notepat: "play notes", camera: "take pictures", wand: "draw in 3 d", + tone: "make a tone", smear: "smudge the paint", bleep: "tap to beep", + wipe: "clear the screen", melody: "play a tune", + // internet + wifi: "wireless internet", meme: "a shared joke", app: "a small program", + link: "tap to go there", post: "share it online", ping: "a quick hello", + spam: "junk messages", lag: "a slow connection", dm: "a direct message", + emoji: "a tiny picture", blog: "an online journal", + // slang + lit: "exciting", flex: "show off", bop: "a great song", salty: "bitter or upset", + // code + hash: "a scrambled id", cache: "a fast stash", debug: "hunt for bugs", class: "a code blueprint", + // aesthetic computer pieces + clock: "shows the time", line: "draw a line", oval: "draw an oval", + brush: "paint with a brush", spray: "spray paint", + // vibes + lofi: "soft lo-fi sound", zen: "calm and clear", mellow: "relaxed and gentle", + serene: "calm and peaceful", + // keys + esc: "cancel or exit", ctrl: "control key", alt: "the alt key", + cmd: "the command key", opt: "the option key", fn: "function key", + // internet + url: "a web address", feed: "a stream of posts", like: "a tap of approval", + wiki: "an editable site", cloud: "storage online", + // snacks + pretzel: "a knotted salty snack", nachos: "chips with cheese", + fudge: "soft chocolate candy", jerky: "dried meat snack", + // space + comet: "an icy space ball", orbit: "a path around", galaxy: "a sea of stars", + nebula: "a space gas cloud", meteor: "a shooting star", cosmos: "all of space", + void: "empty dark space", + // music + tempo: "the speed of music", chord: "notes played together", verse: "a song section", + chorus: "the repeated part", remix: "a reworked song", riff: "a catchy phrase", + pitch: "how high or low", rhythm: "the timing pattern", snare: "a sharp drum", + synth: "an electronic sound", scale: "a ladder of notes", bass: "the low sound", + // emotions + joy: "great happiness", fear: "feeling afraid", brave: "full of courage", + proud: "happy with yourself", + // tools + wrench: "turns nuts and bolts", pliers: "grips and bends", chisel: "carves wood or stone", + clamp: "holds things tight", level: "checks if flat", sander: "smooths surfaces", + file: "smooths rough edges", bolt: "a metal fastener", +}; + +function shuffle(arr, rnd) { + for (let i = arr.length - 1; i > 0; i -= 1) { + const j = rnd(i + 1); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + +// Word categories: correct words + matching distractors. +const WORD_ROUNDS = [ + { + label: "ANIMALS", say: "animals", + correct: ["cat", "dog", "fox", "owl", "bee", "cow", "ant", "ram", "hen", "elk", "pig", "bat", "rat", "hog", "ape", "doe", "goat", "wolf", "frog", "crab"], + wrong: ["cup", "rug", "jar", "fan", "pen", "map", "key", "box", "hat", "mug", "net", "log", "cap", "tin"], + }, + { + label: "COLORS", say: "colors", + correct: ["red", "tan", "pink", "blue", "gold", "lime", "teal", "gray", "rose", "navy", "cyan", "green", "brown", "black", "white", "olive", "coral", "mint", "beige", "amber"], + wrong: ["leg", "oak", "bug", "dot", "sun", "egg", "tin", "pod", "cup", "jar", "box", "fan", "rug", "log"], + }, + { + label: "FRUITS", say: "fruits", + correct: ["plum", "lime", "pear", "fig", "kiwi", "date", "lemon", "apple", "grape", "melon", "peach", "mango", "berry", "cherry", "banana", "orange", "guava", "papaya", "lychee", "apricot"], + wrong: ["desk", "sock", "lamp", "fork", "coin", "boot", "rope", "drum", "vase", "nail", "brick", "spoon", "chair", "clock"], + }, + { + label: "RHYMES", say: "words that rhyme with cat", + correct: ["cat", "hat", "bat", "rat", "mat", "sat", "fat", "vat", "pat", "flat", "chat", "gnat", "spat", "brat", "scat", "slat", "that", "splat", "drat", "prat"], + wrong: ["cot", "hit", "bet", "rot", "mug", "sit", "fan", "van", "pen", "top", "cup", "dog", "sun", "box"], + }, + { + label: "S-WORDS", say: "words starting with s", + correct: ["sun", "sky", "sea", "sip", "sad", "sob", "saw", "sit", "sum", "ski", "spy", "six", "sew", "set", "say", "son", "sir", "sap", "soy", "sag"], + wrong: ["fun", "dry", "tea", "tip", "mad", "job", "jaw", "fit", "gum", "fly", "cry", "hop", "run", "bed"], + }, + { + label: "DOUBLES", say: "words with double letters", + correct: ["bee", "see", "moo", "egg", "add", "odd", "ebb", "too", "zoo", "off", "ill", "boo", "all", "inn", "tree", "free", "book", "food", "deep", "feet"], + wrong: ["bet", "set", "mob", "ego", "ace", "old", "elf", "tot", "zip", "oaf", "ink", "bow", "cat", "fan"], + }, + { + label: "BODY", say: "body parts", + correct: ["arm", "leg", "ear", "eye", "toe", "hip", "jaw", "rib", "lip", "gum", "shin", "knee", "hand", "foot", "nose", "chin", "neck", "back", "head", "heel"], + wrong: ["cup", "jar", "fan", "pen", "map", "box", "rug", "mug", "net", "log", "lamp", "sock", "fork", "coin"], + }, + { + label: "HOME", say: "household objects", + correct: ["cup", "jar", "fan", "pen", "map", "box", "rug", "mug", "net", "lamp", "sock", "fork", "bowl", "dish", "sofa", "desk", "chair", "clock", "broom", "towel"], + wrong: ["cat", "dog", "fox", "arm", "leg", "ear", "eye", "toe", "cow", "bee", "pig", "owl", "ram", "hen"], + }, + { + label: "FOOD", say: "food", + correct: ["bread", "rice", "egg", "soup", "cake", "meat", "milk", "bean", "corn", "fish", "ham", "jam", "taco", "pie", "stew", "toast", "honey", "pasta", "salad", "cheese"], + wrong: ["desk", "lamp", "sock", "fork", "coin", "boot", "rope", "drum", "vase", "nail", "brick", "clock", "chair", "spoon"], + }, + { + label: "WEATHER", say: "weather", + correct: ["rain", "snow", "wind", "fog", "sun", "cloud", "storm", "ice", "hail", "frost", "mist", "sleet", "gale", "dew", "heat", "cold", "sunny", "windy", "cloudy", "rainy"], + wrong: ["desk", "sock", "fork", "coin", "boot", "rope", "drum", "vase", "nail", "brick", "clock", "chair", "spoon", "lamp"], + }, + { + label: "VERBS", say: "action words", + correct: ["run", "jump", "hop", "sit", "eat", "sip", "nap", "dig", "hug", "kick", "walk", "swim", "jog", "spin", "clap", "sing", "read", "cook", "wave", "throw"], + wrong: ["cup", "jar", "fan", "desk", "lamp", "sock", "rock", "coin", "tree", "book", "door", "wall", "roof", "chair"], + }, + { + label: "SPACE", say: "outer space", + correct: ["star", "moon", "sun", "mars", "comet", "orbit", "space", "alien", "rocket", "planet", "galaxy", "venus", "saturn", "pluto", "meteor", "cosmos", "nebula", "lunar", "solar", "crater"], + wrong: ["cup", "jar", "fan", "pen", "desk", "lamp", "sock", "fork", "coin", "boot", "chair", "clock", "spoon", "broom"], + }, +]; + +// 🇲🇽 Mexinom — Spanish word categories with a Mexican-culture flavor +// (street food, lotería-style animals, fiesta colors, market fruit). Plain +// ASCII (no accents) so the bitmap font renders cleanly. +const ES_WORD_ROUNDS = [ + { + label: "COMIDA", say: "comida mexicana", + correct: ["taco", "mole", "elote", "chile", "salsa", "queso", "tamal", "atole", "sopa", "pan", "frijol", "nopal", "pozole", "birria", "carne", "pollo", "arroz", "tortilla"], + wrong: ["mesa", "silla", "reloj", "libro", "tren", "gorra", "clave", "vaso", "foco", "muro", "red", "taza"], + }, + { + label: "ANIMALES", say: "animales", + correct: ["gato", "perro", "burro", "coyote", "puma", "lobo", "gallo", "toro", "mono", "oso", "pato", "leon", "tigre", "vaca", "rana", "pez", "aguila", "conejo"], + wrong: ["mesa", "taza", "sol", "casa", "jugo", "reloj", "silla", "vaso", "tren", "flor", "muro", "foco"], + }, + { + label: "COLORES", say: "colores", + correct: ["rojo", "azul", "gris", "rosa", "verde", "negro", "blanco", "oro", "cafe", "morado", "amarillo", "naranja", "violeta", "plata", "turquesa", "celeste", "lila", "vino"], + wrong: ["pato", "mesa", "mar", "pie", "ojo", "mano", "dedo", "casa", "luz", "rio", "sal", "vaso"], + }, + { + label: "FRUTAS", say: "frutas", + correct: ["mango", "guayaba", "lima", "melon", "papaya", "mora", "higo", "coco", "pera", "uva", "fresa", "limon", "platano", "sandia", "durazno", "ciruela", "pina", "manzana"], + wrong: ["mesa", "sol", "tren", "lapiz", "bota", "vaso", "gorra", "clavo", "silla", "foco", "red", "reloj"], + }, + { + label: "FIESTA", say: "cosas de fiesta", + correct: ["pinata", "globo", "dulce", "musica", "baile", "feria", "vela", "regalo", "fuego", "confeti", "banda", "mariachi", "cohete", "premio", "fiesta", "corona", "canto", "amigo"], + wrong: ["mesa", "tren", "reloj", "muro", "foco", "clavo", "papel", "caja", "vaso", "red", "silla", "libro"], + }, + { + label: "CUERPO", say: "partes del cuerpo", + correct: ["ojo", "mano", "pie", "dedo", "boca", "nariz", "oreja", "cabeza", "pelo", "cara", "brazo", "pierna", "diente", "lengua", "cuello", "rodilla", "codo", "corazon"], + wrong: ["mesa", "silla", "reloj", "libro", "tren", "taza", "vaso", "foco", "clavo", "papel", "caja", "gorra"], + }, + { + label: "CASA", say: "cosas de la casa", + correct: ["mesa", "silla", "cama", "puerta", "ventana", "plato", "cuchara", "tenedor", "cuchillo", "olla", "sarten", "escoba", "toalla", "jabon", "llave", "lampara", "espejo", "cobija"], + wrong: ["gato", "perro", "taco", "mango", "sol", "mar", "flor", "rio", "toro", "pato", "oso", "luz"], + }, + { + label: "NATURALEZA", say: "cosas de la naturaleza", + correct: ["sol", "mar", "flor", "rio", "luna", "estrella", "nube", "lluvia", "viento", "montana", "arbol", "hoja", "roca", "arena", "lago", "cielo", "nieve", "tierra"], + wrong: ["mesa", "silla", "reloj", "libro", "tren", "taza", "vaso", "foco", "gorra", "clavo", "papel", "caja"], + }, + { + label: "FAMILIA", say: "la familia", + correct: ["mama", "papa", "hijo", "hija", "hermano", "hermana", "abuelo", "abuela", "tio", "tia", "primo", "prima", "nieto", "nieta", "esposo", "esposa", "bebe", "sobrino"], + wrong: ["mesa", "silla", "taco", "mango", "sol", "gato", "perro", "flor", "vaso", "reloj", "libro", "tren"], + }, +]; + +// 🇩🇰 Dannom — Danish word categories (mad, dyr, farver, frugt, hygge) in real +// Danish spelling (æ ø å). Rendered with MatrixChunky8 (has the glyphs); each +// munch speaks the English translation aloud (see TRANSLATE_DA). +const DA_WORD_ROUNDS = [ + { + label: "MAD", say: "danish food", + correct: ["brød", "ost", "pølse", "kage", "suppe", "smør", "fisk", "salt", "sukker", "grød", "æg", "mel", "mælk", "sild", "kød", "honning"], + wrong: ["bord", "stol", "ur", "bog", "tog", "sko", "glas", "sten", "vej", "dør", "hus"], + }, + { + label: "DYR", say: "danish animals", + correct: ["kat", "hund", "ko", "gris", "hest", "and", "ravn", "ulv", "bjørn", "ræv", "mus", "får", "ged", "høne", "fugl", "ørn"], + wrong: ["hus", "tæppe", "ur", "bog", "tog", "stol", "glas", "sten", "vej", "dør", "bord"], + }, + { + label: "FARVER", say: "danish colors", + correct: ["rød", "blå", "grøn", "gul", "sort", "hvid", "grå", "brun", "lilla", "pink", "orange", "turkis", "guld", "sølv", "beige", "lyserød"], + wrong: ["kat", "bord", "regn", "vind", "hus", "sten", "sko", "glas", "vej", "ur", "tog"], + }, + { + label: "FRUGT", say: "danish fruit", + correct: ["æble", "pære", "blomme", "banan", "drue", "fersken", "citron", "melon", "bær", "jordbær", "hindbær", "kirsebær", "appelsin", "ananas", "blåbær", "abrikos"], + wrong: ["bord", "stol", "ur", "bog", "tog", "sko", "glas", "sten", "vej", "dør", "hus"], + }, + { + label: "HYGGE", say: "cozy danish things", + correct: ["lys", "te", "bog", "sofa", "tæppe", "ild", "ven", "kaffe", "pude", "kakao", "pejs", "bål", "sok", "hue", "kram", "smil"], + wrong: ["regn", "vind", "sten", "tog", "ur", "glas", "sko", "dør", "vej", "hus", "bord"], + }, + { + label: "KROP", say: "danish body parts", + correct: ["hånd", "fod", "øje", "øre", "næse", "mund", "arm", "ben", "hår", "hoved", "finger", "tå", "hals", "knæ", "tand", "ryg"], + wrong: ["bord", "stol", "ur", "bog", "tog", "sko", "glas", "sten", "vej", "dør", "hus"], + }, + { + label: "HUS", say: "danish household objects", + correct: ["stol", "bord", "seng", "lampe", "dør", "gulv", "væg", "spejl", "skab", "kop", "kniv", "ske", "gryde", "gaffel", "kande", "vindue"], + wrong: ["kat", "hund", "fisk", "regn", "vind", "sten", "vej", "bær", "drue", "gris", "ræv"], + }, + { + label: "NATUR", say: "danish nature", + correct: ["sol", "måne", "sø", "hav", "skov", "træ", "blad", "flod", "bjerg", "himmel", "sky", "stjerne", "strand", "mark", "græs", "blomst"], + wrong: ["bord", "stol", "ur", "bog", "tog", "sko", "glas", "dør", "hus", "kop", "seng"], + }, + { + label: "TØJ", say: "danish clothing", + correct: ["sko", "bluse", "bukser", "trøje", "kjole", "hat", "hue", "sok", "frakke", "jakke", "vest", "slips", "bælte", "støvle", "skjorte", "handske"], + wrong: ["brød", "ost", "kat", "hund", "fisk", "sten", "træ", "sol", "hav", "regn", "vind"], + }, +]; + +// 🇷🇺 Rusnom — Russian word categories (еда, животные, цвета, фрукты, природа) +// in Cyrillic. Rendered with MatrixChunky8 (full Russian alphabet); each munch +// speaks the English translation aloud (see TRANSLATE_RU). +const RU_WORD_ROUNDS = [ + { + label: "ЕДА", say: "russian food", + correct: ["хлеб", "сыр", "суп", "мясо", "рыба", "сахар", "соль", "каша", "яйцо", "масло", "торт", "молоко", "борщ", "блин", "пирог", "мёд"], + wrong: ["стол", "стул", "книга", "окно", "дверь", "ключ", "часы", "лампа", "пол", "стена", "дом"], + }, + { + label: "ЖИВОТНЫЕ", say: "russian animals", + correct: ["кот", "собака", "корова", "конь", "волк", "лиса", "медведь", "заяц", "мышь", "коза", "бык", "овца", "лев", "тигр", "слон", "олень"], + wrong: ["стол", "окно", "книга", "дверь", "ключ", "часы", "дом", "лампа", "пол", "стена", "стул"], + }, + { + label: "ЦВЕТА", say: "russian colors", + correct: ["красный", "синий", "зелёный", "жёлтый", "чёрный", "белый", "серый", "розовый", "оранжевый", "фиолетовый", "коричневый", "голубой", "золотой", "серебряный", "бежевый", "бирюзовый"], + wrong: ["кот", "стол", "окно", "дом", "ключ", "часы", "мышь", "рыба", "лес", "сад", "книга"], + }, + { + label: "ФРУКТЫ", say: "russian fruit", + correct: ["яблоко", "груша", "слива", "банан", "виноград", "лимон", "персик", "дыня", "ягода", "апельсин", "мандарин", "ананас", "вишня", "клубника", "малина", "абрикос"], + wrong: ["стол", "стул", "книга", "окно", "дверь", "ключ", "часы", "дом", "пол", "стена", "лампа"], + }, + { + label: "ПРИРОДА", say: "russian nature", + correct: ["солнце", "луна", "река", "море", "лес", "гора", "небо", "дождь", "снег", "ветер", "звезда", "облако", "трава", "цветок", "дерево", "озеро"], + wrong: ["стол", "книга", "окно", "ключ", "часы", "дом", "лампа", "пол", "стена", "дверь", "стул"], + }, + { + label: "ТЕЛО", say: "russian body parts", + correct: ["голова", "рука", "нога", "глаз", "нос", "рот", "ухо", "зуб", "волосы", "палец", "живот", "спина", "шея", "губа", "плечо", "колено"], + wrong: ["стол", "стул", "окно", "дверь", "ключ", "часы", "лампа", "дом", "книга", "пол", "стена"], + }, + { + label: "ДОМ", say: "russian household things", + correct: ["стол", "стул", "книга", "окно", "дверь", "ключ", "часы", "лампа", "кровать", "шкаф", "диван", "зеркало", "ковёр", "полка", "чашка", "тарелка"], + wrong: ["кот", "собака", "рыба", "солнце", "луна", "река", "лес", "гора", "снег", "дождь", "хлеб"], + }, + { + label: "ОДЕЖДА", say: "russian clothing", + correct: ["рубашка", "футболка", "платье", "юбка", "шапка", "шарф", "куртка", "пальто", "свитер", "пояс", "галстук", "носок", "перчатка", "кофта", "шляпа", "майка"], + wrong: ["хлеб", "сыр", "кот", "собака", "рыба", "солнце", "река", "лес", "снег", "стол", "окно"], + }, + { + label: "СЕМЬЯ", say: "russian family", + correct: ["мама", "папа", "брат", "сестра", "сын", "дочь", "бабушка", "дедушка", "дядя", "тётя", "внук", "внучка", "муж", "жена", "мать", "отец"], + wrong: ["стол", "окно", "ключ", "часы", "лампа", "солнце", "река", "лес", "хлеб", "кот", "дом"], + }, +]; + +// 😼 Catnom — the Categories parlor game, nom-style: every board is one +// category and you eat what belongs. Half the rounds are AC / code flavored, +// half are everyday gen-z language — all still simple word-learning at heart. +const CAT_WORD_ROUNDS = [ + { + label: "SLANG", say: "slang words", + correct: ["rizz", "sus", "cap", "goat", "mid", "drip", "slay", "bet", "yeet", "vibe", "fam", "bussin", "lit", "flex", "bop", "salty"], + wrong: ["desk", "lamp", "fork", "sock", "rug", "mop", "jar", "pail", "hose", "rake", "crate", "broom"], + }, + { + label: "CODE", say: "coding words", + correct: ["bug", "loop", "git", "byte", "bool", "null", "array", "stack", "repo", "pixel", "code", "crash", "hash", "cache", "debug", "class"], + wrong: ["soup", "sand", "lake", "goose", "pony", "barn", "kite", "plum", "wool", "fog", "hay", "moss"], + }, + { + label: "PIECES", say: "aesthetic computer pieces", + correct: ["prompt", "paint", "chat", "notepat", "wand", "tone", "camera", "smear", "bleep", "wipe", "melody", "clock", "line", "oval", "brush", "spray"], + wrong: ["sofa", "ladder", "pickle", "anchor", "mitten", "faucet", "wallet", "helmet", "sponge", "teapot", "kettle", "basket"], + }, + { + label: "VIBES", say: "chill vibes", + correct: ["chill", "cozy", "calm", "lofi", "zen", "mellow", "soft", "comfy", "peace", "dream", "warm", "quiet", "breezy", "snug", "serene", "gentle"], + wrong: ["loud", "rush", "panic", "gloom", "harsh", "fuss", "riot", "blare", "jolt", "grind", "chaos", "stress"], + }, + { + label: "KEYS", say: "keyboard keys", + correct: ["esc", "tab", "shift", "enter", "space", "ctrl", "alt", "caps", "home", "end", "del", "fn", "cmd", "opt", "win", "pgup"], + wrong: ["door", "knob", "bell", "gate", "couch", "vent", "sill", "twig", "pond", "moss", "fern", "brook"], + }, + { + label: "ONLINE", say: "internet words", + correct: ["wifi", "meme", "app", "link", "post", "blog", "ping", "spam", "lag", "dm", "emoji", "url", "feed", "like", "wiki", "cloud"], + wrong: ["cave", "quilt", "anvil", "plow", "broom", "candle", "stump", "wagon", "shovel", "brick", "saddle", "barrel"], + }, + { + label: "SNACKS", say: "snacks", + correct: ["chips", "candy", "cookie", "donut", "popcorn", "pretzel", "gummy", "nachos", "cracker", "muffin", "fudge", "jerky", "waffle", "taco", "pizza", "fries"], + wrong: ["brick", "spoon", "towel", "pencil", "ladder", "hammer", "saddle", "bucket", "candle", "pillow", "magnet", "zipper"], + }, + { + label: "SPACE", say: "outer space", + correct: ["moon", "star", "mars", "comet", "planet", "orbit", "galaxy", "meteor", "nebula", "sun", "venus", "saturn", "rocket", "cosmos", "pluto", "void"], + wrong: ["chair", "spoon", "towel", "fence", "kettle", "jacket", "pillow", "ladder", "mailbox", "sandal", "blender", "wallet"], + }, + { + label: "MUSIC", say: "music words", + correct: ["beat", "tempo", "chord", "note", "drum", "bass", "tune", "verse", "chorus", "remix", "riff", "pitch", "rhythm", "snare", "synth", "scale"], + wrong: ["spoon", "towel", "fence", "kettle", "jacket", "pillow", "ladder", "wallet", "sandal", "mailbox", "pebble", "blender"], + }, + { + label: "EMOTIONS", say: "feelings", + correct: ["happy", "sad", "angry", "scared", "calm", "proud", "shy", "bored", "joy", "fear", "mad", "glad", "upset", "brave", "tired", "worry"], + wrong: ["spoon", "fork", "towel", "brick", "ladder", "kettle", "pillow", "wallet", "sandal", "magnet", "bucket", "pebble"], + }, + { + label: "TOOLS", say: "tools", + correct: ["hammer", "wrench", "drill", "saw", "pliers", "screw", "nail", "ruler", "chisel", "clamp", "level", "tape", "sander", "wire", "bolt", "file"], + wrong: ["apple", "pillow", "jacket", "cookie", "kitten", "bubble", "cloud", "meadow", "blanket", "muffin", "sandal", "teapot"], + }, +]; + +function newRound({ randInt }) { + // AC's randInt(n) is inclusive (0..n); wrap it to a 0..n-1 index helper. + const rnd = (n) => randInt(Math.max(1, n) - 1); + const total = COLS * ROWS; + // More correct cells as levels climb, but always solvable & not all-correct. + const nCorrect = min(total - 4, 5 + level + rnd(3)); + const cells = []; + + if (mode === "art") { + // 🖼️ One Getty AAT style, dealt from museum objects whose source taxonomy + // explicitly assigns that style. Decoys come from the other AAT rounds. + const categories = artCatalog?.categories || []; + if (!categories.length) throw new Error("Artnom catalog has no categories"); + const category = categories[rnd(categories.length)]; + ruleLabel = category.label.toUpperCase(); + ruleSpeech = category.speech || category.label; + noteScale = []; + const goods = shuffle([...(category.artworks || [])], rnd); + const bads = shuffle( + categories + .filter((candidate) => candidate.id !== category.id) + .flatMap((candidate) => candidate.artworks || []), + rnd, + ); + let nc = min(nCorrect, goods.length); + nc = max(nc, min(goods.length, total - bads.length)); + for (let i = 0; i < nc; i += 1) { + const artwork = goods[i]; + cells.push({ value: artwork.id, artwork, correct: true }); + } + for (let i = 0; i < total - nc; i += 1) { + const artwork = bads[i]; + cells.push({ value: artwork.id, artwork, correct: false }); + } + } else if (mode === "note") { + // 🎵 Build a board of note squares satisfying (or breaking) a musical rule. + const noteRound = NOTE_ROUNDS[rnd(NOTE_ROUNDS.length)]; + const test = noteRound.test; + ruleLabel = noteRound.label; + ruleSpeech = noteRound.say; + noteScale = noteRound.scale; + const octs = [3, 4, 5]; // tight pool so HIGH/LOW rules have room to differ + // 🚫 No repeats: enumerate every letter×octave combo, partition by the + // rule, shuffle, and deal — each note appears at most once per board. + const goodPool = [], badPool = []; + for (const l of LETTERS) + for (const o of octs) (test(l, o) ? goodPool : badPool).push({ letter: l, oct: o }); + shuffle(goodPool, rnd); + shuffle(badPool, rnd); + let nc = min(nCorrect, goodPool.length); + nc = max(nc, min(goodPool.length, total - badPool.length)); + for (let i = 0; i < nc; i += 1) { + const g = goodPool[i]; + cells.push({ value: noteLabel(g.letter, g.oct), letter: g.letter, oct: g.oct, correct: true }); + } + for (let i = 0; i < total - nc; i += 1) { + const b = badPool[i % badPool.length]; + cells.push({ value: noteLabel(b.letter, b.oct), letter: b.letter, oct: b.oct, correct: false }); + } + } else if (mode === "word") { + const rounds = lang === "es" ? ES_WORD_ROUNDS : lang === "da" ? DA_WORD_ROUNDS : lang === "ru" ? RU_WORD_ROUNDS : lang === "cat" ? CAT_WORD_ROUNDS : WORD_ROUNDS; + const round = rounds[rnd(rounds.length)]; + ruleLabel = round.label; + ruleSpeech = round.say; + const goods = shuffle([...round.correct], rnd); + const bads = shuffle([...round.wrong], rnd); + // 🚫 No repeats: answers never duplicate (capped to the pool), and when + // the decoy pool is smaller than the leftover space the board carries + // more answers instead of cycling decoys — 25 distinct words. + let nc = min(nCorrect, goods.length); + nc = min(goods.length, max(nc, total - bads.length)); + for (let i = 0; i < nc; i += 1) + cells.push({ value: goods[i], correct: true }); + for (let i = 0; i < total - nc; i += 1) + cells.push({ value: bads[i % bads.length], correct: false }); + } else { + // Pick a numeric rule + a per-board range. Ranges start at 40 so a 5×5 + // board can always be dealt from DISTINCT values (see the deal below). + const maxVal = [40, 60, 80][rnd(3)]; + const rules = ["multiples", "factors", "primes", "even", "odd"]; + const pick = rules[rnd(rules.length)]; + let test, label, speech; + + if (pick === "multiples") { + const n = 2 + rnd(8); // 2..9 + label = "X" + n; // "multiples of n", e.g. X3 + speech = `multiples of ${n}`; + test = (v) => v % n === 0; + } else if (pick === "factors") { + const targets = [12, 16, 18, 20, 24, 36, 48]; + const n = targets[rnd(targets.length)]; + label = "F" + n; // "factors of n", e.g. F24 + speech = `factors of ${n}`; + test = (v) => n % v === 0; + } else if (pick === "primes") { + label = "PRIMES"; + speech = "prime numbers"; + test = (v) => isPrime(v); + } else if (pick === "even") { + label = "EVENS"; + speech = "even numbers"; + test = (v) => v % 2 === 0; + } else { + label = "ODDS"; + speech = "odd numbers"; + test = (v) => v % 2 === 1; + } + + ruleLabel = label; + ruleSpeech = speech; + // 🚫 No repeats: partition 1..maxVal by the rule, shuffle, and deal — + // every answer and every decoy appears at most once per board. If the + // decoy pool runs short the board carries more answers instead. + const goodPool = [], badPool = []; + for (let v = 1; v <= maxVal; v += 1) (test(v) ? goodPool : badPool).push(v); + shuffle(goodPool, rnd); + shuffle(badPool, rnd); + let nc = min(nCorrect, goodPool.length); + nc = max(nc, min(goodPool.length, total - badPool.length)); + for (let i = 0; i < nc; i += 1) cells.push({ value: goodPool[i], correct: true }); + for (let i = 0; i < total - nc; i += 1) cells.push({ value: badPool[i], correct: false }); + } + + shuffle(cells, rnd); + grid = cells.map((c) => ({ ...c, eaten: false, flash: 0, known: false, failed: false })); + remaining = grid.filter((c) => c.correct && !c.eaten).length; + boardCorrectTotal = max(1, remaining); + foundValues = []; + + // Place muncher centrally, away from any troggle. + muncher = { col: 2, row: 2 }; + muncherVis = { col: 2, row: 2 }; + troggles = spawnTroggles(rnd); + invuln = 60; + facing = { x: 1, y: 0 }; + + // ⏳ Per-board beat budget (generous; tightens a little as levels climb). + // Note mode runs a peppier groove (BPM 92) with a slightly tighter budget. + // Ticks ride the global wall-clock beat grid; resync the local index so the + // fresh board doesn't immediately burn a beat at start. + beatsMax = mode === "note" ? max(20, 44 - level) : max(24, 52 - level); + beatsLeft = beatsMax; + beatPhase = 0; + beatMs = (60 / (mode === "note" ? 92 : BPM)) * 1000; + lastBeatIndex = null; + + // Clear any leftover animation state from the previous round. + deathPhase = 0; + clearPhase = 0; + pendingOver = false; + flashRed = 0; + flashGold = 0; + confetti = []; + chompPhase = 0; + walkTarget = null; + walkTick = 0; + lastArtwork = null; + if (mode === "art") preloadArtBoard(); +} + +// Hotlink the museum's documented 200px IIIF derivatives. The generated +// manifest keeps image URLs remote; this cache only lives for the current +// piece session and avoids re-decoding a work when it returns as a decoy. +function preloadArtBoard() { + if (!artPreload) return; + const generation = artLoadGeneration; + for (const cell of grid) { + const url = cell.artwork?.image; + if (!url || artImages.has(url)) continue; + artImages.set(url, "loading"); + artPreload(url) + .then((file) => { + if (generation !== artLoadGeneration) return; + artImages.set(url, squareArtThumbnail(file?.img) || "failed"); + }) + .catch(() => { + if (generation !== artLoadGeneration) return; + artImages.set(url, "failed"); + }); + } +} + +// Center-crop + downsample once when a museum image arrives. Rendering then +// scales a compact square buffer instead of recropping a large IIIF bitmap on +// every frame (25 tiles × 60fps would otherwise allocate continuously). +function squareArtThumbnail(source, size = 64) { + if (!source?.pixels || !source.width || !source.height) return null; + const side = min(source.width, source.height); + const ox = floor((source.width - side) / 2); + const oy = floor((source.height - side) / 2); + const pixels = new Uint8ClampedArray(size * size * 4); + for (let y = 0; y < size; y += 1) { + const sy = oy + min(side - 1, floor((y / size) * side)); + for (let x = 0; x < size; x += 1) { + const sx = ox + min(side - 1, floor((x / size) * side)); + const si = (sy * source.width + sx) * 4; + const di = (y * size + x) * 4; + pixels[di] = source.pixels[si]; + pixels[di + 1] = source.pixels[si + 1]; + pixels[di + 2] = source.pixels[si + 2]; + pixels[di + 3] = source.pixels[si + 3]; + } + } + return { width: size, height: size, pixels }; +} + +function spawnTroggles(rnd) { + // 0 at L1, 1 at L2-3, 2 at L4-5, 3 at L6-7, 4 at L8-9, 5 at L10+ — the danger + // keeps climbing well past the old 3-troggle plateau. + const count = min(5, floor(level / 2)); + const list = []; + for (let i = 0; i < count; i += 1) { + // Edge spawn, resampled until it lands ≥3 tiles (Manhattan) from the + // muncher's fresh start — mid-edge cells sit only 2 away in his own + // row/column and marched in for an unavoidable hit as spawn grace expired. + let col, row, dir; + do { + const horizontal = rnd(2) === 0; + col = horizontal ? (rnd(2) ? 0 : COLS - 1) : rnd(COLS); + row = horizontal ? rnd(ROWS) : rnd(2) ? 0 : ROWS - 1; + dir = horizontal + ? { x: rnd(2) ? 1 : -1, y: 0 } + : { x: 0, y: rnd(2) ? 1 : -1 }; + } while (abs(col - muncher.col) + abs(row - muncher.row) < 3); + list.push({ col, row, dir, hue: [255, 90 + rnd(120), 60][i % 3] }); + } + return list; +} + +function idx(col, row) { + return row * COLS + col; +} + +// 🧮 Sim ─────────────────────────────────────────────────────────────────── +function nomSim({ gizmo, seconds, sound, clock, num: { randInt } }) { + snd = sound; + if (sound && !synth) synth = Synth(sound); + // 🕰️ Sample the network-synced wall clock and derive the global beat grid. + nowMs = clock?.time?.()?.getTime?.() ?? (nowMs + 1000 / FPS); + beatIndex = Math.floor(nowMs / beatMs); + beatPhase = (nowMs % beatMs) / beatMs; // 0..1 within the current beat + frames += 1; + mouth = (mouth + 1) % 40; + if (introTimer > 0) introTimer -= 1; // intro card fade (beats + troggles frozen) + if (invuln > 0) invuln -= 1; + if (chompPhase > 0) chompPhase -= 1; + if (messageTimer > 0) { + messageTimer -= 1; + if (messageTimer === 0) message = ""; + } + grid.forEach((c) => { + if (c.flash > 0) c.flash -= 1; + }); + + // Fire any queued melody notes whose time has come. + for (let i = melody.length - 1; i >= 0; i -= 1) { + if (frames >= melody[i].at) { + note(melody[i]); + melody.splice(i, 1); + } + } + + // 🎥 Camera: smooth lean toward the Muncher, ease zoom home, decay shake. + const cc = layout.cell || 32; + const target = deathPhase > 0 ? deathPos : muncher; + const leanX = (2 - target.col) * cc * 0.16; + const leanY = (2 - target.row) * cc * 0.16; + cam.x += (leanX - cam.x) * 0.1; + cam.y += (leanY - cam.y) * 0.1; + cam.zoom += (camTargetZoom - cam.zoom) * 0.18; + camTargetZoom += (1 - camTargetZoom) * 0.08; + cam.shake *= 0.86; + if (cam.shake < 0.05) cam.shake = 0; + + // 🏃 Slide the Muncher's display position toward its logical cell. + muncherVis.col += (muncher.col - muncherVis.col) * 0.3; + muncherVis.row += (muncher.row - muncherVis.row) * 0.3; + if (abs(muncher.col - muncherVis.col) < 0.01) muncherVis.col = muncher.col; + if (abs(muncher.row - muncherVis.row) < 0.01) muncherVis.row = muncher.row; + + if (flashRed > 0) flashRed -= 1; + if (flashGold > 0) flashGold -= 1; + if (flashGreen > 0) flashGreen -= 1; + if (beatPulse > 0) beatPulse -= 1; + + // ✨ Confetti physics (level-clear) — compacted in place, no per-frame array. + if (confetti.length) { + let live = 0; + for (const p of confetti) { + p.x += p.vx; + p.y += p.vy; + p.vy += 0.12; + if (p.y < screenH + 40) confetti[live++] = p; + } + confetti.length = live; + } + + // 💀 Death animation timeline (world frozen while it plays out) — sudden + // death, so the fall always lands on the game-over screen. + if (deathPhase > 0) { + deathPhase -= 1; + if (deathPhase === 0 && pendingOver) { + pendingOver = false; + state = "over"; + jingleOver(); + submitRun(); + } + return; + } + + // 🎉 Level-clear celebration timeline. + if (clearPhase > 0) clearPhase -= 1; + + if (state !== "play") return; + + // ⏳ Beat-based timeline, ticked off the global wall-clock grid: whenever the + // synced beat index advances, this board loses a beat (metronome + visual + // pulse). Running out costs a life. Because every board reads the same clock, + // their metronomes pulse together no matter when each one started. + // While the intro card is up, freeze the budget (keep the index aligned so it + // doesn't lurch when the grace ends). + if (lastBeatIndex === null || introTimer > 0) lastBeatIndex = beatIndex; + if (introTimer === 0 && beatIndex > lastBeatIndex) { + lastBeatIndex = beatIndex; // collapse any beats missed while backgrounded + beatPulse = 8; + if (beatsLeft > 0) { + beatsLeft -= 1; + tick(beatsLeft); + } + if (beatsLeft <= 0) timeUp(); + } + if (beatsLeft <= 3) cam.shake = max(cam.shake, 2.4); // panic shake + + // 👣 Tap-to-move walker — step toward the tapped square on a steady cadence, + // munching on arrival so one tap does the whole trip. + if (walkTarget) { + if (walkTick > 0) { + walkTick -= 1; + } else { + stepToward(walkTarget); + walkTick = WALK_STEP_FRAMES; + } + if ( + deathPhase === 0 && walkTarget && + walkTarget.col === muncher.col && walkTarget.row === muncher.row + ) { + walkTarget = null; + munch(); + } + } + + // Troggles step on a steady beat (speeds up with level) — keeps accelerating + // to ~L14 instead of flattening out at L10. + troggleClock ||= new gizmo.Hourglass(seconds(max(0.22, 0.72 - level * 0.035)), { + flipped: () => { + troggles.forEach((t) => { + let nc = t.col + t.dir.x; + let nr = t.row + t.dir.y; + if (nc < 0 || nc >= COLS) { + t.dir.x *= -1; + nc = t.col + t.dir.x; + } + if (nr < 0 || nr >= ROWS) { + t.dir.y *= -1; + nr = t.row + t.dir.y; + } + t.col = max(0, min(COLS - 1, nc)); + t.row = max(0, min(ROWS - 1, nr)); + }); + checkTroggleHit(); + }, + autoFlip: true, + }); + if (introTimer === 0) troggleClock?.step(); // troggles hold still during the intro +} + +function checkTroggleHit() { + if (invuln > 0) return; + if (troggles.some((t) => t.col === muncher.col && t.row === muncher.row)) { + loseLife(); + } +} + +// ☠️ Sudden death — any hit, wrong munch, or time-out ends the game outright. +function loseLife() { + combo = 0; + walkTarget = null; // a hit stops the tap-to-move walk + killChord(); + deathPos = { col: muncher.col, row: muncher.row }; + deathPhase = 54; // play the death animation, then game over + cam.shake = 11; // hard jolt + flashRed = 18; // red flash + sadDeath(); + flash("GAME OVER"); + pendingOver = true; // over fires when the animation ends +} + +function flash(msg) { + message = msg; + messageTimer = 90; +} + +// ⏳ Ran out of beats on this board — sudden death. +function timeUp() { + loseLife(); + flash("TIME!"); // show the reason; the overlay says GAME OVER after the fall +} + +// 🥁 Metronome tick — real notepat perc kit: kick on the global downbeat (every +// 4th beat of the shared wall-clock grid, so all boards land the kick together), +// closed hi-hat on the off-beats (falls back to a synth click if the kit is +// absent). In the final 3 beats it plays a distinct, rising warning beep. +const WARN_BEEPS = { 3: 700, 2: 950, 1: 1300 }; // rising pitch = more urgent +function tick(remainingBeats) { + const warn = WARN_BEEPS[remainingBeats]; + if (warn) { + if (synth) synth.kick({ volume: 0.4 }); // pulse under the beep + note({ type: "square", tone: warn, duration: 0.14, volume: 0.36, attack: 0.001 }); + if (remainingBeats === 1) // last gasp: a quick second blip + playMelody([{ tone: warn * 1.5, type: "square", dur: 0.1, vol: 0.3, t: 6 }], 0); + return; + } + const downbeat = beatIndex % 4 === 0; // global grid → synced across instances + if (synth) { + if (downbeat) synth.kick({ volume: 0.55 }); + else synth.hat({ volume: 0.3 }); + } else if (downbeat) { + note({ type: "sine", tone: 92, duration: 0.09, volume: 0.22 }); + } else { + note({ type: "square", tone: 1100, duration: 0.015, volume: 0.09 }); + } + groove(downbeat); +} + +// 🎶 Background groove — a tiny band riding the shared wall-clock beat grid: +// a walking triangle bass under the kick/hat pattern, a soft snare backbeat, +// and a sparkle arpeggio on each bar turn. Chords cycle i–VI–III–VII +// (Am F C G), one chord per 4 global beats, so every board everywhere is +// grooving in the same key at the same moment — like the synced metronomes. +// Note mode sits this out (its boards make their own music from the scale). +const GROOVE_ROOTS = [110, 87.31, 65.41, 98]; // A2 F2 C2 G2 +const GROOVE_TRIADS = [ + [220, 261.63, 329.63], // Am — A3 C4 E4 + [174.61, 220, 261.63], // F — F3 A3 C4 + [130.81, 164.81, 196], // C — C3 E3 G3 + [196, 246.94, 293.66], // G — G3 B3 D4 +]; +function groove(downbeat) { + if (mode === "note" || state !== "play") return; + const bar = floor(beatIndex / 4) % GROOVE_ROOTS.length; + const root = GROOVE_ROOTS[bar]; + const beatInBar = ((beatIndex % 4) + 4) % 4; + // Bass walk: root on the one, up an octave between, the fifth on the three. + const bass = beatInBar === 0 ? root : beatInBar === 2 ? root * 1.5 : root * 2; + note({ type: "triangle", tone: bass, duration: 0.22, volume: 0.17, attack: 0.008 }); + if (beatInBar === 2 && synth) synth.snare({ volume: 0.16 }); // soft backbeat + // Sparkle arp turning the bar — sits out in panic time (warn beeps own it). + if (beatInBar === 3 && beatsLeft > 6) { + const tri = GROOVE_TRIADS[bar]; + const half = round((beatMs / 1000) * FPS * 0.5); + playMelody( + [ + { tone: tri[1] * 2, type: "sine", dur: 0.09, vol: 0.1, t: 0 }, + { tone: tri[2] * 2, type: "sine", dur: 0.09, vol: 0.09, t: round(half / 2) }, + { tone: tri[0] * 4, type: "sine", dur: 0.12, vol: 0.08, t: half }, + ], + 0, + ); + } +} + +// 🎮 Movement + munching ───────────────────────────────────────────────────── +function move(dx, dy) { + if (state !== "play" || deathPhase > 0) return; + const pc = muncher.col, + pr = muncher.row; + muncher.col = (muncher.col + dx + COLS) % COLS; // wrap around edges + muncher.row = (muncher.row + dy + ROWS) % ROWS; + if (dx || dy) facing = { x: dx, y: dy }; + // On a wrap, snap the visual so it doesn't slide all the way across. + if (abs(muncher.col - pc) > 1) muncherVis.col = muncher.col; + if (abs(muncher.row - pr) > 1) muncherVis.row = muncher.row; + if (muncher.col !== pc || muncher.row !== pr) moveBlip(); + checkTroggleHit(); +} + +function munch() { + if (state !== "play") return; + const cell = grid[idx(muncher.col, muncher.row)]; + chompPhase = 10; // chomp animation regardless of outcome + if (!cell || cell.eaten) { + note({ type: "sine", tone: 200, duration: 0.04, volume: 0.18 }); + return; + } + if (cell.failed) { + note({ type: "sine", tone: 150, duration: 0.04, volume: 0.14 }); // already X'd + return; + } + if (cell.correct) { + cell.eaten = true; + cell.flash = 14; + combo += 1; + score += munchPoints(combo); + correctMunches += 1; + remaining -= 1; + beatsLeft = min(beatsMax, beatsLeft + 1); // munching earns a beat back + if (mode === "note") noteMunchGood(cell); + else chompGood(); + camTargetZoom = 1.06; // tiny zoom punch on every good munch + + if (mode === "art") revealArtwork(cell.artwork); + + // Collect the value + light up its (now frightened) duplicates. Artnom + // objects are unique visual answers, so its reveal lives in the caption. + const v = String(cell.value); + if (mode !== "art" && !foundValues.includes(v)) foundValues.push(v); + grid.forEach((c) => { + if (!c.eaten && String(c.value) === v) { + c.known = true; + c.flash = 14; + } + }); + + if (remaining === 1) flash("LAST ONE!"); + // Speak the English translation (mexinom / dannom), else announce the last one. + // Note mode stays musical — no speech, the note itself sounds. + if (mode === "word" && lang !== "en") sayTranslation(v); + else if (mode !== "note" && mode !== "art" && remaining === 1) say("last one"); + if (remaining <= 0) { + score += clearPoints(level, beatsLeft); + state = "clear"; + clearPhase = 96; + camTargetZoom = 1.16; // bigger celebratory push-in + flashGold = 14; + spawnConfetti(); + flash("LEVEL CLEAR!"); + jingleClear(); + submitRun(); + } + } else { + cell.flash = 14; + cell.failed = true; // X it out — can't be tried again + if (mode === "note") noteMunchBad(cell); + else { + if (mode === "art") revealArtwork(cell.artwork); + else if (mode === "word" && lang !== "en") sayTranslation(cell.value); + chompBad(); + } + loseLife(); + } +} + +// Museum-object feedback: speak the full credit, but keep the visual caption +// compact enough for a phone. The canonical record remains one I-key away. +function revealArtwork(artwork) { + if (!artwork) return; + lastArtwork = artwork; + const trim = (value, length) => { + const text = String(value || "unknown"); + return text.length > length ? `${text.slice(0, length - 1)}…` : text; + }; + caption = `${trim(artwork.title, 30)} — ${trim(artwork.artist, 22)}`; + captionFrame = frames; + const speech = `${artwork.title}, by ${artwork.artist}, ${artwork.date}`; + warmed.add(speech); + speakFn?.(speech, "jeffrey", "cloud", { volume: 1, provider: "jeffrey" }); + flash("I: MUSEUM RECORD"); +} + +// ⌨️ Smart munch (space/enter): munch what you're on; otherwise step toward the +// nearest confirmed-green square and munch it if you land on one — so rapid +// presses chain through a run of greens for combos. +function smartMunch() { + if (state !== "play" || deathPhase > 0) return; + const cur = grid[idx(muncher.col, muncher.row)]; + if (cur && cur.correct && !cur.eaten) { + munch(); + return; + } + const tgt = nearestGreen(); + if (!tgt) { + munch(); // nothing to chain to → normal munch (empty blip / risk) + return; + } + stepToward(tgt); + const now = grid[idx(muncher.col, muncher.row)]; + if (now && now.correct && !now.eaten) munch(); // landed on a green → eat it +} + +// Nearest known-green uneaten cell (toroidal distance), excluding the current. +function nearestGreen() { + let best = null, + bestD = Infinity; + for (let r = 0; r < ROWS; r += 1) + for (let c = 0; c < COLS; c += 1) { + const cd = grid[idx(c, r)]; + if (!cd || !cd.known || cd.eaten || cd.failed) continue; + const dc = min((c - muncher.col + COLS) % COLS, (muncher.col - c + COLS) % COLS); + const dr = min((r - muncher.row + ROWS) % ROWS, (muncher.row - r + ROWS) % ROWS); + const d = dc + dr; + if (d > 0 && d < bestD) { + bestD = d; + best = { col: c, row: r }; + } + } + return best; +} + +// One step toward a target, taking the shorter (possibly wrapping) direction. +function stepToward(t) { + const dcF = (t.col - muncher.col + COLS) % COLS, + dcB = (muncher.col - t.col + COLS) % COLS; + const drF = (t.row - muncher.row + ROWS) % ROWS, + drB = (muncher.row - t.row + ROWS) % ROWS; + const colDist = min(dcF, dcB), + rowDist = min(drF, drB); + if (colDist >= rowDist && colDist > 0) move(dcF <= dcB ? 1 : -1, 0); + else if (rowDist > 0) move(0, drF <= drB ? 1 : -1); +} + +// 🇲🇽 / 🇩🇰 / 🇷🇺 / 😼 Speak the meaning of a non-plain-English answer. +function sayTranslation(v) { + const table = lang === "da" ? TRANSLATE_DA : lang === "ru" ? TRANSLATE_RU : lang === "cat" ? TRANSLATE_CAT : TRANSLATE; + say(table[v] || String(v)); +} + +// 🔊 Sound ─────────────────────────────────────────────────────────────────── +function note({ type = "sine", tone, duration = 0.1, volume = 0.3, attack = 0.004 }) { + snd?.synth?.({ + type, + tone, + attack, + decay: duration * 0.5, + sustain: 0, + release: 0.04, + volume, + duration, + }); +} + +// Schedule a little tune; each note plays `gap` frames after the previous +// (unless it carries its own `t` offset). Times are relative to `frames` now. +function playMelody(notes, gap = 6) { + let t = 0; + notes.forEach((n) => { + t = n.t != null ? n.t : t; + melody.push({ + at: frames + t, + type: n.type || "sine", + tone: n.tone, + duration: n.dur || 0.12, + volume: n.vol || 0.3, + }); + t += gap; + }); +} + +function moveBlip() { + note({ type: "sine", tone: 340, duration: 0.02, volume: 0.16 }); +} + +// 🗣️ Speak a phrase in Jeffrey's ElevenLabs voice (auto-cached on the AC CDN) +// and surface it as the on-screen caption. The caption is set synchronously, so +// the word shows the instant it's spoken — before the audio finishes loading. +function say(text) { + if (!text) return; + caption = text; + captionFrame = frames; + warmed.add(text); // it's about to be fetched; don't also prewarm it + speakFn?.(text, "jeffrey", "cloud", { volume: 1, provider: "jeffrey" }); +} + +// 🔥 Warm the TTS cache for a phrase without playing it (preloadOnly), so the +// first munch that speaks it plays instantly. Deduped per session. +function warm(text) { + if (!text || warmed.has(text)) return; + warmed.add(text); + speakFn?.(text, "jeffrey", "cloud", { provider: "jeffrey", preloadOnly: true }); +} + +// 🔥 Pre-warm every distinct utterance the current board can speak (each cell's +// meaning in a translation edition), so munches voice back with no lag. +function prewarmBoard() { + if (mode !== "word" || lang === "en") return; // only translation editions speak per-munch + const table = lang === "da" ? TRANSLATE_DA : lang === "ru" ? TRANSLATE_RU : lang === "cat" ? TRANSLATE_CAT : TRANSLATE; + const seen = new Set(); + for (const cd of grid) { + const t = table[cd.value] || String(cd.value); + if (seen.has(t)) continue; + seen.add(t); + warm(t); + } +} + +// Stop the sustained held-munch chord. +function killChord() { + heldChord?.forEach((v) => v?.kill?.(0.18)); + heldChord = null; +} + +// 🎹 Pentatonic note for the held "durational munch" chord (varies by cell). +function pentatonic(col, row) { + const scale = [261.63, 293.66, 329.63, 392.0, 440.0]; // C D E G A + const oct = row < 2 ? 2 : row < 4 ? 1 : 0.5; + return scale[col % scale.length] * oct; +} + +function chompGood() { + // A two-part "nom": a low chunk then a bright ping that rises with the combo. + const ping = 540 + min(combo, 8) * 45; + note({ type: "square", tone: 150, duration: 0.05, volume: 0.3 }); + playMelody( + [ + { tone: ping, type: "sine", dur: 0.07, vol: 0.32, t: 1 }, + { tone: ping * 1.5, type: "triangle", dur: 0.05, vol: 0.22, t: 4 }, + ], + 0, + ); +} + +function chompBad() { + note({ type: "sawtooth", tone: 190, duration: 0.16, volume: 0.3 }); + playMelody([{ tone: 120, type: "sawtooth", dur: 0.2, vol: 0.28, t: 3 }], 0); +} + +// 🎵 Note-mode munch — voice the square's actual note, then a combo ping above. +function noteMunchGood(cell) { + if (!synth) return; + synth.note(cellNote(cell), { wave: "triangle", duration: 0.2, volume: 0.34 }); + const top = 660 + min(combo, 8) * 40; // bright ping rises with the combo + synth.note(top, { wave: "sine", duration: 0.08, volume: 0.2 }); +} + +// 🎵 Wrong note — sound it anyway (so you hear what you bit), then a sour buzz. +function noteMunchBad(cell) { + if (!synth) return; + synth.note(cellNote(cell), { wave: "triangle", duration: 0.2, volume: 0.34 }); + synth.note(140, { wave: "sawtooth", duration: 0.18, volume: 0.3 }); +} + +// 🎼 Play the current board's rule scale ascending when a board begins. +function playScale() { + if (!synth || !noteScale.length) return; + noteScale.forEach((n, i) => { + setTimeout(() => { + if (synth) + synth.note(String(n).toLowerCase(), { wave: "triangle", duration: 0.18, volume: 0.3 }); + }, i * 110); + }); +} + +// 😢 Death — a redder, sadder fall: a wobbling downward glissando into a +// low, mournful sigh. +function sadDeath() { + const notes = []; + const steps = 12; + for (let i = 0; i < steps; i += 1) { + const f = 415 * pow(2, -(i / 7)); // slide down ~1.7 octaves + notes.push({ tone: f, type: "triangle", dur: 0.09, vol: 0.3, t: i * 2 }); + } + playMelody(notes, 0); + playMelody( + [ + { tone: 147, type: "sine", dur: 0.45, vol: 0.34, t: steps * 2 + 3 }, // D3 + { tone: 110, type: "sine", dur: 0.7, vol: 0.32, t: steps * 2 + 11 }, // A2 + ], + 0, + ); +} + +// 🎉 Level clear — triumphant fanfare with a sparkle on top. +// 🏆 Victory melody — a triumphant ascending fanfare, a sparkle run, and a +// big held final chord. +function jingleClear() { + playMelody( + [ + { tone: 523, dur: 0.12 }, // C5 + { tone: 659, dur: 0.12 }, // E5 + { tone: 784, dur: 0.12 }, // G5 + { tone: 1047, dur: 0.16 }, // C6 + { tone: 880, dur: 0.12 }, // A5 + { tone: 988, dur: 0.12 }, // B5 + { tone: 1047, dur: 0.14 }, // C6 + { tone: 1319, dur: 0.34 }, // E6 hold + ], + 6, + ); + // sparkle run on top + playMelody( + [ + { tone: 1568, type: "sine", dur: 0.08, vol: 0.2, t: 54 }, + { tone: 2093, type: "sine", dur: 0.08, vol: 0.2, t: 60 }, + { tone: 2637, type: "sine", dur: 0.16, vol: 0.2, t: 66 }, + ], + 0, + ); + // big held C-major triad to finish + playMelody( + [ + { tone: 523, type: "triangle", dur: 0.6, vol: 0.3, t: 70 }, + { tone: 659, type: "triangle", dur: 0.6, vol: 0.24, t: 70 }, + { tone: 784, type: "triangle", dur: 0.6, vol: 0.2, t: 70 }, + { tone: 1047, type: "sine", dur: 0.6, vol: 0.2, t: 70 }, + ], + 0, + ); + if (synth) synth.crash({ volume: 0.4 }); // cymbal hit on the win +} + +function jingleOver() { + playMelody( + [ + { tone: 392, type: "triangle", dur: 0.18 }, // G4 + { tone: 311, type: "triangle", dur: 0.18 }, // Eb4 (minor turn) + { tone: 262, type: "triangle", dur: 0.22 }, // C4 + { tone: 196, type: "triangle", dur: 0.5 }, // G3 long + ], + 9, + ); +} + +// 🎊 Burst of confetti from the top of the board (deterministic, no RNG). +function spawnConfetti() { + confetti = []; + const cols = [ + [255, 210, 90], + [120, 235, 120], + [255, 120, 160], + [120, 180, 255], + [255, 255, 255], + ]; + const bcx = layout.x + (layout.cell * COLS) / 2; + for (let i = 0; i < 44; i += 1) { + confetti.push({ + x: bcx + (((i * 53) % 100) - 50) * (layout.cell * COLS) * 0.006, + y: layout.y + 4, + vx: (((i * 31) % 7) - 3) * 0.7, + vy: -2.4 - ((i * 17) % 5) * 0.5, + s: (2 + (i % 3)) * max(1, Math.ceil(hudScale / 2)), // bigger bits on big displays + c: cols[i % cols.length], + }); + } +} + +function startChirp() { + playMelody( + [ + { tone: 392, dur: 0.06 }, + { tone: 523, dur: 0.06 }, + { tone: 784, dur: 0.1 }, + ], + 4, + ); +} + +// 🎬 Announce a freshly-started board: green flash, start chime (or the scale in +// note mode), and speak the rule. Called on auto-start and on each advance. +function announceBoard() { + flashGreen = 16; + if (mode === "note") playScale(); + else startChirp(); + say(ruleSpeech); + prewarmBoard(); // warm the board's munch utterances so they voice back instantly +} + +// 🎪 Act ───────────────────────────────────────────────────────────────────── +function nomAct({ event: e, sound, speak, cursor, net, num: { randInt } }) { + snd = sound; + // ⌨️/🖱️ Input mode: pressing a key hides the cursor + hover; moving the mouse + // brings them back. + if (e.name?.startsWith?.("keyboard:down")) { + hover = null; + cursor?.("none"); + } + if (e.is("move") || e.is("draw") || e.is("touch")) cursor?.("native"); + speakFn = speak; + if (sound && !synth) synth = Synth(sound); + + // 📣 The board auto-started in boot(); announce its rule + play the start chime + // the moment audio is actually available (boot has no sound/speak handle). + if (pendingStart && (synth || speakFn)) { + pendingStart = false; + announceBoard(); + } + + // 🏛️ Open the canonical museum object page for the last revealed work. + // Handle this before the generic end-screen "any key" advance. + if (mode === "art" && e.is("keyboard:down:i") && lastArtwork?.url) { + net?.web?.(lastArtwork.url, true); + return; + } + + // Advance from end-of-board states (clear → next level, over/win → fresh game). + const advance = () => { + if (state === "clear") { + level += 1; + newRound({ randInt }); + } else if (state === "over" || state === "win") { + resetGame({ randInt }); + newRound({ randInt }); + introTimer = INTRO_FRAMES; // re-show the title card on a fresh game + } else return; + state = "play"; + announceBoard(); + }; + + if (state !== "play") { + // Any key-down or a tap advances from the clear/over overlays. + if (e.name?.startsWith?.("keyboard:down")) { + advance(); + } else if (e.is("touch")) { + advance(); + // This tap *started* the board — swallow its release so the trailing + // lift doesn't immediately munch the center square. + swallowTap = true; + } + return; + } + + // Release the held chord even mid-death so it never sticks. + if ( + e.is("keyboard:up:space") || + e.is("keyboard:up:enter") || + e.is("keyboard:up:return") || + e.is("lift") + ) + killChord(); + + // 👆 Tap to move / munch: tapping the muncher's own square (or off-board) + // munches in place; tapping any other square sends the muncher walking there + // — sim steps it across the grid and it munches on arrival, so one tap does + // the whole trip. Always consume the swallow flag here — even mid-death — + // so it can never leak into the next gesture. + if (e.is("lift")) { + const consumed = swallowTap; + swallowTap = false; + if (!consumed && deathPhase === 0) { + const t = cellAt(e.x, e.y); + if (!t || (t.col === muncher.col && t.row === muncher.row)) { + walkTarget = null; + munch(); + } else { + walkTarget = { col: t.col, row: t.row }; + walkTick = 0; // first step lands immediately + note({ type: "sine", tone: 500, duration: 0.03, volume: 0.14 }); // pick blip + } + } + } + + // 🖱️ Hover highlight — track the cell under the pointer. + if (e.is("move") || e.is("draw")) hover = cellAt(e.x, e.y); + + if (deathPhase > 0) return; // ignore other input mid-death + + // Movement — arrows + WASD + vim (hjkl). Manual steering cancels the walker. + const step = (dx, dy) => { walkTarget = null; move(dx, dy); }; + if (e.is("keyboard:down:arrowleft") || e.is("keyboard:down:a") || e.is("keyboard:down:h")) step(-1, 0); + if (e.is("keyboard:down:arrowright") || e.is("keyboard:down:d") || e.is("keyboard:down:l")) step(1, 0); + if (e.is("keyboard:down:arrowup") || e.is("keyboard:down:w") || e.is("keyboard:down:k")) step(0, -1); + if (e.is("keyboard:down:arrowdown") || e.is("keyboard:down:s") || e.is("keyboard:down:j")) step(0, 1); + + // Munch (space + enter behave identically) — smart-chain to the nearest + // green, and sustain a pentatonic chord while held (durational, musical). + if ( + e.is("keyboard:down:space") || + e.is("keyboard:down:enter") || + e.is("keyboard:down:return") + ) { + walkTarget = null; // keyboard munch takes over from the walker + smartMunch(); + if (!heldChord && synth) { + if (mode === "note") { + // 🎵 Sustain the square's actual note while held (a soft drone). + const cell = grid[idx(muncher.col, muncher.row)]; + if (cell && !cell.eaten && !cell.failed) { + const n = cellNote(cell); + heldChord = [ + synth.hold(n, { wave: "sine", volume: 0.14 }), + synth.hold(n, { wave: "triangle", volume: 0.08 }), + ]; + } + } else { + // Sustained pentatonic chord via the shared synth controller. + const root = pentatonic(muncher.col, muncher.row); + heldChord = [ + synth.hold(root, { wave: "sine", volume: 0.18 }), + synth.hold(root * 1.5, { wave: "sine", volume: 0.12 }), + synth.hold(root * 2, { wave: "triangle", volume: 0.08 }), + ]; + } + } + } + +} + +function resetGame({ randInt }) { + level = 1; + score = 0; + correctMunches = 0; + lastSubmittedKey = ""; + troggleClock = null; + message = ""; + messageTimer = 0; +} + +// 📐 Layout helpers (recomputed each paint from current screen) ─────────────── +let layout = { x: 0, y: 0, cell: 32, top: 40 }; + +// 🔍 Readability-first chrome: every HUD element (timer, counters, banners, +// caption) scales with the display instead of sitting at fixed pixel sizes. +// hudScale 1 fits phones; large desktop windows climb toward 5. +let hudScale = 1; +let timerSize = 4; // big beat-timer glyph scale (drives the header height) +let capSize = null; // computeLayout pins the caption scale on short windows (else null) + +// The caption strip's nominal size + reserved band height (pre-shrink), so +// the lives row and LEFT counter can sit above it instead of underneath. +function captionSize(screen) { + // computeLayout pins capSize on short windows so the caption band shrinks in + // lock-step with the space it reserves (see the short-window guard below). + if (capSize != null) return capSize; + return max(1, round(screen.height / 240), hudScale + 1); +} +function captionBandH(screen) { + const { h } = fontMetrics(cellFont()); + const gh = h * captionSize(screen); + return gh + round(gh * 0.4) + 5; +} + +function computeLayout(screen) { + screenH = screen.height; + capSize = null; // recomputed below; a short window may pin it + // 🔍 HUD scale from the display's short side — chrome text grows with the + // window instead of sitting at fixed pixel sizes (see hudScale above). + hudScale = max(1, min(5, floor(min(screen.width, screen.height) / 200))); + timerSize = 3 + hudScale; + // 📱 Phone-width screens trade chrome for board: slimmer margins so cells + // get as big as possible under a thumb. + const compact = screen.width < 460; + // Word boards go near edge-to-edge: whole words render on one line + // (never split — see fitText), so cells get every pixel the screen has. + // Number/note boards keep the roomier framing. + const sideMargin = mode === "word" ? 4 : compact ? 8 : max(14, floor(screen.width * 0.06)); + let top = 14 + 10 * timerSize; // header band sized to the big beat timer + let bottomPad = 12 + captionBandH(screen); // HUD band above the caption strip + // 🩳 Short-window guard: on a low viewport the header + footer bands would + // crowd the 5×5 board off the screen (board overlapping the timer / counter / + // caption). If they'd leave the board less than ~45% of the height, shrink the + // timer AND caption glyphs themselves — not just their reserved bands — until + // the board gets its share. Everything the HUD draws is sized from timerSize / + // captionSize, so the reservation and the render stay in lock-step. + const hudBudget = round(screen.height * 0.55); + while (top + bottomPad > hudBudget && (timerSize > 1 || captionSize(screen) > 1)) { + if (timerSize > 1) timerSize -= 1; + const cs = captionSize(screen); + if (cs > 1) capSize = cs - 1; + top = 14 + 10 * timerSize; + bottomPad = 12 + captionBandH(screen); + } + const availW = screen.width - sideMargin * 2; + const availH = screen.height - top - bottomPad; + // Smallest cell that still fits BETWEEN the bands (and the side margins), so a + // squat window shrinks the board to fit instead of overflowing the HUD. Only + // bites on tight screens — roomy windows keep the resting 18px minimum below. + const minCell = max(6, min(18, floor(availH / ROWS), floor(availW / COLS))); + // Resting fit inside the margined area … + const restFit = min(availW / COLS, availH / ROWS); + // … but never so large that the camera's max zoom punch overflows the full + // viewport — mobile screens are tight, so keep the whole board on-screen even + // at peak zoom (we still reserve a hair for lean + shake via the clamp below). + // Word mode skips that reservation (cells need the width for whole words); + // applyCamera just centers the board during the brief zoom punches instead. + const zoomFit = min(screen.width / COLS, availH / ROWS) / MAX_ZOOM; + const cell = max(minCell, floor(mode === "word" ? restFit : min(restFit, zoomFit))); + const gw = cell * COLS; + const gh = cell * ROWS; + // Mutated in place — one long-lived object, no per-frame allocation. + layout.cell = cell; + layout.baseCell = cell; + layout.x = floor((screen.width - gw) / 2); + layout.y = top + floor((availH - gh) / 2); + layout.top = top; + layout.bottomPad = bottomPad; +} + +// 🎥 Fold the dynamic camera (zoom about board center + lean + shake) into the +// live layout, so both drawing and touch hit-testing stay consistent. +function applyCamera(screen) { + const z = cam.zoom; + const cell = max(8, round(layout.baseCell * z)); + const gw = cell * COLS; + const gh = cell * ROWS; + const shx = cam.shake ? sin(frames * 1.7) * cam.shake : 0; + const shy = cam.shake ? cos(frames * 2.3) * cam.shake : 0; + // Centered + lean + shake, then clamped so no edge ever leaves the viewport. + const bx = (screen.width - gw) / 2 + cam.x + shx; + const by = layout.top + (screen.height - layout.top - layout.bottomPad - gh) / 2 + cam.y + shy; + const pad = 2; + const maxX = screen.width - gw - pad; // rightmost on-screen origin + const maxY = screen.height - layout.bottomPad - gh; // keep clear of the HUD band + layout.cell = cell; + // If the (zoomed) board is wider/taller than the space, just center it; + // otherwise clamp the leaned/shaken position to the on-screen range. + layout.x = round(maxX < pad ? (screen.width - gw) / 2 : max(pad, min(maxX, bx))); + layout.y = round( + maxY < layout.top + ? layout.top + (screen.height - layout.top - layout.bottomPad - gh) / 2 + : max(layout.top, min(maxY, by)), + ); +} + +function cellAt(px, py) { + const { x, y, cell } = layout; + const col = floor((px - x) / cell); + const row = floor((py - y) / cell); + if (col < 0 || col >= COLS || row < 0 || row >= ROWS) return null; + return { col, row }; +} + +// 🎨 Paint ─────────────────────────────────────────────────────────────────── +// Either/or: an hd edition paints ONLY the hi-res Canvas2D layer (a worker +// OffscreenCanvas managed by disk's `hd()` API); everyone else paints the +// classic pixel buffer. disk's hd() hands back null in preview/icon mode — +// thumbnails capture the pixel buffer, so the game drops to full pixel +// painting for exactly those frames. Tapes stay hd end-to-end: bios mirrors +// each hd bitmap into a live MediaRecorder while recording. +let pixelWashDark = null; // theme of the last hidden-pixel-buffer wash +function nomPaint(api) { + setTheme(api.dark !== false); // 🌗 follow the system theme, live + const layer = hiRes && api.hd ? api.hd() : null; + if (layer) { + // Keep the (hidden) pixel buffer a clean theme wash — freeze-frames and + // piece transitions sample it, and a flat color beats stale art there. + // The buffer persists between frames, so one wash per theme is enough; + // skipping the rest saves a full-buffer clone + upload every frame. + if (pixelWashDark !== dark) { + api.wipe(...T.bg); + pixelWashDark = dark; + } + const a = hdApi(layer); + paintGame(a); + paintCaption(a); + } else { + pixelWashDark = null; // pixel path owns the buffer again — wash on re-entry + paintGame(api); + paintCaption(api); + } +} + +// 💬 Always-on caption: the last spoken text, centered along the bottom over a +// translucent strip so it stays legible on any board. Uses the cell font so +// Cyrillic / accented meanings (rusnom, mexinom) render real glyphs. +// Caption band geometry — instructional content always sits ABOVE the board, +// never below, so the strip hugs the board's top edge. Shared with the +// transient message banner so the two stack instead of colliding. +function captionBand(screen) { + if (!caption || state === "title") return null; + const font = cellFont(); + const { adv, h } = fontMetrics(font); + // Scale with the render resolution (hd draws at native pixels), then shrink + // to fit long meanings ("aesthetic computer pieces") within the screen. + const maxW = screen.width - 8; + let size = captionSize(screen); + while (size > 1 && caption.length * adv * size > maxW) size -= 1; + const gh = h * size; + const y = max(4, layout.y - gh - round(gh * 0.4) - 4); + return { font, adv, size, gh, y }; +} + +function paintCaption({ ink, write, box, screen }) { + const band = captionBand(screen); + if (!band) return; + const { font, adv, size, gh, y } = band; + const w = caption.length * adv * size; + const x = max(2, round((screen.width - w) / 2)); + const fade = min(1, (frames - captionFrame) / 6); // quick ease-in on change + const [br, bg, bb, ba = 255] = T.captionBg; + ink(br, bg, bb, round(ba * fade)).box(0, y - 3, screen.width, gh + 7); + ink(...T.captionText).write( + caption, { x, y, size }, undefined, undefined, false, font || undefined, + ); +} + +function handleColor(colors, index, ch) { + const color = colors?.[index]; + if (color && [color.r, color.g, color.b].every(Number.isFinite)) + return [color.r, color.g, color.b]; + return charColor(String(ch).toLowerCase()); +} + +function paintHandle({ ink }, handle, colors, x, y, size = 1) { + const value = String(handle || ""); + const advance = 6 * size; + for (let index = 0; index < value.length; index += 1) + ink(...handleColor(colors, index, value[index])).write( + value[index], { x: x + index * advance, y, size }, + ); + return value.length * advance; +} + +// 🖼️ Canvas2D adapter — mirrors the exact slice of the AC paint API this +// engine uses (wipe / ink().box/.line/.write + bare box/line after ink) on +// the hd layer's pre-scaled 2d context, so paintGame runs unchanged at +// native device resolution. Text renders as vector glyphs at the same fixed +// per-glyph advances as FONT_METRICS, so every width/centering measurement +// in the game's layout math stays identical to the pixel path. +// +// Built once per (ctx, size) and reused every frame. Three caches keep the +// per-frame Canvas2D cost near zero: +// color memo — rgba() strings rebuilt only when the ink actually changes, +// and fill/stroke styles set only when they differ +// glyphW — per font+char advance measured ONCE (measureText is one of +// the slowest 2d calls; advances scale linearly with size) +// sprites — whole rendered labels cached as OffscreenCanvases and drawn +// back as a single drawImage (a board speaks a small +// vocabulary, so per-frame text rasterization drops to zero) +let hdCached = null; // { ctx, width, height, api } +const glyphW = new Map(); // "font|char" → advance at 100px +const sprites = new Map(); // "font|size|r,g,b|text" → { c, w, h, pad } +const SPRITE_CAP = 480; // hard bound; cleared wholesale when exceeded +let comicFontPromise = null; + +function loadComicFont() { + if (comicFontPromise || typeof FontFace === "undefined" || typeof document === "undefined") + return comicFontPromise; + comicFontPromise = new FontFace( + "Comic Relief", + "url(https://papers.aesthetic.computer/foundry/fonts/ComicRelief-Regular.ttf)", + ).load().then((font) => { + document.fonts.add(font); + glyphW.clear(); + sprites.clear(); + return font; + }).catch(() => null); + return comicFontPromise; +} + +// Font styling for the hd layer — unifont stays a neutral monospace (it +// carries Cyrillic/accents for the word editions); everything else (numbers, +// HUD, titles) gets a rounded, chunky face for arcade character. Layout +// metrics stay FONT_METRICS regardless, so both paths measure identically. +function hdFont(font, px) { + return font === "unifont" + ? `500 ${px * 0.72}px ui-monospace, Menlo, Consolas, monospace` + : `400 ${px * 0.92}px "Comic Relief", "Comic Sans MS", ui-rounded, sans-serif`; +} + +// Per-glyph advance from the cache, measuring on first sight only. +function glyphWidth(sctx, font, ch, px) { + const key = `${font || "_"}|${ch}`; + let w = glyphW.get(key); + if (w === undefined) { + sctx.font = hdFont(font, 100); + w = sctx.measureText(ch).width; + glyphW.set(key, w); + } + return (w * px) / 100; +} + +// Rasterize a label once at device resolution: glyphs centered on the same +// fixed advances as the bitmap fonts, with padding so wide glyphs and +// descenders never clip at the sprite edge. +function makeSprite(str, font, size, scale, rgb) { + const { adv, h } = fontMetrics(font); + const px = h * size; + const w = str.length * adv * size; + const pad = Math.ceil(px * 0.3); + const c = new OffscreenCanvas( + max(1, Math.ceil((w + pad * 2) * scale)), + max(1, Math.ceil((px + pad * 2) * scale)), + ); + const sctx = c.getContext("2d"); + sctx.setTransform(scale, 0, 0, scale, 0, 0); + sctx.textBaseline = "middle"; + // Measure first (glyphWidth may swap the font), then set the render font. + const widths = []; + for (let i = 0; i < str.length; i += 1) + widths.push(glyphWidth(sctx, font, str[i], px)); + sctx.font = hdFont(font, px); + sctx.fillStyle = rgb; + const cy = pad + px / 2; + for (let i = 0; i < str.length; i += 1) + sctx.fillText(str[i], pad + i * adv * size + (adv * size - widths[i]) / 2, cy); + return { c, w: w + pad * 2, h: px + pad * 2, pad }; +} + +function hdApi(layer) { + if ( + hdCached && hdCached.ctx === layer.ctx && + hdCached.width === layer.width && hdCached.height === layer.height + ) + return hdCached.api; + const { ctx, width, height, scale } = layer; + let r = 255, g = 255, b = 255, a = 1; // current ink + let cr = -1, cg = -1, cb = -1, ca = -1; // memoized color components + let cssStr = "rgba(255,255,255,1)"; + let lastFill = null, lastStroke = null; + ctx.lineWidth = 1; + const setColor = (args) => { + if (args.length >= 3) { + r = args[0]; g = args[1]; b = args[2]; + a = (args[3] ?? 255) / 255; + } else { + r = g = b = args[0] ?? 255; + a = (args[1] ?? 255) / 255; + } + if (r !== cr || g !== cg || b !== cb || a !== ca) { + cssStr = `rgba(${r},${g},${b},${a})`; + cr = r; cg = g; cb = b; ca = a; + } + }; + const fill = () => { + if (lastFill !== cssStr) ctx.fillStyle = lastFill = cssStr; + }; + const stroke = () => { + if (lastStroke !== cssStr) ctx.strokeStyle = lastStroke = cssStr; + }; + const box = (x, y, w, h, mode2) => { + if (mode2 === "outline") { + stroke(); + ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); + } else { + fill(); + ctx.fillRect(x, y, w, h); + } + }; + const line = (x1, y1, x2, y2) => { + stroke(); + ctx.beginPath(); + ctx.moveTo(x1 + 0.5, y1 + 0.5); + ctx.lineTo(x2 + 0.5, y2 + 0.5); + ctx.stroke(); + }; + const write = (txt, pos = {}, _bg, _bounds, _wrap, font) => { + const str = String(txt); + if (!str) return; + const { adv, h } = fontMetrics(font); + const size = pos.size || 1; + let x = pos.x ?? 0; + if (pos.center === "x") x = (width - str.length * adv * size) / 2; + const y = pos.y ?? 0; + // Alpha rides globalAlpha at draw time so ghosted text (wrap previews, + // fades) shares the opaque sprite instead of forking the cache. + const key = `${font || "_"}|${size}|${scale}|${r},${g},${b}|${str}`; + let spr = sprites.get(key); + if (spr === undefined) { + if (sprites.size >= SPRITE_CAP) sprites.clear(); + spr = makeSprite(str, font, size, scale, `rgb(${r},${g},${b})`); + sprites.set(key, spr); + } + if (a < 1) ctx.globalAlpha = a; + if (pos.rot) { + // Spin around the label's own center (diagonal cell fits — see fitText). + const w2 = str.length * adv * size, h2 = h * size; + ctx.save(); + ctx.translate(x + w2 / 2, y + h2 / 2); + ctx.rotate((pos.rot * Math.PI) / 180); + ctx.drawImage(spr.c, -w2 / 2 - spr.pad, -h2 / 2 - spr.pad, spr.w, spr.h); + ctx.restore(); + } else { + ctx.drawImage(spr.c, x - spr.pad, y - spr.pad, spr.w, spr.h); + } + if (a < 1) ctx.globalAlpha = 1; + }; + const chain = { box, line, write, hd: true }; + const ink = (...args) => { + setColor(args); + return chain; + }; + const wipe = (...args) => { + setColor(args); + fill(); + ctx.fillRect(0, 0, width, height); + }; + const api = { wipe, ink, box, line, write, screen: { width, height } }; + hdCached = { ctx, width: layer.width, height: layer.height, api }; + return api; +} + +function paintGame({ wipe, ink, screen, write, box, line, text, paste }) { + computeLayout(screen); + wipe(...T.bg); + paintBackground({ ink, box, screen }); + + applyCamera(screen); // dynamic camera → updates layout in place + const { x, y, cell } = layout; + + const tf = beatsMax ? beatsLeft / beatsMax : 0; + const lowTime = beatsLeft <= 3 && state === "play"; + const blink = (frames >> 2) % 2 === 0; // fast ~7.5Hz blink + + // 😱 Whole-screen red blink when about to die. + if (lowTime && blink) ink(150, 0, 0, 115).box(0, 0, screen.width, screen.height); + + // ⏳ Beat timeline bar across the very top (height rides the HUD scale). + const tcol = tf > 0.5 ? T.timeHi : tf > 0.25 ? T.timeMid : T.timeLow; + const barH = 2 + hudScale + (beatPulse > 4 ? 1 : 0); + ink(...T.trackBg).box(0, 0, screen.width, barH); + ink(...tcol).box(0, 0, round(screen.width * tf), barH); + + // ⏳ Big timer (beats left), top-left — blinks near zero. + const timeCol = lowTime ? (blink ? T.timerPanic : T.timerPanicAlt) : tcol; + bigNum(ink, `${beatsLeft}`, 8, 8, timerSize, timeCol); + + // Rule title, top-right. Word editions render it in + // unifont so Cyrillic / accented category labels (ЕДА, ФРУКТЫ, …) show real + // glyphs. Sized to the room left of the big timer so the two never collide + // on narrow phone screens. + const labelFont = cellFont(); + const { adv: lAdv, h: lH } = fontMetrics(labelFont); + const timerEnd = 8 + `${beatsLeft}`.length * 6 * timerSize + 12; // timer's right edge + let ruleSize = 1 + hudScale; + while (ruleSize > 1 && timerEnd + ruleLabel.length * lAdv * ruleSize + 8 > screen.width) + ruleSize -= 1; + const ruleX = max(timerEnd, screen.width - 8 - ruleLabel.length * lAdv * ruleSize); + ink(...T.ruleLabel).write( + ruleLabel, + { x: ruleX, y: max(6, round((layout.top - lH * ruleSize) / 2)), size: ruleSize }, + undefined, undefined, false, labelFont || undefined, + ); + + // Grid — two passes: every tile background/outline first, THEN every label. + // Words always render whole at scale 1 (see fitText), so on a very tight + // screen a long word can be a touch wider than its cell; painting all the + // backgrounds first lets it bleed over the tile seam instead of getting + // overpainted by its neighbour's background. + const gameOver = state === "over"; + for (let r = 0; r < ROWS; r += 1) { + for (let c = 0; c < COLS; c += 1) { + const cx = x + c * cell; + const cy = y + r * cell; + const cd = grid[idx(c, r)]; + const txt = String(cd.value); + const colorWord = COLOR_WORDS[txt]; // color-name → real color + const missed = gameOver && cd.correct && !cd.eaten; // answer you didn't get + + // Tile background. + let bg; + if (cd.flash > 0) bg = cd.correct ? [40, 150, 70] : [160, 45, 45]; + else if (cd.eaten) bg = T.eaten; + else if (cd.failed) bg = T.failedBg; + else if (cd.known) bg = [40, 168, 80]; // confirmed-correct → whole square green + else if (missed) bg = T.missedBg; // revealed missed answer = gray + else if (mode === "art") bg = dark ? [28, 31, 45] : [215, 218, 226]; + else if (mode === "note") bg = noteTile(cd.letter); // pitch-class tint + else if (colorWord) bg = colorWord; // color squares ARE that color + else bg = tileColor(txt); + ink(...bg).box(cx + 1, cy + 1, cell - 2, cell - 2); + + // Outline. + if (cd.known && !cd.eaten) + ink(...T.knownOutline).box(cx + 1, cy + 1, cell - 2, cell - 2, "outline"); + else if (missed) + ink(...T.missedOutline).box(cx + 1, cy + 1, cell - 2, cell - 2, "outline"); + else ink(...T.outline).box(cx + 1, cy + 1, cell - 2, cell - 2, "outline"); + + // 🖱️ Hover highlight. + if (hover && hover.col === c && hover.row === r && !cd.eaten && state === "play") { + ink(...T.hoverFill).box(cx + 1, cy + 1, cell - 2, cell - 2); + ink(...T.hoverOutline).box(cx + 1, cy + 1, cell - 2, cell - 2, "outline"); + } + } + } + for (let r = 0; r < ROWS; r += 1) { + for (let c = 0; c < COLS; c += 1) { + const cd = grid[idx(c, r)]; + if (cd.eaten) continue; + const cx = x + c * cell; + const cy = y + r * cell; + const txt = String(cd.value); + const colorWord = COLOR_WORDS[txt]; + const missed = gameOver && cd.correct && !cd.eaten; + + if (mode === "art") { + const bitmap = artImages.get(cd.artwork?.image); + const inset = max(2, round(cell * 0.06)); + const size = max(1, cell - inset * 2); + if (bitmap && bitmap !== "loading" && bitmap !== "failed" && paste) { + paste(bitmap, cx + inset, cy + inset, { width: size, height: size }); + } else { + // A tiny museum-frame placeholder while IIIF arrives (or if a source + // image disappears); the board remains playable either way. + const pulse = bitmap === "failed" ? 70 : round(70 + 25 * sin(frames * 0.12)); + ink(pulse, pulse, pulse + 12).box(cx + inset, cy + inset, size, size); + ink(pulse + 35).box( + cx + inset + round(size * 0.18), + cy + inset + round(size * 0.18), + max(1, round(size * 0.64)), + max(1, round(size * 0.64)), + "outline", + ); + } + + // State tint sits over the image so correctness remains legible without + // replacing the art. Missed works are revealed in gray at game over. + if (missed) ink(125, 130, 142, 145).box(cx + inset, cy + inset, size, size); + if (cd.known && !cd.failed) + ink(40, 190, 90, 105).box(cx + inset, cy + inset, size, size); + if (cd.failed) ink(195, 42, 52, 125).box(cx + inset, cy + inset, size, size); + if (cd.flash > 0) { + const tint = cd.correct ? [50, 220, 105] : [235, 55, 65]; + ink(tint[0], tint[1], tint[2], min(170, cd.flash * 10)).box( + cx + inset, + cy + inset, + size, + size, + ); + } + if (hover && hover.col === c && hover.row === r && state === "play") { + ink(...T.hoverOutline).box(cx + 1, cy + 1, cell - 2, cell - 2, "outline"); + ink(...T.hoverOutline).box(cx + 2, cy + 2, cell - 4, cell - 4, "outline"); + } + if (cd.failed) { + ink(255, 225, 225); + line(cx + 5, cy + 5, cx + cell - 5, cy + cell - 5); + line(cx + cell - 5, cy + 5, cx + 5, cy + cell - 5); + } + continue; + } + + // Value — ONE color per answer (color-words use their hue + contrast), + // centered in the cell. Word editions: unifont, whole word, scale 1. + const font = cellFont(); + let jx = 0, jy = 0; + if (cd.known && !cd.failed) { + jx = sin(frames * 0.9 + c * 1.7) * 1.6; // afraid jitter + jy = cos(frames * 1.1 + r * 1.3) * 1.2; + } + let col; + if (cd.failed) col = [120, 95, 100]; + else if (cd.known) col = [10, 40, 18]; // dark on the green known square + else if (missed) col = T.missedText; + else if (mode === "note") col = noteColor(cd.letter); // note's keyboard hue + else if (colorWord) col = luma(colorWord) > 140 ? [25, 25, 30] : [245, 245, 255]; + else col = charColor(txt[0]); + drawCellText(ink, txt, cx, cy, cell, font, col, 255, jx, jy); + + // ✖ X out tried-and-wrong cells (can't be tried again). + if (cd.failed) { + ink(225, 70, 70); + line(cx + 4, cy + 4, cx + cell - 4, cy + cell - 4); + line(cx + cell - 4, cy + 4, cx + 4, cy + cell - 4); + } + } + } + + // 👻 Wrap preview — ONLY the wrap moves available from the muncher's current + // edge (the squares closest to its possible moves), ghosted just outside that + // edge. No far-side / whole-edge highlights. + const wrapHint = (destCol, destRow, gx, gy) => { + const cd = grid[idx(destCol, destRow)]; + if (!cd || cd.eaten) return; + const t = String(cd.value); + const font = cellFont(); + const cw = COLOR_WORDS[t]; + const gc = mode === "note" ? noteColor(cd.letter) : cw ? themedText(cw) : charColor(t[0]); + drawCellText(ink, t, gx, gy, cell, font, gc, 95); + ink(...T.wrapHint).box(gx + 2, gy + 2, cell - 4, cell - 4, "outline"); + }; + const mc = muncher.col, + mr = muncher.row; + if (mode !== "art" && state === "play" && deathPhase === 0) { + if (mc === 0) wrapHint(COLS - 1, mr, x - cell, y + mr * cell); // wrap-left + if (mc === COLS - 1) wrapHint(0, mr, x + COLS * cell, y + mr * cell); // wrap-right + if (mr === 0) wrapHint(mc, ROWS - 1, x + mc * cell, y - cell); // wrap-up + if (mr === ROWS - 1) wrapHint(mc, 0, x + mc * cell, y + ROWS * cell); // wrap-down + + // ✅ Emphasize any adjacent confirmed-correct (green) square you can step + // onto and munch — a pulsing bright-green ring beckons. + const pul = round(70 + 90 * (0.5 + 0.5 * sin(frames * 0.28))); + for (const [dx, dy] of [[0, -1], [0, 1], [-1, 0], [1, 0]]) { + const nc = (mc + dx + COLS) % COLS, + nr = (mr + dy + ROWS) % ROWS; + const cd = grid[idx(nc, nr)]; + if (cd && cd.known && !cd.eaten && !cd.failed) { + const ncx = x + nc * cell, + ncy = y + nr * cell; + ink(T.beckon[0], T.beckon[1], T.beckon[2], pul).box(ncx + 1, ncy + 1, cell - 2, cell - 2, "outline"); + ink(T.beckon[0], T.beckon[1], T.beckon[2], pul).box(ncx + 2, ncy + 2, cell - 4, cell - 4, "outline"); + } + } + } + + // Troggles. + troggles.forEach((t) => { + const cx = x + t.col * cell; + const cy = y + t.row * cell; + paintTroggle({ ink, box, line }, cx, cy, cell, t.hue); + }); + + // Muncher — dying, celebrating, or normal. + if (deathPhase > 0) { + const cx = x + deathPos.col * cell; + const cy = y + deathPos.row * cell; + paintDeadMuncher({ ink, box, line }, cx, cy, cell); + } else if (!(invuln > 0 && (invuln >> 2) % 2 === 0)) { + const cx = x + muncherVis.col * cell; + const cy = y + muncherVis.row * cell; + paintMuncher({ ink, box }, cx, cy, cell, clearPhase > 0); + } + + // ✨ Confetti (level-clear). + confetti.forEach((p) => ink(p.c[0], p.c[1], p.c[2]).box(p.x, p.y, p.s, p.s)); + + // 🎶 / 🔴 / 🟡 / 🟢 Full-screen washes — the beat vibe pulse plus the + // death/clear/board-start flashes, folded into ONE src-over-composited fill + // so a busy frame pays a single screen of overdraw instead of four. + let wr = 0, wg = 0, wb = 0, wa = 0; + const addWash = (rr, gg, bb, aa) => { + const af = aa / 255; + const na = af + wa * (1 - af); + if (na <= 0) return; + wr = (rr * af + wr * wa * (1 - af)) / na; + wg = (gg * af + wg * wa * (1 - af)) / na; + wb = (bb * af + wb * wa * (1 - af)) / na; + wa = na; + }; + if (beatPulse > 0 && state === "play") { + const vibe = [[80, 120, 255], [80, 220, 230], [170, 110, 240]][(beatsMax - beatsLeft) % 3]; + addWash(vibe[0], vibe[1], vibe[2], beatPulse * 4); + } + if (flashRed > 0) addWash(210, 25, 25, min(190, flashRed * 11)); + if (flashGold > 0) addWash(255, 215, 90, min(150, flashGold * 11)); + if (flashGreen > 0) addWash(60, 220, 110, min(150, flashGreen * 11)); + if (wa > 0) + ink(round(wr), round(wg), round(wb), round(wa * 255)).box(0, 0, screen.width, screen.height); + + // 🔢 Items-left, bottom-right, big — riding just above the caption strip. + const hudBottom = screen.height - captionBandH(screen); + const runLabel = `L${level} ${score}`; + let runSize = min(3, 1 + hudScale); + while (runSize > 1 && runLabel.length * 6 * runSize > screen.width * 0.38) runSize -= 1; + const runY = hudBottom - 8 * runSize - 4; + ink(...T.introName).write(runLabel, { x: 8, y: runY, size: runSize }); + if (playerHandle) { + let handleSize = max(1, min(2, hudScale)); + while (handleSize > 1 && playerHandle.length * 6 * handleSize > screen.width * 0.4) + handleSize -= 1; + paintHandle({ ink }, playerHandle, playerColors, 8, runY - 10 * handleSize - 2, handleSize); + } + const leftN = `${remaining}`; + const lsz = timerSize; + const leftW = leftN.length * 6 * lsz; + const leftX = screen.width - 8 - leftW; + bigNum(ink, leftN, leftX, hudBottom - 8 * lsz - 4, lsz, remaining <= 3 ? T.leftWarn : T.leftOk); + const lls = max(1, hudScale); + ink(...T.leftLabel).write("LEFT", { + x: max(4, leftX - ("LEFT".length * 6 * lls + 8)), + y: hudBottom - 8 * lls - 8, + size: lls, + }); + + // 🍽️ Collected answers — stack down the left, measured to fit the margin. + // Word boards run near edge-to-edge, so on narrow screens there's no gutter + // at all — skip the list rather than stack it over the board. + let fcy = layout.top + 6; // below the header band + const leftRoom = layout.x - 6; + const chipMin = 18; // slimmest gutter that still fits a size-1 chip + const font = cellFont(); + const { adv: fAdv, h: fH } = fontMetrics(font); + for (const v of foundValues) { + if (leftRoom < chipMin) break; + // Largest size whose nominal width fits the left gutter (stable, no async). + let size = 1; + for (let s = hudScale + 1; s >= 2; s -= 1) + if (v.length * fAdv * s + 4 <= leftRoom) { size = s; break; } + const fit = { size, width: v.length * fAdv * size, height: fH * size }; + if (fcy + fit.height > hudBottom - 10) break; + const w = fit.width + 4; + const ltr = mode === "note" ? v.slice(0, -1) : null; // "C4" → "C", "F#5" → "F#" + const cw = mode === "note" ? null : COLOR_WORDS[v]; + const bgc = mode === "note" ? noteTile(ltr) : cw || tileColor(v); + ink(...bgc).box(4, fcy, w, fit.height + 2); + const tcol = mode === "note" + ? noteColor(ltr) + : cw ? (luma(cw) > 140 ? [25, 25, 30] : [245, 245, 255]) : charColor(v[0]); + ink(...tcol).write(v, { x: 6, y: fcy + 1, size: fit.size }, undefined, undefined, false, font || undefined); + fcy += fit.height + 4; + } + + // 👆 Tap-to-move target — a pulsing golden ring on the destination square + // so the walk-in-progress is always legible. + if (walkTarget && state === "play" && deathPhase === 0) { + const pul = round(120 + 100 * (0.5 + 0.5 * sin(frames * 0.4))); + const tx = x + walkTarget.col * cell, + ty = y + walkTarget.row * cell; + ink(255, 220, 90, pul).box(tx + 1, ty + 1, cell - 2, cell - 2, "outline"); + ink(255, 220, 90, round(pul * 0.6)).box(tx + 3, ty + 3, cell - 6, cell - 6, "outline"); + } + + // 📣 Transient message banner — large, with a colored background. Stacks + // above the caption band when one is showing (all instruction above the + // board, none below). + if (message && state !== "over") { + let ms = 1 + hudScale; + while (ms > 1 && message.length * 6 * ms + 10 > screen.width) ms -= 1; + const mw = message.length * 6 * ms; + const mx = max(4, round(screen.width / 2 - mw / 2)); + const band = captionBand(screen); + const myy = max(4, (band ? band.y : layout.y) - 8 * ms - 6); + let mbg, mfg; + if (/OUCH|TIME|OVER/.test(message)) (mbg = [205, 45, 45]), (mfg = [255, 240, 240]); + else if (/CLEAR/.test(message)) (mbg = [45, 175, 85]), (mfg = [12, 30, 14]); + else if (/LAST/.test(message)) (mbg = [235, 200, 60]), (mfg = [40, 28, 0]); + else (mbg = [80, 90, 150]), (mfg = [240, 240, 255]); + ink(...mbg).box(mx - 5, myy - 3, mw + 10, 8 * ms + 6); + ink(0, 0, 0, 110).box(mx - 5, myy - 3, mw + 10, 8 * ms + 6, "outline"); + ink(...mfg).write(message, { x: mx, y: myy, size: ms }); + } + + // 🪧 Intro card — name + native edition, fading over the live board. No + // instructions, no "press any key": the board is already playing. + if (introTimer > 0) paintIntro({ ink, screen, write, text }); + + // Let the celebration play out, then show the prompt overlay. + if (state === "clear" && clearPhase === 0) + overlay( + { ink, screen, write }, + "LEVEL CLEAR", + mode === "art" && lastArtwork ? "I: record · press to continue" : "press any key", + ); + if (state === "over") { + overlay( + { ink, screen, write }, + "GAME OVER", + mode === "art" && lastArtwork ? "I: record · press to retry" : "press to retry", + ); + paintLeaderboard({ ink, screen, write }); + } +} + +// Cute monster: fatter the more it has eaten; shrinks + trembles when starving +// (low on beats). Its mouth only moves while actually munching. +// 🌌 Subtle animated backdrop — a slowly drifting dot grid that brightens on +// each beat. Sits behind everything, low-contrast so it never distracts. +function paintBackground({ ink, box, screen }) { + // Spacing scales with the viewport so the dot count stays bounded (~15×15 + // worst case) on big desktop windows instead of growing with screen area. + const sp = max(40, Math.ceil(screen.width / 15), Math.ceil(screen.height / 15)); + const dx = (frames * 0.18) % sp; + const dy = (frames * 0.11) % sp; + const pulse = beatPulse > 0 ? 6 : 0; + for (let gy = -sp; gy < screen.height + sp; gy += sp) { + for (let gx = -sp; gx < screen.width + sp; gx += sp) { + // Faint, just barely off the background — brighter on dark, dimmer on light. + const b = 4 + pulse + 3 * sin((gx + gy) * 0.05 + frames * 0.035); + if (dark) ink(14 + round(b * 0.4), 16 + round(b * 0.5), 32 + round(b)); // dim blue-violet + else ink(225 - round(b * 1.2), 229 - round(b * 1.1), 240 - round(b * 0.6)); // soft graphite-blue + box(gx + dx, gy + dy, 2, 2); + } + } +} + +// Big number in the default font (kerns cleanly at scale, unlike unifont). +// Returns the end x. Default glyph advance ≈ 6px × size. +function bigNum(ink, txt, x, y, sz, col) { + ink(...col).write(txt, { x, y, size: sz }); + return x + txt.length * 6 * sz; +} + +function paintMuncher({ ink, box }, cx, cy, cell, dance = false) { + const fullness = max(0, min(1, 1 - remaining / boardCorrectTotal)); // hungry→full + let scale = 0.72 + fullness * 0.4; // skinny → round + const dying = beatsLeft <= 3 && state === "play"; + if (dying) scale *= 0.66 + 0.12 * abs(sin(frames * 0.8)); // shrink, panicked + const s = max(8, floor((cell - 2) * 0.8 * scale)); + const legH = max(2, floor(s * 0.18)); + const bodyH = s - legH; + const trem = dying ? 1.8 : 0; + const ox = dying ? sin(frames * 1.4) * trem : 0; + const oy = + (dance ? -abs(sin(frames * 0.45)) * s * 0.28 : 0) + + (dying ? cos(frames * 1.8) * trem : 0); + const x = cx + (cell - s) / 2 + ox; + const y = cy + (cell - s) / 2 + oy; + + // 🦵 Legs — two simple feet that bob. + const bob = (frames >> (dance ? 1 : 3)) % 2; + const legW = max(2, floor(s * 0.26)); + const footY = y + bodyH; + ink(60, 175, 80); + box(x + s * 0.12, footY - (bob ? 1 : 0), legW, legH); + box(x + s * 0.62, footY - (bob ? 0 : 1), legW, legH); + + // 🟩 Body — one flat healthy green; brightens if time is running out. + const hc = [125, 235, 130]; + ink(...(dying ? [min(255, hc[0] + 30), min(255, hc[1] + 20), hc[2]] : hc)).box(x, y, s, bodyH); + + // 👀 Eyes — two squares; pupil glances toward the last move direction. + const eo = max(2, floor(s * 0.2)); + const ey = y + s * 0.2; + const exL = x + s * 0.18, + exR = x + s * 0.56; + ink(255).box(exL, ey, eo, eo); + ink(255).box(exR, ey, eo, eo); + const pup = max(1, floor(eo * 0.5)); + const gx = floor((eo - pup) / 2) * facing.x; + const gy = floor((eo - pup) / 2) * facing.y; + ink(20, 40, 25).box(exL + (eo - pup) / 2 + gx, ey + (eo - pup) / 2 + gy, pup, pup); + ink(20, 40, 25).box(exR + (eo - pup) / 2 + gx, ey + (eo - pup) / 2 + gy, pup, pup); + + // 👄 Mouth — closed line; opens to a simple dark square while munching. + const my = y + bodyH * 0.66; + if (chompPhase > 0) { + ink(25, 40, 25).box(x + s * 0.3, my - s * 0.05, s * 0.4, s * 0.26); + } else { + ink(40, 80, 45).box(x + s * 0.32, my, s * 0.36, max(1, floor(s * 0.08))); + } +} + +// 💀 The Muncher's sad end: turns deep red, sinks + squashes, X-ed-out eyes, +// a frown, and a falling blue tear. +function paintDeadMuncher({ ink, box, line }, cx, cy, cell) { + const inset = cell * 0.12; + const s = cell - inset * 2; + const p = 1 - deathPhase / 54; // 0 → 1 over the animation + const sink = s * 0.55 * p; + const x = cx + inset; + const y = cy + inset + sink; + const bodyH = s * (1 - 0.3 * p); // squash down as it sinks + const a = max(20, 255 - p * 130); // fade out + + // Body — deep, sad red. + ink(170 - p * 70, 28, 30, a).box(x, y, s, bodyH); + ink(90, 12, 14, a).box(x, y, s, bodyH, "outline"); + + // ✖ Dead eyes. + const ex = max(3, floor(s * 0.16)); + const ey = y + s * 0.16; + ink(25, 0, 0, a); + const xeye = (ox) => { + line(x + ox, ey, x + ox + ex, ey + ex); + line(x + ox + ex, ey, x + ox, ey + ex); + }; + xeye(s * 0.16); + xeye(s * 0.56); + + // ☹ Frown. + ink(30, 0, 0, a); + const my = y + bodyH * 0.64; + box(x + s * 0.3, my, s * 0.4, max(1, floor(s * 0.08))); + box(x + s * 0.26, my - max(1, floor(s * 0.06)), max(1, floor(s * 0.08)), max(1, floor(s * 0.06))); + box(x + s * 0.66 - s * 0.08, my - max(1, floor(s * 0.06)), max(1, floor(s * 0.08)), max(1, floor(s * 0.06))); + + // 💧 Tear. + if ((frames >> 2) % 2) + ink(120, 180, 255, a).box( + x + s * 0.22, + ey + ex + 2 + p * s * 0.4, + max(1, floor(s * 0.08)), + max(2, floor(s * 0.16)), + ); +} + +// 😠 Troggle — an angry red enemy: eyes with pupils, slanted-down eyebrows, +// and a jagged downturned mouth with fangs. +function paintTroggle({ ink, box, line }, cx, cy, cell, hue) { + const pad = cell * 0.16; + const s = cell - pad * 2; + const x = cx + pad; + const y = cy + pad; + ink(205, 48, 46).box(x, y, s, s); // red body + ink(120, 14, 14).box(x, y, s, s, "outline"); + + // Eyes (white) with dark pupils that point at the muncher's general area. + const eo = max(2, floor(s * 0.2)); + const ey = y + s * 0.32; + const exL = x + s * 0.16, + exR = x + s * 0.56; + ink(255).box(exL, ey, eo, eo); + ink(255).box(exR, ey, eo, eo); + const pup = max(1, floor(eo * 0.5)); + ink(25, 0, 0).box(exL + (eo - pup) / 2, ey + (eo - pup) / 2 + 1, pup, pup); + ink(25, 0, 0).box(exR + (eo - pup) / 2, ey + (eo - pup) / 2 + 1, pup, pup); + + // 😠 Angry eyebrows — slant down toward the center. + ink(20, 0, 0); + line(x + s * 0.1, y + s * 0.2, x + s * 0.38, y + s * 0.3); + line(x + s * 0.9, y + s * 0.2, x + s * 0.62, y + s * 0.3); + + // 😡 Jagged, downturned mouth with two fangs. + const my = y + s * 0.7; + ink(25, 0, 0); + line(x + s * 0.22, my + s * 0.06, x + s * 0.4, my - s * 0.04); + line(x + s * 0.4, my - s * 0.04, x + s * 0.6, my - s * 0.04); + line(x + s * 0.6, my - s * 0.04, x + s * 0.78, my + s * 0.06); + ink(255); + box(x + s * 0.4, my - s * 0.04, max(1, floor(s * 0.08)), max(1, floor(s * 0.1))); + box(x + s * 0.54, my - s * 0.04, max(1, floor(s * 0.08)), max(1, floor(s * 0.1))); +} + +function gameName() { + if (mode === "art") return "ARTNOM"; + if (mode === "note") return "NOTENOM"; + if (mode !== "word") return "NUMBNOM"; + return lang === "es" ? "MEXINOM" : lang === "da" ? "DANNOM" : lang === "ru" ? "RUSNOM" : lang === "cat" ? "CATNOM" : "ENGNOM"; +} +// Native edition subtitle, in each language's own script/diacritics. Rendered in +// unifont (carries accents + Cyrillic) so the symbols are correct. +function editionText() { + if (mode === "art") return "art history edition"; + if (mode === "note") return "music edition"; + if (mode !== "word") return "number edition"; + return lang === "es" ? "edición mexicana" + : lang === "da" ? "dansk udgave" + : lang === "ru" ? "русское издание" + : lang === "cat" ? "category edition" + : "english edition"; +} + +// 🪧 Intro card over the live board — just the name + native edition, sized to +// the screen, fading out near the end. No instructions, no "press any key". +function paintIntro({ ink, screen, write }) { + const fade = introTimer > 44 ? 1 : introTimer / 44; // ease out over the last ~0.7s + const A = (v) => max(0, round(v * fade)); + // Dim the board so the title reads, but let it show through (board already up). + ink(T.bg[0], T.bg[1], T.bg[2], A(dark ? 165 : 190)).box(0, 0, screen.width, screen.height); + + const cy = round(screen.height / 2); + // Responsive game name (ASCII, default 6px font): fit ~78% of the width, + // scaled up with the display (HUD scale) for readability. + const name = gameName(); + const nameSize = max(1, min(2 + hudScale * 2, floor((screen.width * 0.78) / max(1, name.length * 6)))); + const nameH = nameSize * 10; + ink(T.introName[0], T.introName[1], T.introName[2], A(255)).write(name, { center: "x", y: cy - nameH, size: nameSize }); + + // Native edition subtitle in unifont (accents + Cyrillic render correctly). + const sub = editionText(); + const subSize = max(1, min(hudScale + 1, floor((screen.width * 0.8) / max(1, sub.length * 8)))); + ink(T.introSub[0], T.introSub[1], T.introSub[2], A(255)).write( + sub, + { center: "x", y: cy + max(6, nameSize * 3), size: subSize }, + undefined, undefined, false, "unifont", + ); +} + +function overlay({ ink, screen, write }, title, sub) { + // Lighter scrim so the board (incl. revealed missed answers) shows through. + ink(0, 0, 0, 125).box(0, 0, screen.width, screen.height); + // Big title on a colored banner — red for game over, green for clear. + const over = /OVER/.test(title); + const bg = over ? [195, 45, 45] : [45, 175, 85]; + const ts = max(2, min(2 + hudScale, floor((screen.width * 0.85) / (title.length * 6)))); + const tw = title.length * 6 * ts; + const tx = max(4, round(screen.width / 2 - tw / 2)); + const ty = round(screen.height / 2 - 8 * ts - 4); + ink(...bg).box(tx - 10, ty - 6, tw + 20, 8 * ts + 12); + ink(0, 0, 0, 150).box(tx - 10, ty - 6, tw + 20, 8 * ts + 12, "outline"); + ink(255, 248, 230).write(title, { x: tx, y: ty, size: ts }); + const ss = max(1, round(ts / 2)); + const sy = round(screen.height / 2 + 8 * ts); + const sw = sub.length * 6 * ss; + ink(...T.bg).box(round((screen.width - sw) / 2) - 5, sy - 3, sw + 10, 8 * ss + 6); + ink(...T.overlaySub).write(sub, { center: "x", y: sy, size: ss }); +} + +function paintLeaderboard({ ink, screen }) { + const rows = leaderboard.slice(0, screen.height < 430 ? 3 : 5); + const size = max(1, min(hudScale, 2)); + const lineH = 11 * size; + let y = round(screen.height / 2 + 78); + const maxY = screen.height - lineH * (rows.length + 1) - 6; + y = min(y, maxY); + for (const row of rows) { + const rank = `#${row.rank}`; + const handle = String(row.handle || "@player"); + const points = String(row.score || 0); + const gap = 6 * size; + const width = (rank.length + handle.length + points.length) * 6 * size + gap * 2; + let x = max(4, round((screen.width - width) / 2)); + const mine = playerHandle && handle.toLowerCase() === playerHandle.toLowerCase(); + ink(...T.bg).box(x - 5, y - 3, width + 10, 8 * size + 6); + ink(...(mine ? T.introName : T.overlaySub)).write(rank, { x, y, size }); + x += rank.length * 6 * size + gap; + x += paintHandle({ ink }, handle, row.colors, x, y, size) + gap; + ink(...(mine ? T.introName : T.overlaySub)).write(points, { x, y, size }); + y += lineH; + } + const status = leaderboardStatus || (rows.length ? "" : "no ranked runs yet"); + if (status) { + let statusSize = size; + while (statusSize > 1 && status.length * 6 * statusSize > screen.width - 8) + statusSize -= 1; + const statusW = status.length * 6 * statusSize; + ink(...T.bg).box(round((screen.width - statusW) / 2) - 5, y - 3, statusW + 10, 8 * statusSize + 6); + ink(...T.overlaySub).write(status, { center: "x", y, size: statusSize }); + } else if (leaderboardRank && leaderboardRank > rows.length) { + ink(...T.overlaySub).write(`#${leaderboardRank}`, { center: "x", y, size }); + } +} + +function nomLeave() { + killChord(); + submitRun(); +} + +// 📰 Meta — built from explicit params so the title is correct at load time, +// independent of boot() ordering or leftover singleton state. Each wrapper +// piece exports `meta() { return makeMeta([]); }`. +function makeMeta(params) { + const { mode: m, lang: l } = resolveMode(params); + const title = m === "art" + ? "Artnom" + : m === "note" + ? "Notenom" + : m !== "word" + ? "Numbnom" + : l === "es" ? "Mexinom" : l === "da" ? "Dannom" : l === "ru" ? "Rusnom" : l === "cat" ? "Catnom" : "Engnom"; + return { + title, + desc: m === "art" + ? "Munch museum thumbnails by art-historical style — Getty AAT categories, IIIF images, and canonical object records." + : "Eat the squares that match the rule — numbnom (numbers), engnom (words), mexinom (español), dannom (dansk), rusnom (русский), notenom (notes), catnom (categories).", + }; +} +function meta() { + return makeMeta([ + mode === "art" ? "art" : mode === "note" ? "notes" : mode === "word" ? lang : "numbers", + ]); +} + +// Native Xbox adapter for the shared Nom engine. Appended by bundle-nom.mjs +// inside the same closure as nomBoot/nomSim/nomPaint/nomAct/nomLeave. + +let xboxNomSeed = 0x4e4f4d; +let xboxNomScreen = { width: 1920, height: 1080 }; +let xboxNomInk = [255, 255, 255, 255]; +let xboxNomBackdrop = [0, 0, 0]; +let xboxNomPreviousButtons = new Set(); + +function xboxNomRandom() { + xboxNomSeed = (Math.imul(xboxNomSeed, 1664525) + 1013904223) >>> 0; + return xboxNomSeed / 0x100000000; +} + +function xboxNomRandInt(maximum) { + return Math.floor(xboxNomRandom() * (Math.max(0, Number(maximum) || 0) + 1)); +} + +function xboxNomView() { + const view = typeof gameView === "function" ? gameView() : null; + xboxNomScreen = { + width: Math.max(320, Math.round(Number(view?.width) || 1920)), + height: Math.max(240, Math.round(Number(view?.height) || 1080)), + }; +} + +function xboxNomColor(values) { + const color = values.length === 1 && Array.isArray(values[0]) ? values[0] : values; + return [ + Math.max(0, Math.min(255, Math.round(Number(color[0]) || 0))), + Math.max(0, Math.min(255, Math.round(Number(color[1]) || 0))), + Math.max(0, Math.min(255, Math.round(Number(color[2]) || 0))), + color[3] == null ? 255 : Math.max(0, Math.min(255, Math.round(Number(color[3]) || 0))), + ]; +} + +function xboxNomOpaque(color) { + if (color[3] >= 250) return color; + const alpha = color[3] / 255; + return [0, 1, 2].map((index) => + Math.round(color[index] * alpha + xboxNomBackdrop[index] * (1 - alpha))); +} + +function xboxNomRect(x, y, width, height, style) { + // The current OSKIEWAR host's box primitive is opaque. Dropping translucent + // overlays preserves the board; drawing their blended color as an opaque + // rectangle would erase every command beneath it. + if (xboxNomInk[3] < 250) return; + const [r, g, b] = xboxNomOpaque(xboxNomInk); + if (style === "outline") { + box(x, y, width, 1, r, g, b); + box(x, y + height - 1, width, 1, r, g, b); + box(x, y, 1, height, r, g, b); + box(x + width - 1, y, 1, height, r, g, b); + } else box(x, y, width, height, r, g, b); +} + +function xboxNomLine(x1, y1, x2, y2) { + const [r, g, b] = xboxNomOpaque(xboxNomInk); + line(x1, y1, x2, y2, 1, r, g, b); +} + +function xboxNomWrite(value, position = {}) { + const text = String(value || ""); + const size = Number(position.size) || 1; + const pixelSize = size * 10; + const x = position.center === "x" + ? (xboxNomScreen.width - text.length * 6 * size) / 2 + : Number(position.x) || 0; + const y = Number(position.y) || 0; + const [r, g, b] = xboxNomOpaque(xboxNomInk); + if (typeof comicWrite === "function") comicWrite(text, x, y, pixelSize, r, g, b); + else write(text, x, y, pixelSize, r, g, b); +} + +const xboxNomChain = { + box: xboxNomRect, + line: xboxNomLine, + write: xboxNomWrite, + hd: false, +}; + +function xboxNomInkFn(...values) { + xboxNomInk = xboxNomColor(values); + return xboxNomChain; +} + +function xboxNomWipe(...values) { + xboxNomBackdrop = xboxNomColor(values).slice(0, 3); + wipe(...xboxNomBackdrop); +} + +function xboxNomFrequency(note) { + const match = String(note).toLowerCase().match(/^([a-g])([#b]?)(-?\d)?$/); + if (!match) return Number(note) || 440; + const semitones = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; + const accidental = match[2] === "#" ? 1 : match[2] === "b" ? -1 : 0; + const octave = match[3] == null ? 4 : Number(match[3]); + return 440 * Math.pow(2, (semitones[match[1]] + accidental + (octave - 4) * 12 - 9) / 12); +} + +const xboxNomSound = { + freq: xboxNomFrequency, + synth(options = {}) { + const tone = Number(options.tone) || 440; + const volume = Number(options.volume) || .2; + if (options.duration === "🔁" && typeof oscillator === "function") { + oscillator(tone, volume); + return { kill: () => typeof oscillatorStop === "function" && oscillatorStop() }; + } + xboxNativeSynth(tone, Math.max(.01, Number(options.duration) || .08)); + return { kill() {}, update() {} }; + }, +}; + +class XboxNomHourglass { + constructor(maximum = 1, options = {}) { + this.maximum = Math.max(1, Number(maximum) || 1); + this.options = options; + this.ticks = 0; + } + step() { + this.ticks += 1; + if (this.ticks < this.maximum) return; + this.options.completed?.(); + this.options.flipped?.(); + if (this.options.autoFlip) this.ticks = 0; + } +} + +const xboxNomNum = { randInt: xboxNomRandInt }; +const xboxNomGizmo = { Hourglass: XboxNomHourglass }; +const xboxNomClock = { + resync() {}, + time() { + const snapshot = typeof runtime === "function" ? runtime() : null; + return new Date(Number(snapshot?.unixMs) || Date.now()); + }, +}; + +function xboxNomEvent(name) { + return { name, is: (candidate) => candidate === name }; +} + +function xboxNomDispatch(button, down) { + const key = { + ArrowLeft: "arrowleft", ArrowRight: "arrowright", + ArrowUp: "arrowup", ArrowDown: "arrowdown", + A: "space", Menu: "enter", + }[button]; + if (!key) return; + nomAct({ + event: xboxNomEvent(`keyboard:${down ? "down" : "up"}:${key}`), + sound: xboxNomSound, + speak() {}, cursor() {}, net: {}, num: xboxNomNum, + }); +} + +function xboxNomSampleInput() { + const snapshot = typeof gamepad === "function" ? gamepad(0) : { down: [] }; + const current = new Set(Array.isArray(snapshot?.down) ? snapshot.down : []); + for (const button of current) + if (!xboxNomPreviousButtons.has(button)) xboxNomDispatch(button, true); + for (const button of xboxNomPreviousButtons) + if (!current.has(button)) xboxNomDispatch(button, false); + xboxNomPreviousButtons = current; +} + +const xboxNomPaintApi = { + get screen() { return xboxNomScreen; }, + get dark() { return true; }, + wipe: xboxNomWipe, + ink: xboxNomInkFn, + box: xboxNomRect, + line: xboxNomLine, + write: xboxNomWrite, +}; + +globalThis.boot = function boot() { + xboxNomView(); + xboxNomPreviousButtons.clear(); + nomBoot({ + params: ["danish"], hud: { label() {} }, clock: xboxNomClock, + net: {}, + handle: () => typeof nomHandle === "function" ? nomHandle() : null, + authorize: null, num: xboxNomNum, + }); + if (typeof telemetry === "function") telemetry("NOM_READY", "dannom shared-engine"); +}; + +globalThis.sim = function sim() { + xboxNomView(); + xboxNomSampleInput(); + nomSim({ + gizmo: xboxNomGizmo, seconds: (value) => value * 60, + sound: xboxNomSound, clock: xboxNomClock, num: xboxNomNum, + }); +}; + +globalThis.paint = function paint() { + xboxNomView(); + nomPaint(xboxNomPaintApi); +}; + +// OSKIEWAR samples controller state in its fixed simulation step too. Keeping +// act empty prevents a native edge callback from advancing Nom twice. +globalThis.act = function act() {}; +globalThis.leave = function leave() { nomLeave(); }; + +})(synth); diff --git a/xbox/live/nom-host-adapter.js b/xbox/live/nom-host-adapter.js new file mode 100644 index 0000000000..a137fdc609 --- /dev/null +++ b/xbox/live/nom-host-adapter.js @@ -0,0 +1,208 @@ +// Native Xbox adapter for the shared Nom engine. Appended by bundle-nom.mjs +// inside the same closure as nomBoot/nomSim/nomPaint/nomAct/nomLeave. + +let xboxNomSeed = 0x4e4f4d; +let xboxNomScreen = { width: 1920, height: 1080 }; +let xboxNomInk = [255, 255, 255, 255]; +let xboxNomBackdrop = [0, 0, 0]; +let xboxNomPreviousButtons = new Set(); + +function xboxNomRandom() { + xboxNomSeed = (Math.imul(xboxNomSeed, 1664525) + 1013904223) >>> 0; + return xboxNomSeed / 0x100000000; +} + +function xboxNomRandInt(maximum) { + return Math.floor(xboxNomRandom() * (Math.max(0, Number(maximum) || 0) + 1)); +} + +function xboxNomView() { + const view = typeof gameView === "function" ? gameView() : null; + xboxNomScreen = { + width: Math.max(320, Math.round(Number(view?.width) || 1920)), + height: Math.max(240, Math.round(Number(view?.height) || 1080)), + }; +} + +function xboxNomColor(values) { + const color = values.length === 1 && Array.isArray(values[0]) ? values[0] : values; + return [ + Math.max(0, Math.min(255, Math.round(Number(color[0]) || 0))), + Math.max(0, Math.min(255, Math.round(Number(color[1]) || 0))), + Math.max(0, Math.min(255, Math.round(Number(color[2]) || 0))), + color[3] == null ? 255 : Math.max(0, Math.min(255, Math.round(Number(color[3]) || 0))), + ]; +} + +function xboxNomOpaque(color) { + if (color[3] >= 250) return color; + const alpha = color[3] / 255; + return [0, 1, 2].map((index) => + Math.round(color[index] * alpha + xboxNomBackdrop[index] * (1 - alpha))); +} + +function xboxNomRect(x, y, width, height, style) { + // The current OSKIEWAR host's box primitive is opaque. Dropping translucent + // overlays preserves the board; drawing their blended color as an opaque + // rectangle would erase every command beneath it. + if (xboxNomInk[3] < 250) return; + const [r, g, b] = xboxNomOpaque(xboxNomInk); + if (style === "outline") { + box(x, y, width, 1, r, g, b); + box(x, y + height - 1, width, 1, r, g, b); + box(x, y, 1, height, r, g, b); + box(x + width - 1, y, 1, height, r, g, b); + } else box(x, y, width, height, r, g, b); +} + +function xboxNomLine(x1, y1, x2, y2) { + const [r, g, b] = xboxNomOpaque(xboxNomInk); + line(x1, y1, x2, y2, 1, r, g, b); +} + +function xboxNomWrite(value, position = {}) { + const text = String(value || ""); + const size = Number(position.size) || 1; + const pixelSize = size * 10; + const x = position.center === "x" + ? (xboxNomScreen.width - text.length * 6 * size) / 2 + : Number(position.x) || 0; + const y = Number(position.y) || 0; + const [r, g, b] = xboxNomOpaque(xboxNomInk); + if (typeof comicWrite === "function") comicWrite(text, x, y, pixelSize, r, g, b); + else write(text, x, y, pixelSize, r, g, b); +} + +const xboxNomChain = { + box: xboxNomRect, + line: xboxNomLine, + write: xboxNomWrite, + hd: false, +}; + +function xboxNomInkFn(...values) { + xboxNomInk = xboxNomColor(values); + return xboxNomChain; +} + +function xboxNomWipe(...values) { + xboxNomBackdrop = xboxNomColor(values).slice(0, 3); + wipe(...xboxNomBackdrop); +} + +function xboxNomFrequency(note) { + const match = String(note).toLowerCase().match(/^([a-g])([#b]?)(-?\d)?$/); + if (!match) return Number(note) || 440; + const semitones = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; + const accidental = match[2] === "#" ? 1 : match[2] === "b" ? -1 : 0; + const octave = match[3] == null ? 4 : Number(match[3]); + return 440 * Math.pow(2, (semitones[match[1]] + accidental + (octave - 4) * 12 - 9) / 12); +} + +const xboxNomSound = { + freq: xboxNomFrequency, + synth(options = {}) { + const tone = Number(options.tone) || 440; + const volume = Number(options.volume) || .2; + if (options.duration === "🔁" && typeof oscillator === "function") { + oscillator(tone, volume); + return { kill: () => typeof oscillatorStop === "function" && oscillatorStop() }; + } + xboxNativeSynth(tone, Math.max(.01, Number(options.duration) || .08)); + return { kill() {}, update() {} }; + }, +}; + +class XboxNomHourglass { + constructor(maximum = 1, options = {}) { + this.maximum = Math.max(1, Number(maximum) || 1); + this.options = options; + this.ticks = 0; + } + step() { + this.ticks += 1; + if (this.ticks < this.maximum) return; + this.options.completed?.(); + this.options.flipped?.(); + if (this.options.autoFlip) this.ticks = 0; + } +} + +const xboxNomNum = { randInt: xboxNomRandInt }; +const xboxNomGizmo = { Hourglass: XboxNomHourglass }; +const xboxNomClock = { + resync() {}, + time() { + const snapshot = typeof runtime === "function" ? runtime() : null; + return new Date(Number(snapshot?.unixMs) || Date.now()); + }, +}; + +function xboxNomEvent(name) { + return { name, is: (candidate) => candidate === name }; +} + +function xboxNomDispatch(button, down) { + const key = { + ArrowLeft: "arrowleft", ArrowRight: "arrowright", + ArrowUp: "arrowup", ArrowDown: "arrowdown", + A: "space", Menu: "enter", + }[button]; + if (!key) return; + nomAct({ + event: xboxNomEvent(`keyboard:${down ? "down" : "up"}:${key}`), + sound: xboxNomSound, + speak() {}, cursor() {}, net: {}, num: xboxNomNum, + }); +} + +function xboxNomSampleInput() { + const snapshot = typeof gamepad === "function" ? gamepad(0) : { down: [] }; + const current = new Set(Array.isArray(snapshot?.down) ? snapshot.down : []); + for (const button of current) + if (!xboxNomPreviousButtons.has(button)) xboxNomDispatch(button, true); + for (const button of xboxNomPreviousButtons) + if (!current.has(button)) xboxNomDispatch(button, false); + xboxNomPreviousButtons = current; +} + +const xboxNomPaintApi = { + get screen() { return xboxNomScreen; }, + get dark() { return true; }, + wipe: xboxNomWipe, + ink: xboxNomInkFn, + box: xboxNomRect, + line: xboxNomLine, + write: xboxNomWrite, +}; + +globalThis.boot = function boot() { + xboxNomView(); + xboxNomPreviousButtons.clear(); + nomBoot({ + params: ["danish"], hud: { label() {} }, clock: xboxNomClock, + net: {}, + handle: () => typeof nomHandle === "function" ? nomHandle() : null, + authorize: null, num: xboxNomNum, + }); + if (typeof telemetry === "function") telemetry("NOM_READY", "dannom shared-engine"); +}; + +globalThis.sim = function sim() { + xboxNomView(); + xboxNomSampleInput(); + nomSim({ + gizmo: xboxNomGizmo, seconds: (value) => value * 60, + sound: xboxNomSound, clock: xboxNomClock, num: xboxNomNum, + }); +}; + +globalThis.paint = function paint() { + xboxNomView(); + nomPaint(xboxNomPaintApi); +}; + +// OSKIEWAR samples controller state in its fixed simulation step too. Keeping +// act empty prevents a native edge callback from advancing Nom twice. +globalThis.act = function act() {}; +globalThis.leave = function leave() { nomLeave(); }; diff --git a/xbox/live/tests/dannom-bundle.test.mjs b/xbox/live/tests/dannom-bundle.test.mjs new file mode 100644 index 0000000000..a26f270f4c --- /dev/null +++ b/xbox/live/tests/dannom-bundle.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildNomBundle } from "../../tools/bundle-nom.mjs"; + +function nativeProbe(source) { + const commands = []; + const pad = { down: [] }; + let now = 1_786_089_600_000; + const globals = { + wipe: (...args) => commands.push(["wipe", ...args]), + box: (...args) => commands.push(["box", ...args]), + line: (...args) => commands.push(["line", ...args]), + write: (...args) => commands.push(["write", ...args]), + comicWrite: (...args) => commands.push(["comicWrite", ...args]), + synth: () => {}, oscillator: () => {}, oscillatorStop: () => {}, + gameView: () => ({ width: 1280, height: 720 }), + gamepad: () => ({ connected: true, down: pad.down.slice() }), + runtime: () => ({ unixMs: now, monotonicUs: now * 1000 }), + capabilities: () => ({ platform: "xbox-uwp", inputFamily: "xbox" }), + telemetry: (...args) => commands.push(["telemetry", ...args]), + }; + const names = Object.keys(globals); + const values = Object.values(globals); + const lifecycle = new Function(...names, + `${source}\nreturn { boot: globalThis.boot, sim: globalThis.sim, paint: globalThis.paint, act: globalThis.act, leave: globalThis.leave };`)(...values); + return { lifecycle, commands, pad, tick(ms = 17) { now += ms; lifecycle.sim(); } }; +} + +test("the native Dannom bundle contains the shared engine and Xbox lifecycle", async () => { + const source = await buildNomBundle(); + assert.match(source, /source: system\/public\/aesthetic\.computer\/lib\/nom\.mjs/); + assert.match(source, /const COLS = 5/); + assert.match(source, /if \(state === "over"\) \{[\s\S]*?paintLeaderboard/); + assert.match(source, /leaderboard\.slice\(0, screen\.height < 430 \? 3 : 5\)/); + assert.doesNotMatch(source, /^import\s/m); + const probe = nativeProbe(source); + probe.lifecycle.boot(); + probe.tick(); + probe.lifecycle.paint(); + assert.ok(probe.commands.some(([name]) => name === "wipe")); + assert.ok(probe.commands.some(([name]) => name === "box")); + assert.ok(probe.commands.some(([name]) => name === "comicWrite")); +}); + +test("Xbox A and d-pad edges enter the shared Nom act path once", async () => { + const probe = nativeProbe(await buildNomBundle()); + probe.lifecycle.boot(); + probe.pad.down = ["ArrowRight", "A"]; + probe.tick(); + probe.tick(); // held buttons must not create another edge + probe.pad.down = []; + probe.tick(); + probe.lifecycle.paint(); + assert.ok(probe.commands.filter(([name]) => name === "comicWrite").length > 10); +}); diff --git a/xbox/tools/bundle-nom.mjs b/xbox/tools/bundle-nom.mjs new file mode 100644 index 0000000000..68fba10ea9 --- /dev/null +++ b/xbox/tools/bundle-nom.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +const root = new URL("../../", import.meta.url); +const sources = [ + "system/public/aesthetic.computer/lib/percussion.mjs", + "system/public/aesthetic.computer/lib/synth.mjs", + "system/public/aesthetic.computer/lib/nom-score.mjs", + "system/public/aesthetic.computer/lib/nom.mjs", +]; + +function flattenModule(source, path) { + let flat = source + .replace(/^import[^\n]*\n?/gm, "") + .replace(/^export\s+default\s+[^;]+;\s*$/gm, "") + .replace(/^export\s*\{[^}]*\};\s*$/gm, "") + .replace(/^export\s+(?=(?:const|let|class|function)\b)/gm, ""); + if (path.endsWith("/nom.mjs")) { + for (const name of ["boot", "sim", "paint", "act", "leave"]) + flat = flat.replace(new RegExp(`^function ${name}\\(`, "m"), `function nom${name[0].toUpperCase()}${name.slice(1)}(`); + } + return flat.trim(); +} + +export async function buildNomBundle() { + const modules = []; + for (const path of sources) { + const source = await readFile(new URL(path, root), "utf8"); + modules.push(`// source: ${path}\n${flattenModule(source, path)}`); + } + const adapter = await readFile(new URL("xbox/live/nom-host-adapter.js", root), "utf8"); + return `// Dannom for the OSKIEWAR-generation native Xbox host. GENERATED.\n` + + `// Rebuild: node xbox/tools/bundle-nom.mjs\n(function (xboxNativeSynth) {\n${modules.join("\n\n")}\n\n${adapter}\n})(synth);\n`; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const output = new URL("xbox/live/dannom.js", root); + await writeFile(output, await buildNomBundle()); + console.log(fileURLToPath(output)); +} diff --git a/xbox/tools/render-nom-frame.mjs b/xbox/tools/render-nom-frame.mjs new file mode 100644 index 0000000000..908333e87a --- /dev/null +++ b/xbox/tools/render-nom-frame.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { buildNomBundle } from "./bundle-nom.mjs"; + +const gameOver = process.argv.includes("--over"); +const outputArg = process.argv.slice(2).find((arg) => !arg.startsWith("--")); +const output = resolve(outputArg || "tmp/dannom-native-frame.svg"); +const commands = []; +const pad = { down: [] }; +let now = 1_786_089_600_000; +const host = { + wipe: (...args) => commands.push(["wipe", ...args]), + box: (...args) => commands.push(["box", ...args]), + line: (...args) => commands.push(["line", ...args]), + write: (...args) => commands.push(["write", ...args]), + comicWrite: (...args) => commands.push(["text", ...args]), + synth() {}, oscillator() {}, oscillatorStop() {}, + gameView: () => ({ width: 1280, height: 720 }), + gamepad: () => ({ connected: true, down: pad.down.slice() }), + runtime: () => ({ unixMs: now, monotonicUs: now * 1000 }), + capabilities: () => ({ platform: "xbox-uwp", inputFamily: "xbox" }), + nomHandle: () => "jeffrey", + telemetry() {}, +}; +const names = Object.keys(host); +const lifecycle = new Function(...names, + `${await buildNomBundle()}\nreturn { boot: globalThis.boot, sim: globalThis.sim, paint: globalThis.paint };`, +)(...Object.values(host)); +lifecycle.boot(); +for (let frame = 0; frame < 150; frame += 1) { + now += 1000 / 60; + lifecycle.sim(); +} +if (gameOver) { + pad.down = ["A"]; + now += 1000 / 60; + lifecycle.sim(); + pad.down = []; + for (let frame = 0; frame < 60; frame += 1) { + now += 1000 / 60; + lifecycle.sim(); + } +} +lifecycle.paint(); + +const escape = (value) => String(value).replaceAll("&", "&") + .replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +const rgb = (r, g, b) => `rgb(${r},${g},${b})`; +const body = commands.map(([kind, ...args]) => { + if (kind === "wipe") + return ``; + if (kind === "box") { + const [x, y, width, height, r, g, b] = args; + return ``; + } + if (kind === "line") { + const [x1, y1, x2, y2, width, r, g, b] = args; + return ``; + } + if (kind === "text" || kind === "write") { + const [value, x, y, size, r = 255, g = 255, b = 255] = args; + return `${escape(value)}`; + } + return ""; +}).join("\n"); +const font = (await readFile(new URL( + "../../system/public/papers.aesthetic.computer/foundry/fonts/ComicRelief-Regular.ttf", + import.meta.url, +))).toString("base64"); +const svg = ` + +${body} +`; +await writeFile(output, svg); +console.log(output);