From bd40f63fb7ac9b23daff960bd1fe59648d859415 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 9 Jun 2026 10:57:48 -0700 Subject: [PATCH] nom: fold notenom into shared engine + add swipe pen events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/nom.mjs: add a third `note` mode (notenom) alongside number/word — pitch-class palette, note rules (C MAJOR/A MINOR/SHARPS/chords/HIGH/LOW), per-cell note voicing, scale-on-start, BPM 92 override (own beatMs on the wall-clock grid). Troggles on. Merged with the clock-synced beats + MatrixChunky8 text-fitting that landed on main. - disks/notenom.mjs: 828-line standalone → thin wrapper forcing note mode. - lib/pen.mjs: emit first-class `swipe:left/right/up/down` pen events on a finished gesture (24px dominant-axis threshold), fired just before `lift`. - lib/disk.mjs: pen-event `is()` reads the 2nd colon segment of `swipe:*` as a direction (not a pointer index); additive, primary-pointer only. - nom: swipe moves one square, tap anywhere munches (replaces tap-to-move), plus an on-screen deadzone overlay visualizing the 24px swipe threshold. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../aesthetic.computer/disks/notenom.mjs | 831 +----------------- system/public/aesthetic.computer/lib/disk.mjs | 8 + system/public/aesthetic.computer/lib/nom.mjs | 310 +++++-- system/public/aesthetic.computer/lib/pen.mjs | 28 +- 4 files changed, 296 insertions(+), 881 deletions(-) diff --git a/system/public/aesthetic.computer/disks/notenom.mjs b/system/public/aesthetic.computer/disks/notenom.mjs index 64268b83ed..81f322395d 100644 --- a/system/public/aesthetic.computer/disks/notenom.mjs +++ b/system/public/aesthetic.computer/disks/notenom.mjs @@ -1,828 +1,15 @@ // notenom, 2026.06.08 -// Munch the squares whose musical note matches the rule, on the beat. +// Music edition of the "nom" muncher game — eat the grid squares whose note +// matches the rule (C MAJOR, A MINOR, SHARPS, chords, HIGH / LOW) on the beat, +// while dodging troggles. Each munch voices the note; the rule's scale plays on +// board start. Shares the engine in ../lib/nom.mjs with numbnom (numbers), +// engnom (words), mexinom (español) and dannom (dansk) — this just forces note +// mode (BPM 92, per-cell note voicing) at boot. -import { Synth } from "../lib/synth.mjs"; // shared virtual synth + perc kit +import { boot as nomBoot, sim, paint, act, meta } from "../lib/nom.mjs"; -/* #region 📚 README - A music edition of the "nom" grid game (sibling to numbnom / engnom). - A green muncher hops a 5x5 grid of note squares. Eat every square that - satisfies the musical rule shown up top before the beat-timer runs out. - Each munch plays its note; the scale plays on board start, an arpeggio on - clear, and a kick rides the beat. - - Controls: - Arrows / WASD / hjkl — move the Muncher - Space / Enter — munch the current square - Tap a square — slide toward it (tap your own square to munch) -#endregion */ - -const COLS = 5; -const ROWS = 5; -const START_LIVES = 3; - -// 🎵 Note model ─────────────────────────────────────────────────────────────── -const LETTERS = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; -const SEMI = { C: 0, "C#": 1, D: 2, "D#": 3, E: 4, F: 5, "F#": 6, G: 7, "G#": 8, A: 9, "A#": 10, B: 11 }; - -// 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 NOTE_COLORS[letter] || [220, 220, 235]; -} -function tileColor(letter) { - const c = noteColor(letter); - return [round(c[0] * 0.22) + 6, round(c[1] * 0.22) + 6, round(c[2] * 0.22) + 6]; -} -function luma(c) { - return 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]; -} - -// 🎼 Rules — one per board. `test(letter, oct)` decides correctness, and -// `scale` is the ascending run played when the board begins. -const RULES = [ - { - label: "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", - test: (l) => ["A", "B", "C", "D", "E", "F", "G"].includes(l), - scale: ["A3", "B3", "C4", "D4", "E4", "F4", "G4", "A4"], - }, - { - label: "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", - test: (l) => l.includes("#"), - scale: ["C#4", "D#4", "F#4", "G#4", "A#4", "C#5"], - }, - { - label: "C CHORD", - test: (l) => ["C", "E", "G"].includes(l), - scale: ["C4", "E4", "G4", "C5"], - }, - { - label: "HIGH", - test: (l, o) => o >= 5, - scale: ["C5", "E5", "G5", "C6"], - }, - { - label: "LOW", - test: (l, o) => o <= 3, - scale: ["C3", "E3", "G3", "C4"], - }, -]; - -// 🌐 Game state -let state = "title"; // title | play | clear | over -let grid = []; // [{ letter, oct, correct, eaten, failed, flash, known }] -let rule = RULES[0]; -let muncher = { col: 2, row: 2 }; -let muncherVis = { col: 2, row: 2 }; -let lives = START_LIVES; -let level = 1; -let remaining = 0; -let boardCorrectTotal = 1; -let message = ""; -let messageTimer = 0; -let lifeFlyers = []; - -// 🔊 Feel -let synth = null; // virtual synth controller (notes + perc kit), lazy-created -let frames = 0; -let facing = { x: 1, y: 0 }; -let chompPhase = 0; -let combo = 0; -let screenH = 512; -let confetti = []; -let flashRed = 0; -let flashGold = 0; -let flashGreen = 0; -let mouth = 0; - -// ⏳ Per-board countdown — measured in musical beats. -const FPS = 60; -const BPM = 92; -const BEAT = Math.round((60 / BPM) * FPS); // frames per beat -let beatsLeft = 0; -let beatsMax = 1; -let beatPhase = 0; -let beatPulse = 0; -let beatCount = 0; // total beats elapsed (drives kick/hat pattern) -let heldVoices = null; // sustained voices while munch is held - -const { abs, floor, min, max, round, sin, cos } = Math; - -// 🥾 Boot -function boot({ hud }) { - hud?.label?.(""); -} - -// 🎲 Round generation ────────────────────────────────────────────────────────── -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; -} - -function noteLabel(letter, oct) { - return letter + oct; // e.g. "C4", "F#5" — 2-3 chars -} - -function newRound({ randInt }) { - const rnd = (n) => randInt(Math.max(1, n) - 1); // 0..n-1 - rule = RULES[rnd(RULES.length)]; - const total = COLS * ROWS; - const nCorrect = min(total - 4, 5 + level + rnd(3)); - - // Octave pool — keep it tight so HIGH/LOW rules have room to differ. - const octs = [3, 4, 5]; - - // Build a "correct" note (letter + octave satisfying the rule). - const makeGood = () => { - for (let tries = 0; tries < 80; tries += 1) { - const l = LETTERS[rnd(LETTERS.length)]; - const o = octs[rnd(octs.length)]; - if (rule.test(l, o)) return { letter: l, oct: o }; - } - // Fallback from the rule's own scale. - const s = rule.scale[rnd(rule.scale.length)]; - const m = s.match(/^([A-G]#?)(\d)$/); - return { letter: m[1], oct: parseInt(m[2], 10) }; - }; - const makeBad = () => { - for (let tries = 0; tries < 80; tries += 1) { - const l = LETTERS[rnd(LETTERS.length)]; - const o = octs[rnd(octs.length)]; - if (!rule.test(l, o)) return { letter: l, oct: o }; - } - return { letter: "C", oct: 4 }; - }; - - const cells = []; - for (let i = 0; i < nCorrect; i += 1) cells.push({ ...makeGood(), correct: true }); - for (let i = 0; i < total - nCorrect; i += 1) cells.push({ ...makeBad(), correct: false }); - shuffle(cells, rnd); - - grid = cells.map((c) => ({ - ...c, - correct: rule.test(c.letter, c.oct), // re-verify against the live rule - eaten: false, - failed: false, - flash: 0, - known: false, - })); - remaining = grid.filter((c) => c.correct).length; - boardCorrectTotal = max(1, remaining); - - muncher = { col: 2, row: 2 }; - muncherVis = { col: 2, row: 2 }; - facing = { x: 1, y: 0 }; - - beatsMax = max(24, 44 - level); - beatsLeft = beatsMax; - beatPhase = 0; - beatCount = 0; - combo = 0; - flashRed = 0; - flashGold = 0; - confetti = []; - chompPhase = 0; -} - -function idx(col, row) { - return row * COLS + col; -} - -// 🧮 Sim ──────────────────────────────────────────────────────────────────── -function sim({ sound }) { - if (sound && !synth) synth = Synth(sound, { volume: 0.32 }); - frames += 1; - mouth = (mouth + 1) % 40; - if (chompPhase > 0) chompPhase -= 1; - if (messageTimer > 0) { - messageTimer -= 1; - if (messageTimer === 0) message = ""; - } - grid.forEach((c) => { - if (c.flash > 0) c.flash -= 1; - }); - if (flashRed > 0) flashRed -= 1; - if (flashGold > 0) flashGold -= 1; - if (flashGreen > 0) flashGreen -= 1; - if (beatPulse > 0) beatPulse -= 1; - - // 🏃 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; - - // 💔 Life-flyers sail from the bottom-left into the board. - if (lifeFlyers.length) { - lifeFlyers.forEach((f) => (f.t += 0.045)); - lifeFlyers = lifeFlyers.filter((f) => f.t < 1); - } - - // ✨ Confetti physics (board-clear). - if (confetti.length) { - confetti.forEach((p) => { - p.x += p.vx; - p.y += p.vy; - p.vy += 0.12; - }); - confetti = confetti.filter((p) => p.y < screenH + 40); - } - - if (state !== "play") return; - - // ⏳ Beat timeline — each beat ticks the metronome; running out costs a life. - beatPhase += 1; - if (beatPhase >= BEAT) { - beatPhase = 0; - beatPulse = 8; - beatCount += 1; - tick(); - if (beatsLeft > 0) beatsLeft -= 1; - if (beatsLeft <= 0) timeUp(); - } -} - -// 🥁 Metronome — kick on the downbeat, hat on the off-beats; a rising warning -// blip in the final three beats. -function tick() { - if (!synth) return; - if (beatsLeft <= 3 && beatsLeft > 0) { - synth.kick({ volume: 0.5 }); - const warn = { 3: "a5", 2: "c6", 1: "e6" }[beatsLeft]; - if (warn) synth.note(warn, { wave: "square", duration: 0.12, volume: 0.3 }); - return; - } - if (beatCount % 4 === 0) synth.kick({ volume: 0.55 }); - else synth.hat({ volume: 0.26 }); -} - -function flash(msg) { - message = msg; - messageTimer = 90; -} - -function timeUp() { - flash("TIME!"); - loseLife(); - beatsLeft = beatsMax; - beatPhase = 0; -} - -function loseLife() { - lifeFlyers.push({ slot: lives - 1, t: 0 }); - lives -= 1; - combo = 0; - flashRed = 18; - if (synth) { - // A short downward sigh. - synth.note("a3", { wave: "triangle", duration: 0.16, volume: 0.32 }); - synth.note("d3", { wave: "sine", duration: 0.4, volume: 0.3 }); - } - if (lives <= 0) { - state = "over"; - flash("GAME OVER"); - jingleOver(); - } else { - flash("OUCH!"); - } -} - -// 🎮 Movement + munching ────────────────────────────────────────────────────── -function move(dx, dy) { - if (state !== "play") return; - const pc = muncher.col; - const pr = muncher.row; - muncher.col = (muncher.col + dx + COLS) % COLS; - muncher.row = (muncher.row + dy + ROWS) % ROWS; - if (dx || dy) facing = { x: dx, y: dy }; - 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(); -} - -// Tap-to-move: slide along ONE axis (no diagonals). -function moveTo(col, row) { - if (state !== "play") return; - const dc = col - muncher.col; - const dr = row - muncher.row; - if (dc === 0 && dr === 0) return; - if (abs(dc) >= abs(dr)) { - facing = { x: Math.sign(dc), y: 0 }; - muncher.col = max(0, min(COLS - 1, col)); - } else { - facing = { x: 0, y: Math.sign(dr) }; - muncher.row = max(0, min(ROWS - 1, row)); - } - moveBlip(); -} - -function cellNote(cell) { - return noteLabel(cell.letter, cell.oct).toLowerCase(); -} - -function munch() { - if (state !== "play") return; - const cell = grid[idx(muncher.col, muncher.row)]; - chompPhase = 10; - if (!cell || cell.eaten || cell.failed) { - if (synth) synth.note(180, { wave: "sine", duration: 0.04, volume: 0.16 }); - return; - } - - // Always voice the square's actual note when you bite it. - if (synth) synth.note(cellNote(cell), { wave: "triangle", duration: 0.2, volume: 0.34 }); - - if (cell.correct) { - cell.eaten = true; - cell.flash = 14; - combo += 1; - remaining -= 1; - beatsLeft = min(beatsMax, beatsLeft + 1); // earn a beat back - - // Light up matching (still-uneaten) duplicates of this note. - const key = noteLabel(cell.letter, cell.oct); - grid.forEach((c) => { - if (!c.eaten && !c.failed && noteLabel(c.letter, c.oct) === key) { - c.known = true; - c.flash = 14; - } - }); - - // A bright combo ping a touch above the note. - if (synth) { - const top = 660 + min(combo, 8) * 40; - synth.note(top, { wave: "sine", duration: 0.08, volume: 0.2 }); - } - - if (remaining === 1) flash("LAST ONE!"); - if (remaining <= 0) { - state = "clear"; - flashGold = 14; - spawnConfetti(); - flash("BOARD CLEAR!"); - jingleClear(); - } - } else { - cell.flash = 14; - cell.failed = true; // X it out - if (synth) synth.note(140, { wave: "sawtooth", duration: 0.18, volume: 0.3 }); - loseLife(); - } -} - -function moveBlip() { - if (synth) synth.note(340, { wave: "sine", duration: 0.02, volume: 0.14 }); -} - -// 🎼 Play the rule's scale ascending when a board begins. -function playScale() { - if (!synth) return; - rule.scale.forEach((n, i) => { - setTimeout(() => { - if (synth) synth.note(n.toLowerCase(), { wave: "triangle", duration: 0.18, volume: 0.3 }); - }, i * 110); - }); -} - -// 🎉 Victory arpeggio + cymbal on board clear. -function jingleClear() { - if (!synth) return; - ["c4", "e4", "g4", "c5", "e5", "g5", "c6"].forEach((n, i) => { - setTimeout(() => { - if (synth) synth.note(n, { wave: "triangle", duration: 0.16, volume: 0.3 }); - }, i * 80); - }); - synth.crash?.({ volume: 0.4 }); - setTimeout(() => synth?.chord?.(["c5", "e5", "g5"], { wave: "sine", duration: 0.5, volume: 0.24 }), 600); -} - -// 💀 A short minor turn on game over. -function jingleOver() { - if (!synth) return; - ["g4", "d#4", "c4", "g3"].forEach((n, i) => { - setTimeout(() => { - if (synth) synth.note(n, { wave: "triangle", duration: 0.3, volume: 0.3 }); - }, i * 170); - }); -} - -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), - c: cols[i % cols.length], - }); - } -} - -// 🎪 Act ───────────────────────────────────────────────────────────────────── -function act({ event: e, sound, num: { randInt } }) { - if (sound && !synth) synth = Synth(sound, { volume: 0.32 }); - - const startBoard = () => { - state = "play"; - flashGreen = 16; - playScale(); - }; - - const advance = () => { - if (state === "title") { - resetGame(); - newRound({ randInt }); - } else if (state === "clear") { - level += 1; - newRound({ randInt }); - } else if (state === "over") { - resetGame(); - newRound({ randInt }); - } else return; - startBoard(); - }; - - if (state !== "play") { - if ( - e.is("keyboard:down:space") || - e.is("keyboard:down:enter") || - e.is("keyboard:down:return") || - e.is("touch") - ) - advance(); - return; - } - - // Release the sustained munch voices. - if ( - e.is("keyboard:up:space") || - e.is("keyboard:up:enter") || - e.is("keyboard:up:return") || - e.is("lift") - ) - killHeld(); - - // Movement — arrows + WASD + vim (hjkl). - if (e.is("keyboard:down:arrowleft") || e.is("keyboard:down:a") || e.is("keyboard:down:h")) move(-1, 0); - if (e.is("keyboard:down:arrowright") || e.is("keyboard:down:d") || e.is("keyboard:down:l")) move(1, 0); - if (e.is("keyboard:down:arrowup") || e.is("keyboard:down:w") || e.is("keyboard:down:k")) move(0, -1); - if (e.is("keyboard:down:arrowdown") || e.is("keyboard:down:s") || e.is("keyboard:down:j")) move(0, 1); - - // Munch — and sustain the bitten note while the key is held. - if ( - e.is("keyboard:down:space") || - e.is("keyboard:down:enter") || - e.is("keyboard:down:return") - ) { - munch(); - if (!heldVoices && synth) { - const cell = grid[idx(muncher.col, muncher.row)]; - if (cell && !cell.eaten && !cell.failed) { - const n = cellNote(cell); - heldVoices = [ - synth.hold(n, { wave: "sine", volume: 0.14 }), - synth.hold(n, { wave: "triangle", volume: 0.08 }), - ]; - } - } - } - - // Touch: tap a square to slide there; tap your own square / off-board to munch. - if (e.is("touch")) { - const hit = cellAt(e.x, e.y); - if (hit && (hit.col !== muncher.col || hit.row !== muncher.row)) moveTo(hit.col, hit.row); - else munch(); - } -} - -function killHeld() { - heldVoices?.forEach((v) => v?.kill?.(0.16)); - heldVoices = null; -} - -function resetGame() { - lives = START_LIVES; - level = 1; - message = ""; - messageTimer = 0; -} - -// 📐 Layout ─────────────────────────────────────────────────────────────────── -let layout = { x: 0, y: 0, cell: 32, top: 54, bottomPad: 30 }; - -function computeLayout(screen) { - screenH = screen.height; - const sideMargin = max(28, floor(screen.width * 0.12)); - const top = 54; - const bottomPad = 30; - const availW = screen.width - sideMargin * 2; - const availH = screen.height - top - bottomPad; - const cell = max(18, floor(min(availW / COLS, availH / ROWS))); - const gw = cell * COLS; - const gh = cell * ROWS; - layout = { - cell, - x: floor((screen.width - gw) / 2), - y: top + floor((availH - gh) / 2), - top, - bottomPad, - }; -} - -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 ──────────────────────────────────────────────────────────────────── -function paint({ wipe, ink, screen, write, box, line }) { - computeLayout(screen); - wipe(8, 10, 26); - paintBackground({ ink, box, screen }); - - if (state === "title") return paintTitle({ ink, screen, write }); - - const { x, y, cell } = layout; - const tf = beatsMax ? beatsLeft / beatsMax : 0; - const lowTime = beatsLeft <= 3 && state === "play"; - const blink = (frames >> 2) % 2 === 0; - - if (lowTime && blink) ink(150, 0, 0, 115).box(0, 0, screen.width, screen.height); - - // ⏳ Beat timeline bar across the top. - const tcol = tf > 0.5 ? [120, 220, 120] : tf > 0.25 ? [240, 210, 90] : [240, 80, 80]; - ink(20, 24, 44).box(0, 0, screen.width, 3 + (beatPulse > 4 ? 1 : 0)); - ink(...tcol).box(0, 0, round(screen.width * tf), 3 + (beatPulse > 4 ? 1 : 0)); - - // ⏳ Big beats-left counter, top-left. - const timeCol = lowTime ? (blink ? [255, 60, 60] : [255, 235, 120]) : tcol; - ink(...timeCol).write(`${beatsLeft}`, { x: 8, y: 6, size: 3 }, undefined, undefined, false, "unifont"); - - // Rule title, top-right. - const ruleX = max(4, screen.width - 8 - rule.label.length * 12); - ink(255, 230, 120).write(rule.label, { x: ruleX, y: 9, size: 2 }); - - // Grid. - 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 = noteLabel(cd.letter, cd.oct); - const missed = gameOver && cd.correct && !cd.eaten; - - // Tile background. - let bg; - if (cd.flash > 0) bg = cd.correct ? [40, 150, 70] : [160, 45, 45]; - else if (cd.eaten) bg = [15, 17, 34]; - else if (cd.failed) bg = [44, 22, 24]; - else if (missed) bg = [72, 74, 82]; - else bg = tileColor(cd.letter); - ink(...bg).box(cx + 1, cy + 1, cell - 2, cell - 2); - - // Outline. - if (cd.known && !cd.eaten && !cd.failed) - ink(120, 230, 140).box(cx + 1, cy + 1, cell - 2, cell - 2, "outline"); - else if (missed) - ink(210, 214, 222).box(cx + 1, cy + 1, cell - 2, cell - 2, "outline"); - else ink(60, 70, 120).box(cx + 1, cy + 1, cell - 2, cell - 2, "outline"); - - // Note label. - if (!cd.eaten) { - const fs = txt.length * 12 <= cell - 8 ? 2 : 1; - let tx = cx + cell / 2 - (txt.length * 6 * fs) / 2; - let ty = cy + cell / 2 - fs * 4; - if (cd.known && !cd.failed) { - tx += sin(frames * 0.9 + c * 1.7) * 1.6; - ty += cos(frames * 1.1 + r * 1.3) * 1.2; - } - let col; - if (cd.failed) col = [120, 95, 100]; - else if (missed) col = [228, 230, 238]; - else col = noteColor(cd.letter); - ink(...col).write(txt, { x: tx, y: ty, size: fs }); - - 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); - } - } - } - } - - // Muncher. - { - const cx = x + muncherVis.col * cell; - const cy = y + muncherVis.row * cell; - paintMuncher({ ink, box }, cx, cy, cell, state === "clear"); - } - - // ✨ Confetti. - confetti.forEach((p) => ink(p.c[0], p.c[1], p.c[2]).box(p.x, p.y, p.s, p.s)); - - // 🎶 Beat ambience pulse. - if (beatPulse > 0 && state === "play") { - const vibe = [[80, 120, 255], [80, 220, 230], [170, 110, 240]][beatCount % 3]; - ink(vibe[0], vibe[1], vibe[2], beatPulse * 4).box(0, 0, screen.width, screen.height); - } - - // Full-screen flashes. - if (flashRed > 0) ink(210, 25, 25, min(190, flashRed * 11)).box(0, 0, screen.width, screen.height); - if (flashGold > 0) ink(255, 215, 90, min(150, flashGold * 11)).box(0, 0, screen.width, screen.height); - if (flashGreen > 0) ink(60, 220, 110, min(150, flashGreen * 11)).box(0, 0, screen.width, screen.height); - - // 💚 Lives — little monsters bottom-left. - const mcx = x + muncherVis.col * cell + cell / 2; - const mcy = y + muncherVis.row * cell + cell / 2; - const lifeSz = 13; - const lifeGap = 17; - const lifeY = screen.height - lifeSz - 6; - for (let i = 0; i < lives; i += 1) - drawMiniMonster({ ink, box }, 6 + i * lifeGap, lifeY, lifeSz, mcx, mcy, healthColor(lives)); - - // 💔 Lost-life flyers. - const bcx = x + (cell * COLS) / 2; - const bcy = y + (cell * ROWS) / 2; - lifeFlyers.forEach((f) => { - const sx = 6 + f.slot * lifeGap + lifeSz / 2; - const sy = lifeY + lifeSz / 2; - const e = f.t * f.t; - const fx = sx + (bcx - sx) * e; - const fy = sy + (bcy - sy) * e; - const sz = max(4, lifeSz * (1 - 0.6 * f.t)); - drawMiniMonster({ ink, box }, fx - sz / 2, fy - sz / 2, sz, bcx, bcy, [235, 95, 95]); - }); - - // 🔢 Notes-left, bottom-right. - const leftN = `${remaining}`; - const leftCol = remaining <= 3 ? [245, 130, 130] : [150, 210, 175]; - const leftW = leftN.length * 16; - ink(...leftCol).write(leftN, { x: screen.width - 10 - leftW, y: screen.height - 38 }, undefined, undefined, false, "unifont"); - ink(140, 165, 195).write("LEFT", { x: screen.width - 10 - leftW - 30, y: screen.height - 24 }); - - // 📣 Transient message banner. - if (message) { - const ms = 2; - const mw = message.length * 6 * ms; - const mx = max(4, round(screen.width / 2 - mw / 2)); - const myy = 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 }); - } - - // Overlays. - if (state === "clear" && confetti.length === 0) - overlay({ ink, screen, write }, "BOARD CLEAR", "press any key"); - if (state === "over") - overlay({ ink, screen, write }, "GAME OVER", "press to retry"); -} - -function paintBackground({ ink, box, screen }) { - const sp = 40; - 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) { - const b = 4 + pulse + 3 * sin((gx + gy) * 0.05 + frames * 0.035); - ink(14 + round(b * 0.4), 16 + round(b * 0.5), 32 + round(b)); - box(gx + dx, gy + dy, 2, 2); - } - } -} - -function healthColor(n) { - if (n >= 3) return [125, 235, 130]; - if (n === 2) return [205, 220, 95]; - return [235, 150, 70]; -} - -function drawMiniMonster({ ink, box }, x, y, s, lookX, lookY, color) { - const legH = max(1, floor(s * 0.18)); - const bodyH = s - legH; - ink(round(color[0] * 0.7), round(color[1] * 0.7), round(color[2] * 0.7)); - box(x + s * 0.14, y + bodyH, max(1, floor(s * 0.24)), legH); - box(x + s * 0.6, y + bodyH, max(1, floor(s * 0.24)), legH); - ink(...color).box(x, y, s, bodyH); - const cxp = x + s / 2; - const cyp = y + bodyH / 2; - let dx = lookX - cxp; - let dy = lookY - cyp; - const d = Math.hypot(dx, dy) || 1; - dx /= d; - dy /= d; - const eo = max(2, floor(s * 0.3)); - const eyy = y + s * 0.22; - const eyL = x + s * 0.14; - const eyR = x + s * 0.54; - ink(255).box(eyL, eyy, eo, eo); - ink(255).box(eyR, eyy, eo, eo); - const pup = max(1, floor(eo * 0.5)); - const gx = round((dx * (eo - pup)) / 2); - const gy = round((dy * (eo - pup)) / 2); - ink(20, 30, 20).box(eyL + (eo - pup) / 2 + gx, eyy + (eo - pup) / 2 + gy, pup, pup); - ink(20, 30, 20).box(eyR + (eo - pup) / 2 + gx, eyy + (eo - pup) / 2 + gy, pup, pup); -} - -function paintMuncher({ ink, box }, cx, cy, cell, dance = false) { - const fullness = max(0, min(1, 1 - remaining / boardCorrectTotal)); - let scale = 0.72 + fullness * 0.4; - const dying = beatsLeft <= 3 && state === "play"; - if (dying) scale *= 0.66 + 0.12 * abs(sin(frames * 0.8)); - 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; - - 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); - - ink(...(dying ? [150, 220, 130] : [125, 235, 130])).box(x, y, s, bodyH); - - const eo = max(2, floor(s * 0.2)); - const ey = y + s * 0.2; - const exL = x + s * 0.18; - const 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); - - 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))); - } -} - -function paintTitle({ ink, screen, write }) { - ink(255, 230, 120).write("NOTENOM", { center: "x", y: screen.height / 2 - 40, size: 3 }); - ink(120, 235, 120).write("music edition", { center: "x", y: screen.height / 2 - 6 }); - ink(180, 200, 255).write("eat the notes that match the rule", { center: "x", y: screen.height / 2 + 14 }); - ink(140, 160, 220).write("arrows move - space munches - mind the beat", { center: "x", y: screen.height / 2 + 28 }); - if (mouth < 24) - ink(255).write("press any key to start", { center: "x", y: screen.height / 2 + 54 }); -} - -function overlay({ ink, screen, write }, title, sub) { - ink(0, 0, 0, 125).box(0, 0, screen.width, screen.height); - ink(255, 230, 120).write(title, { center: "x", y: screen.height / 2 - 18, size: 2 }); - ink(200, 215, 255).write(sub, { center: "x", y: screen.height / 2 + 12 }); -} - -// 📰 Meta -function meta() { - return { - title: "Notenom", - desc: "Munch the squares whose musical note matches the rule, on the beat.", - }; +function boot(api) { + nomBoot({ ...api, params: ["notes"] }); } export { boot, sim, paint, act, meta }; diff --git a/system/public/aesthetic.computer/lib/disk.mjs b/system/public/aesthetic.computer/lib/disk.mjs index d309b13563..d14ea0e725 100644 --- a/system/public/aesthetic.computer/lib/disk.mjs +++ b/system/public/aesthetic.computer/lib/disk.mjs @@ -13097,6 +13097,14 @@ async function makeFrame({ data: { type, content } }) { hudButtonActive: currentHUDButtonActive, // Add global HUD button flag is: (e) => { let [name, pointer] = e.split(":"); + // 👆 Swipe events carry a *direction* in the second slot + // (swipe:left / swipe:right / swipe:up / swipe:down), not a pointer + // index. Bare `swipe` (or `swipe:any`) matches any direction. + if (data.name === "swipe") { + if (name !== "swipe") return false; + if (!pointer || pointer === "any") return data.isPrimary === true; + return data.direction === pointer && data.isPrimary === true; + } if (pointer) { if (pointer === "any") { return name === data.name; diff --git a/system/public/aesthetic.computer/lib/nom.mjs b/system/public/aesthetic.computer/lib/nom.mjs index bbf1f3d01f..9c0bd3f216 100644 --- a/system/public/aesthetic.computer/lib/nom.mjs +++ b/system/public/aesthetic.computer/lib/nom.mjs @@ -1,20 +1,22 @@ -// nom — shared engine for the muncher games (numbnom / engnom / mexinom / dannom), 2026.06.07 +// nom — shared engine for the muncher games (numbnom / engnom / mexinom / dannom / notenom), 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. +// rule at the top — but dodge the Troggles. `numbnom` = numbers, `engnom` = words, +// `notenom` = musical notes (note mode voices each square + plays the scale). import { Synth } from "./synth.mjs"; // shared virtual synth + perc kit /* #region 📚 README Controls: - Arrows / WASD — move the Muncher - Space / Enter — munch the current square - Tap a square — jump straight to it - Tap off-board — munch the current square + Arrows / WASD — move the Muncher + Space / Enter — munch the current square + Swipe (mobile) — move one square in the swipe direction + Tap (mobile) — munch the current square Modes (pass after a colon): munchers → numbers munchers:numbers → numbers munchers:words → words + munchers:notes → musical notes (notenom) #endregion */ /* #region 🏁 TODO @@ -28,8 +30,9 @@ const ROWS = 5; const START_LIVES = 3; // 🌐 Game state -let mode = "number"; // "number" | "word" +let mode = "number"; // "number" | "word" | "note" let lang = "en"; // word language: "en" (engnom) | "es" (mexinom) | "da" (dannom) +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") @@ -37,6 +40,9 @@ let ruleSpeech = ""; // spoken phrase (e.g. "multiples of 3") 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 swipedGesture = false; // 👆 set on swipe:, suppresses the lift-tap munch +let drag = null; // 👆 { sx, sy, x, y } live gesture, drawn as the swipe overlay +const SWIPE_MIN = 24; // px to register a swipe — MUST match #detectSwipe in lib/pen.mjs let troggles = []; let lives = START_LIVES; let level = 1; // internal progression counter (board #) — never displayed @@ -81,9 +87,10 @@ let confetti = []; // { x, y, vx, vy, s, c } celebration bits // 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) +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 / BEAT_MS) +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 @@ -104,6 +111,8 @@ function boot({ params, hud, clock, num: { randInt } }) { } else if (p === "words" || p === "word" || p === "english" || p === "en") { mode = "word"; lang = "en"; + } else if (p === "notes" || p === "note" || p === "music" || p === "notenom") { + mode = "note"; } else if (p) { mode = "number"; } @@ -205,6 +214,68 @@ function fitText(str, cell, font) { } } +// 🎵 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 NOTE_COLORS[letter] || [220, 220, 235]; +} +function noteTile(letter) { + const c = noteColor(letter); + return [round(c[0] * 0.22) + 6, round(c[1] * 0.22) + 6, round(c[2] * 0.22) + 6]; +} +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"], + }, +]; + // Spanish → English (spoken on each munch in mexinom). const TRANSLATE = { taco: "taco", mole: "mole sauce", elote: "corn", chile: "chili", salsa: "salsa", @@ -353,7 +424,42 @@ function newRound({ randInt }) { const nCorrect = min(total - 4, 5 + level + rnd(3)); const cells = []; - if (mode === "word") { + 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 + const makeGood = () => { + for (let tries = 0; tries < 80; tries += 1) { + const l = LETTERS[rnd(LETTERS.length)]; + const o = octs[rnd(octs.length)]; + if (test(l, o)) return { letter: l, oct: o }; + } + const m = noteRound.scale[rnd(noteRound.scale.length)].match(/^([A-G]#?)(\d)$/); + return { letter: m[1], oct: parseInt(m[2], 10) }; + }; + const makeBad = () => { + for (let tries = 0; tries < 80; tries += 1) { + const l = LETTERS[rnd(LETTERS.length)]; + const o = octs[rnd(octs.length)]; + if (!test(l, o)) return { letter: l, oct: o }; + } + return { letter: "C", oct: 4 }; + }; + for (let i = 0; i < nCorrect; i += 1) { + const g = makeGood(); + cells.push({ value: noteLabel(g.letter, g.oct), letter: g.letter, oct: g.oct, correct: true }); + } + for (let i = 0; i < total - nCorrect; i += 1) { + const b = makeBad(); + cells.push({ value: noteLabel(b.letter, b.oct), letter: b.letter, oct: b.oct, correct: false }); + } + // Re-verify against the live rule (covers the fallback paths). + cells.forEach((c) => (c.correct = test(c.letter, c.oct))); + } else if (mode === "word") { const rounds = lang === "es" ? ES_WORD_ROUNDS : lang === "da" ? DA_WORD_ROUNDS : WORD_ROUNDS; const round = rounds[rnd(rounds.length)]; ruleLabel = round.label; @@ -449,11 +555,13 @@ function newRound({ randInt }) { 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 = max(28, 52 - level); + beatsMax = mode === "note" ? max(24, 44 - level) : max(28, 52 - level); beatsLeft = beatsMax; beatPhase = 0; + beatMs = (60 / (mode === "note" ? 92 : BPM)) * 1000; lastBeatIndex = null; // Clear any leftover animation state from the previous round. @@ -493,8 +601,8 @@ function sim({ gizmo, seconds, sound, clock, num: { randInt } }) { 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 / BEAT_MS); - beatPhase = (nowMs % BEAT_MS) / BEAT_MS; // 0..1 within the current beat + beatIndex = Math.floor(nowMs / beatMs); + beatPhase = (nowMs % beatMs) / beatMs; // 0..1 within the current beat frames += 1; mouth = (mouth + 1) % 40; if (invuln > 0) invuln -= 1; @@ -692,23 +800,6 @@ function move(dx, dy) { checkTroggleHit(); } -// Tap-to-move: slide along ONE axis only (no diagonal jumps). Snap to the -// tapped column (staying in this row) or the tapped row (staying in this -// column), whichever the tap leans toward. -// Tap-to-move: ONE square per tap toward the tapped cell. Tapping the cell the -// muncher is already on munches it. -function moveTo(col, row) { - if (state !== "play" || deathPhase > 0) return; - const dc = col - muncher.col, - dr = row - muncher.row; - if (dc === 0 && dr === 0) { - munch(); // nom the square you're standing on - return; - } - if (abs(dc) >= abs(dr)) move(Math.sign(dc), 0); - else move(0, Math.sign(dr)); -} - function munch() { if (state !== "play") return; const cell = grid[idx(muncher.col, muncher.row)]; @@ -727,7 +818,8 @@ function munch() { combo += 1; remaining -= 1; beatsLeft = min(beatsMax, beatsLeft + 1); // munching earns a beat back - chompGood(); + if (mode === "note") noteMunchGood(cell); + else chompGood(); camTargetZoom = 1.06; // tiny zoom punch on every good munch // Collect the value + light up its (now frightened) duplicates. @@ -742,8 +834,9 @@ function munch() { 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 (remaining === 1) say("last one"); + else if (mode !== "note" && remaining === 1) say("last one"); if (remaining <= 0) { state = "clear"; clearPhase = 96; @@ -756,8 +849,11 @@ function munch() { } else { cell.flash = 14; cell.failed = true; // X it out — can't be tried again - if (mode === "word" && lang !== "en") sayTranslation(cell.value); - chompBad(); + if (mode === "note") noteMunchBad(cell); + else { + if (mode === "word" && lang !== "en") sayTranslation(cell.value); + chompBad(); + } loseLife(); } } @@ -891,6 +987,32 @@ function chompBad() { 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() { @@ -1021,7 +1143,8 @@ function act({ event: e, sound, speak, cursor, num: { randInt } }) { } else return; state = "play"; flashGreen = 16; // green "go!" flash on board start - startChirp(); + if (mode === "note") playScale(); // 🎼 hear the scale you're hunting + else startChirp(); say(ruleSpeech); }; @@ -1029,7 +1152,14 @@ function act({ event: e, sound, speak, cursor, num: { randInt } }) { // "Press any key to start" — honor it literally: any key-down or a tap // advances (previously only space/enter/touch did, so other keys felt like // the title was stalling). - if (e.name?.startsWith?.("keyboard:down") || e.is("touch")) advance(); + 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. + swipedGesture = true; + } return; } @@ -1042,6 +1172,20 @@ function act({ event: e, sound, speak, cursor, num: { randInt } }) { ) killChord(); + // 📱 Tap anywhere to munch; a swipe (fired just before this lift) suppresses + // the tap. Always consume the flag here — even mid-death — so it can never + // leak into the next gesture. + if (e.is("lift")) { + const wasSwipe = swipedGesture; + swipedGesture = false; + drag = null; // clear the swipe overlay + if (!wasSwipe && deathPhase === 0) munch(); + } + + // 👆 Track the live gesture so paint can visualize the swipe threshold. + if (e.is("touch")) drag = { sx: e.x, sy: e.y, x: e.x, y: e.y }; + if (e.is("draw") && drag) { drag.x = e.x; drag.y = e.y; } + // 🖱️ Hover highlight — track the cell under the pointer. if (e.is("move") || e.is("draw")) hover = cellAt(e.x, e.y); @@ -1053,6 +1197,13 @@ function act({ event: e, sound, speak, cursor, num: { randInt } }) { if (e.is("keyboard:down:arrowup") || e.is("keyboard:down:w") || e.is("keyboard:down:k")) move(0, -1); if (e.is("keyboard:down:arrowdown") || e.is("keyboard:down:s") || e.is("keyboard:down:j")) move(0, 1); + // 📱 Swipe to move one square in that direction (flag it so the gesture's + // closing tap doesn't also munch — see the lift handler below). + if (e.is("swipe:left")) { move(-1, 0); swipedGesture = true; } + if (e.is("swipe:right")) { move(1, 0); swipedGesture = true; } + if (e.is("swipe:up")) { move(0, -1); swipedGesture = true; } + if (e.is("swipe:down")) { move(0, 1); swipedGesture = true; } + // Munch (space + enter behave identically) — smart-chain to the nearest // green, and sustain a pentatonic chord while held (durational, musical). if ( @@ -1062,22 +1213,28 @@ function act({ event: e, sound, speak, cursor, num: { randInt } }) { ) { smartMunch(); if (!heldChord && synth) { - // 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 }), - ]; + 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 }), + ]; + } } } - // Touch: tap a square to jump there; tap outside the board to munch. - if (e.is("touch")) { - const hit = cellAt(e.x, e.y); - if (hit) moveTo(hit.col, hit.row); - else munch(); - } } function resetGame({ randInt }) { @@ -1188,6 +1345,7 @@ function paint({ wipe, ink, screen, write, box, line, text }) { else if (cd.failed) bg = [44, 22, 24]; else if (cd.known) bg = [40, 168, 80]; // confirmed-correct → whole square green else if (missed) bg = [72, 74, 82]; // revealed missed answer = gray + 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); @@ -1221,6 +1379,7 @@ function paint({ wipe, ink, screen, write, box, line, text }) { 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 = [228, 230, 238]; + 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]); ink(...col).write(txt, { x: tx, y: ty, size: fit.size }, undefined, undefined, false, font || undefined); @@ -1244,7 +1403,7 @@ function paint({ wipe, ink, screen, write, box, line, text }) { const t = String(cd.value); const font = cellFont(); const fit = fitText(t, cell, font); - const gc = COLOR_WORDS[t] || charColor(t[0]); + const gc = mode === "note" ? noteColor(cd.letter) : COLOR_WORDS[t] || charColor(t[0]); ink(gc[0], gc[1], gc[2], 95).write( t, { x: gx + (cell - fit.width) / 2, y: gy + (cell - fit.height) / 2, size: fit.size }, @@ -1353,13 +1512,38 @@ function paint({ wipe, ink, screen, write, box, line, text }) { if (big.width + 4 <= leftRoom) fit = { ...big, size: 2 }; else fit = { ...fit, size: 1 }; const w = fit.width + 4; - const cw = COLOR_WORDS[v]; - ink(...(cw || tileColor(v))).box(4, fcy, w, fit.height + 2); - const tcol = cw ? (luma(cw) > 140 ? [25, 25, 30] : [245, 245, 255]) : charColor(v[0]); + 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; } + // 👆 Swipe overlay (mobile) — visualizes the SWIPE_MIN deadzone so the gesture + // is legible: a square that flips to green once the dominant axis clears the + // threshold, with a line to your finger + the resolved direction. + if (drag && state === "play" && deathPhase === 0) { + const dx = drag.x - drag.sx, + dy = drag.y - drag.sy; + const adx = abs(dx), + ady = abs(dy); + const past = max(adx, ady) >= SWIPE_MIN; + const c = past ? [120, 255, 160] : [255, 255, 255]; + // Deadzone square (Chebyshev threshold) centered on the touch start. + ink(c[0], c[1], c[2], 70).box(drag.sx - SWIPE_MIN, drag.sy - SWIPE_MIN, SWIPE_MIN * 2, SWIPE_MIN * 2, "outline"); + ink(c[0], c[1], c[2], 200).line(drag.sx, drag.sy, drag.x, drag.y); // vector to finger + ink(120, 220, 255).box(drag.sx - 1, drag.sy - 1, 3, 3); // origin dot + ink(255, 230, 60).box(drag.x - 2, drag.y - 2, 4, 4); // finger dot + if (past) { + const dir = adx >= ady ? (dx < 0 ? "LEFT" : "RIGHT") : dy < 0 ? "UP" : "DOWN"; + ink(...c).write(dir, { x: round(drag.sx - dir.length * 3), y: drag.sy - SWIPE_MIN - 11 }); + } + } + // 📣 Transient message banner — large, with a colored background. if (message) { const ms = 2; @@ -1579,10 +1763,12 @@ function paintTroggle({ ink, box, line }, cx, cy, cell, hue) { } function gameName() { + if (mode === "note") return "NOTENOM"; if (mode !== "word") return "NUMBNOM"; return lang === "es" ? "MEXINOM" : lang === "da" ? "DANNOM" : "ENGNOM"; } function editionText() { + if (mode === "note") return "music edition"; if (mode !== "word") return "number edition"; return lang === "es" ? "edicion mexicana" : lang === "da" ? "dansk udgave" : "english edition"; } @@ -1590,8 +1776,14 @@ function editionText() { function paintTitle({ ink, screen, write }) { ink(255, 230, 120).write(gameName(), { center: "x", y: screen.height / 2 - 40, size: 3 }); ink(120, 235, 120).write(editionText(), { center: "x", y: screen.height / 2 - 6 }); - ink(180, 200, 255).write("eat squares that match the rule", { center: "x", y: screen.height / 2 + 14 }); - ink(140, 160, 220).write("arrows move - space munches - dodge troggles", { center: "x", y: screen.height / 2 + 28 }); + ink(180, 200, 255).write( + mode === "note" ? "eat the notes that match the rule" : "eat squares that match the rule", + { center: "x", y: screen.height / 2 + 14 }, + ); + ink(140, 160, 220).write("swipe / arrows move · tap / space munch · dodge troggles", { + center: "x", + y: screen.height / 2 + 28, + }); // Blinking prompt. if (mouth < 24) ink(255).write("press any key to start", { center: "x", y: screen.height / 2 + 54 }); @@ -1615,10 +1807,14 @@ function overlay({ ink, screen, write }, title, sub) { // 📰 Meta function meta() { - const title = mode !== "word" ? "Numbnom" : lang === "es" ? "Mexinom" : lang === "da" ? "Dannom" : "Engnom"; + const title = mode === "note" + ? "Notenom" + : mode !== "word" + ? "Numbnom" + : lang === "es" ? "Mexinom" : lang === "da" ? "Dannom" : "Engnom"; return { title, - desc: "Eat the squares that match the rule — numbnom (numbers), engnom (words), mexinom (español), dannom (dansk).", + desc: "Eat the squares that match the rule — numbnom (numbers), engnom (words), mexinom (español), dannom (dansk), notenom (notes).", }; } diff --git a/system/public/aesthetic.computer/lib/pen.mjs b/system/public/aesthetic.computer/lib/pen.mjs index 646489d390..6fbf6e8c8b 100644 --- a/system/public/aesthetic.computer/lib/pen.mjs +++ b/system/public/aesthetic.computer/lib/pen.mjs @@ -303,7 +303,12 @@ export class Pen { const pointerId = getPointerId(e); const pointer = pen.pointers[pointerId]; if (!pointer) return; - if (pointer.drawing) pen.#event("lift", pointer); + if (pointer.drawing) { + // 👆 A finished gesture may be a directional swipe. Emit `swipe:` + // *before* `lift` so pieces can move on the swipe and suppress the tap. + pen.#detectSwipe(pointer); + pen.#event("lift", pointer); + } pointer.drawing = false; pointer.dragBox = undefined; pen.penCursor = true; @@ -481,9 +486,27 @@ export class Pen { } } + // 👆 Classify a finished gesture as a directional swipe (left/right/up/down) + // and emit a `swipe:` pen event. The whole-gesture travel must + // clear `SWIPE_MIN` virtual pixels on its dominant axis; shorter gestures stay + // taps. Fired just before `lift` (see `up`) so pieces see the swipe first. + #detectSwipe(pointer) { + const start = pointer.penDragStartPos; + if (!start) return; + const dx = pointer.x - start.x; + const dy = pointer.y - start.y; + const adx = Math.abs(dx); + const ady = Math.abs(dy); + const SWIPE_MIN = 24; // virtual pixels of travel needed to count as a swipe + if (Math.max(adx, ady) < SWIPE_MIN) return; // too short → it was a tap + const direction = + adx >= ady ? (dx < 0 ? "left" : "right") : dy < 0 ? "up" : "down"; + this.#event("swipe", pointer, { direction }); + } + // TODO: Merge this logic into the above events & consolidate class properties. // Check the hardware for any changes. - #event(name, pointer) { + #event(name, pointer, extra = {}) { const pen = this; // 💁 Calculate pointer delta based on the pointer's lastEvent so it @@ -509,6 +532,7 @@ export class Pen { pressure: pointer.pressure, drag: pointer.dragBox, penChanged: this.changedInPiece, + ...extra, // e.g. { direction } for swipe events }); // Assign data to individual pointer. -- 2.51.2