diff --git a/docs/nopaint-construct-migration-ledger.md b/docs/nopaint-construct-migration-ledger.md index 3b2d1eae2e..976f96cd47 100644 --- a/docs/nopaint-construct-migration-ledger.md +++ b/docs/nopaint-construct-migration-ledger.md @@ -47,7 +47,7 @@ of the Construct implementation. ## Pieces that own their slug `bubbles`, `walker`, `dark-window`, `line`, `box`, `stamp`, `frame`, -`caterpillar`, and `softy` are real AC pieces that export their own +`caterpillar`, `softy`, and `wafer` 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 @@ -83,6 +83,19 @@ 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. +Wafer accumulates the same way, for a different reason: a bite *erases* +(Construct switches to blend mode 7, cuts an ellipse, switches back), so the +biscuit is only ever the sum of what has been taken out of it. + +## Reading a brush's real work + +Most of a brush's behaviour lives in **function blocks**, not in the sheet's +top-level events — the sheet just calls `Bite`, `BiscuitEnlarge`, +`CircularBite`. A function block is a type-`4` node carrying its name in +`node[1][0]` with the same conditions/actions/children tail as an event, and +`decode-sheet.mjs` prints them as `function `. If a decoded sheet looks +too thin to be the whole brush, the work is in the functions. + 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. @@ -99,9 +112,11 @@ renderers use bounded translucent primitives and do not claim pixel parity. Dark Window selects and plays each of its four original note samples, and Caterpillar picks between `trotting along` and `rain bow road` from its own -score. Build and Wafer have multi-cue event vocabularies, while the current No -Paint conductor owns one active brush sample at a time. Their primary sample is -wired; exact per-event cue sequencing requires a conductor audio-timeline API. +score. Build and Wafer have multi-cue event vocabularies — Wafer alone has +five, one per bite plus appear and enlarge — while the current No Paint +conductor owns one active brush sample at a time. Their primary sample is +wired, and the full vocabulary is recorded in each contract's `source`; exact +per-event cue sequencing requires a conductor audio-timeline API. Stamp's `mirrored` is real: Construct mirrored on a negative X scale, and `paste` now reaches that by handing `grid` a per-axis `{x, y}` scale. diff --git a/system/public/aesthetic.computer/disks/nopaint.mjs b/system/public/aesthetic.computer/disks/nopaint.mjs index 15639702d1..51ccc0e5b5 100644 --- a/system/public/aesthetic.computer/disks/nopaint.mjs +++ b/system/public/aesthetic.computer/disks/nopaint.mjs @@ -19,6 +19,7 @@ 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 { nopaintProposal as waferProposal } from "./wafer.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"; @@ -46,6 +47,7 @@ const COMPATIBLE_BRUSHES = Object.freeze(new Map([ [frameProposal.slug, frameProposal], [caterpillarProposal.slug, caterpillarProposal], [softyProposal.slug, softyProposal], + [waferProposal.slug, waferProposal], ])); let loopState = "choosing"; @@ -1200,22 +1202,6 @@ function renderProposal($) { Math.max(2, proposal.h / stripes - 1), ); } - } else if (proposal.kind === "wafer") { - const cols = 7; - const rows = 7; - const cw = proposal.w / cols; - const ch = proposal.h / rows; - for (let row = 0; row < rows; row += 1) { - for (let col = 0; col < cols; col += 1) { - const lift = Math.sin(phase + row * 0.5 + col * 0.35) * 3; - ink(color[0], color[1], color[2], (row + col) % 2 ? 70 : 150).box( - proposal.x + col * cw, - proposal.y + row * ch + lift, - Math.max(1, cw - 1), - Math.max(1, ch - 1), - ); - } - } } else if (proposal.kind === "wipe") { // A recovered "wipe" is a translucent wash in No Paint 3.0. Random // proposals must accumulate history; they may never replace it opaquely. diff --git a/system/public/aesthetic.computer/disks/wafer.mjs b/system/public/aesthetic.computer/disks/wafer.mjs new file mode 100644 index 0000000000..89d79e17fa --- /dev/null +++ b/system/public/aesthetic.computer/disks/wafer.mjs @@ -0,0 +1,15 @@ +// Wafer, 26.08.10 +// The original No Paint biscuit as a standalone AC piece: it appears, gets +// nibbled around its rim in a shuffled order, then grows and starts again. + +import { createNoPaintBrushPiece } from "../lib/nopaint-brush-piece.mjs"; +import { waferProposal as nopaintProposal } from "../lib/nopaint-construct-wafer.mjs"; + +const system = "nopaint"; +const piece = createNoPaintBrushPiece(nopaintProposal, { + title: "Wafer", + desc: "Paint with the recovered biscuit as it is nibbled and enlarged.", +}); + +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 5ce0a52efd..9cbdef0c8a 100644 --- a/system/public/aesthetic.computer/lib/nopaint-construct-catalog.mjs +++ b/system/public/aesthetic.computer/lib/nopaint-construct-catalog.mjs @@ -34,11 +34,6 @@ export const bannerProposal = contract("banner", "Banner", ({ random, base }) => ({ segments: [4, 8, 16][Math.floor(random() * 3)], color: base.color }), ({ ink }, s, frame) => { for (let i = 0; i < s.segments; i++) ink(s.color[0], s.color[1], s.color[2], 60 + i * 8).box(s.x + Math.sin(frame / 24 + i * .5) * s.drift, s.y + i * s.h / s.segments, s.w, Math.max(1, s.h / s.segments - 1)); }); -export const waferProposal = contract("wafer", "Wafer", - { cellSizes: [16, 16, 32, 32, 32, 32, 48, 48, 48, 64, 64, 96], bites: 3 }, - ({ random, base }) => ({ cellSize: [16, 16, 32, 32, 32, 32, 48, 48, 48, 64, 64, 96][Math.floor(random() * 12)], color: base.color }), - ({ ink }, s, frame) => { for (let y = 0; y < s.h; y += s.cellSize) for (let x = 0; x < s.w; x += s.cellSize) if ((x / s.cellSize + y / s.cellSize + Math.floor(frame / 30)) % 5) ink(s.color).box(s.x + x, s.y + y, s.cellSize - 1, s.cellSize - 1); }); - export const walkerProposal = contract("walker", "Walker", { blip: true, noiseShift: 1 / 30, alphaLevels: [60, 80] }, ({ random, width, height, base }) => ({ points: points(random, width, height, 24), color: base.color }), @@ -70,10 +65,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, Caterpillar, and Softy graduated to their own disks; a name only stays -// in this fallback catalog until a real piece owns it. +// Frame, Caterpillar, Softy, and Wafer graduated to their own disks; a name only +// stays in this fallback catalog until a real piece owns it. export const nonConflictingConstructProposals = frozen([ - buildProposal, bubblesProposal, bannerProposal, waferProposal, + buildProposal, bubblesProposal, bannerProposal, walkerProposal, auraProposal, triangleProposal, ellipseProposal, breatheProposal, vignetteProposal, rainbowProposal, ]); diff --git a/system/public/aesthetic.computer/lib/nopaint-construct-wafer.mjs b/system/public/aesthetic.computer/lib/nopaint-construct-wafer.mjs new file mode 100644 index 0000000000..7d4d717db1 --- /dev/null +++ b/system/public/aesthetic.computer/lib/nopaint-construct-wafer.mjs @@ -0,0 +1,199 @@ +// Wafer, recovered from the Construct "Wafer" event sheet and its Bite / +// CircularBite / BiscuitEnlarge / BiscuitRender functions. Read out of the +// compiled expression table (toolchain/nopaint/expressions.mjs): the weighted +// radius list, the twelve bite positions 30° apart, the three erasing ellipses +// per bite and their jitter, the half-second Draw timer, the thirteen bites +// before the biscuit grows by 1.3, and all five cues. +// +// The bite positions are not walked in order. Construct builds an +// AdvancedRandom permutation of twelve and reads biteAngle as +// `30 * permutation[biscuitLocationIndex]`, reshuffling on every enlarge, so +// the biscuit is nibbled around its rim in a different order each generation. +// +// A bite erases — Construct switches to blend mode 7 (destination-out), cuts an +// ellipse, and switches back. Like Softy, the biscuit is therefore a layer that +// accumulates rather than a shape that can be redrawn from the frame number. + +const frozen = (value) => Object.freeze(value); + +export const WAFER = frozen({ + canvas: 256, + // choose(16,16,32,32,32,32,48,48,48,64,64,96): 32 is four times as likely as + // 96, so most wafers are middling. + radii: frozen([16, 16, 32, 32, 32, 32, 48, 48, 48, 64, 64, 96]), + positions: 12, + arc: 30, // 360 / 12, inlined in the biteAngle expression + bitesPerVisit: 3, // Bite cuts three ellipses + biteRadius: frozen([.2, .5]), // random(.2 * radius, .5 * radius) + biteAngleJitter: 5, // biteAngle + random(-5, 5) + biteDistanceJitter: 4, // radius + random(-4, 4) + drawSeconds: .5, // Timer "Draw" + bitesBeforeEnlarge: 13, + enlarge: 1.3, + particleDistance: .65, // .65 * radius out along biteAngle + colorJitter: frozen([-5, 0, 5]), + cues: frozen({ + appear: "wafer - nibble appear", + bites: frozen(["wafer - nibble bite 1", "wafer - nibble bite 2", "wafer - nibble bite 3"]), + enlarge: "wafer - enlarge", + }), +}); + +const choose = (random, values) => values[Math.floor(random() * values.length)]; +const clamp = (value) => Math.max(0, Math.min(255, Math.round(value))); + +// AdvancedRandom's "create permutation of 12 from 0", as a Fisher-Yates the +// score can reproduce from its seed. +export function permutation(random, count) { + const order = Array.from({ length: count }, (_, index) => index); + for (let index = count - 1; index > 0; index -= 1) { + const swap = Math.floor(random() * (index + 1)); + [order[index], order[swap]] = [order[swap], order[index]]; + } + return frozen(order); +} + +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; + }; +} + +function disc(layer, centerX, centerY, radius, color) { + const top = Math.max(0, Math.floor(centerY - radius)); + const bottom = Math.min(layer.height - 1, Math.ceil(centerY + radius)); + for (let y = top; y <= bottom; y += 1) { + const span = Math.sqrt(Math.max(0, radius * radius - (y - centerY) ** 2)); + const left = Math.max(0, Math.floor(centerX - span)); + const right = Math.min(layer.width - 1, Math.ceil(centerX + span)); + for (let x = left; x <= right; x += 1) { + const at = (y * layer.width + x) * 4; + layer.pixels[at] = color[0]; + layer.pixels[at + 1] = color[1]; + layer.pixels[at + 2] = color[2]; + layer.pixels[at + 3] = 255; + } + } +} + +// Construct's blend mode 7 on an ellipse: the bite takes the biscuit away. +function erase(layer, centerX, centerY, radius) { + const top = Math.max(0, Math.floor(centerY - radius)); + const bottom = Math.min(layer.height - 1, Math.ceil(centerY + radius)); + for (let y = top; y <= bottom; y += 1) { + const span = Math.sqrt(Math.max(0, radius * radius - (y - centerY) ** 2)); + const left = Math.max(0, Math.floor(centerX - span)); + const right = Math.min(layer.width - 1, Math.ceil(centerX + span)); + for (let x = left; x <= right; x += 1) layer.pixels[(y * layer.width + x) * 4 + 3] = 0; + } +} + +function biteInto(layer, state, score) { + const angle = WAFER.arc * state.order[state.index % WAFER.positions]; + for (let cut = 0; cut < WAFER.bitesPerVisit; cut += 1) { + const { random } = state; + const [low, high] = WAFER.biteRadius; + const biteRadius = (low + random() * (high - low)) * state.radius; + const adjusted = angle + random() * WAFER.biteAngleJitter * 2 - WAFER.biteAngleJitter; + const reach = state.radius + + random() * WAFER.biteDistanceJitter * 2 - WAFER.biteDistanceJitter; + const radians = adjusted * Math.PI / 180; + erase(layer, score.x + Math.cos(radians) * reach, + score.y + Math.sin(radians) * reach, biteRadius); + } + state.index += 1; + return angle; +} + +const layers = new WeakMap(); +function layerFor(score) { + let state = layers.get(score); + if (!state) { + const width = Math.max(1, Math.round(score.width)); + const height = Math.max(1, Math.round(score.height)); + const random = seededWalk(score.seed); + state = { + random, index: 0, drawn: 0, generation: 0, + radius: score.radius, + order: permutation(random, WAFER.positions), + layer: { width, height, pixels: new Uint8ClampedArray(width * height * 4) }, + }; + layers.set(score, state); + reset(state, score); + } + return state; +} + +// BiscuitRender: the biscuit is redrawn whole, then nibbled. CircularBite runs +// twelve bites before the first frame is ever shown. +function reset(state, score) { + state.layer.pixels.fill(0); + disc(state.layer, score.x, score.y, state.radius, score.color); + state.index = 0; + for (let bite = 0; bite < WAFER.positions; bite += 1) biteInto(state.layer, state, score); + state.index = 0; +} + +export const waferProposal = frozen({ + version: 1, + slug: "wafer", + label: "Wafer", + compatible: true, + source: frozen({ ...WAFER, actionSheet: "Wafer", + objects: frozen(["NibbleBiscuit", "NibbleParticles", "NibblePalette"]), + biteAngle: "30 * AdvancedRandom.permutation[biscuitLocationIndex]", + // NibblePalette reads its colours out of a sprite; only the ±5 per-channel + // jitter around them is recovered, so the proposal's own colour stands in. + palette: "not recovered", + reconstructed: frozen(["the palette colour", "the nibble particles"]) }), + generate({ random, width, height, base }) { + const radius = choose(random, WAFER.radii); + const scale = Math.min(width, height) / WAFER.canvas; + const color = frozen(base.color.slice(0, 3) + .map((channel) => clamp(channel + choose(random, WAFER.colorJitter)))); + return frozen({ + ...base, + kind: "wafer", + seed: Math.floor(random() * 0xffffffff), + radius: Math.max(1, radius * scale), + sourceRadius: radius, + color, + scale, + x: random() * width, + y: random() * height, + width, height, + brush: frozen({ + slug: "wafer", + params: frozen([String(radius)]), + colon: frozen([]), + parameters: frozen({ radius, positions: WAFER.positions, + bitesBeforeEnlarge: WAFER.bitesBeforeEnlarge, cue: WAFER.cues.appear }), + }), + }); + }, + render({ paste }, score, tick) { + const state = layerFor(score); + const due = Math.floor(tick / 60 / WAFER.drawSeconds); + while (state.drawn < due) { + state.drawn += 1; + if (state.index < WAFER.bitesBeforeEnlarge) { + biteInto(state.layer, state, score); + continue; + } + // BiscuitEnlarge: grow, reshuffle, and start nibbling again. Stop once + // the biscuit has outgrown the painting rather than growing forever. + const grown = state.radius * WAFER.enlarge; + if (grown > Math.max(score.width, score.height)) { state.drawn = due; break; } + state.radius = grown; + state.generation += 1; + state.order = permutation(state.random, WAFER.positions); + reset(state, score); + } + paste(state.layer, 0, 0); + }, +}); diff --git a/system/tests/nopaint-construct-catalog.test.mjs b/system/tests/nopaint-construct-catalog.test.mjs index 51c5fda6f3..791c1f1f6b 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", "triangle", "vignette", "wafer", "walker"]; +const expected = ["aura", "banner", "breathe", "bubbles", "build", "ellipse", "rainbow", "triangle", "vignette", "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-wafer.test.mjs b/system/tests/nopaint-construct-wafer.test.mjs new file mode 100644 index 0000000000..552fdb4051 --- /dev/null +++ b/system/tests/nopaint-construct-wafer.test.mjs @@ -0,0 +1,122 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { seededRandom } from "../public/aesthetic.computer/lib/nopaint-proposals.mjs"; +import { + WAFER, + permutation, + waferProposal, +} from "../public/aesthetic.computer/lib/nopaint-construct-wafer.mjs"; + +const base = Object.freeze({ + color: Object.freeze([120, 90, 60, 128]), x: 10, y: 20, w: 120, h: 80, + drift: 4, thickness: 2, points: Object.freeze([]), phase: 0, +}); +const make = (seed = "wafer", width = 256, height = 256) => + waferProposal.generate({ random: seededRandom(seed), width, height, base }); + +const render = (score, tick) => { + const pasted = []; + waferProposal.render({ paste: (...args) => pasted.push(args) }, score, tick); + assert.equal(pasted.length, 1, "one paste per frame"); + return pasted[0][0]; +}; +const solid = (layer) => layer.pixels.reduce( + (total, value, index) => index % 4 === 3 && value > 0 ? total + 1 : total, 0); + +test("Wafer keeps the constants read out of the expression table", () => { + assert.deepEqual(WAFER.radii, [16, 16, 32, 32, 32, 32, 48, 48, 48, 64, 64, 96]); + assert.equal(WAFER.positions, 12); + assert.equal(WAFER.arc, 30); + assert.equal(WAFER.arc * WAFER.positions, 360, "the bites go once around"); + assert.equal(WAFER.bitesPerVisit, 3); + assert.deepEqual(WAFER.biteRadius, [.2, .5]); + assert.equal(WAFER.biteAngleJitter, 5); + assert.equal(WAFER.biteDistanceJitter, 4); + assert.equal(WAFER.drawSeconds, .5); + assert.equal(WAFER.bitesBeforeEnlarge, 13); + assert.equal(WAFER.enlarge, 1.3); + assert.deepEqual(WAFER.colorJitter, [-5, 0, 5]); + assert.deepEqual(WAFER.cues.bites, [ + "wafer - nibble bite 1", "wafer - nibble bite 2", "wafer - nibble bite 3"]); + assert.equal(WAFER.cues.appear, "wafer - nibble appear"); + assert.equal(WAFER.cues.enlarge, "wafer - enlarge"); +}); + +test("the bite order is a permutation of all twelve positions", () => { + for (let seed = 0; seed < 50; seed += 1) { + const order = permutation(seededRandom(`wafer:${seed}`), WAFER.positions); + assert.deepEqual([...order].sort((a, b) => a - b), + Array.from({ length: WAFER.positions }, (_, index) => index), + "every rim position is visited exactly once"); + } + // Shuffled, not walked in order — at least one seed must reorder it. + const ordered = Array.from({ length: WAFER.positions }, (_, index) => index); + const shuffled = Array.from({ length: 20 }, (_, seed) => + permutation(seededRandom(`shuffle:${seed}`), WAFER.positions)); + assert.ok(shuffled.some((order) => order.join() !== ordered.join())); +}); + +test("Wafer generates deterministically inside the recovered ranges", () => { + assert.deepEqual(make(), make()); + const radii = new Set(); + for (let seed = 0; seed < 200; seed += 1) { + const score = make(`wafer:${seed}`); + radii.add(score.sourceRadius); + assert.ok(WAFER.radii.includes(score.sourceRadius)); + // Each channel is the proposal colour plus one of the recovered jitters. + score.color.forEach((channel, index) => { + const offsets = WAFER.colorJitter.map((jitter) => + Math.max(0, Math.min(255, base.color[index] + jitter))); + assert.ok(offsets.includes(channel), `channel ${index} is jittered by ±5`); + }); + } + assert.deepEqual([...radii].sort((a, b) => a - b), [16, 32, 48, 64, 96]); +}); + +test("the biscuit appears already nibbled, then loses more as the clock runs", () => { + const score = make("wafer:bites", 256, 256); + const layer = render(score, 0); + const start = solid(layer); + assert.ok(start > 0, "the biscuit is drawn"); + + // CircularBite runs twelve bites before anything is shown, so the disc is + // never whole: it must be smaller than a plain circle of the same radius. + const whole = Math.PI * score.radius * score.radius; + assert.ok(start < whole, `${start} px is less than an unbitten ${Math.round(whole)}`); + + // Timer "Draw" is half a second; 30 frames is one bite. + render(score, 30 * 6); + const bitten = solid(layer); + assert.ok(bitten < start, `bites remove pixels (${start} → ${bitten})`); +}); + +test("after thirteen bites the biscuit grows and is nibbled fresh", () => { + const score = make("wafer:enlarge", 256, 256); + const framesPerBite = 60 * WAFER.drawSeconds; + const layer = render(score, 0); + const beforeEnlarge = solid(layer); + // One tick past the thirteenth bite is the enlarge. + render(score, framesPerBite * (WAFER.bitesBeforeEnlarge + 1)); + const afterEnlarge = solid(layer); + assert.ok(afterEnlarge > beforeEnlarge, + `the biscuit grows by ${WAFER.enlarge} (${beforeEnlarge} → ${afterEnlarge})`); +}); + +test("the biscuit stops growing instead of swallowing the painting forever", () => { + const score = make("wafer:cap", 128, 128); + const layer = render(score, 0); + render(score, 60 * 60 * 10); + const settled = solid(layer); + render(score, 60 * 60 * 60); + assert.equal(solid(layer), settled, "growth is capped"); + assert.ok(settled <= layer.width * layer.height); +}); + +test("No Paint 3 resolves wafer 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/wafer.mjs"); + assert.equal(piece.system, "nopaint"); + assert.equal(COMPATIBLE_BRUSHES.get("wafer"), piece.nopaintProposal); + assert.equal(piece.nopaintProposal, waferProposal); +}); diff --git a/toolchain/nopaint/decode-sheet.mjs b/toolchain/nopaint/decode-sheet.mjs index 6d94f6b24d..569493e7c2 100644 --- a/toolchain/nopaint/decode-sheet.mjs +++ b/toolchain/nopaint/decode-sheet.mjs @@ -54,9 +54,11 @@ function walk(node, depth) { console.log(`${pad}// ${node[1].replace(/\s+/g, " ").slice(0, 120)}`); return; } - // An event block is [0, …, conditions, actions, children]. - if (node[0] === 0 && Array.isArray(node[6]) && Array.isArray(node[7])) { - console.log(`${pad}event`); + // An event block is [0, …, conditions, actions, children]; a function block + // is [4, [name, …], …] with the same tail. Naming the functions matters — + // a brush's real work usually lives in them, and the sheet only calls them. + if ((node[0] === 0 || node[0] === 4) && Array.isArray(node[6]) && Array.isArray(node[7])) { + console.log(`${pad}${node[0] === 4 ? `function ${node[1][0]}` : "event"}`); for (const condition of node[6]) { console.log(`${pad} when object=${condition[0]} ace=${condition[1]}${condition[2] ? ` (${condition[2]})` : ""}`); const printed = parameters(condition[9]);