diff --git a/docs/nopaint-construct-audio-spec.md b/docs/nopaint-construct-audio-spec.md new file mode 100644 --- /dev/null +++ b/docs/nopaint-construct-audio-spec.md @@ -0,0 +1,82 @@ +# No Paint Construct interaction/audio specification + +This document treats the preserved Construct export as evidence for No Paint's +interaction grammar. The canonical source is +`system/public/nopaint.art/data.json`: `project[6]` contains event sheets, +`project[7]` contains the audio manifest, and object `189`, action `95` is +Audio `PlayByName`. Runtime playback is implemented in +`scripts/c3runtime.js` around lines 1897–1904. + +## Decision controls + +| Control phase | Canonical asset | Event-sheet evidence | +|---|---|---| +| No down | `generic - no button pressed (metal brush)` | Core / `CheckButtonsCore`, event 270 | +| No held crossing | `generic - button rollover` | Core / `CheckButtonsCore`, event 278 | +| No release | `generic - no button released (middle)` | Core / `No & Paint` / `No` | +| Paint down | `generic - paint button pressed (psst)` | Core / `CheckButtonsCore`, event 281 | +| Paint held crossing | `generic - button rollover` | Core / `CheckButtonsCore`, event 289 | +| Paint release | `generic - paint button released (cha)` | Core / `No & Paint` / `Paint` | + +A pointer may go down on one decision, remain held, cross the boundary, and +release on the other. Crossing announces the newly selected control; only the +control under release performs the decision. + +## Pause, completion, and saving + +| Action | Canonical asset | +|---|---| +| Pause down | `generic - pressing pause` | +| Enter pause | `generic - entering pause` | +| Leave pause / Back | `generic - pause release` | +| Generic control down/up | `generic - button press` / `generic - button release` | +| Save down/up | `generic - save button pressed` / `generic - save button released` | +| Save processing | `generic - processing save` (loop) | +| Save complete | `generic - saved` | + +In No Paint 3.0, tapping the painting enters completion mode. Back returns to +the decision loop; tapping the painting again is the same Back transition. +Done invokes the existing AC prompt `done` command. + +## Brush and tool cues + +Primitive starts are `box - start`, `line - start`, `triangle - start`, and the +historically misspelled `elipse - start`; their common release is +`primitive - released`. Theme-on-start tools use their matching theme assets: +Rainbow, Grid Worm, Banner, Quicksand, Light Bump, Contrast, Blur, Spin, +Scroll (`scroll - theme 2021`), Mirror, Aura, Breathe, Vignette, Saturate, +Sharpen, and Bubbles. Additional implemented native mappings are Softy +`softy - landed`, Wafer `wafer - nibble appear`, Wash +`wipe - individual wipe`, and Camera `camera - fx`. + +The original background loops are `no paint - theme` and +`no paint - painting music`. They are not automatically enabled by the native +piece; ambient music requires a separate product decision from interaction +cues. + +## Source-map limits + +No `.map`, `.c3p`, or `sourceMappingURL` survives in the export. A conventional +JavaScript source map could only improve Construct engine/custom-script symbol +provenance. It cannot reconstruct declarative event-sheet meaning, which is +encoded in `data.json` and referenced by runtime IDs. + +The useful map is therefore semantic: JSON path → sheet/group/function/event → +condition/action → audio asset. Exact audited decision paths include: + +- No release: `project.6.39.1.44.8.0.7.3.6.0.1.0` +- Paint release: `project.6.39.1.44.8.1.7.9.6.0.1.0` +- No rollover: `project.6.39.1.50.8.1.8.2.8.0.8.0.7.0.6.0.1.0` +- Paint rollover: `project.6.39.1.50.8.2.8.2.8.0.8.0.7.0.6.0.1.0` + +## Verification contract + +For every mapped cue: + +1. The asset exists and appears in the Construct audio manifest. +2. AC preloads it from the same-origin `/nopaint.art/media/` archive mount. +3. The browser journey records `path: "legacy"`; a synth fallback cannot make + the legacy-sample assertion pass. +4. Held-pointer tests cover down, rollover, release, and the resulting decision. +5. Assets under `media/unused - ...` are noncanonical unless new evidence says + otherwise. diff --git a/system/public/aesthetic.computer/disks/nopaint.mjs b/system/public/aesthetic.computer/disks/nopaint.mjs --- a/system/public/aesthetic.computer/disks/nopaint.mjs +++ b/system/public/aesthetic.computer/disks/nopaint.mjs @@ -32,11 +32,36 @@ const cueSamples = new Map(); let cueEvents = []; const LEGACY_CUES = Object.freeze({ + "no-down": "generic - no button pressed (metal brush).webm", no: "generic - no button released (middle).webm", + "paint-down": "generic - paint button pressed (psst).webm", paint: "generic - paint button released (cha).webm", - back: "generic - cancel.webm", - done: "generic - saved.webm", + rollover: "generic - button rollover.webm", + "button-down": "generic - button press.webm", + back: "generic - pause release.webm", + "done-down": "generic - save button pressed.webm", + done: "generic - save button released.webm", + "pause-in": "generic - entering pause.webm", + "pause-out": "generic - pause release.webm", + "primitive-release": "primitive - released.webm", +}); + +const BRUSH_CUES = Object.freeze({ + rect: "box - start.webm", + oval: "elipse - start.webm", + line: "line - start.webm", + softy: "softy - landed.webm", + bubbles: "bubbles - theme.webm", + "grid-worm": "grid worm - theme.webm", + walker: "common - jitter.webm", + banner: "banner - theme.webm", + wafer: "wafer - nibble appear.webm", + wipe: "wipe - individual wipe.webm", + camera: "camera - fx.webm", }); + +const brushCueSamples = new Map(); +let brushCueProposal = 0; function playCue(api, name) { const fallback = () => api.sound?.synth?.({ @@ -55,6 +80,16 @@ return api.sound.play(sample, { volume: 0.72 }); } cueEvents.push({ name, path: "synth" }); return fallback(); +} + +function playBrushCue(api, kind) { + if (brushCueProposal === proposalNumber) return; + const sample = brushCueSamples.get(kind); + if (!sample || !api.sound?.play) return; + brushCueProposal = proposalNumber; + cueEvents.push({ name: `brush:${kind}`, path: "legacy" }); + api.sound.play(sample, { volume: 0.48 }); + publishTestState(); } function initialNavigationURL() { @@ -218,6 +253,7 @@ ); proposalNumber += 1; proposalFrame = 0; transition("proposing"); + playBrushCue(api, proposal.kind); api.system.nopaint.needsPresent = true; api.needsPaint(); } @@ -301,6 +337,9 @@ function commitProposal(api) { if (loopState !== "proposing" && loopState !== "paused") return; playCue(api, "paint"); + if (["rect", "oval", "line"].includes(proposal?.kind)) { + playCue(api, "primitive-release"); + } transition("committing"); recordDecision(api, "paint"); @@ -316,10 +355,13 @@ chooseProposal(api); publishTestState(); } -function togglePaused({ needsPaint }) { +function togglePaused(api) { + const { needsPaint } = api; if (loopState === "paused") { + playCue(api, "pause-out"); transition(stateBeforePause); } else if (loopState === "proposing") { + playCue(api, "pause-in"); stateBeforePause = loopState; transition("paused"); } @@ -370,6 +412,7 @@ finishMode, doneCount, audio: { ready: [...cueSamples.keys()], + brushReady: [...brushCueSamples.keys()], events: cueEvents.map((event) => ({ ...event })), }, lastDownload, @@ -412,6 +455,7 @@ if (configuredSeed === sessionSeed) return; sessionSeed = configuredSeed; random = seededRandom(sessionSeed); proposalNumber = 0; + brushCueProposal = 0; clearProposal(testApi); chooseProposal(testApi); publishTestState(); @@ -447,6 +491,7 @@ saveCount = 0; finishMode = false; doneCount = 0; cueEvents = []; + brushCueProposal = 0; lastDownload = null; archiveOrigin = archiveId ? { type: "nopaint-archive", @@ -482,6 +527,15 @@ for (const [name, filename] of Object.entries(LEGACY_CUES)) { net.preload(`/nopaint.art/media/${filename}`) .then((sample) => cueSamples.set(name, sample)) .catch(() => {}); // playCue supplies an immediate native synth fallback. + } + for (const [kind, filename] of Object.entries(BRUSH_CUES)) { + net.preload(`/nopaint.art/media/${filename}`) + .then((sample) => { + brushCueSamples.set(kind, sample); + if (proposal?.kind === kind) playBrushCue(testApi, kind); + publishTestState(); + }) + .catch(() => {}); } } @@ -696,12 +750,18 @@ publishTestState(); }; if (finishMode) { - backButton.btn.act(e, leaveFinishMode); - doneButton.btn.act(e, () => { - playCue($, "done"); - doneCount += 1; - publishTestState(); - if (!initialNavigationURL()?.searchParams.has("test")) $.jump("done"); + backButton.btn.act(e, { + down: () => playCue($, "button-down"), + push: leaveFinishMode, + }); + doneButton.btn.act(e, { + down: () => playCue($, "done-down"), + push: () => { + playCue($, "done"); + doneCount += 1; + publishTestState(); + if (!initialNavigationURL()?.searchParams.has("test")) $.jump("done"); + }, }); const stage = interfaceLayout($.screen).stage; if ( @@ -712,8 +772,18 @@ ) leaveFinishMode(); return; } - noButton.btn.act(e, () => discardProposal($)); - paintButton.btn.act(e, () => commitProposal($)); + noButton.btn.act(e, { + down: () => playCue($, "no-down"), + rollover: () => playCue($, "rollover"), + cancel: () => playCue($, "back"), + push: () => discardProposal($), + }); + paintButton.btn.act(e, { + down: () => playCue($, "paint-down"), + rollover: () => playCue($, "rollover"), + cancel: () => playCue($, "back"), + push: () => commitProposal($), + }); const stage = interfaceLayout($.screen).stage; if ( e.is("lift:1") && diff --git a/system/public/aesthetic.computer/lib/disk.mjs b/system/public/aesthetic.computer/lib/disk.mjs --- a/system/public/aesthetic.computer/lib/disk.mjs +++ b/system/public/aesthetic.computer/lib/disk.mjs @@ -9132,8 +9132,11 @@ rejection(reject); return; } + preloadPromises[path] = { resolve, reject }; + // Register the receiver before asking BIOS to load. BIOS deliberately + // acknowledges an HTMLAudioElement immediately, so sending first can + // race the reply and leave this promise pending forever. send({ type: "sfx:load", content: path }); - preloadPromises[path] = { resolve, reject }; options.signal?.addEventListener("abort", () => { rejection(reject); diff --git a/tests/browser/nopaint-journey.test.mjs b/tests/browser/nopaint-journey.test.mjs --- a/tests/browser/nopaint-journey.test.mjs +++ b/tests/browser/nopaint-journey.test.mjs @@ -34,6 +34,16 @@ { timeout: 20000 }, ); await ac.wait(500); } + try { + await ac.page.waitForFunction( + () => window.__acNoPaintTest?.()?.audio?.events?.some( + ({ name, path }) => name === "brush:banner" && path === "legacy", + ), + { timeout: 15000 }, + ); + } catch (error) { + throw new Error(`${error.message}; audio=${JSON.stringify((await ac.nopaintState())?.audio)}`); + } const state = await ac.nopaintState(); await receipt("01-first-proposal"); if (!state) { @@ -50,6 +60,10 @@ expect(state?.state === "proposing", `state is proposing (got ${state?.state})`); expect(state?.proposalNumber === 1, "first proposal is numbered 1"); expect(state?.operation === "banner", `seed begins with Banner, not Camera (got ${state?.operation})`); expect(state?.ready === true, "proposal buffer reports ready"); + expect( + state?.audio?.events?.some(({ name, path }) => name === "brush:banner" && path === "legacy"), + "the proposal starts its recovered Banner theme", + ); expect( [state?.controls?.no, state?.controls?.paint] .every((box) => box && box.w > 0 && box.h > 0), @@ -133,8 +147,9 @@ expect( after?.paintingFingerprint === before?.paintingFingerprint, "No leaves the accepted painting unchanged", ); - expect(after?.audio?.events?.at(-1)?.name === "no", "No emits its interaction cue"); - expect(after?.audio?.events?.at(-1)?.path === "legacy", "No uses the recovered Construct sample"); + const noCue = after?.audio?.events?.findLast(({ name }) => name === "no"); + expect(Boolean(noCue), "No emits its interaction cue"); + expect(noCue?.path === "legacy", "No uses the recovered Construct sample"); }); await scenario("Paint commits and persists the proposal score", async (expect) => { @@ -148,8 +163,39 @@ expect( after?.paintingFingerprint !== before?.paintingFingerprint, "Paint changes the accepted painting", ); - expect(after?.audio?.events?.at(-1)?.name === "paint", "Paint emits its interaction cue"); - expect(after?.audio?.events?.at(-1)?.path === "legacy", "Paint uses the recovered Construct sample"); + const paintCue = after?.audio?.events?.findLast(({ name }) => name === "paint"); + expect(Boolean(paintCue), "Paint emits its interaction cue"); + expect(paintCue?.path === "legacy", "Paint uses the recovered Construct sample"); + }); + + await scenario("A held pointer can slide from No to Paint before release", async (expect) => { + const before = await ac.nopaintState(); + const rect = await ac.page.evaluate(() => Array.from(document.querySelectorAll("canvas")) + .map((canvas) => { + const box = canvas.getBoundingClientRect(); + return { x: box.x, y: box.y, width: box.width, height: box.height }; + }) + .filter((box) => box.width > 0 && box.height > 0) + .sort((a, b) => b.width * b.height - a.width * a.height)[0]); + const screen = before.layout.screenResolution; + const point = (box) => ({ + x: rect.x + (box.x + box.w / 2) * rect.width / screen.width, + y: rect.y + (box.y + box.h / 2) * rect.height / screen.height, + }); + const no = point(before.controls.no); + const paint = point(before.controls.paint); + await ac.page.mouse.move(no.x, no.y); + await ac.page.mouse.down(); + await ac.page.mouse.move(paint.x, paint.y, { steps: 12 }); + await ac.page.mouse.up(); + await ac.wait(250); + const after = await ac.nopaintState(); + expect(after?.proposalNumber === before.proposalNumber + 1, "release chooses the slid-to button"); + expect(after?.decisions?.at(-1)?.decision === "paint", "sliding No → Paint commits Paint"); + const recentCues = after?.audio?.events?.slice(-6).map(({ name }) => name) || []; + expect(recentCues.includes("no-down"), "hold begins with the No press cue"); + expect(recentCues.includes("paint-down") || recentCues.includes("rollover"), "crossing announces Paint"); + expect(recentCues.includes("paint"), "release emits the Paint cue before the next brush theme"); }); await scenario("Pause freezes and resumes the live proposal", async (expect) => {