diff --git a/docs/nopaint-construct-migration-ledger.md b/docs/nopaint-construct-migration-ledger.md index adcdfcd213..3b2d1eae2e 100644 --- a/docs/nopaint-construct-migration-ledger.md +++ b/docs/nopaint-construct-migration-ledger.md @@ -46,8 +46,9 @@ of the Construct implementation. ## Pieces that own their slug -`bubbles`, `walker`, `dark-window`, `line`, `box`, `stamp`, `frame`, and -`caterpillar` are real AC pieces that export their own `nopaintProposal`; +`bubbles`, `walker`, `dark-window`, `line`, `box`, `stamp`, `frame`, +`caterpillar`, and `softy` are real AC pieces that export their own +`nopaintProposal`; `disks/nopaint.mjs` imports each one and registers it after the fallback catalog, so the piece's contract wins by slug. A name leaves `nopaint-construct-catalog.mjs` on the day a piece takes it over — that catalog @@ -68,6 +69,20 @@ values. Both are now read out of `C3_ExpressionFuncs`: egg: `length == 6` swaps the cue and cycles hue along the body instead of fading lightness. +## Redrawing versus accumulating + +Most recovered brushes are a function of the frame, so `render` can redraw them +whole. Softy is not: it stamps a soft circle into a layer on every Move tick, +and the stroke is the accumulation. Redrawing the whole stroke each frame costs +**~1.7s a frame** at the largest radius, so Softy keeps a per-score layer, +composites only the stamps the clock has newly reached, and pastes that layer +once — peak 7ms on the first frame, 0.4ms after. Any future brush that paints +a trail rather than a shape wants the same treatment. + +Because a Construct stroke ran until you pressed Paint and a proposal has to +end, Softy's walk is capped at a fixed number of stamps. That length is the one +invented number in it. + The safe image filters/transforms `contrast`, `flip`, `invert`, `light-bump`, `mirror`, `quicksand`, `recurse`, `saturate`, `scroll`, `sharpen`, `spin`, `turn`, and `zoom` are also migrated and wired into the proposal conductor. diff --git a/system/public/aesthetic.computer/disks/nopaint.mjs b/system/public/aesthetic.computer/disks/nopaint.mjs index 5442d5108d..15639702d1 100644 --- a/system/public/aesthetic.computer/disks/nopaint.mjs +++ b/system/public/aesthetic.computer/disks/nopaint.mjs @@ -18,6 +18,7 @@ import { nopaintProposal as walkerProposal } from "./walker.mjs"; import { nopaintProposal as stampProposal } from "./stamp.mjs"; import { nopaintProposal as frameProposal } from "./frame.mjs"; import { nopaintProposal as caterpillarProposal } from "./caterpillar.mjs"; +import { nopaintProposal as softyProposal } from "./softy.mjs"; import { gridWormProposal } from "../lib/nopaint-construct-brushes.mjs"; import { nonConflictingConstructProposals } from "../lib/nopaint-construct-catalog.mjs"; import { recoveredConstructTransforms } from "../lib/nopaint-construct-transforms.mjs"; @@ -44,6 +45,7 @@ const COMPATIBLE_BRUSHES = Object.freeze(new Map([ [stampProposal.slug, stampProposal], [frameProposal.slug, frameProposal], [caterpillarProposal.slug, caterpillarProposal], + [softyProposal.slug, softyProposal], ])); let loopState = "choosing"; @@ -1155,17 +1157,6 @@ function renderProposal($) { ); } else if (proposal.kind === "line") { lineProposal.render($, proposal, proposalFrame); - } else if (proposal.kind === "softy") { - proposal.points.slice(0, 12).forEach((point, index) => { - const breathe = 1 + Math.sin(phase + index * 0.7) * 0.18; - ink(color[0], color[1], color[2], 22).oval( - point.x + drift, - point.y - drift, - point.size * 2.8 * breathe, - point.size * 2.8 * breathe, - true, - ); - }); } else if (proposal.kind === "bubbles") { proposal.points.forEach((point, index) => { const rise = (proposalFrame * (0.08 + (index % 4) * 0.03)) % buffer.height; diff --git a/system/public/aesthetic.computer/disks/softy.mjs b/system/public/aesthetic.computer/disks/softy.mjs new file mode 100644 index 0000000000..1125941e5f --- /dev/null +++ b/system/public/aesthetic.computer/disks/softy.mjs @@ -0,0 +1,15 @@ +// Softy, 26.08.10 +// The original No Paint soft brush as a standalone AC piece: a soft circle +// that walks, turns, and drifts its colour as it goes. + +import { createNoPaintBrushPiece } from "../lib/nopaint-brush-piece.mjs"; +import { softyProposal as nopaintProposal } from "../lib/nopaint-construct-softy.mjs"; + +const system = "nopaint"; +const piece = createNoPaintBrushPiece(nopaintProposal, { + title: "Softy", + desc: "Paint with the recovered soft circle as it wanders and shifts hue.", +}); + +export const { boot, sim, paint, bake, meta } = piece; +export { nopaintProposal, system }; diff --git a/system/public/aesthetic.computer/lib/nopaint-construct-catalog.mjs b/system/public/aesthetic.computer/lib/nopaint-construct-catalog.mjs index 7d2d853603..5ce0a52efd 100644 --- a/system/public/aesthetic.computer/lib/nopaint-construct-catalog.mjs +++ b/system/public/aesthetic.computer/lib/nopaint-construct-catalog.mjs @@ -19,11 +19,6 @@ function contract(slug, label, source, generateScore, render) { }, render }); } -export const softyProposal = contract("softy", "Softy", - { modes: ["S", "M", "L"], radii: [[6, 16], [16, 48], [48, 64]], landingFrames: 4 }, - ({ random, width, height, base }) => ({ points: points(random, width, height, 12), mode: ["S", "M", "L"][Math.floor(random() * 3)], color: base.color }), - ({ ink }, s, frame) => s.points.forEach((p, i) => ink(s.color[0], s.color[1], s.color[2], 22).oval(p.x, p.y, p.size * (1 + .18 * Math.sin(frame / 24 + i * .7)), p.size * (1 + .18 * Math.sin(frame / 24 + i * .7)), true))); - export const buildProposal = contract("build", "Build", { brickSizes: [4, 8, 16, 32, 64, 128], colorAlpha: 1 }, ({ random, width, height, base }) => ({ brickSize: [4, 8, 16, 32, 64, 128][Math.floor(random() * 6)], points: points(random, width, height, 24), color: base.color }), @@ -75,10 +70,10 @@ export const rainbowProposal = contract("rainbow", "Rainbow", { effect: "AdjustHSL", hueRange: [-100, 100] }, ({ base }) => ({ color: base.color }), ({ ink }, s, frame) => { for (let i = 0; i < 7; i++) { const hue = (frame / 120 + i / 7) * Math.PI * 2; ink(Math.round(128 + Math.sin(hue) * 127), Math.round(128 + Math.sin(hue + 2.094) * 127), Math.round(128 + Math.sin(hue + 4.188) * 127), 96).box(s.x, s.y + i * s.h / 7, s.w, Math.ceil(s.h / 7)); } }); -// Frame and Caterpillar graduated to their own disks; a name only stays in this -// fallback catalog until a real piece owns it. +// Frame, Caterpillar, and Softy graduated to their own disks; a name only stays +// in this fallback catalog until a real piece owns it. export const nonConflictingConstructProposals = frozen([ - softyProposal, buildProposal, bubblesProposal, bannerProposal, waferProposal, + buildProposal, bubblesProposal, bannerProposal, waferProposal, walkerProposal, auraProposal, triangleProposal, ellipseProposal, breatheProposal, vignetteProposal, rainbowProposal, ]); diff --git a/system/public/aesthetic.computer/lib/nopaint-construct-softy.mjs b/system/public/aesthetic.computer/lib/nopaint-construct-softy.mjs new file mode 100644 index 0000000000..d03cccbcc1 --- /dev/null +++ b/system/public/aesthetic.computer/lib/nopaint-construct-softy.mjs @@ -0,0 +1,214 @@ +// Softy, recovered from the Construct "Softy" event sheet. Every constant here +// was read out of the compiled expression table (toolchain/nopaint/ +// expressions.mjs): the weighted size tiers, the three radius bands and their +// turn levels, the hardness and step formulas, the four timers, the hue drift, +// and the blurp's radius-pitched playback rate. +// +// Construct built a 2r × 2r buffer whose alpha is +// 255 - (distance(centre) - hardness) / (radius - hardness) * 255 +// and stamped it into a layer on every Move tick. This does the same: one mask +// per score, composited into a layer that only ever grows by the stamps the +// clock has actually reached. Redrawing the whole stroke each frame instead +// costs ~1.7s a frame at the largest radius; pasting the layer costs ~0.2ms. + +const frozen = (value) => Object.freeze(value); + +export const SOFTY = frozen({ + canvas: 256, + // The picker weights S:5 M:1 L:2, so most Softies are small. + tiers: frozen([ + frozen({ name: "S", weight: 5, radius: frozen([6, 16]), turn: frozen([1, 2]) }), + frozen({ name: "M", weight: 1, radius: frozen([16, 48]), turn: frozen([2, 3]) }), + frozen({ name: "L", weight: 2, radius: frozen([48, 64]), turn: frozen([4, 5, 6]) }), + ]), + minHardnessGap: 6, // hardness = round(random(0, radius - 6)) + stepDivisor: 4, // step = max(1, (radius - hardness) / 4) + speeds: frozen([.5, 1, 1.5]), + turnSpeeds: frozen([16, 30, 45]), + hueDirections: frozen([-1, 1]), + colorSeconds: .1, // Timer "Color" + moveSeconds: .1, // Timer "Move", × speed + turnSeconds: .2, // Timer "Turn", × turnSpeed + reheadOdds: 10, // one turn in ten picks a whole new heading + hueStep: .001, + wander: .002, // s and l each drift ±.002 per colour tick + cue: "softy - landed", // played on the "Blurp" tag every Move tick + // A Construct stroke ran until you pressed Paint. A proposal has to end, so + // the walk is capped — this length is ours, everything above is not. + stamps: 96, +}); + +const choose = (random, values) => values[Math.floor(random() * values.length)]; + +export function softyCueRate(radius, jitter = 0) { + return 1.8 - radius / 64 + jitter; // Construct adds random(-.1, .1). +} + +function hslaToRgba(hue, saturation, lightness) { + const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; + const sector = ((hue % 1) + 1) % 1 * 6; + const second = chroma * (1 - Math.abs(sector % 2 - 1)); + const [r, g, b] = [[chroma, second, 0], [second, chroma, 0], [0, chroma, second], + [0, second, chroma], [second, 0, chroma], [chroma, 0, second]][Math.floor(sector) % 6]; + const base = lightness - chroma / 2; + return [r, g, b].map((channel) => Math.round((channel + base) * 255)); +} + +// Colour, turning, and movement each run on their own Construct timer, so the +// walk is generated once at whatever rates the score drew rather than re-derived +// per frame. Capping it at SOFTY.stamps keeps a redraw bounded. +function walkOf(score) { + const random = seededWalk(score.seed); + const turnEvery = score.turnSeconds / score.moveSeconds; + const colorEvery = SOFTY.colorSeconds / score.moveSeconds; + let angle = score.startAngle - 90; + let [x, y] = [score.x, score.y]; + let [hue, saturation, lightness] = [score.hue, score.saturation, score.lightness]; + const stamps = []; + for (let move = 0; move < SOFTY.stamps; move += 1) { + if (move && move % turnEvery < 1) { + angle = random() * SOFTY.reheadOdds < 1 + ? random() * 360 + : angle + random() * score.turnLevel * 2 - score.turnLevel; + } + if (move && move % colorEvery < 1) { + hue = (hue + SOFTY.hueStep * score.hueDirection) % 1; + saturation = Math.min(1, saturation + random() * SOFTY.wander * 2 - SOFTY.wander); + lightness = Math.min(1, lightness + random() * SOFTY.wander * 2 - SOFTY.wander); + } + stamps.push(frozen({ x, y, color: frozen(hslaToRgba(hue, saturation, lightness)) })); + const radians = angle * Math.PI / 180; + x += Math.cos(radians) * score.step; + y += Math.sin(radians) * score.step; + } + return frozen(stamps); +} + +function seededWalk(seed) { + let state = seed >>> 0 || 1; + return () => { + state += 0x6d2b79f5; + let value = state; + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 4294967296; + }; +} + +// Construct's soft circle, as an alpha mask: opaque inside `hardness`, falling +// linearly to nothing at the rim. One per score, tinted as it is stamped. +function softMask(radius, hardness) { + const size = Math.max(1, Math.ceil(radius * 2)); + const alpha = new Uint8ClampedArray(size * size); + const falloff = Math.max(1, radius - hardness); + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + const distance = Math.hypot(x - radius, y - radius); + alpha[y * size + x] = distance <= hardness + ? 255 + : 255 - (distance - hardness) / falloff * 255; + } + } + return { size, alpha }; +} + +// Source-over of one tinted stamp into the score's layer. +function stampInto(layer, mask, centerX, centerY, color) { + const left = Math.round(centerX - mask.size / 2); + const top = Math.round(centerY - mask.size / 2); + for (let y = 0; y < mask.size; y += 1) { + const destY = top + y; + if (destY < 0 || destY >= layer.height) continue; + for (let x = 0; x < mask.size; x += 1) { + const source = mask.alpha[y * mask.size + x]; + if (source === 0) continue; + const destX = left + x; + if (destX < 0 || destX >= layer.width) continue; + const at = (destY * layer.width + destX) * 4; + const under = layer.pixels[at + 3] * (255 - source) / 255; + const total = source + under; + layer.pixels[at] = (color[0] * source + layer.pixels[at] * under) / total; + layer.pixels[at + 1] = (color[1] * source + layer.pixels[at + 1] * under) / total; + layer.pixels[at + 2] = (color[2] * source + layer.pixels[at + 2] * under) / total; + layer.pixels[at + 3] = total; + } + } +} + +const layers = new WeakMap(); +function layerFor(score) { + let layer = layers.get(score); + if (!layer) { + const width = Math.max(1, Math.round(score.width)); + const height = Math.max(1, Math.round(score.height)); + layers.set(score, layer = { + width, height, + pixels: new Uint8ClampedArray(width * height * 4), + mask: softMask(Math.max(1, score.radius * score.scale), score.hardness * score.scale), + stamps: walkOf(score), + placed: 0, + }); + } + return layer; +} + +export const softyProposal = frozen({ + version: 1, + slug: "softy", + label: "Softy", + compatible: true, + source: frozen({ ...SOFTY, actionSheet: "Softy", object: "SoftCircleBuffer", + falloff: "255 - (distance - hardness) / (radius - hardness) * 255", + reconstructed: frozen(["stroke length"]) }), + generate({ random, width, height, base }) { + const weights = SOFTY.tiers.reduce((sum, tier) => sum + tier.weight, 0); + let cursor = random() * weights; + const tier = SOFTY.tiers.find((entry) => (cursor -= entry.weight) < 0) || SOFTY.tiers[0]; + const [low, high] = tier.radius; + const radius = Math.round(low + random() * (high - low)); + const hardness = Math.round(random() * Math.max(0, radius - SOFTY.minHardnessGap)); + const speed = choose(random, SOFTY.speeds); + const turnSpeed = choose(random, SOFTY.turnSpeeds); + // Construct laid Softy out on a 256 painting; carry its radii and step + // onto whatever this canvas actually is. + const scale = Math.min(width, height) / SOFTY.canvas; + return frozen({ + ...base, + kind: "softy", + seed: Math.floor(random() * 0xffffffff), + tier: tier.name, + radius, hardness, speed, turnSpeed, scale, + turnLevel: choose(random, tier.turn), + hueDirection: choose(random, SOFTY.hueDirections), + startAngle: Math.floor(random() * 360), + step: Math.max(1, (radius - hardness) / SOFTY.stepDivisor), + moveSeconds: SOFTY.moveSeconds * speed, + turnSeconds: SOFTY.turnSeconds * turnSpeed, + // ProcessColorParameter's unspecified case is the shared random colour. + hue: random(), + saturation: .2 + random() * .8, + lightness: .4 + random() * .2, + x: random() * width, + y: random() * height, + width, height, + brush: frozen({ + slug: "softy", + params: frozen([String(Math.round(radius * scale)), tier.name]), + colon: frozen([]), + parameters: frozen({ tier: tier.name, radius, hardness, speed, + cue: SOFTY.cue, cueRate: softyCueRate(radius) }), + }), + }); + }, + render({ paste }, score, tick) { + const layer = layerFor(score); + const due = Math.min(layer.stamps.length, + 1 + Math.floor(tick / 60 / score.moveSeconds)); + while (layer.placed < due) { + const { x, y, color } = layer.stamps[layer.placed]; + stampInto(layer, layer.mask, x, y, color); + layer.placed += 1; + } + paste(layer, 0, 0); + }, +}); diff --git a/system/tests/nopaint-construct-catalog.test.mjs b/system/tests/nopaint-construct-catalog.test.mjs index 3edcd439bc..51c5fda6f3 100644 --- a/system/tests/nopaint-construct-catalog.test.mjs +++ b/system/tests/nopaint-construct-catalog.test.mjs @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { seededRandom } from "../public/aesthetic.computer/lib/nopaint-proposals.mjs"; import { nonConflictingConstructProposals } from "../public/aesthetic.computer/lib/nopaint-construct-catalog.mjs"; -const expected = ["aura", "banner", "breathe", "bubbles", "build", "ellipse", "rainbow", "softy", "triangle", "vignette", "wafer", "walker"]; +const expected = ["aura", "banner", "breathe", "bubbles", "build", "ellipse", "rainbow", "triangle", "vignette", "wafer", "walker"]; const base = Object.freeze({ color: Object.freeze([20, 40, 60, 128]), x: 20, y: 30, w: 180, h: 140, drift: 8, thickness: 3, points: Object.freeze([]), phase: 0 }); test("all non-conflicting recovered brush names have deterministic contracts", () => { diff --git a/system/tests/nopaint-construct-softy.test.mjs b/system/tests/nopaint-construct-softy.test.mjs new file mode 100644 index 0000000000..c15518dccd --- /dev/null +++ b/system/tests/nopaint-construct-softy.test.mjs @@ -0,0 +1,126 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { seededRandom } from "../public/aesthetic.computer/lib/nopaint-proposals.mjs"; +import { + SOFTY, + softyCueRate, + softyProposal, +} from "../public/aesthetic.computer/lib/nopaint-construct-softy.mjs"; + +const base = Object.freeze({ + color: Object.freeze([20, 40, 60, 128]), x: 10, y: 20, w: 120, h: 80, + drift: 4, thickness: 2, points: Object.freeze([]), phase: 0, +}); +const make = (seed = "softy", width = 256, height = 256) => + softyProposal.generate({ random: seededRandom(seed), width, height, base }); + +test("Softy keeps the constants read out of the expression table", () => { + // Weighted tier pick S:5 M:1 L:2, radius bands, and per-tier turn levels. + assert.deepEqual(SOFTY.tiers.map((t) => [t.name, t.weight, t.radius, t.turn]), [ + ["S", 5, [6, 16], [1, 2]], + ["M", 1, [16, 48], [2, 3]], + ["L", 2, [48, 64], [4, 5, 6]], + ]); + assert.deepEqual(SOFTY.speeds, [.5, 1, 1.5]); + assert.deepEqual(SOFTY.turnSpeeds, [16, 30, 45]); + assert.deepEqual(SOFTY.hueDirections, [-1, 1]); + assert.equal(SOFTY.colorSeconds, .1); + assert.equal(SOFTY.moveSeconds, .1); + assert.equal(SOFTY.turnSeconds, .2); + assert.equal(SOFTY.reheadOdds, 10); + assert.equal(SOFTY.minHardnessGap, 6); + assert.equal(SOFTY.stepDivisor, 4); + assert.equal(SOFTY.cue, "softy - landed"); + // The blurp pitches down as the brush grows: 1.8 - radius / 64. + assert.equal(softyCueRate(64), 0.8); + assert.equal(softyCueRate(6), 1.8 - 6 / 64); +}); + +test("Softy generates deterministically inside every recovered range", () => { + assert.deepEqual(make(), make()); + const tiers = new Set(); + for (let seed = 0; seed < 300; seed += 1) { + const score = make(`softy:${seed}`); + const tier = SOFTY.tiers.find((entry) => entry.name === score.tier); + tiers.add(score.tier); + assert.ok(tier, `${score.tier} is a real tier`); + assert.ok(score.radius >= tier.radius[0] && score.radius <= tier.radius[1], + `radius ${score.radius} is inside ${tier.name}`); + assert.ok(tier.turn.includes(score.turnLevel)); + assert.ok(SOFTY.speeds.includes(score.speed)); + assert.ok(SOFTY.turnSpeeds.includes(score.turnSpeed)); + assert.ok(SOFTY.hueDirections.includes(score.hueDirection)); + // hardness = round(random(0, radius - 6)), step = max(1, (r - h) / 4). + assert.ok(score.hardness >= 0 + && score.hardness <= Math.max(0, score.radius - SOFTY.minHardnessGap)); + assert.equal(score.step, + Math.max(1, (score.radius - score.hardness) / SOFTY.stepDivisor)); + assert.ok(score.startAngle >= 0 && score.startAngle < 360); + } + assert.deepEqual([...tiers].sort(), ["L", "M", "S"], "every tier is reachable"); +}); + +test("the weighted picker favours S the way the original list did", () => { + const counts = { S: 0, M: 0, L: 0 }; + for (let seed = 0; seed < 3000; seed += 1) counts[make(`tier:${seed}`).tier] += 1; + // S:5 M:1 L:2 out of 8. Loose bounds — this asserts the ordering, not the RNG. + assert.ok(counts.S > counts.L && counts.L > counts.M, + `S ${counts.S} > L ${counts.L} > M ${counts.M}`); +}); + +test("Softy accumulates its stroke instead of redrawing it", () => { + const score = make("softy:paint", 128, 128); + const pasted = []; + const render = (tick) => { + pasted.length = 0; + softyProposal.render({ paste: (...args) => pasted.push(args) }, score, tick); + return pasted; + }; + const [[layer]] = render(0); + assert.equal(layer.width, 128); + assert.equal(layer.height, 128); + assert.equal(pasted.length, 1, "one paste per frame, whatever the stroke length"); + + const painted = () => layer.pixels.reduce( + (total, value, index) => index % 4 === 3 && value > 0 ? total + 1 : total, 0); + const early = painted(); + assert.ok(early > 0, "the first stamp lands immediately"); + render(600); + const later = painted(); + assert.ok(later > early, `the stroke grows (${early} → ${later})`); + + // The walk is capped, so a much later frame adds nothing more. + render(60 * 60); + const settled = painted(); + render(60 * 600); + assert.equal(painted(), settled, "the stroke ends rather than running forever"); +}); + +test("the soft falloff is opaque at the core and empty at the rim", () => { + const score = Object.freeze({ ...make("softy:falloff", 128, 128), x: 64, y: 64 }); + const pasted = []; + softyProposal.render({ paste: (...args) => pasted.push(args) }, score, 0); + const [layer] = pasted[0]; + const alphaAt = (x, y) => layer.pixels[(y * layer.width + x) * 4 + 3]; + const radius = score.radius * score.scale; + const hardness = score.hardness * score.scale; + assert.equal(alphaAt(64, 64), 255, "the core is solid"); + if (hardness >= 1) assert.equal(alphaAt(64, Math.round(64 - hardness + 1)), 255); + assert.equal(alphaAt(64, Math.round(64 - radius - 2)), 0, "nothing past the rim"); + // Alpha falls monotonically from the core out to the rim. + let previous = 256; + for (let offset = Math.ceil(hardness); offset < radius; offset += 1) { + const alpha = alphaAt(64, Math.round(64 - offset)); + assert.ok(alpha <= previous, `alpha falls at ${offset} (${alpha} > ${previous})`); + previous = alpha; + } +}); + +test("No Paint 3 resolves softy to the standalone piece's contract", async () => { + const { COMPATIBLE_BRUSHES } = await import( + "../public/aesthetic.computer/disks/nopaint.mjs"); + const piece = await import("../public/aesthetic.computer/disks/softy.mjs"); + assert.equal(piece.system, "nopaint"); + assert.equal(COMPATIBLE_BRUSHES.get("softy"), piece.nopaintProposal); + assert.equal(piece.nopaintProposal, softyProposal); +}); diff --git a/toolchain/nopaint/decode-sheet.mjs b/toolchain/nopaint/decode-sheet.mjs index ac43edc457..6d94f6b24d 100644 --- a/toolchain/nopaint/decode-sheet.mjs +++ b/toolchain/nopaint/decode-sheet.mjs @@ -36,9 +36,12 @@ const expression = (value) => { }; // [type, id, behavior, sid, flags, …, params]; params sit at index 6 for -// actions and index 9 for conditions. +// actions and index 9 for conditions. Type 2 is an audio file reference — +// [name, isLooping] — not an expression, so print it as written. const parameters = (list) => (list || []) - .map(([type, value]) => ` p${type}: ${expression(value)}`).join("\n"); + .map(([type, value]) => ` p${type}: ${type === 2 + ? `audio ${JSON.stringify(value?.[0])}${value?.[1] ? " (looping)" : ""}` + : expression(value)}`).join("\n"); function walk(node, depth) { if (!Array.isArray(node)) return;