From bad5d30c6c38c58b840245855c1d829bd7b93652 Mon Sep 17 00:00:00 2001 From: Parth Jadhav Date: Sat, 7 Nov 2026 15:02:43 +0530 Subject: [PATCH 01/29] Give each cross-module value one owner The capture-height cap, the parked-pointer sentinel, the void gradient, the spaghetti tint the renderer branches on, the fx view type and the slow-upload decision were each written out in two or three modules that had to agree without anything tying them together. Each now lives with the module that owns it and the others import it. Co-Authored-By: Claude Opus 5.5 --- src/advanced-tools.ts | 4 ++-- src/engine.ts | 41 ++++++++++++++++++++--------------------- src/fx-render.ts | 17 +++++++++-------- src/gl.ts | 13 +++++++++++++ src/heavy-tools.ts | 6 +++--- src/math.ts | 11 +++++++++++ src/overlay.ts | 20 ++++++++++++++++++-- src/pointer-input.ts | 10 +++++----- src/postfx.ts | 4 ++-- src/surface.ts | 5 ++--- src/tools.ts | 4 ++-- 11 files changed, 87 insertions(+), 48 deletions(-) diff --git a/src/advanced-tools.ts b/src/advanced-tools.ts index a9b4a4a..5c952e9 100644 --- a/src/advanced-tools.ts +++ b/src/advanced-tools.ts @@ -1,5 +1,5 @@ import { acidSprayerArt, gravityGunArt, laserCutterArt, stickyBombArt } from "./advanced-toolart"; -import { TAU } from "./math"; +import { PARKED_POINTER, TAU } from "./math"; import { createEngineState } from "./tool-kit"; import { includeTopologySegment, surfaceRuns, type TopologyBounds } from "./topology"; import type { RageLayerEngineApi, Tool } from "./types"; @@ -445,7 +445,7 @@ export const stickyBombs: Tool = { } }, backgroundTick(engine, dt) { - stickyBombs.tick?.(engine, dt, false, { x: -1000, y: -1000 }); + stickyBombs.tick?.(engine, dt, false, { x: PARKED_POINTER, y: PARKED_POINTER }); }, }; diff --git a/src/engine.ts b/src/engine.ts index d6b51b1..1694f64 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -1,6 +1,6 @@ import { SoundEngine } from "./audio"; import { BugSwarm } from "./bugs-sim"; -import { defaultCaptureFilter } from "./capture"; +import { defaultCaptureFilter, MAX_CAPTURE_HEIGHT } from "./capture"; import { CaptureController, type CaptureHost } from "./capture-controller"; import { type ComboEvent, ComboTracker, type InteractionKind } from "./combos"; import type { ContentCheckpoint, ContentLayer } from "./content"; @@ -18,11 +18,18 @@ import { shardBudget, voronoiCells, } from "./fracture"; -import { drawAccretionDisc, drawEventHorizon, drawFlame, FxPainter } from "./fx-render"; -import { measuredUploadCostMs, SLOW_UPLOAD_THRESHOLD_MS } from "./gl"; +import { + drawAccretionDisc, + drawEventHorizon, + drawFlame, + FxPainter, + SPAGHETTI_COOL, + SPAGHETTI_WARM, +} from "./fx-render"; +import { uploadsProbedFast } from "./gl"; import { DestructionHistory, type DestructionHistoryEntry, type HistoryState } from "./history"; -import { REST_AIM_X, REST_AIM_Y, TAU } from "./math"; -import { Overlay } from "./overlay"; +import { isParked, PARKED_POINTER, REST_AIM_X, REST_AIM_Y, TAU } from "./math"; +import { Overlay, paintVoid } from "./overlay"; import { ParticleSystem, type ParticleWorld, scratchParticle } from "./particles"; import { detectInitialQuality, @@ -61,7 +68,6 @@ import { WOOD } from "./wood"; /** Engines currently alive in this document — refcount for the sprite cache. */ let liveEngines = 0; -const MAX_CAPTURE_HEIGHT = 12000; /** The heat field feeding the shimmer shader, as a fraction of the fx canvas. */ const HEAT_SCALE = 8; /** @@ -180,7 +186,7 @@ export class RageLayerEngine implements RageLayerEngineApi { private artVY = 0; private readonly artAimX = REST_AIM_X; private readonly artAimY = REST_AIM_Y; - private artPrev: Vec2 = { x: -1000, y: -1000 }; + private artPrev: Vec2 = { x: PARKED_POINTER, y: PARKED_POINTER }; private raf = 0; /** True after the loop deliberately stopped, so its next wake starts from a fresh clock. */ private frameClockSleeping = true; @@ -843,13 +849,7 @@ export class RageLayerEngine implements RageLayerEngineApi { const sx = this.scrollX; const sy = Math.max(0, Math.min(this.scrollY, this.overlay.height - h)); - // The void behind the page, so holes read as holes and not as transparency. - const bg = ctx.createRadialGradient(w / 2, 0, 0, w / 2, 0, Math.max(w, h) * 1.2); - bg.addColorStop(0, "#17130f"); - bg.addColorStop(0.55, "#0c0a08"); - bg.addColorStop(1, "#060504"); - ctx.fillStyle = bg; - ctx.fillRect(0, 0, w, h); + paintVoid(ctx, w, h); const layer = this.contentLayer; if (layer?.ready) { @@ -1972,12 +1972,12 @@ export class RageLayerEngine implements RageLayerEngineApi { */ private stepToolArt(dt: number) { const p = this.pointer; - if (p.x <= -999) { + if (isParked(p)) { this.artVX = this.artVY = 0; - this.artPrev.x = this.artPrev.y = -1000; + this.artPrev.x = this.artPrev.y = PARKED_POINTER; return; } - if (this.artPrev.x > -999 && dt > 0) { + if (!isParked(this.artPrev) && dt > 0) { const k = Math.min(1, dt * 14); this.artVX += ((p.x - this.artPrev.x) / dt - this.artVX) * k; this.artVY += ((p.y - this.artPrev.y) / dt - this.artVY) * k; @@ -2157,7 +2157,7 @@ export class RageLayerEngine implements RageLayerEngineApi { maxLife: 0.5 + Math.random() * 0.7, size: 1.4 + Math.random() * 2.6, gravity: 0, - color: Math.random() < 0.4 ? "#c98bff" : "#ffb066", + color: Math.random() < 0.4 ? SPAGHETTI_COOL : SPAGHETTI_WARM, }); } this.heat(s.x, s.y, r * 2.4, 0.55 * s.charge); @@ -2193,7 +2193,7 @@ export class RageLayerEngine implements RageLayerEngineApi { // can stay as the last retained canvas image without keeping rAF alive. // Held gestures and live effects still animate through `hasActiveWork`. const artVisible = - this.opts.toolStyle === "3d" && !!this.activeTool?.art && this.pointer.x > -999; + this.opts.toolStyle === "3d" && !!this.activeTool?.art && !isParked(this.pointer); const idle = this.particles.count === 0 && this.fire.count === 0 && @@ -2362,8 +2362,7 @@ export class RageLayerEngine implements RageLayerEngineApi { // optimal and the hole cache below is pure overhead; a slow probe marks // the browsers whose canvas the `destination-in` family deopts. An // unprobed environment (no WebGL at all) is treated as slow. - const uploadCost = measuredUploadCostMs(); - if (uploadCost !== null && uploadCost <= SLOW_UPLOAD_THRESHOLD_MS) { + if (uploadsProbedFast()) { ctx.globalCompositeOperation = "destination-in"; ctx.drawImage( layer.surface, diff --git a/src/fx-render.ts b/src/fx-render.ts index 55cb233..846ff6d 100644 --- a/src/fx-render.ts +++ b/src/fx-render.ts @@ -13,6 +13,7 @@ */ import { TAU } from "./math"; +import type { FxView } from "./overlay"; import { blit, blitRect, blitStreak, sprites } from "./sprites"; import type { Flame, Particle, Singularity } from "./types"; @@ -71,13 +72,13 @@ function dustClusterPath(): Path2D | null { return dustCluster; } -/** The document band the fx canvas currently covers. */ -export interface FxView { - left: number; - top: number; - right: number; - bottom: number; -} +/** + * The two tints of accretion-disc "spaghetti". The cool one draws with the + * water streak and the warm one with the hot streak, so the disc reads as + * having temperature structure rather than one glowing smear. + */ +export const SPAGHETTI_COOL = "#c98bff"; +export const SPAGHETTI_WARM = "#ffb066"; export class FxPainter { /** Per-frame render buckets, reused to keep the render path allocation-free. */ @@ -642,7 +643,7 @@ export class FxPainter { const speed = Math.hypot(p.vx, p.vy); blitStreak( ctx, - p.color === "#c98bff" ? sprite.streakWater : sprite.streakHot, + p.color === SPAGHETTI_COOL ? sprite.streakWater : sprite.streakHot, p.x, p.y, Math.atan2(p.vy, p.vx), diff --git a/src/gl.ts b/src/gl.ts index 1a593c1..01cc3c0 100644 --- a/src/gl.ts +++ b/src/gl.ts @@ -189,6 +189,19 @@ export function canvasUploadCostMs(gl: WebGLRenderingContext | WebGL2RenderingCo return uploadCostMs; } +/** Whether per-frame canvas→GL pipelines lose more in transfer than they gain. Probes once. */ +export function canvasUploadsAreSlow(gl: WebGLRenderingContext | WebGL2RenderingContext): boolean { + return canvasUploadCostMs(gl) > SLOW_UPLOAD_THRESHOLD_MS; +} + +/** + * Whether an earlier probe showed canvas uploads to be fast. Without WebGL + * nothing ever probes, and those environments count as slow. + */ +export function uploadsProbedFast(): boolean { + return uploadCostMs !== null && uploadCostMs <= SLOW_UPLOAD_THRESHOLD_MS; +} + // ── GPU pass timing ───────────────────────────────────────────────────────── /** The constants both disjoint timer-query extensions share. */ diff --git a/src/heavy-tools.ts b/src/heavy-tools.ts index 9187804..5542748 100644 --- a/src/heavy-tools.ts +++ b/src/heavy-tools.ts @@ -12,7 +12,7 @@ import { emojiCursor } from "./cursors"; import { drawBurnChannel } from "./decals"; -import { emit, rand, TAU } from "./math"; +import { emit, PARKED_POINTER, rand, TAU } from "./math"; import { createEngineState } from "./tool-kit"; import { blackHoleArt, bugsArt, demolitionArt, lightningArt, rocketArt } from "./toolart"; import type { RageLayerEngineApi, Tool, Vec2 } from "./types"; @@ -256,7 +256,7 @@ export const rocketLauncher: Tool = { } }, backgroundTick(engine, dt) { - rocketLauncher.tick?.(engine, dt, false, { x: -1000, y: -1000 }); + rocketLauncher.tick?.(engine, dt, false, { x: PARKED_POINTER, y: PARKED_POINTER }); }, }; @@ -485,7 +485,7 @@ export const lightning: Tool = { } }, backgroundTick(engine, dt) { - lightning.tick?.(engine, dt, false, { x: -1000, y: -1000 }); + lightning.tick?.(engine, dt, false, { x: PARKED_POINTER, y: PARKED_POINTER }); }, }; diff --git a/src/math.ts b/src/math.ts index 0f7b60a..572c221 100644 --- a/src/math.ts +++ b/src/math.ts @@ -22,6 +22,17 @@ export const TAU = Math.PI * 2; export const REST_AIM_X = -0.55; export const REST_AIM_Y = -0.835; +/** + * Where the pointer sits while it is off the page (left, cancelled, or a + * background tick with no pointer at all): far outside anything drawable. + */ +export const PARKED_POINTER = -1000; + +/** Whether a pointer position is the parked one rather than a real point. */ +export function isParked(point: { x: number }): boolean { + return point.x < -100; +} + /** Uniform random in `[min, max)`. */ export function rand(min: number, max: number): number { return min + Math.random() * (max - min); diff --git a/src/overlay.ts b/src/overlay.ts index 20260e1..9570e42 100644 --- a/src/overlay.ts +++ b/src/overlay.ts @@ -29,6 +29,21 @@ import { pickPixelRatio, RAGELAYER_IGNORE_ATTR } from "./capture"; export const FX_MARGIN = 120; /** The document band the fx canvas currently covers. */ +/** The void behind the page, so holes read as holes and not as transparency. */ +const VOID_STOPS = [ + [0, "#17130f"], + [55, "#0c0a08"], + [100, "#060504"], +] as const; + +/** Paint the void onto a 2D canvas the same way the void layer shows it on screen. */ +export function paintVoid(ctx: CanvasRenderingContext2D, w: number, h: number) { + const bg = ctx.createRadialGradient(w / 2, 0, 0, w / 2, 0, Math.max(w, h) * 1.2); + for (const [percent, color] of VOID_STOPS) bg.addColorStop(percent / 100, color); + ctx.fillStyle = bg; + ctx.fillRect(0, 0, w, h); +} + export interface FxView { left: number; top: number; @@ -117,8 +132,9 @@ export class Overlay { position: "absolute", inset: "0", display: "none", - background: - "radial-gradient(ellipse 120% 60% at 50% 0%, #17130f 0%, #0c0a08 55%, #060504 100%)", + background: `radial-gradient(ellipse 120% 60% at 50% 0%, ${VOID_STOPS.map( + ([percent, color]) => `${color} ${percent}%`, + ).join(", ")})`, } satisfies Partial); this.container.appendChild(this.voidLayer); diff --git a/src/pointer-input.ts b/src/pointer-input.ts index c381868..57af08f 100644 --- a/src/pointer-input.ts +++ b/src/pointer-input.ts @@ -1,3 +1,4 @@ +import { isParked, PARKED_POINTER } from "./math"; import type { PointerInputController, PointerInputHost } from "./pointer-input-host"; import type { ToolPointerEvent, Vec2 } from "./types"; @@ -7,7 +8,6 @@ import type { ToolPointerEvent, Vec2 } from "./types"; * total displacement without letting an event storm allocate indefinitely. */ const POINTER_RING_CAP = 32; -const PARKED_POINTER = -1000; /** * Own the complete pointer gesture lifecycle for an engine. @@ -77,8 +77,8 @@ export function createPointerInput(host: PointerInputHost): PointerInputControll const move = pendingMoves[i]; moveScratch.x = move.x; moveScratch.y = move.y; - moveScratch.dx = previous.x < -100 ? 0 : move.x - previous.x; - moveScratch.dy = previous.y < -100 ? 0 : move.y - previous.y; + moveScratch.dx = isParked(previous) ? 0 : move.x - previous.x; + moveScratch.dy = isParked(previous) ? 0 : move.y - previous.y; moveScratch.buttons = move.buttons; previous.x = move.x; previous.y = move.y; @@ -149,8 +149,8 @@ export function createPointerInput(host: PointerInputHost): PointerInputControll const toolEvent = { x, y, - dx: previous.x < -100 ? 0 : x - previous.x, - dy: previous.y < -100 ? 0 : y - previous.y, + dx: isParked(previous) ? 0 : x - previous.x, + dy: isParked(previous) ? 0 : y - previous.y, buttons: event.buttons, }; previous.x = x; diff --git a/src/postfx.ts b/src/postfx.ts index 320b637..371aa5f 100644 --- a/src/postfx.ts +++ b/src/postfx.ts @@ -27,7 +27,7 @@ * in the DOM. Nothing else in the engine knows the difference. */ -import { canvasUploadCostMs, GpuTimer, SLOW_UPLOAD_THRESHOLD_MS } from "./gl"; +import { canvasUploadsAreSlow, GpuTimer } from "./gl"; import type { PerfCounterSink } from "./perf-counters"; const VERT = ` @@ -254,7 +254,7 @@ export class PostFX { // transfer is slow (Gecko/WebKit, per the probe) the bloom would cost // several frames of budget on its own. Stay unavailable — the engine then // presents the plain 2D fx canvas — and hand the context straight back. - if (canvasUploadCostMs(gl) > SLOW_UPLOAD_THRESHOLD_MS) { + if (canvasUploadsAreSlow(gl)) { gl.getExtension("WEBGL_lose_context")?.loseContext(); return; } diff --git a/src/surface.ts b/src/surface.ts index 74500d1..6145400 100644 --- a/src/surface.ts +++ b/src/surface.ts @@ -44,14 +44,13 @@ */ import { - canvasUploadCostMs, + canvasUploadsAreSlow, createProgram, createQuad, createTexture, type GLProgram, GpuTimer, maxTextureSize, - SLOW_UPLOAD_THRESHOLD_MS, } from "./gl"; import type { PerfCounterSink } from "./perf-counters"; @@ -380,7 +379,7 @@ export class SurfaceRenderer { // browsers that pay a fixed multi-ms toll per canvas→texture transfer // (Gecko and WebKit today, per the probe), shading costs far more than it // shows — refuse, and the raw 2D surface presents instead. - if (canvasUploadCostMs(gl) > SLOW_UPLOAD_THRESHOLD_MS) return false; + if (canvasUploadsAreSlow(gl)) return false; if (!this.program) { this.program = createProgram(gl, FRAG); diff --git a/src/tools.ts b/src/tools.ts index 110be45..0b7c3f5 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -1,6 +1,6 @@ import { emojiCursor } from "./cursors"; import { drawBulletHole, drawCrack, drawGash, drawSplat, randomPaint } from "./decals"; -import { emit, TAU } from "./math"; +import { emit, isParked, TAU } from "./math"; import { scratchParticle } from "./particles"; import { createEngineState, debris, dustPuff } from "./tool-kit"; import { @@ -519,7 +519,7 @@ export const waterHose: Tool = { tick(engine, dt, held, pointer) { const state = waterState.get(engine); engine.sound.loop("water", held ? 0.3 : 0); - if (!held || pointer.x < -100) { + if (!held || isParked(pointer)) { state.spawnDebt = state.streamDebt = state.mistDebt = 0; return; } From 038a45c970ae359826fa06864cae1928f3609a4c Mon Sep 17 00:00:00 2001 From: Parth Jadhav Date: Sat, 7 Nov 2026 15:03:27 +0530 Subject: [PATCH 02/29] Describe sound loops in one table the engine reads The engine silenced held-tool loops by naming water, saw, flamethrower and void itself, apart from the if-chain in audio.ts that configured them. A table of loop voices now holds the filter settings and whether a loop belongs to a held tool, and the engine fades whatever the table marks as held. Co-Authored-By: Claude Opus 5.5 --- src/audio.ts | 55 +++++++++++++++++++++++++++++---------------------- src/engine.ts | 7 ++----- 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/src/audio.ts b/src/audio.ts index 63d804e..059c067 100644 --- a/src/audio.ts +++ b/src/audio.ts @@ -1,5 +1,31 @@ import type { SoundApi } from "./types"; +type LoopName = Parameters[0]; + +interface LoopVoice { + filter: BiquadFilterType; + frequency: number; + q?: number; + playbackRate?: number; + /** Sounds only while a tool is held, so a released or cancelled gesture fades it. */ + held: boolean; +} + +/** How each continuous loop is filtered out of the shared noise buffer. */ +const LOOP_VOICES: Record = { + fire: { filter: "lowpass", frequency: 500, playbackRate: 0.35, held: false }, + water: { filter: "highpass", frequency: 2500, held: true }, + saw: { filter: "bandpass", frequency: 1400, q: 6, playbackRate: 1.6, held: true }, + flamethrower: { filter: "bandpass", frequency: 700, q: 0.4, playbackRate: 0.6, held: true }, + // Sub-bass rumble: a singularity should be felt more than heard. + void: { filter: "lowpass", frequency: 110, q: 8, playbackRate: 0.2, held: true }, +}; + +/** The loops a held tool drives, as opposed to the ambient crackle of a burning page. */ +export const HELD_TOOL_LOOPS = (Object.keys(LOOP_VOICES) as LoopName[]).filter( + (name) => LOOP_VOICES[name].held, +); + /** * Fully procedural sound effects — no audio assets, so the package stays a * zero-download dependency. Everything is synthesized from noise buffers and @@ -300,30 +326,11 @@ export class SoundEngine implements SoundApi { const filter = ctx.createBiquadFilter(); const gain = ctx.createGain(); gain.gain.value = 0; - if (name === "fire") { - filter.type = "lowpass"; - filter.frequency.value = 500; - source.playbackRate.value = 0.35; - } else if (name === "water") { - filter.type = "highpass"; - filter.frequency.value = 2500; - } else if (name === "void") { - // Sub-bass rumble: a singularity should be felt more than heard. - filter.type = "lowpass"; - filter.frequency.value = 110; - filter.Q.value = 8; - source.playbackRate.value = 0.2; - } else if (name === "flamethrower") { - filter.type = "bandpass"; - filter.frequency.value = 700; - filter.Q.value = 0.4; - source.playbackRate.value = 0.6; - } else { - filter.type = "bandpass"; - filter.frequency.value = 1400; - filter.Q.value = 6; - source.playbackRate.value = 1.6; - } + const voice = LOOP_VOICES[name]; + filter.type = voice.filter; + filter.frequency.value = voice.frequency; + if (voice.q !== undefined) filter.Q.value = voice.q; + if (voice.playbackRate !== undefined) source.playbackRate.value = voice.playbackRate; source.connect(filter).connect(gain).connect(this.master); source.start(); entry = { source, gain, filter, stopTimer: null }; diff --git a/src/engine.ts b/src/engine.ts index 1694f64..252db53 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -1,4 +1,4 @@ -import { SoundEngine } from "./audio"; +import { HELD_TOOL_LOOPS, SoundEngine } from "./audio"; import { BugSwarm } from "./bugs-sim"; import { defaultCaptureFilter, MAX_CAPTURE_HEIGHT } from "./capture"; import { CaptureController, type CaptureHost } from "./capture-controller"; @@ -1599,10 +1599,7 @@ export class RageLayerEngine implements RageLayerEngineApi { /** Loops driven by a held tool, distinct from persistent ambient fire. */ private silenceToolLoops() { - this.sound.loop("water", 0); - this.sound.loop("saw", 0); - this.sound.loop("flamethrower", 0); - this.sound.loop("void", 0); + for (const name of HELD_TOOL_LOOPS) this.sound.loop(name, 0); } private emit(event: EngineEvent) { From 985f18a3193d5bc35e2e6eb2aab0f0a7fc8feb2e Mon Sep 17 00:00:00 2001 From: Parth Jadhav Date: Sat, 7 Nov 2026 15:04:10 +0530 Subject: [PATCH 03/29] Let the overlay own its damage and fx canvases The engine copied, measured, restored, erased and cleared the overlay's damage canvas by hand, and rebuilt the fx transform from the overlay's offset fields on the promise that positionFx had already run. Those operations are now Overlay methods, so the canvas layout can change in one file. Post-FX and heat-field sizing, written out twice, is one method. Co-Authored-By: Claude Opus 5.5 --- src/engine.ts | 107 +++++++++++++------------------------------------ src/overlay.ts | 55 ++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 80 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index 252db53..7a850e3 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -691,13 +691,7 @@ export class RageLayerEngine implements RageLayerEngineApi { private createHistoryEntry(label?: string): EngineHistoryEntry { const content = this.contentLayer?.createCheckpoint() ?? null; - let damage: HTMLCanvasElement | null = null; - if (this.overlay.damageReady) { - damage = document.createElement("canvas"); - damage.width = this.overlay.damageCanvas.width; - damage.height = this.overlay.damageCanvas.height; - damage.getContext("2d")?.drawImage(this.overlay.damageCanvas, 0, 0); - } + const damage = this.overlay.snapshotDamage(); const fuel = this.fire.snapshotFuel(); const pixelCost = (content?.pixelCost ?? 0) + @@ -725,9 +719,7 @@ export class RageLayerEngine implements RageLayerEngineApi { private estimateHistoryPixelCost(): number { return ( (this.contentLayer?.checkpointPixelCost ?? 0) + - (this.overlay.damageReady - ? this.overlay.damageCanvas.width * this.overlay.damageCanvas.height - : 0) + + this.overlay.damagePixelCost + this.fieldPixelCost() ); } @@ -742,21 +734,7 @@ export class RageLayerEngine implements RageLayerEngineApi { try { if (entry.content) this.contentLayer?.restoreCheckpoint(entry.content); else this.contentLayer?.restoreAll(); - if (entry.damage) { - this.overlay.ensureDamage(); - this.overlay.damageCtx.save(); - this.overlay.damageCtx.setTransform(1, 0, 0, 1, 0, 0); - this.overlay.damageCtx.clearRect( - 0, - 0, - this.overlay.damageCanvas.width, - this.overlay.damageCanvas.height, - ); - this.overlay.damageCtx.drawImage(entry.damage, 0, 0); - this.overlay.damageCtx.restore(); - } else if (this.overlay.damageReady) { - this.overlay.damageCtx.clearRect(0, 0, this.overlay.width, this.overlay.height); - } + this.overlay.restoreDamage(entry.damage); this.fire.restoreFuel(entry.fuel); this.destruction = entry.destruction; for (let i = 0; i < this.pageElements.length; i++) { @@ -780,8 +758,7 @@ export class RageLayerEngine implements RageLayerEngineApi { // page, without synthesizing an onUp action such as a singularity collapse. this.pointerInput.cancel(); if (!this.restoringHistory) this.checkpoint("clear"); - if (this.overlay.damageReady) - this.overlay.damageCtx.clearRect(0, 0, this.overlay.width, this.overlay.height); + this.overlay.clearDamage(); this.contentLayer?.restoreAll(); this.fire.clear(); this.particles.clear(); @@ -991,14 +968,8 @@ export class RageLayerEngine implements RageLayerEngineApi { this.destruction = Math.max(0, this.destruction - 0.004); // Repair the real content from the pristine snapshot... this.contentLayer?.restore(x, y, radius); - // ...and sweep any overlay decals (also the fallback path). Nothing to - // sweep if the damage canvas was never painted on. - if (!this.overlay.damageReady) return; - const ctx = this.overlay.damageCtx; - ctx.globalCompositeOperation = "destination-out"; - blit(ctx, sprites().erase, x, y, radius, 1); - ctx.globalAlpha = 1; - ctx.globalCompositeOperation = "source-over"; + // ...and sweep any overlay decals (also the fallback path). + this.overlay.eraseDamage(x, y, radius, 1); } /** @@ -1008,13 +979,7 @@ export class RageLayerEngine implements RageLayerEngineApi { */ washSurface(x: number, y: number, radius: number, strength = 1) { this.contentLayer?.wash(x, y, radius, strength); - if (this.overlay.damageReady) { - const ctx = this.overlay.damageCtx; - ctx.globalCompositeOperation = "destination-out"; - blit(ctx, sprites().erase, x, y, radius, Math.min(1, strength)); - ctx.globalAlpha = 1; - ctx.globalCompositeOperation = "source-over"; - } + this.overlay.eraseDamage(x, y, radius, Math.min(1, strength)); this.markSurface(x, y, radius); this.signalInteraction("water", x, y); } @@ -1637,16 +1602,24 @@ export class RageLayerEngine implements RageLayerEngineApi { postfx.counters = this.monitor; this.heatCanvas = document.createElement("canvas"); this.heatCtx = this.heatCanvas.getContext("2d", { willReadFrequently: false }); - postfx.resize( - this.overlay.fxCanvas.width, - this.overlay.fxCanvas.height, - this.overlay.fxWidth, - this.overlay.fxHeight, + this.fitPostFXToOverlay(); + } + + /** Match the post-FX chain and its heat field to the fx canvas geometry. */ + private fitPostFXToOverlay() { + const overlay = this.overlay; + this.postfx?.resize( + overlay.fxCanvas.width, + overlay.fxCanvas.height, + overlay.fxWidth, + overlay.fxHeight, ); - this.heatCanvas.width = Math.max(1, Math.round(this.overlay.fxWidth / HEAT_SCALE)); - this.heatCanvas.height = Math.max(1, Math.round(this.overlay.fxHeight / HEAT_SCALE)); - // A resized canvas is transparent again; the next `resetHeat` must fill it. - this.heatDirty = true; + if (this.heatCanvas) { + this.heatCanvas.width = Math.max(1, Math.round(overlay.fxWidth / HEAT_SCALE)); + this.heatCanvas.height = Math.max(1, Math.round(overlay.fxHeight / HEAT_SCALE)); + // A resized canvas is transparent again; the next `resetHeat` must fill it. + this.heatDirty = true; + } } private setPostFXEnabled(enabled: boolean) { @@ -1695,20 +1668,8 @@ export class RageLayerEngine implements RageLayerEngineApi { */ private resize() { const { width, height } = this.docSize(); - const overlay = this.overlay; - overlay.resize(width, height, this.opts.effectsPixelRatio); - this.postfx?.resize( - overlay.fxCanvas.width, - overlay.fxCanvas.height, - overlay.fxWidth, - overlay.fxHeight, - ); - if (this.heatCanvas) { - this.heatCanvas.width = Math.max(1, Math.round(overlay.fxWidth / HEAT_SCALE)); - this.heatCanvas.height = Math.max(1, Math.round(overlay.fxHeight / HEAT_SCALE)); - // A resized canvas is transparent again; the next `resetHeat` must fill it. - this.heatDirty = true; - } + this.overlay.resize(width, height, this.opts.effectsPixelRatio); + this.fitPostFXToOverlay(); } private onScroll = () => { @@ -2204,13 +2165,13 @@ export class RageLayerEngine implements RageLayerEngineApi { // Nothing to draw: clear once after the last active frame, then leave the // canvas (and the compositor) completely alone while idle. if (this.fxPainted) { - this.clearFxCanvas(ctx); + this.overlay.clearFx(); if (this.postfxActive) this.postfx?.clear(); this.fxPainted = false; } return; } - this.clearFxCanvas(ctx); + this.overlay.clearFx(); this.fxPainted = true; // Bugs crawl *on* the page, under every particle and piece of debris. @@ -2286,18 +2247,6 @@ export class RageLayerEngine implements RageLayerEngineApi { this.postFXFrameMs = this.present(time); } - /** Clear every backing-store pixel regardless of the document-space transform. */ - private clearFxCanvas(ctx: CanvasRenderingContext2D) { - // Re-apply `positionFx`'s document-space transform directly instead of - // paying a save/restore state push per frame. Callers only run after - // `positionFx`, so the overlay's parked offsets are always current. - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); - const overlay = this.overlay; - const dpr = overlay.fxDpr; - ctx.setTransform(dpr, 0, 0, dpr, -overlay.fxOffsetX * dpr, -overlay.fxOffsetY * dpr); - } - /** Scratch for `maskFxToPage`'s per-frame hole-run query; reused, never grows hot. */ private readonly maskRectScratch: number[] = []; diff --git a/src/overlay.ts b/src/overlay.ts index 9570e42..d80075f 100644 --- a/src/overlay.ts +++ b/src/overlay.ts @@ -24,11 +24,11 @@ */ import { pickPixelRatio, RAGELAYER_IGNORE_ATTR } from "./capture"; +import { blit, sprites } from "./sprites"; /** Extra margin (CSS px) drawn beyond the viewport so nothing pops at the edge. */ export const FX_MARGIN = 120; -/** The document band the fx canvas currently covers. */ /** The void behind the page, so holes read as holes and not as transparency. */ const VOID_STOPS = [ [0, "#17130f"], @@ -44,6 +44,7 @@ export function paintVoid(ctx: CanvasRenderingContext2D, w: number, h: number) { ctx.fillRect(0, 0, w, h); } +/** The document band the fx canvas currently covers. */ export interface FxView { left: number; top: number; @@ -289,6 +290,58 @@ export class Overlay { if (this.damaged) this.damageCtx.clearRect(0, 0, this.width, this.height); } + /** Rub decals out under a soft round brush, if any were ever drawn. */ + eraseDamage(x: number, y: number, radius: number, alpha: number) { + if (!this.damaged) return; + const ctx = this.damageCtx; + ctx.globalCompositeOperation = "destination-out"; + blit(ctx, sprites().erase, x, y, radius, alpha); + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; + } + + /** Backing-store pixels a damage snapshot holds; 0 when nothing was drawn. */ + get damagePixelCost(): number { + return this.damaged ? this.damageCanvas.width * this.damageCanvas.height : 0; + } + + /** Copy the decals for a history checkpoint, or null when nothing was drawn. */ + snapshotDamage(): HTMLCanvasElement | null { + if (!this.damaged) return null; + const copy = document.createElement("canvas"); + copy.width = this.damageCanvas.width; + copy.height = this.damageCanvas.height; + copy.getContext("2d")?.drawImage(this.damageCanvas, 0, 0); + return copy; + } + + /** Replace the decals with a `snapshotDamage` copy, or clear them for null. */ + restoreDamage(snapshot: HTMLCanvasElement | null) { + if (!snapshot) { + this.clearDamage(); + return; + } + this.ensureDamage(); + const ctx = this.damageCtx; + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, this.damageCanvas.width, this.damageCanvas.height); + ctx.drawImage(snapshot, 0, 0); + ctx.restore(); + } + + /** + * Clear every fx backing-store pixel and re-apply the document transform + * the last `positionFx` set, without a per-frame save/restore. + */ + clearFx() { + const ctx = this.fxCtx; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, this.fxCanvas.width, this.fxCanvas.height); + const dpr = this.fxDpr; + ctx.setTransform(dpr, 0, 0, dpr, -this.fxOffsetX * dpr, -this.fxOffsetY * dpr); + } + /** The next `positionFx` re-applies the transform even if nothing scrolled. */ invalidateFxTransform() { this.fxOffsetX = this.fxOffsetY = -1; From 6fd9b5cad0689205d472cfa0d5217de9d9f0a023 Mon Sep 17 00:00:00 2001 From: Parth Jadhav Date: Sat, 7 Nov 2026 15:04:59 +0530 Subject: [PATCH 04/29] Re-read a decal target's device scale when its canvas resizes decals.ts cached each context's dpr forever, assuming targets keep one transform for life. The overlay and the content layer both re-pick dpr on resize or recapture (pickPixelRatio drops to 1.5 past 4500px), and after that stamps were rasterised at the stale scale. A dpr change always resizes the canvas, so the cache is now keyed on the canvas size as well. Co-Authored-By: Claude Opus 5.5 --- src/decals.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/decals.ts b/src/decals.ts index 8d7ac0d..c3470c8 100644 --- a/src/decals.ts +++ b/src/decals.ts @@ -51,16 +51,20 @@ let stampCache: WeakMap> | nul * keep a fixed dpr transform for their lifetime, and `hypot(a, b)` makes the * read indifferent to any rotation active at the time. */ -let ctxScale: WeakMap | null = null; +let ctxScale: WeakMap< + CanvasRenderingContext2D, + { scale: number; width: number; height: number } +> | null = null; function deviceScale(ctx: CanvasRenderingContext2D): number { - let scale = (ctxScale ??= new WeakMap()).get(ctx); - if (scale === undefined) { + const { width, height } = ctx.canvas; + let cached = (ctxScale ??= new WeakMap()).get(ctx); + if (cached === undefined || cached.width !== width || cached.height !== height) { const m = ctx.getTransform(); - scale = Math.hypot(m.a, m.b) || 1; - ctxScale.set(ctx, scale); + cached = { scale: Math.hypot(m.a, m.b) || 1, width, height }; + ctxScale.set(ctx, cached); } - return scale; + return cached.scale; } /** From 8a2c577a090f29725627687058b0b3d01e0da846 Mon Sep 17 00:00:00 2001 From: Parth Jadhav Date: Sat, 7 Nov 2026 15:06:18 +0530 Subject: [PATCH 05/29] Remove dead decals, an unused setter, and constant wood knobs drawBrittleFracture, drawDent and drawSurfaceScore had no callers, and neither did PerformanceMonitor.setQualityReason. WOOD carried flammability and burnRate of 1 (pure multiplications by one), an unread colour, and a restitution that Math.max always overrode; its toughness check in the gun made a crack branch unreachable. Those knobs and the branch go, along with the test that only restated the knob values. Co-Authored-By: Claude Opus 5.5 --- src/decals.ts | 103 +++---------------------------------------- src/engine.ts | 2 +- src/flames.ts | 6 +-- src/performance.ts | 5 --- src/tools.ts | 8 ++-- src/wood.ts | 4 -- tests/tools.test.mjs | 8 ---- 7 files changed, 11 insertions(+), 125 deletions(-) diff --git a/src/decals.ts b/src/decals.ts index c3470c8..afe1a54 100644 --- a/src/decals.ts +++ b/src/decals.ts @@ -46,10 +46,11 @@ const STAMPS_PER_SPRITE = 12; const MAX_STAMP_HALF = 160; let stampCache: WeakMap> | null = null; /** - * Device scale (dpr) per context, read once — `getTransform` allocates a - * DOMMatrix, which the per-frame scorch path cannot afford. Damage targets - * keep a fixed dpr transform for their lifetime, and `hypot(a, b)` makes the - * read indifferent to any rotation active at the time. + * Device scale (dpr) per context — `getTransform` allocates a DOMMatrix, which + * the per-frame scorch path cannot afford. A target only changes its dpr by + * resizing its canvas (which also resets the transform), so the cached read is + * keyed on the canvas size too. `hypot(a, b)` makes the read indifferent to any + * rotation active at the time. */ let ctxScale: WeakMap< CanvasRenderingContext2D, @@ -235,47 +236,6 @@ export function drawCrack( ctx.restore(); } -/** - * Radial fracture for brittle sheets such as glass and ice. - * - * Unlike the wandering plaster faults above, a brittle impact carries straight - * rays away from the contact point and joins them with uneven circumferential - * cracks. There is deliberately no opaque dent or powder halo: neither belongs - * on a transparent pane and both obscure the fracture geometry. - */ -export function drawBrittleFracture( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - radius = 42, -) { - const reach = Math.max(12, radius); - const radial = new Path2D(); - const ring = new Path2D(); - - ctx.save(); - ctx.globalCompositeOperation = "source-atop"; - ctx.translate(x, y); - ctx.rotate(Math.random() * TAU); - - for (let index = 0; index < 10; index++) { - const angle = (index / 10) * TAU + rand(-0.12, 0.12); - const length = reach * rand(0.72, 1.08); - radial.moveTo(Math.cos(angle) * 3, Math.sin(angle) * 3); - radial.lineTo(Math.cos(angle) * length, Math.sin(angle) * length); - const ringRadius = reach * rand(0.44, 0.58); - const px = Math.cos(angle) * ringRadius; - const py = Math.sin(angle) * ringRadius; - if (index === 0) ring.moveTo(px, py); - else ring.lineTo(px, py); - } - ring.closePath(); - - fractureStroke(ctx, radial, 1.3, 0.72, 0.38); - fractureStroke(ctx, ring, 0.75, 0.48, 0.3); - ctx.restore(); -} - /** Layered bullet hole: dark core, ragged rim, short plaster cracks around it. */ export function drawBulletHole(ctx: CanvasRenderingContext2D, x: number, y: number, scale = 1) { ctx.save(); @@ -383,59 +343,6 @@ export function drawBurnChannel(ctx: CanvasRenderingContext2D, points: number[]) ctx.restore(); } -/** - * A shallow elastic or metallic depression. Unlike `drawCrack`, this keeps the - * surface continuous: rubber compresses and sheet metal buckles before either - * material tears. - */ -export function drawDent(ctx: CanvasRenderingContext2D, x: number, y: number, radius: number) { - ctx.save(); - ctx.globalCompositeOperation = "source-atop"; - ctx.translate(x, y); - ctx.scale(1, 0.64); - const shade = ctx.createRadialGradient(0, 0, radius * 0.08, 0, 0, radius); - shade.addColorStop(0, "rgba(4, 7, 10, 0.34)"); - shade.addColorStop(0.58, "rgba(12, 15, 18, 0.16)"); - shade.addColorStop(0.78, "rgba(255, 255, 255, 0.1)"); - shade.addColorStop(1, "rgba(255, 255, 255, 0)"); - ctx.fillStyle = shade; - ctx.beginPath(); - ctx.arc(0, 0, radius, 0, TAU); - ctx.fill(); - ctx.strokeStyle = "rgba(5, 7, 9, 0.3)"; - ctx.lineWidth = 1.4; - ctx.beginPath(); - ctx.arc(0, 0, radius * 0.72, Math.PI * 0.12, Math.PI * 0.88); - ctx.stroke(); - ctx.restore(); -} - -/** A continuous abrasion where a cutting edge skids across a surface it cannot sever. */ -export function drawSurfaceScore( - ctx: CanvasRenderingContext2D, - x1: number, - y1: number, - x2: number, - y2: number, -) { - ctx.save(); - ctx.globalCompositeOperation = "source-atop"; - ctx.lineCap = "round"; - ctx.strokeStyle = "rgba(18, 20, 22, 0.58)"; - ctx.lineWidth = 2.4; - ctx.beginPath(); - ctx.moveTo(x1, y1); - ctx.lineTo(x2, y2); - ctx.stroke(); - ctx.strokeStyle = "rgba(255, 255, 255, 0.22)"; - ctx.lineWidth = 0.9; - ctx.beginPath(); - ctx.moveTo(x1, y1 + 1.25); - ctx.lineTo(x2, y2 + 1.25); - ctx.stroke(); - ctx.restore(); -} - /** * One chainsaw gash segment between two points. * diff --git a/src/engine.ts b/src/engine.ts index 7a850e3..dc13e03 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -1136,7 +1136,7 @@ export class RageLayerEngine implements RageLayerEngineApi { cell, { density: 0.0018 * WOOD.density, - restitution: Math.max(0.14, WOOD.restitution), + restitution: 0.14, friction: 0.62, ttl: options.ttl ?? 10 + Math.random() * 8, }, diff --git a/src/flames.ts b/src/flames.ts index 23c15ec..1f2ee79 100644 --- a/src/flames.ts +++ b/src/flames.ts @@ -24,7 +24,6 @@ import { TAU } from "./math"; // of, synchronously) instead of allocating a literal per ember and puff. import { scratchParticle } from "./particles"; import type { ContentApi, Flame, Particle, SoundApi } from "./types"; -import { WOOD } from "./wood.js"; /** * Wood-fuel grid resolution, CSS px per cell. Coarse cells are enough — the @@ -148,7 +147,6 @@ export class FlameField { /** Returns true when a new flame was actually lit (the caller repaints). */ spawn(host: FlameHost, x: number, y: number, intensity = 0.35): boolean { this.fuel.ensure(host.width, host.height); - intensity *= WOOD.flammability; if (intensity <= 0.015) return false; // Fire needs a page to burn. Where the content is mostly gone the void // shows through, and the void is not a place — a flame floating on it @@ -309,12 +307,12 @@ export class FlameField { // Stage 2 — burning: the char deepens and erosion begins. layer.char(f.x, f.y + 2, f.radius * 0.85, 0.16); layer.burn(f.x, f.y + 2, f.radius * 0.22); - this.consumeFuel(host, f.x, f.y, (9 + 13 * f.intensity) * WOOD.burnRate); + this.consumeFuel(host, f.x, f.y, 9 + 13 * f.intensity); } else { // Stage 3 — deepening: the fire is inside the material now, eating // fast toward breakthrough, and the rim glows with thrown embers. layer.burn(f.x, f.y + 2, f.radius * (0.3 + f.intensity * 0.35)); - this.consumeFuel(host, f.x, f.y, (13 + 16 * f.intensity) * WOOD.burnRate); + this.consumeFuel(host, f.x, f.y, 13 + 16 * f.intensity); if (Math.random() < 0.5) { const a = Math.random() * TAU; const p = scratchParticle( diff --git a/src/performance.ts b/src/performance.ts index 55faf50..1fe411b 100644 --- a/src/performance.ts +++ b/src/performance.ts @@ -366,11 +366,6 @@ export class PerformanceMonitor implements PerfCounterSink { this.captureMs = durationMs; } - setQualityReason(reason: string) { - if (this.disposed) return; - this.qualityReason = reason; - } - observeRaf(now: number) { if (this.disposed) return; if (this.lastRafAt > 0) { diff --git a/src/tools.ts b/src/tools.ts index 0b7c3f5..754d649 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -14,7 +14,7 @@ import { } from "./toolart"; import { includeTopologySegment, surfaceRuns, type TopologyBounds } from "./topology"; import type { RageLayerEngineApi, Tool, ToolPointerEvent, Vec2 } from "./types"; -import { WOOD } from "./wood.js"; +import { WOOD } from "./wood"; /** * A spot the hammer is working on. The page doesn't give way on the first @@ -224,10 +224,8 @@ function fireShot(engine: RageLayerEngineApi, x: number, y: number, spread = 0) // Dress the rim first, then punch clean through the real page content — // order matters: punching last keeps the hole genuinely transparent. - const penetrates = WOOD.toughness < 2; - drawBulletHole(engine.surfaceCtx, sx, sy, penetrates ? 0.9 + Math.random() * 0.4 : 0.65); - if (penetrates) engine.content?.punch(sx, sy, 5); - else drawCrack(engine.surfaceCtx, sx, sy, 0.45); + drawBulletHole(engine.surfaceCtx, sx, sy, 0.9 + Math.random() * 0.4); + engine.content?.punch(sx, sy, 5); // A round is a perfectly good fly-swatter. engine.squashBugs(sx, sy, 12); diff --git a/src/wood.ts b/src/wood.ts index 97b84b5..90db6ea 100644 --- a/src/wood.ts +++ b/src/wood.ts @@ -2,10 +2,6 @@ export const WOOD = Object.freeze({ toughness: 1.35, density: 1.25, - flammability: 1, - burnRate: 1, conductivity: 0.03, corrosionResistance: 0.3, - restitution: 0.1, - color: "#9b6a38", } as const); diff --git a/tests/tools.test.mjs b/tests/tools.test.mjs index bcce270..9b525a8 100644 --- a/tests/tools.test.mjs +++ b/tests/tools.test.mjs @@ -10,7 +10,6 @@ import { paintball, waterHose, } from "../src/tools.ts"; -import { WOOD } from "../src/wood.js"; import { makeCanvas, readPixels } from "./support/dom.mjs"; import { createTestEngine, @@ -47,13 +46,6 @@ describe("every base tool is well-formed", () => { } }); - test("all base tools share one immutable wood physics contract", () => { - expect(Object.isFrozen(WOOD)).toBe(true); - expect(WOOD.toughness).toBeGreaterThan(1); - expect(WOOD.flammability).toBe(1); - expect(WOOD.burnRate).toBeGreaterThan(0); - }); - test("no tool throws when used over an ordinary page", () => { for (const tool of baseTools) { const engine = armed(tool); From d2844203221c966c7061dd7c963f2ff7142400dd Mon Sep 17 00:00:00 2001 From: Parth Jadhav Date: Sat, 7 Nov 2026 15:06:59 +0530 Subject: [PATCH 06/29] Register icon bounds next to each drawing The measured bounds of the base and heavy tool art sat in a table in toolart/icons.ts, which therefore imported every drawing; the toolbar imported icons through toolart/index, and the base tools imported their art through the same index. So the toolbar and the base tools chunk both pulled in the heavy ordnance art. Bounds now register beside each drawing (as the advanced art already did), and those modules import only the art they use. Co-Authored-By: Claude Opus 5.5 --- src/heavy-tools.ts | 2 +- src/toolart/base.ts | 15 +++++++++++++++ src/toolart/heavy.ts | 13 +++++++++++++ src/toolart/icons.ts | 35 +---------------------------------- src/toolbar.ts | 2 +- src/tools.ts | 2 +- 6 files changed, 32 insertions(+), 37 deletions(-) diff --git a/src/heavy-tools.ts b/src/heavy-tools.ts index 5542748..d52ac9f 100644 --- a/src/heavy-tools.ts +++ b/src/heavy-tools.ts @@ -14,7 +14,7 @@ import { emojiCursor } from "./cursors"; import { drawBurnChannel } from "./decals"; import { emit, PARKED_POINTER, rand, TAU } from "./math"; import { createEngineState } from "./tool-kit"; -import { blackHoleArt, bugsArt, demolitionArt, lightningArt, rocketArt } from "./toolart"; +import { blackHoleArt, bugsArt, demolitionArt, lightningArt, rocketArt } from "./toolart/heavy"; import type { RageLayerEngineApi, Tool, Vec2 } from "./types"; import { WOOD } from "./wood"; diff --git a/src/toolart/base.ts b/src/toolart/base.ts index 99bcb6a..3689626 100644 --- a/src/toolart/base.ts +++ b/src/toolart/base.ts @@ -5,6 +5,7 @@ * than stored). */ +import { registerToolIconBounds } from "../icon-bounds"; import { TAU } from "../math"; import type { ToolArtFn } from "../types"; import { @@ -705,3 +706,17 @@ export const broomArt: ToolArtFn = (ctx, s) => { } ctx.restore(); }; + +/* + * Exact alpha silhouettes of the rest poses above, measured at the icon bake's + * 64px origin. Besides protecting narrow details (claws, bristles, antennae) + * from clipping, they spare every built-in toolbar icon a 256×256 + * `getImageData` readback. Re-measure when a drawing changes. + */ +registerToolIconBounds(hammerArt, [69, 32, 175, 148]); +registerToolIconBounds(gunArt, [61, 53, 119, 119]); +registerToolIconBounds(flamethrowerArt, [56, 54, 120, 125]); +registerToolIconBounds(waterHoseArt, [50, 63, 108, 118]); +registerToolIconBounds(chainsawArt, [59, 58, 157, 126]); +registerToolIconBounds(paintballArt, [59, 51, 119, 122]); +registerToolIconBounds(broomArt, [43, 0, 150, 80]); diff --git a/src/toolart/heavy.ts b/src/toolart/heavy.ts index 48a77c3..9361208 100644 --- a/src/toolart/heavy.ts +++ b/src/toolart/heavy.ts @@ -3,6 +3,7 @@ * ring and specimen jar. Same conventions as `./base`. */ +import { registerToolIconBounds } from "../icon-bounds"; import { TAU } from "../math"; import type { ToolArtFn } from "../types"; import { @@ -415,3 +416,15 @@ function drawBug(ctx: Ctx, x: number, y: number, ang: number, scale: number) { ctx.stroke(); ctx.restore(); } + +/* + * Exact alpha silhouettes of the rest poses above, measured at the icon bake's + * 64px origin. Besides protecting narrow details (claws, bristles, antennae) + * from clipping, they spare every built-in toolbar icon a 256×256 + * `getImageData` readback. Re-measure when a drawing changes. + */ +registerToolIconBounds(demolitionArt, [61, 24, 160, 91]); +registerToolIconBounds(rocketArt, [52, 51, 155, 120]); +registerToolIconBounds(lightningArt, [63, 63, 139, 147]); +registerToolIconBounds(blackHoleArt, [44, 44, 127, 123]); +registerToolIconBounds(bugsArt, [60, 53, 114, 112]); diff --git a/src/toolart/icons.ts b/src/toolart/icons.ts index 98607f2..11a63ed 100644 --- a/src/toolart/icons.ts +++ b/src/toolart/icons.ts @@ -5,19 +5,9 @@ * renders large, finds the true alpha bounds, and fits that crop into the icon. */ -import { registerToolIconBounds, toolIconBounds } from "../icon-bounds"; +import { toolIconBounds } from "../icon-bounds"; import { REST_AIM_X, REST_AIM_Y } from "../math"; import type { ToolArtFn, ToolArtState } from "../types"; -import { - broomArt, - chainsawArt, - flamethrowerArt, - gunArt, - hammerArt, - paintballArt, - waterHoseArt, -} from "./base"; -import { blackHoleArt, bugsArt, demolitionArt, lightningArt, rocketArt } from "./heavy"; /** * The rest pose every icon is baked from: mid-idle, nothing pressed. Since the @@ -35,29 +25,6 @@ const ICON_STATE: ToolArtState = { aimY: REST_AIM_Y, }; -/** - * Exact alpha silhouettes for the built-in rest poses, measured at the icon - * bake's 64px origin. Besides protecting narrow details (claws, bristles, - * antennae) from clipping, this avoids a 256×256 `getImageData` readback for - * every built-in toolbar icon. Custom art still gets the accurate scan path. - */ -for (const [art, bounds] of [ - [hammerArt, [69, 32, 175, 148]], - [gunArt, [61, 53, 119, 119]], - [flamethrowerArt, [56, 54, 120, 125]], - [waterHoseArt, [50, 63, 108, 118]], - [chainsawArt, [59, 58, 157, 126]], - [paintballArt, [59, 51, 119, 122]], - [broomArt, [43, 0, 150, 80]], - [demolitionArt, [61, 24, 160, 91]], - [rocketArt, [52, 51, 155, 120]], - [lightningArt, [63, 63, 139, 147]], - [blackHoleArt, [44, 44, 127, 123]], - [bugsArt, [60, 53, 114, 112]], -] as const) { - registerToolIconBounds(art, bounds); -} - /** Reopening an identical toolbar should not rasterize and PNG-encode built-ins again. */ const iconCache = new WeakMap>(); diff --git a/src/toolbar.ts b/src/toolbar.ts index f33ff2f..db3cd92 100644 --- a/src/toolbar.ts +++ b/src/toolbar.ts @@ -16,7 +16,7 @@ import type { RageLayerEngine } from "./engine"; import { copyBlobToClipboard, downloadBlob, snapshotFilename } from "./share"; import { type RageLayerStrings, resolveStrings, toolStrings } from "./strings"; -import { toolIconDataUrl } from "./toolart"; +import { toolIconDataUrl } from "./toolart/icons"; import { TOOLBAR_ICONS } from "./toolbar-icons"; import type { CaptureStatus, Tool, ToolStyle } from "./types"; diff --git a/src/tools.ts b/src/tools.ts index 754d649..462df72 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -11,7 +11,7 @@ import { hammerArt, paintballArt, waterHoseArt, -} from "./toolart"; +} from "./toolart/base"; import { includeTopologySegment, surfaceRuns, type TopologyBounds } from "./topology"; import type { RageLayerEngineApi, Tool, ToolPointerEvent, Vec2 } from "./types"; import { WOOD } from "./wood"; From 44c525cd67822b89487510b093ea22aeab565d9d Mon Sep 17 00:00:00 2001 From: Parth Jadhav Date: Sat, 7 Nov 2026 15:07:38 +0530 Subject: [PATCH 07/29] Define the official tool order once default-tools.ts and lazy.ts each spelled out "base tools minus the last, then heavy, then advanced, then the last base tool", and the lazy loader's doc promised a gravity tool it does not load. Both now call officialToolOrder. Co-Authored-By: Claude Opus 5.5 --- src/default-tools.ts | 8 ++------ src/lazy.ts | 5 +++-- src/tool-order.ts | 14 ++++++++++++++ 3 files changed, 19 insertions(+), 8 deletions(-) create mode 100644 src/tool-order.ts diff --git a/src/default-tools.ts b/src/default-tools.ts index b18fcbe..92f9462 100644 --- a/src/default-tools.ts +++ b/src/default-tools.ts @@ -1,12 +1,8 @@ import { advancedTools } from "./advanced-tools"; import { heavyTools } from "./heavy-tools"; +import { officialToolOrder } from "./tool-order"; import { baseTools } from "./tools"; import type { Tool } from "./types"; /** Complete built-in toolset in the order used by the official toolbar. */ -export const defaultTools: Tool[] = [ - ...baseTools.slice(0, -1), - ...heavyTools, - ...advancedTools, - baseTools.at(-1)!, -]; +export const defaultTools: Tool[] = officialToolOrder(baseTools, heavyTools, advancedTools); diff --git a/src/lazy.ts b/src/lazy.ts index 7410b3e..264d487 100644 --- a/src/lazy.ts +++ b/src/lazy.ts @@ -1,3 +1,4 @@ +import { officialToolOrder } from "./tool-order"; import type { Tool } from "./types"; /** Load the seven everyday tools without downloading the heavy ordnance module. */ @@ -12,7 +13,7 @@ export async function loadHeavyTools(): Promise { return [...heavyTools]; } -/** Load gravity, precision cutting, corrosion, and sticky explosives on demand. */ +/** Load precision cutting, corrosion, and sticky explosives on demand. */ export async function loadAdvancedTools(): Promise { const { advancedTools } = await import("./advanced-tools"); return [...advancedTools]; @@ -25,5 +26,5 @@ export async function loadDefaultTools(): Promise { import("./heavy-tools"), import("./advanced-tools"), ]); - return [...baseTools.slice(0, -1), ...heavyTools, ...advancedTools, baseTools.at(-1)!]; + return officialToolOrder(baseTools, heavyTools, advancedTools); } diff --git a/src/tool-order.ts b/src/tool-order.ts new file mode 100644 index 0000000..8d0b3b4 --- /dev/null +++ b/src/tool-order.ts @@ -0,0 +1,14 @@ +import type { Tool } from "./types"; + +/** + * The official toolbar order: the everyday tools, then the heavy ordnance and + * the advanced tools, with the broom (the last everyday tool) kept at the end + * as the cleanup that follows everything else. + */ +export function officialToolOrder( + baseTools: readonly Tool[], + heavyTools: readonly Tool[], + advancedTools: readonly Tool[], +): Tool[] { + return [...baseTools.slice(0, -1), ...heavyTools, ...advancedTools, ...baseTools.slice(-1)]; +} From f3cb4913d2b748df5c60780e07be3945917ce942 Mon Sep 17 00:00:00 2001 From: Parth Jadhav Date: Sat, 7 Nov 2026 15:08:07 +0530 Subject: [PATCH 08/29] Run React, Vue and the custom element from one toolbar session Each ready-made toolbar built its own engine, registered tools by hand instead of through mountRageLayer, built a ToolbarModel, wired the window shortcut listener, saved and restored focus, and computed roving-focus arrows with its own index wrapping (Vue without the empty-toolbar guard). openToolbarSession and rovingFocusTarget now do that once; each adapter keeps only its rendering and its own defaults. The two refcounted