From 8bb52f11e7507a80609ee208240b4c6384e2ba53 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 10 Aug 2026 20:19:57 -0700 Subject: [PATCH] Take the five weak brushes back to their sheets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Looking at them first was worth it; so was going back to the source rather than nudging pixels until they looked plausible. TriangleRender fills twice — the shadow at exactly +1, +1, then the shape. AC's `poly` only outlines, which is why a triangle read as three stray red lines; `shape` fills. Ellipse takes the same +1. Walker has a repeating one second "CheckWalkerBounds" timer that calls WalkerSetup again the moment it leaves the layout, so it respawns with a fresh animation and a scale from choose(.5,.75,1,1,1,1,1.25), and its step is max(3, max(2, frameWidth/8) - 2). Without that a proposal was empty after two seconds. Vignette is Construct's Vignette effect on a moving vehicle: it darkens *away* from its centre. Drawing it as a soft spot was the opposite picture, so the ramp is inverted — clear around the vehicle, closing in past the radius. Banner and Aura stay the least faithful two, because their appearance is genuinely not in the export. Banner now lays quads between successive cross-sections instead of loose squares, and turns toward a target rather than snapping ±45° every tenth of a second, which was a scribble. Both advances are marked reconstructed. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/nopaint-construct-build.mjs | 64 ++++++++++++++-- .../lib/nopaint-construct-shapes.mjs | 16 ++-- .../lib/nopaint-construct-sprites.mjs | 75 +++++++++++++++---- .../lib/nopaint-construct-vignette.mjs | 44 ++++++++--- .../tests/nopaint-construct-shapes.test.mjs | 6 +- toolchain/nopaint/contact-sheet.mjs | 1 + 6 files changed, 167 insertions(+), 39 deletions(-) diff --git a/system/public/aesthetic.computer/lib/nopaint-construct-build.mjs b/system/public/aesthetic.computer/lib/nopaint-construct-build.mjs index 5b4540450..a58369f4f 100644 --- a/system/public/aesthetic.computer/lib/nopaint-construct-build.mjs +++ b/system/public/aesthetic.computer/lib/nopaint-construct-build.mjs @@ -82,6 +82,35 @@ function fill(layer, x, y, w, h, color, alpha) { } } +// Scanline fill of a convex quad — the banner's ribbon segment. +function quad(layer, points, color) { + const top = Math.max(0, Math.floor(Math.min(...points.map((p) => p[1])))); + const bottom = Math.min(layer.height - 1, Math.ceil(Math.max(...points.map((p) => p[1])))); + for (let y = top; y <= bottom; y += 1) { + let left = Infinity; + let right = -Infinity; + for (let index = 0; index < points.length; index += 1) { + const [ax, ay] = points[index]; + const [bx, by] = points[(index + 1) % points.length]; + if ((ay <= y && by > y) || (by <= y && ay > y)) { + const x = ax + (y - ay) / (by - ay) * (bx - ax); + left = Math.min(left, x); + right = Math.max(right, x); + } + } + if (left > right) continue; + const start = Math.max(0, Math.round(left)); + const end = Math.min(layer.width - 1, Math.round(right)); + for (let x = start; x <= end; 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; + } + } +} + const layers = new WeakMap(); function layerFor(score) { let state = layers.get(score); @@ -157,7 +186,7 @@ export const bannerProposal = frozen({ source: frozen({ ...BANNER, actionSheet: "Banner", imagePoints: frozen(["BaseLeft", "BaseRight", "TopLeft", "TopRight", "BottomLeft", "BottomRight"]), - reconstructed: frozen(["the ribbon's quad geometry"]) }), + reconstructed: frozen(["the ribbon's quad geometry", "the advance per step"]) }), generate({ random, width, height, base }) { const size = choose(random, BANNER.sizes); const speed = choose(random, BANNER.speeds); @@ -185,6 +214,7 @@ export const bannerProposal = frozen({ const due = 1 + Math.floor(tick / 60 / BANNER.drawSeconds); if (state.placed === 0) { state.angle = score.startAngle; + state.target = score.startAngle; state.x = score.x; state.y = score.y; } @@ -192,14 +222,32 @@ export const bannerProposal = frozen({ while (state.placed < steps) { state.placed += 1; // Draw and Turn share a tenth-second beat, so every laid segment also - // turns by one of choose(-45, 45, -15, 15). + // turns by one of choose(-45, 45, -15, 15). The banner is a ribbon of + // width `band` between successive cross-sections — its six image points + // are BaseLeft/BaseRight and the two corners at each end — so lay a quad + // rather than a loose square, or it reads as confetti. const radians = state.angle * Math.PI / 180; - state.x += Math.cos(radians) * score.speed * score.depth; - state.y += Math.sin(radians) * score.speed * score.depth; - fill(state.layer, state.x - score.band / 2, state.y - score.band / 2, - score.band, score.band, - state.placed % 2 ? score.dark : score.light, 255); - state.angle += choose(state.random, BANNER.turns); + const from = { x: state.x, y: state.y }; + // The sheet gives speed and depth but not a distance — speed feeds the + // theme's playback rate, and depth reads as a layer count. Advancing by + // the band width is what keeps the ribbon a ribbon instead of a row of + // loose squares, so that is the reconstructed part. + const advance = score.band; + state.x += Math.cos(radians) * advance; + state.y += Math.sin(radians) * advance; + const across = radians + Math.PI / 2; + const half = score.band / 2; + const dx = Math.cos(across) * half; + const dy = Math.sin(across) * half; + quad(state.layer, [ + [from.x + dx, from.y + dy], [from.x - dx, from.y - dy], + [state.x - dx, state.y - dy], [state.x + dx, state.y + dy], + ], state.placed % 2 ? score.dark : score.light); + // turnAngle is a target the banner rotates toward, not a per-step snap: + // snapping ±45° every tenth of a second makes a scribble, not a banner. + if (state.placed % 8 === 0) state.target = state.angle + choose(state.random, BANNER.turns); + const toward = ((state.target - state.angle + 540) % 360) - 180; + state.angle += Math.sign(toward) * Math.min(Math.abs(toward), 6); } paste(state.layer, 0, 0); }, diff --git a/system/public/aesthetic.computer/lib/nopaint-construct-shapes.mjs b/system/public/aesthetic.computer/lib/nopaint-construct-shapes.mjs index a0f9b29a0..7d91036f6 100644 --- a/system/public/aesthetic.computer/lib/nopaint-construct-shapes.mjs +++ b/system/public/aesthetic.computer/lib/nopaint-construct-shapes.mjs @@ -16,6 +16,8 @@ export const SHAPE = frozen({ shakeSeconds: 1, jitter: frozen([-1, 0, 1]), jitterCue: "common - jitter", + // TriangleRender fills twice: the shadow at exactly +1, +1, then the shape. + shadowOffset: 1, // Ellipse's width and height are ProcessNumericParameter(n, 3, 255) — three // is the floor, not zero. minimumSize: 3, @@ -70,7 +72,7 @@ export const triangleProposal = frozen({ source: frozen({ ...SHAPE, actionSheet: "Triangle", parameters: frozen(["x1", "y1", "x2", "y2", "x3", "y3", "colour"]), cue: "triangle - start", - reconstructed: frozen(["the shadow offset"]) }), + renderFunction: "TriangleRender" }), generate({ random, width, height, base }) { const points = frozen(Array.from({ length: 3 }, () => frozen({ x: Math.floor(random() * width), y: Math.floor(random() * height), @@ -87,8 +89,11 @@ export const triangleProposal = frozen({ const drift = shakeAt(score, 6, tick); const corners = score.points.map(({ x, y }, index) => [x + drift[index * 2], y + drift[index * 2 + 1]]); - ink(score.shadow).poly(corners.map(([x, y]) => [x + 2, y + 2])); - ink(score.color).poly(corners); + // Construct fills both passes; `shape` fills by default where `poly` only + // outlines, which is what made this read as three stray lines. + const offset = SHAPE.shadowOffset; + ink(score.shadow).shape(corners.map(([x, y]) => [x + offset, y + offset])); + ink(score.color).shape(corners); }, }); @@ -100,7 +105,7 @@ export const ellipseProposal = frozen({ source: frozen({ ...SHAPE, actionSheet: "Ellipse", parameters: frozen(["x", "y", "width", "height", "colour"]), cue: "elipse - start", // The original file name is misspelled; keep it. - reconstructed: frozen(["the shadow offset"]) }), + renderFunction: "EllipseRender" }), generate({ random, width, height, base }) { const size = (extent) => Math.max(SHAPE.minimumSize, Math.floor(SHAPE.minimumSize + random() * (extent - SHAPE.minimumSize))); @@ -116,7 +121,8 @@ export const ellipseProposal = frozen({ // x, y, and w all shake; h is the one coordinate the sheet leaves alone. const [driftX, driftY, driftW] = shakeAt(score, 3, tick); const rx = Math.max(SHAPE.minimumSize / 2, score.rx + driftW / 2); - ink(score.shadow).oval(score.cx + driftX + 2, score.cy + driftY + 2, + const offset = SHAPE.shadowOffset; + ink(score.shadow).oval(score.cx + driftX + offset, score.cy + driftY + offset, rx * 2, score.ry * 2, true); ink(score.color).oval(score.cx + driftX, score.cy + driftY, rx * 2, score.ry * 2, true); diff --git a/system/public/aesthetic.computer/lib/nopaint-construct-sprites.mjs b/system/public/aesthetic.computer/lib/nopaint-construct-sprites.mjs index 6b997b64a..06f1c6fc2 100644 --- a/system/public/aesthetic.computer/lib/nopaint-construct-sprites.mjs +++ b/system/public/aesthetic.computer/lib/nopaint-construct-sprites.mjs @@ -100,27 +100,74 @@ export const walkerProposal = frozen({ source: frozen({ actionSheet: "Walker", object: "WalkerElla", animations: walkerAnimations, movement: "reconstructed from fromTop/fromRight/step" }), generate({ random, width, height, base }) { - const name = String(1 + Math.floor(random() * 9)); - const fromTop = random() < .5; - const fromRight = random() < .5; - const scale = .35 + random() * .65; - return frozen({ ...base, kind: "walker", animation: name, fromTop, fromRight, - start: frozen({ x: fromRight ? width : 0, y: fromTop ? 0 : height }), - step: 1 + random() * 2, scale, width, height, - brush: frozen({ slug: "walker", params: frozen([name]), colon: frozen([]), - parameters: frozen({ animation: name, fromTop, fromRight, step: true, scale }) }) }); + return frozen({ ...base, kind: "walker", + seed: Math.floor(random() * 0xffffffff), width, height, + brush: frozen({ slug: "walker", params: frozen([]), colon: frozen([]), + parameters: frozen({ scales: WALKER_SCALES, respawnSeconds: 1 }) }) }); }, render(api, score, tick) { - const animation = walkerAnimations[score.animation]; + const life = walkerLife(score, tick); + if (!life) return; + const animation = walkerAnimations[life.animation]; const sprite = animation.frames[Math.floor(tick * animation.fps / 60) % animation.frames.length]; - const travel = tick * score.step; - const x = score.fromRight ? score.width - travel : travel; - const y = score.fromTop ? travel : score.height - travel; - if (!spritePaste(api, sprite, x, y, score.scale)) + const { x, y } = walkerPlace(life); + if (!spritePaste(api, sprite, x, y, life.scale)) api.ink(score.color).box(x - 3, y - 3, 6, 6); }, }); +// WalkerSetup picks a fresh animation and scale every time, and a repeating one +// second "CheckWalkerBounds" timer calls it again the moment the walker leaves +// the layout. Without that respawn a proposal is empty after a second or two. +export const WALKER_SCALES = frozen([.5, .75, 1, 1, 1, 1, 1.25]); + +// step = max(3, max(2, frameWidth / 8) - 2), off the sheet. +const walkerStep = (sprite) => Math.max(3, Math.max(2, sprite.w / 8) - 2); + +function walkerLife(score, tick) { + let state = (score.seed >>> 0 || 1); + const random = () => { + 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; + }; + let elapsed = 0; + // 512 crossings is far more than a proposal will ever be watched for. + for (let index = 0; index < 512; index += 1) { + const animation = String(1 + Math.floor(random() * 9)); + const scale = WALKER_SCALES[Math.floor(random() * WALKER_SCALES.length)]; + // The sheet walks one axis and wobbles the other; `vertical` is which. + const vertical = random() < .5; + const forward = random() < .5; + const wobble = random(); + const sprite = walkerAnimations[animation].frames[0]; + const step = walkerStep(sprite) * scale; + const reach = vertical ? score.height : score.width; + const span = reach + Math.max(sprite.w, sprite.h) * scale * 2; + const frames = Math.max(1, Math.ceil(span / step)); + if (tick < elapsed + frames) { + const travel = (tick - elapsed) * step - Math.max(sprite.w, sprite.h) * scale; + const across = (vertical ? score.width : score.height) * wobble; + return { animation, scale, vertical, forward, step, travel, across, score }; + } + elapsed += frames; + } + return null; +} + +// One axis moves by `step`; the other drifts by choose(-step/4, 0, 0, 0, step/4) +// — mostly nothing, occasionally a quarter step sideways. +function walkerPlace(life) { + const { score, travel, across } = life; + const lead = life.forward ? travel : (life.vertical ? score.height : score.width) - travel; + const drift = Math.sin(travel / 24) * life.step / 4; + return life.vertical + ? { x: across + drift, y: lead } + : { x: lead, y: across + drift }; +} + // Construct starts frameIndex at 1 and runs a repeating one second "CycleFrame" // timer: knock, then frameIndex = (frameIndex + 1) % AnimationFrameCount. At // 60hz that is 60 ticks a border. diff --git a/system/public/aesthetic.computer/lib/nopaint-construct-vignette.mjs b/system/public/aesthetic.computer/lib/nopaint-construct-vignette.mjs index 83f88dd0a..7c453fd73 100644 --- a/system/public/aesthetic.computer/lib/nopaint-construct-vignette.mjs +++ b/system/public/aesthetic.computer/lib/nopaint-construct-vignette.mjs @@ -49,7 +49,7 @@ export function hslaToRgba(hue, saturation, lightness) { return frozen([r, g, b].map((channel) => Math.round((channel + base) * 255))); } -// One soft radial field, opaque inside `hardness` and falling to nothing at the +// A soft radial field, opaque inside `hardness` and falling to nothing at the // rim — the same ramp Softy stamps, drawn once and large. function field(layer, centerX, centerY, radius, hardness, color, peak) { const falloff = Math.max(1, radius - hardness); @@ -95,6 +95,8 @@ export const vignetteProposal = frozen({ compatible: true, source: frozen({ ...VIGNETTE, actionSheet: "Vignette", // Construct tweened Radius and Hardness; the proposal holds one pose. + effect: "Vignette", + vehicle: "VignetteVehicle", reconstructed: frozen(["the radius/hardness tween"]) }), generate({ random, width, height, base }) { const size = choose(random, VIGNETTE.sizes); @@ -118,7 +120,23 @@ export const vignetteProposal = frozen({ }, render({ paste }, score, tick) { const layer = layerFor(score, (target) => { - field(target, score.x, score.y, score.radius, score.hardness, score.color, 200); + // Construct's Vignette effect darkens *away* from its centre: the + // painting stays clear around the vehicle and closes in past the radius. + // Drawing it as a soft spot, which is what this did first, is the + // opposite picture. + const falloff = Math.max(1, score.radius - score.hardness); + for (let y = 0; y < target.height; y += 1) { + for (let x = 0; x < target.width; x += 1) { + const distance = Math.hypot(x - score.x, y - score.y); + if (distance <= score.hardness) continue; + const alpha = Math.min(1, (distance - score.hardness) / falloff) * 235; + const at = (y * target.width + x) * 4; + target.pixels[at] = score.color[0]; + target.pixels[at + 1] = score.color[1]; + target.pixels[at + 2] = score.color[2]; + target.pixels[at + 3] = alpha; + } + } }); paste(layer, 0, 0); }, @@ -144,7 +162,8 @@ export const auraProposal = frozen({ const petals = Math.max(2, Math.round(rate * 4)); return frozen({ ...base, kind: "aura", spray, rate, angle, petals, - radius: Math.max(4, between(random, [25, 120]) * scale), + // The emitter's own radius range; a bloom this size actually reads. + radius: Math.max(24, between(random, [25, 120]) * scale), color: hslaToRgba(hue, saturation, Math.min(.9, lightness)), x: Math.floor(random() * width), y: Math.floor(random() * height), width, height, @@ -155,17 +174,22 @@ export const auraProposal = frozen({ }, render({ paste }, score, tick) { const layer = layerFor(score, (target) => { - // The emitter sprays through `spray` degrees around `angle` at `rate`. + // The emitter sprays `spray` degrees wide around `angle`. Particles are + // laid along each ray so the bloom reads as spray rather than as blobs. for (let petal = 0; petal < score.petals; petal += 1) { const offset = (petal / Math.max(1, score.petals - 1) - .5) * score.spray; const radians = (score.angle + offset) * Math.PI / 180; - const reach = score.radius * (.4 + .6 * (petal % 3) / 2); - field(target, - score.x + Math.cos(radians) * reach, - score.y + Math.sin(radians) * reach, - score.radius / 2, score.radius / 8, score.color, 90); + for (let along = 1; along <= 5; along += 1) { + const reach = score.radius * along / 5; + const size = score.radius / 3 * (1 - along / 8); + field(target, + score.x + Math.cos(radians) * reach, + score.y + Math.sin(radians) * reach, + size, size / 4, score.color, 150 - along * 18); + } } - field(target, score.x, score.y, score.radius / 2, 0, score.color, 140); + field(target, score.x, score.y, score.radius / 2, score.radius / 6, + score.color, 190); }); paste(layer, 0, 0); }, diff --git a/system/tests/nopaint-construct-shapes.test.mjs b/system/tests/nopaint-construct-shapes.test.mjs index 4071a08f3..f1c4f385b 100644 --- a/system/tests/nopaint-construct-shapes.test.mjs +++ b/system/tests/nopaint-construct-shapes.test.mjs @@ -44,11 +44,13 @@ test("both shapes are deterministic and draw a shadow under themselves", () => { const inks = []; const ink = (...color) => { inks.push(color.length === 1 ? color[0] : color); - return { poly() {}, oval() {} }; + // Construct fills both passes, so Triangle uses `shape`, not `poly`. + return { shape() {}, oval() {} }; }; contract.render({ ink }, score, 0); assert.deepEqual(inks[0], score.shadow, `${contract.slug} lays its shadow first`); assert.deepEqual(inks[1], score.color, `${contract.slug} draws over it`); + assert.equal(SHAPE.shadowOffset, 1, "TriangleRender offsets the shadow by one"); } }); @@ -57,7 +59,7 @@ test("the shake moves every coordinate by one step a second", () => { const corners = (tick) => { const drawn = []; triangleProposal.render({ - ink: () => ({ poly: (points) => drawn.push(points) }), + ink: () => ({ shape: (points) => drawn.push(points) }), }, score, tick); return drawn[1]; // The shape itself, not its shadow. }; diff --git a/toolchain/nopaint/contact-sheet.mjs b/toolchain/nopaint/contact-sheet.mjs index 85f901b6a..575d7e770 100644 --- a/toolchain/nopaint/contact-sheet.mjs +++ b/toolchain/nopaint/contact-sheet.mjs @@ -35,6 +35,7 @@ function apiFor(assets) { oval: (...a) => graph.oval(...a), line: (...a) => graph.line(...a), poly: (...a) => graph.poly(...a), + shape: (...a) => graph.shape(...a), }; }, }; -- 2.51.2