SOURCE / PINNED RELEASE
Made of little things.
Shiftfield
- Release
- 5a170c505f6b…
- Author-recorded commit
- a2e23e0171aa…
- License
- LICENSE
- Author’s source reference
- nostr://npub16nzc4ql748r2kv9202rgzrt0fmtk4n3tzsjl86yf6ty9agqe9u0qjcc7hc/wss%3A%2F%2Fgit.napplet.soy%2F/n-e4fb6341ccd
Archive hash verified: 66de7623efd91da1…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
/* Minesweeper DX — Phase 4 "Juice": the effects layer.
* A single fixed, `pointer-events:none` <canvas> above the DOM board (below
* the overlays) plus a transform-level screen shake on #app. The DOM board
* stays the sanctioned render layer (spec §8 #3 ruling 2026-10-25: hybrid);
* this canvas is decoration only — it never holds game state and never
* receives input, so conformance and the QA hook are untouched.
* Everything is procedural: particles, shockwaves, sweep streaks, flashes and
* the chromatic ghost halo are drawn from primitives at play time. No
* assets, no rAF work while idle (the loop stops when the effect list drains)
* and no canvas raster work on frames with nothing to draw (P2.1 dirty flag).
* `prefers-reduced-motion: reduce` tames the whole layer: no particles, no
* shake, no chromatic split — one brief static flash per event at most. The
* preference is followed live (changeMediaQuery listener), not just at boot.
*/
import type { Sfx } from './sfx.js';
export type FxEvent =
| { kind: 'reveal'; cell: string; count: number }
| { kind: 'flag'; cell: string; mine: boolean }
| { kind: 'unflag'; cell: string }
| { kind: 'shift'; dx: number; dy: number }
| { kind: 'shift-warning'; dx: number; dy: number; turns: number }
| { kind: 'boom'; cell: string; cause: string; shielded: boolean }
| { kind: 'deny' };
interface Particle {
x: number; y: number;
vx: number; vy: number;
age: number; ttl: number;
size: number;
color: string;
/** px/ms² downward; negative = rises (sparks). */
grav: number;
drag: number;
}
interface Ring {
x: number; y: number;
age: number; ttl: number;
r0: number; r1: number;
width: number;
color: string;
}
/** Board-space streak that rides the shift vector across the field. */
interface Sweep {
age: number; ttl: number;
dx: number; dy: number;
// D2 (audit 2026-10-28): per-sweep `color` field deleted — drawSweep always
// painted the ice-blue gradient; train stagger rides age-offset + alpha,
// never a color. (The old sweepBurst even faked a color with an
// `as unknown as string` cast, which nothing read.)
}
interface Flash { age: number; ttl: number; color: string; peak: number; board?: boolean }
/** Toroidal slide tell: a fading gradient band hugging the board EDGES the
* mines exit (and the opposite edge they re-enter). ax/ay are the raw shift
* components (-1/0/1). Distinct property names from Sweep on purpose — the
* draw loop discriminates effects by key presence. */
interface Band { age: number; ttl: number; ax: number; ay: number; strength?: number }
/** Telegraph wind: one drifting streak dot, position normalized (0..1)
* inside the board rect, wrapping toroidally like the mines themselves. */
interface WindDot { u: number; v: number }
type Effect = Particle | Ring | Sweep | Flash | Band;
const TAU = Math.PI * 2;
export class Fx {
private readonly canvas: HTMLCanvasElement;
// NOT readonly: T4 (audit 2026-10-28) re-acquires it on contextrestored.
private ctx2d: CanvasRenderingContext2D | null;
private readonly dpr: number;
private readonly effects: Effect[] = [];
private raf = 0;
private rafDeadLogged = false;
private last = 0;
/** shake impulse state */
private shakeAmp = 0;
private shakeTtl = 0;
private shakeAge = 0;
private shakeEl: HTMLElement | null = null;
/** chromatic split impulse (P2.2: ghosts are a halo around this point,
* not a viewport-wide tint) */
private chroma = 0;
private chromaTtl = 0;
private chromaAge = 0;
private chromaX = 0;
private chromaY = 0;
/** Shifter telegraph (ruling 2026-10-28): persistent on-board wind layer.
* Null while the Shifter is asleep or motion is reduced. urgency 0..1
* ramps with the countdown; frozen = Stabilizer hold (ice tint, no drift). */
private wind: {
dx: number; dy: number; urgency: number; frozen: boolean; dots: WindDot[];
} | null = null;
/** canvas holds pixels from the last draw() — one clear on the first
* empty frame guarantees no residue (P2.1 dirty flag) */
private canvasDirty = false;
/** A2 (audit 2026-10-29): wind-only paint duty-cycle is a WALL-TIME
* accumulator (max 30 Hz) instead of B2's frame-parity skip — parity
* halved an already-degraded paint rate under load (7.3 paints/s at
* 15 fps measured); wall-time never drops below the actual frame rate. */
private lastWindPaint = 0;
/** A4 (audit 2026-10-29) watchdog: >0 while the 2D context is lost and
* contextrestored has NOT fired; the live loop then self-heals by probe. */
private ctxLostAt = 0;
private lostProbeAt = 0;
/** A3 (audit 2026-10-29): board rect cache — one forced-layout fetch per
* 500 ms instead of one per painted frame; resize/scroll invalidate it. */
private rectCache: DOMRect | null = null;
private rectAt = 0;
/** C2 (audit 2026-10-29): the fx canvas flips between viewport-sized
* backing store and board+margin. The 30 Hz wind steady state (depth
* >=6, always-on at the depths that matter) paints only inside
* rect+/-16, yet a viewport buffer (3200x2000 px at dpr 2) re-rastered
* ~2.6 MP of empty margin every frame — software-raster clients
* (VRAM-pressure fallback, the bug 2 "slows over time" case) paid it
* 24-30x/s. Anything that can paint outside the board box (full wash,
* chroma ghosts, sweep strip, particles, rings) flips it back to
* viewport size; flips are transient-bounded, the steady state never
* touches canvas size again. Draw coords stay viewport CSS coords in
* both modes — the board-mode offset rides the draw transform. */
private fullCanvas = true;
private boxX = 0;
private boxY = 0;
private boxW = 0;
private boxH = 0;
/** Covers the wind erase rect (rect+/-16) + band wash (rect+/-8) + AA. */
private static readonly BOARD_MARGIN = 32;
/** E1 (2026-10-28): dirty-rect drawing bookkeeping. The canvas is only
* re-rastered over (last draw's bbox ∪ this frame's bbox); `bboxFull`
* means the last draw painted viewport-wide (flash/sweep), forcing one
* full clear next frame. Without this the sparse blast tail (~1.4 s per
* boom) dirties the whole viewport-sized buffer every frame — the
* software-raster HiDPI case (old/VM GPU) where a full-viewport
* re-raster dominated blast-window frames (25–31 frames >30 ms). */
private prevBBox: { x0: number; y0: number; x1: number; y1: number } | null = null;
private bboxFull = false;
/** E2: chromatic ghost gradients cached per blast center (stops at alpha
* 1; the decaying alpha rides globalAlpha) so the 520 ms chroma window
* allocates 2 gradients total instead of ~2 per frame. */
private chromaGrads: { x: number; y: number; g: [CanvasGradient, CanvasGradient] } | null = null;
// B4 (audit 2026-10-28): sweep/band gradient caches. The sweep gradient is
// built in the effect's own translate/rotate space with a constant half
// band, so ONE lazy CanvasGradient serves every sweep on every shift; the
// band strip gradients depend only on the board rect, keyed by its edges
// (rebuilt on resize/scroll only). Time-varying alpha rides globalAlpha
// with color stops pinned at alpha 1 — the E2 chroma-cache pattern.
private sweepGrad: CanvasGradient | null = null;
private bandGrads: { left: number; top: number; right: number; bottom: number; g: CanvasGradient[] } | null = null;
private reduce: MediaQueryList | null = null;
constructor(
host: HTMLElement,
private readonly center: (cell: string) => { x: number; y: number } | null,
private readonly boardEl: () => HTMLElement | null,
private readonly sfx?: Pick<Sfx, 'play'>,
/** Observer tapped BEFORE the built-in effects run (main.ts marks the
* summary panel .death-blast from the FATAL boom here). */
private readonly tap?: (ev: FxEvent) => void,
) {
this.canvas = document.createElement('canvas');
this.canvas.id = 'fx';
this.canvas.setAttribute('aria-hidden', 'true');
host.prepend(this.canvas);
this.ctx2d = this.canvas.getContext('2d');
this.dpr = Math.min(2, window.devicePixelRatio || 1);
this.resize();
window.addEventListener('resize', this.resize);
// A3 (audit 2026-10-29): scroll moves the board's viewport coords —
// drop the rect cache (passive capture listener, no perf cost).
window.addEventListener('scroll', this.invalidateRect, { capture: true, passive: true });
// T4 (audit 2026-10-28): if the GPU drops the 2D context, every draw
// becomes a silent no-op while the loop keeps burning frames — the wind
// reads dead but gameplay is fine, forever. React to loss/restore: drop
// ctx2d (draw/clearCanvas are null-safe; the rAF loop keeps running) and
// also drop the cached CanvasGradients, which belong to the dead context.
// On restore: re-acquire, re-apply dpr/size bookkeeping, force a redraw.
this.canvas.addEventListener('contextlost', (e) => {
e.preventDefault(); // required or the browser never fires contextrestored
this.ctx2d = null;
this.sweepGrad = null;
this.bandGrads = null;
this.chromaGrads = null;
// A4 (audit 2026-10-29): contextrestored can simply never arrive —
// Chrome under VRAM pressure (e.g. a local LLM sharing the GPU) can
// leave a canvas lost for minutes or until reload. Arm the watchdog;
// the live rAF loop then self-heals via lostWatchdog().
this.ctxLostAt = performance.now();
this.lostProbeAt = this.ctxLostAt;
});
this.canvas.addEventListener('contextrestored', () => this.restoreContext());
if (window.matchMedia) {
this.reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
this.reduce.addEventListener?.('change', () => {
if (this.calm) this.stopAll();
});
}
}
private get calm(): boolean {
return this.reduce?.matches ?? false;
}
private readonly resize = (): void => {
this.rectCache = null; // A3
// C2: rebuild at viewport size; the next frame's layoutFx() flips
// back to the board box if only board-locked layers are live.
this.fullCanvas = true;
this.boxX = 0;
this.boxY = 0;
this.boxW = Math.max(1, window.innerWidth);
this.boxH = Math.max(1, window.innerHeight);
const s = this.canvas.style;
s.left = '0px';
s.top = '0px';
s.width = `${this.boxW}px`;
s.height = `${this.boxH}px`;
this.canvas.width = Math.floor(this.boxW * this.dpr);
this.canvas.height = Math.floor(this.boxH * this.dpr);
// E1: setting width/height implicitly clears the buffer — stale bbox
// bookkeeping must go with it.
this.prevBBox = null;
this.bboxFull = false;
};
private readonly invalidateRect = (): void => {
this.rectCache = null;
};
/** C2: pick canvas mode/geometry from what is live right now. Board
* mode only while every live effect is board-locked (wind, board
* washes, edge bands, board-scoped sweeps); particles/rings/full
* washes/chroma flip to viewport. A real switch resizes the canvas
* (implicit bitmap clear) so bbox bookkeeping restarts and the same
* frame repaints everything alive — geometry-stable calls are pure
* no-ops. */
private layoutFx(): void {
let full = this.chroma > 0.2;
if (!full) {
for (const e of this.effects) {
if (e.age < 0) continue; // pre-born draws nothing
if ('peak' in e) {
if (!(e as Flash).board) full = true;
} else if (!('ax' in e)) {
full = true; // sweep strip / particles / rings reach past the box
}
if (full) break;
}
}
let bx = 0;
let by = 0;
let bw = Math.max(1, window.innerWidth);
let bh = Math.max(1, window.innerHeight);
if (!full) {
const r = this.boardRect();
if (!r) full = true; // no board in DOM yet: keep viewport default
else {
const m = Fx.BOARD_MARGIN;
bx = Math.max(0, Math.floor(r.left - m));
by = Math.max(0, Math.floor(r.top - m));
bw = Math.min(window.innerWidth, Math.ceil(r.right + m)) - bx;
bh = Math.min(window.innerHeight, Math.ceil(r.bottom + m)) - by;
if (bw < 80 || bh < 80) full = true; // degenerate: stay wide
}
}
if (full) {
const vw = Math.max(1, window.innerWidth);
const vh = Math.max(1, window.innerHeight);
if (this.fullCanvas && this.boxW === vw && this.boxH === vh) return;
this.fullCanvas = true;
this.boxX = 0;
this.boxY = 0;
this.boxW = vw;
this.boxH = vh;
} else {
if (!this.fullCanvas && bx === this.boxX && by === this.boxY && bw === this.boxW && bh === this.boxH) return;
this.fullCanvas = false;
this.boxX = bx;
this.boxY = by;
this.boxW = bw;
this.boxH = bh;
}
const s = this.canvas.style;
s.left = `${this.boxX}px`;
s.top = `${this.boxY}px`;
s.width = `${this.boxW}px`;
s.height = `${this.boxH}px`;
this.canvas.width = Math.floor(this.boxW * this.dpr);
this.canvas.height = Math.floor(this.boxH * this.dpr);
this.prevBBox = null;
this.bboxFull = false;
};
// ------------------------------ event API --------------------------------
/** One entry point: every board/engine observation lands here. Sound is
* coupled at the effect site so visuals and audio can never diverge. */
event(ev: FxEvent): void {
try {
this.tap?.(ev);
this.onEvent(ev);
} catch {
// Juice must never break gameplay (or the headless error assertions).
}
}
private onEvent(ev: FxEvent): void {
switch (ev.kind) {
case 'reveal': {
this.sfx?.play('reveal');
if (this.calm) return;
const c = this.center(ev.cell);
if (!c) return;
// gentle puff proportional to how much the flood opened
const n = Math.min(4 + ev.count, 16);
this.burst(c.x, c.y, { n, speed: 0.05, color: '#7fa6c9', grav: -0.00002, ttl: 500, size: 2 });
break;
}
case 'flag': {
this.sfx?.play('flag');
const c = this.center(ev.cell);
if (!c) return;
this.ring(c.x, c.y, { r1: 26, ttl: 380, color: ev.mine ? '#ff6b6b' : '#e8b13c', width: 2 });
if (!this.calm) {
this.burst(c.x, c.y, {
n: ev.mine ? 14 : 8, speed: 0.12, color: ev.mine ? '#ff6b6b' : '#e8b13c',
grav: 0.0002, ttl: 520, size: 2,
});
}
break;
}
case 'unflag':
this.sfx?.play('unflag');
break;
case 'shift': {
this.sfx?.play('shift');
if (this.calm) return;
this.sweepBurst(ev.dx, ev.dy);
break;
}
case 'shift-warning': {
// Final-3-turns beat (ruling 2026-10-28): every remaining turn of a
// shift cycle stamps itself across the field — urgent rising tick,
// one amber warning streak, and a soft board-wide pulse.
this.sfx?.play('shiftWarn');
if (this.calm) return;
const urgency = ev.turns <= 1 ? 0.9 : ev.turns === 2 ? 0.6 : 0.4;
this.sweep(ev.dx, ev.dy, urgency);
this.flash('#e8b13c', 0.07 + 0.05 * (4 - ev.turns), 340, true);
// Edge direction bars on EVERY warning turn (user ruling 2026-10-28):
// at depth 6 the first shift lands ~28 turns in, so previously no
// exit/re-entry band was painted until the landing itself — the
// "screen bar showing the shift direction" read as missing.
this.effects.push({
age: 0, ttl: 460 + urgency * 220,
ax: Math.sign(ev.dx), ay: Math.sign(ev.dy),
strength: 0.4 + 0.35 * urgency,
} as Band);
break;
}
case 'boom': {
const c = this.center(ev.cell) ?? this.boardCenter();
if (ev.shielded || ev.cause === 'SHIELDED') {
this.sfx?.play('shielded');
this.ring(c.x, c.y, { r1: 60, ttl: 500, color: '#4fd2ff', width: 3 });
this.flash('#4fd2ff', 0.16, 260);
this.shake(4, 240);
break;
}
this.sfx?.play('boom');
this.flash('#ffdfc4', 0.55, 320);
this.ring(c.x, c.y, { r1: 160, ttl: 520, color: '#ff7a29', width: 5 });
this.ring(c.x, c.y, { r1: 90, ttl: 380, color: '#ffe9d2', width: 3 });
if (!this.calm) {
this.burst(c.x, c.y, { n: 46, speed: 0.42, color: '#ff7a29', grav: 0.00055, ttl: 900, size: 3 });
this.burst(c.x, c.y, { n: 22, speed: 0.2, color: '#ffd9c0', grav: -0.00012, ttl: 1100, size: 2 });
this.chromatic(9, 520, c);
this.shake(14, 620);
} else {
this.flash('#e5484d', 0.25, 200);
}
break;
}
case 'deny':
this.sfx?.play('deny');
break;
}
}
/** Sector-clear celebration: a rising spark sweep across the whole field.
* `cells` are the revealed tiles (cell keys) to seed sparks from. */
celebrate(cells: string[]): void {
this.sfx?.play('clear');
if (this.calm) {
this.flash('#7ee2a0', 0.14, 300);
return;
}
const step = Math.max(1, Math.floor(cells.length / 24));
for (let i = 0; i < cells.length; i += step) {
const c = this.center(cells[i]);
if (!c) continue;
this.burst(c.x, c.y + 4, {
n: 3,
speed: 0.09,
color: i % (step * 2) === 0 ? '#7ee2a0' : '#e8b13c',
grav: -0.00028, // rises like embers
ttl: 900 + (i % 5) * 120,
size: 2,
delay: (i % 10) * 40,
});
}
this.ringAtBoard({ r1: 220, ttl: 700, color: '#7ee2a0', width: 2 });
}
/** Drop everything and settle (used between tests / on newRun). */
reset(): void {
this.stopAll();
}
private stopAll(): void {
this.effects.length = 0;
this.wind = null;
this.shakeAmp = 0;
this.chroma = 0;
if (this.shakeEl) {
this.shakeEl.style.transform = '';
this.shakeEl.classList.remove('shaking');
}
this.stopRaf();
// the dirty-flag skip in frame() no longer guarantees a final clear on
// this path (loop cancelled mid-flight), so clear here.
this.clearCanvas();
this.canvasDirty = false;
}
// ------------------------------ primitives --------------------------------
private burst(
x: number,
y: number,
o: { n: number; speed: number; color: string; grav: number; ttl: number; size: number; delay?: number },
): void {
const delay = o.delay ?? 0;
for (let i = 0; i < o.n; i++) {
const a = Math.random() * TAU;
const v = o.speed * (0.35 + Math.random() * 0.9);
this.effects.push({
x, y,
vx: Math.cos(a) * v,
vy: Math.sin(a) * v,
age: -delay * Math.random(), // negative age = not born yet
ttl: o.ttl * (0.6 + Math.random() * 0.7),
size: o.size * (0.6 + Math.random()),
color: o.color,
grav: o.grav,
drag: 0.985,
});
}
this.startRaf();
}
private ring(
x: number, y: number,
o: { r1: number; ttl: number; color: string; width: number },
): void {
this.effects.push({ x, y, age: 0, ttl: o.ttl, r0: 2, r1: o.r1, width: o.width, color: o.color });
this.startRaf();
}
private ringAtBoard(o: { r1: number; ttl: number; color: string; width: number }): void {
const c = this.boardCenter();
this.ring(c.x, c.y, o);
}
private flash(color: string, peak: number, ttl: number, board = false): void {
this.effects.push({ age: 0, ttl, color, peak, board } as Flash);
this.startRaf();
}
/** Streak riding the shift direction across the board rect. `strength`
* scales brightness/speed so the final-turn warning streaks read louder
* than the ambient drift but quieter than the shift itself. */
private sweep(dx: number, dy: number, strength = 1): void {
const len = Math.hypot(dx, dy) || 1;
this.effects.push({
age: 0, ttl: 420,
dx: (dx / len) * strength, dy: (dy / len) * strength,
} as Sweep);
this.startRaf();
}
/** The shift ITSELF: a train of three parallel streaks plus edge bands on
* the exit and re-entry edges — makes the whole-field slide and the
* toroidal wrap legible in one beat (ruling 2026-10-28). */
private sweepBurst(dx: number, dy: number): void {
for (const age of [0, -110, -220]) {
const len = Math.hypot(dx, dy) || 1;
this.effects.push({ age, ttl: 420, dx: dx / len, dy: dy / len } as Sweep);
}
this.effects.push({ age: 0, ttl: 560, ax: Math.sign(dx), ay: Math.sign(dy) } as Band);
this.startRaf();
}
// --------------------------- telegraph wind layer -------------------------
/** The Shifter telegraph as a live on-board wind field. Called from the
* render-sync point (main.ts) after EVERY engine change, so direction,
* countdown urgency and Stabilizer freeze ride the same source of truth
* as the HUD chip. Null ⇒ Shifter asleep (or reduced motion). */
setTelegraph(w: { dx: number; dy: number; urgency: number; frozen: boolean } | null): void {
if (!w || this.calm) {
this.wind = null;
return;
}
const urgency = Math.max(0, Math.min(1, w.urgency));
if (!this.wind || this.wind.dx !== w.dx || this.wind.dy !== w.dy) {
const dots: WindDot[] = [];
for (let i = 0; i < Fx.WIND_DOTS; i++) dots.push({ u: Math.random(), v: Math.random() });
this.wind = { dx: w.dx, dy: w.dy, urgency, frozen: w.frozen, dots };
} else {
this.wind.urgency = urgency;
this.wind.frozen = w.frozen;
}
if (this.wind) this.startRaf();
}
/** QA/browser-suite view of the live wind layer (never game logic). */
get telegraphWind(): { dx: number; dy: number; frozen: boolean } | null {
return this.wind
? { dx: this.wind.dx, dy: this.wind.dy, frozen: this.wind.frozen }
: null;
}
/** Streak dots advance toroidally inside the wind field (normalized units). */
private stepWind(dt: number): void {
const w = this.wind;
if (!w || w.frozen) return;
const len = Math.hypot(w.dx, w.dy) || 1;
const speed = 0.00018 + w.urgency * 0.00035; // board-widths per ms
const ux = (w.dx / len) * speed * dt;
const uy = (w.dy / len) * speed * dt;
for (const d of w.dots) {
d.u = ((d.u + ux) % 1 + 1) % 1;
d.v = ((d.v + uy) % 1 + 1) % 1;
}
}
/** Depth-6 awakening intro beat (user ruling 2026-10-28): a subtle,
* one-shot screen shake so the Shifter warning modal has physical weight.
* Amplitude is deliberately near the shielded-thud level (4), not a boom. */
introRumble(): void {
this.shake(3.4, 700);
}
private shake(amp: number, ttl: number): void {
if (this.calm) return;
this.shakeEl = document.getElementById('app');
this.shakeAmp = Math.max(this.shakeAmp, amp);
this.shakeTtl = ttl;
this.shakeAge = 0;
// D5 (audit 2026-10-28): layer promotion lives on .shaking for exactly
// the shake window instead of permanently on #app — the 60 fps #fx
// repaint no longer churns a promoted viewport-sized layer at rest.
this.shakeEl?.classList.add('shaking');
this.startRaf();
}
private chromatic(ampPx: number, ttl: number, at?: { x: number; y: number }): void {
if (this.calm) return;
const c = at ?? this.boardCenter();
this.chromaX = c.x;
this.chromaY = c.y;
this.chroma = Math.max(this.chroma, ampPx);
this.chromaTtl = ttl;
this.chromaAge = 0;
this.startRaf();
}
// -------------------------------- geometry --------------------------------
private boardRect(): DOMRect | null {
// A3 (audit 2026-10-29): B3 fetched exactly one rect per frame, but a
// wind-live idle frame is 24-30/s — ~30 forced-layout syncs/s measured
// (UpdateLayoutTree+PrePaint pairs in the idle-wind CDP trace). The
// board cannot move without a resize/scroll/rebuild, all three covered:
// 500 ms TTL + event invalidation; rebuilds keep identical geometry.
const now = performance.now();
if (this.rectCache && now - this.rectAt < 500) return this.rectCache;
const r = this.boardEl()?.getBoundingClientRect() ?? null;
this.rectCache = r;
this.rectAt = now;
return r;
}
private boardCenter(): { x: number; y: number } {
const r = this.boardRect();
return r ? { x: r.left + r.width / 2, y: r.top + r.height / 2 } : { x: innerWidth / 2, y: innerHeight / 2 };
}
// --------------------------------- loop -----------------------------------
private startRaf(): void {
if (!this.raf) {
this.last = performance.now();
this.rafDeadLogged = false; // fresh chain: a new death may log once
this.raf = requestAnimationFrame(this.frame);
}
}
private stopRaf(): void {
if (this.raf) cancelAnimationFrame(this.raf);
this.raf = 0;
}
/** A4: shared restore path for contextrestored AND the watchdog probe. */
private restoreContext(): void {
this.ctx2d = this.canvas.getContext('2d');
this.ctxLostAt = 0;
this.lostProbeAt = 0;
document.documentElement.classList.remove('fx-dead');
this.resize(); // re-applies dpr sizing (also resets bbox bookkeeping)
this.canvasDirty = true; // force a full repaint once pixels are live
this.startRaf(); // self-stopping if nothing pending; recovers a chain
// that died while the context was gone (e.g. a T2-catched frame error)
}
/** A4 watchdog (runs from the live loop while the context is lost and
* the restore event has not fired). Once the loss lingers past a beat:
* flag html.fx-dead (main.ts keeps a DOM/CSS fallback glow armed on the
* board for the shift telegraph — the fairness-critical signal must not
* depend on the canvas) and probe once per second whether the context
* actually executes commands again (1px paint + getImageData round
* trip; getImageData throws or reads zeros while lost). */
private lostWatchdog(now: number): void {
if (now - this.ctxLostAt < 1200) return;
if (!document.documentElement.classList.contains('fx-dead')) {
console.warn('[fx] canvas context lost, contextrestored not received — DOM telegraph fallback armed, watchdog probing for recovery');
document.documentElement.classList.add('fx-dead');
// D5 (residue variant, playtest 2026-10-29): we preventDefault the
// lost event (required to ever get contextrestored), which per spec
// means the canvas KEEPS displaying its last painted bitmap while the
// context is dead. If that frozen last frame was a flash (amber shift
// wash / blue shield wash), the wash is stuck on screen and a dead
// ctx2d cannot clearRect it. A canvas width/height assignment resets
// the bitmap at the DOM level — works even with the context dead —
// so the stuck wash is gone within ~1.2 s of the loss. Only at arm
// time, so brief losses that restore in-flight keep their bitmap.
this.canvas.width = this.canvas.width;
this.canvas.height = this.canvas.height;
this.prevBBox = null;
this.bboxFull = false;
this.sweepGrad = null;
this.bandGrads = null;
this.chromaGrads = null;
}
if (now - this.lostProbeAt < 1000) return;
this.lostProbeAt = now;
try {
const c = this.canvas.getContext('2d');
if (!c) return;
c.setTransform(1, 0, 0, 1, 0, 0);
c.clearRect(0, 0, 2, 2);
c.fillStyle = 'rgba(7,9,11,255)';
c.fillRect(0, 0, 2, 2);
const px = c.getImageData(0, 0, 1, 1).data;
if (px[3] === 255 && px[0] === 7) {
console.warn('[fx] watchdog recovered live 2D context (contextrestored never fired)');
this.restoreContext(); // resize() wipes the probe pixel
}
} catch {
// still lost: getImageData throws while dead — keep waiting
}
}
// T2 (audit 2026-10-28): a single exception escaping step()/draw() silently
// kills the rAF chain while `raf` stays set — every later startRaf() is a
// no-op and ALL juice is dead for the rest of the run (the "stops entirely,
// forever" signature). Catch at the loop boundary, clear the handle so the
// next game action re-arms the loop, and log once per death (startRaf
// re-arms the flag) so a persistently broken draw cannot spam the console.
private readonly frame = (now: number): void => {
try {
if (this.ctxLostAt) this.lostWatchdog(now);
this.runFrame(now);
} catch (err) {
if (!this.rafDeadLogged) {
this.rafDeadLogged = true;
console.warn('[fx] draw frame threw — juice loop halted until next action:', err);
}
this.raf = 0;
}
};
private runFrame(now: number): void {
// A1 (audit 2026-10-29): the old 48 ms clamp THREW AWAY real time —
// measured clampFactor 0.478 at 15 fps (wind dots advanced at half
// speed; at 2-5 fps the telegraph "froze" — user bug 1's hang/freeze).
// Everything integrated here is wrap/age physics (wind toroidal mod 1,
// particles/rings/sweeps driven by age vs ttl), so it must see WALL
// time; a huge gap just completes effects instead of slow-mo'ing them.
// Capped at 2 s purely for float sanity after a backgrounded tab.
const dt = Math.min(2000, now - this.last);
this.last = now;
this.step(dt);
// P2.1: only touch the canvas when there is something to draw. During
// the shake-only tail (effects drained, chroma decayed) the previous
// frame already left the canvas clean — skip draw() and do a single
// clear on the transition to empty so no residue frame lingers.
const windLive = this.wind !== null && !this.calm;
const hasPaint = this.effects.length > 0 || this.chroma > 0.2 || windLive;
// B2 -> A2 (audit 2026-10-29): keep the wind-only paint duty-cycle at
// max 30 Hz, but bound it by WALL time, not frame parity — parity
// halved an already-degraded rate under load (7.3 paints/s at 15 fps
// measured; dots visibly strobed under the telegraph). Wall-time means
// every frame paints once fps drops below 30; at 60 fps the board-
// sized clear+repaint still happens only ~30/s.
const windOnly = windLive && this.effects.length === 0 && this.chroma <= 0.2;
// C2: size the canvas for what just stepped (board box in the
// wind-only steady state, viewport the moment a burst is live).
if (hasPaint) this.layoutFx();
if (hasPaint && (!windOnly || now - this.lastWindPaint >= 33)) {
this.draw();
this.lastWindPaint = now;
this.canvasDirty = true;
} else if (hasPaint) {
// wind-only throttle window: the last wind frame stays on canvas
} else if (this.canvasDirty) {
this.clearCanvas();
this.canvasDirty = false;
}
// shake decay (transform on #app; overlays are fixed to the viewport and
// inherit the shake too — the whole world shakes as one)
if (this.shakeAmp > 0.1) {
this.shakeAge += dt;
// LINEAR ttl decay guarantees termination: a multiplicative decay
// asymptotes to 0, the rAF loop exits with `> 0.1` false while the
// element is still displaced — #app left translated = page overflow.
const k = Math.max(0, 1 - this.shakeAge / this.shakeTtl);
const amp = this.shakeAmp * k;
if (this.shakeEl) {
this.shakeEl.style.transform = amp <= 0.1
? ''
: `translate(${(Math.random() * 2 - 1) * amp}px, ${(Math.random() * 2 - 1) * amp}px) rotate(${(Math.random() * 2 - 1) * amp * 0.06}deg)`;
if (amp <= 0.1) this.shakeEl.classList.remove('shaking');
}
if (this.shakeAge >= this.shakeTtl) {
this.shakeAmp = 0;
if (this.shakeEl) {
this.shakeEl.style.transform = '';
this.shakeEl.classList.remove('shaking');
}
}
}
if (this.chroma > 0.2) {
this.chromaAge += dt;
this.chroma = Math.max(0, this.chroma * (1 - dt / this.chromaTtl));
if (this.chromaAge >= this.chromaTtl) this.chroma = 0; // canvas fully cleared by loop-exit clearCanvas
}
if (this.effects.length > 0 || this.shakeAmp > 0.1 || this.chroma > 0.2
|| (this.wind !== null && !this.calm)) {
this.raf = requestAnimationFrame(this.frame);
} else {
this.stopRaf();
this.clearCanvas(); // guaranteed final clear at teardown
this.canvasDirty = false;
}
}
private step(dt: number): void {
this.stepWind(dt);
for (let i = this.effects.length - 1; i >= 0; i--) {
const e = this.effects[i];
e.age += dt;
if (e.age > e.ttl) {
this.effects.splice(i, 1);
continue;
}
if ('vx' in e && e.age > 0) {
e.vy += e.grav * dt;
e.x += e.vx * dt;
e.y += e.vy * dt;
const drag = Math.pow(e.drag, dt / 16);
e.vx *= drag;
e.vy *= drag;
}
}
}
private clearCanvas(): void {
this.ctx2d?.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.prevBBox = null;
this.bboxFull = false;
}
/** Radius (CSS px) of the chromatic ghost halo around the blast center
* (P2.2). Two viewport-wide fills became two clamped radial fills. */
private static readonly CHROMA_RADIUS = 260;
/** Telegraph wind streak count (ruling 2026-10-28). */
private static readonly WIND_DOTS = 30;
private draw(): void {
const ctx = this.ctx2d;
if (!ctx) return;
const w = this.canvas.width / this.dpr;
const h = this.canvas.height / this.dpr;
// C2: board mode offsets the transform so every draw call keeps
// using viewport CSS coords; full mode has boxX/Y = 0 (identity).
ctx.setTransform(this.dpr, 0, 0, this.dpr, -this.boxX * this.dpr, -this.boxY * this.dpr);
// B3 (audit 2026-10-28): the board rect CANNOT change mid-frame, yet it
// was fetched 2–8× per frame (bbox loop + every drawSweep/drawBand/
// drawWind call) — each a forced-layout sync point (~50/s measured).
// Fetch once per frame and thread it through the draw helpers.
const rect = this.boardRect();
// E1: dirty-rect drawing. Compute this frame's paint bbox (union of
// live particle/ring/halo bounds), then clear (prev ∪ cur) instead of
// the whole viewport-sized buffer. Flash/sweep paint viewport-wide, so
// frames containing one clear fully. In the sparse blast tail a few
// particles keep the loop alive ~1.4 s per boom — per-frame canvas
// damage shrinks from the full viewport to the particles' few-hundred
// px box, which is what re-raster cost on software-raster machines.
let cx0 = Infinity, cy0 = Infinity, cx1 = -Infinity, cy1 = -Infinity;
let curFull = false;
const grow = (x0: number, y0: number, x1: number, y1: number): void => {
if (x0 < cx0) cx0 = x0;
if (y0 < cy0) cy0 = y0;
if (x1 > cx1) cx1 = x1;
if (y1 > cy1) cy1 = y1;
};
if (this.chroma > 0.2) {
const cr = Fx.CHROMA_RADIUS + this.chroma; // ghosts offset by ±chroma px
grow(this.chromaX - cr, this.chromaY - cr, this.chromaX + cr, this.chromaY + cr);
}
if (this.wind !== null && !this.calm) {
if (rect) grow(rect.left - 16, rect.top - 16, rect.right + 16, rect.bottom + 16);
}
for (const e of this.effects) {
if (e.age < 0) continue; // pre-born: draws nothing this frame
if ('peak' in e) {
if ((e as Flash).board) {
if (rect) grow(rect.left - 8, rect.top - 8, rect.right + 8, rect.bottom + 8);
continue;
}
curFull = true; break;
}
if ('dx' in e) {
// B5: sweeps are board-relative — bound the strip's four rotated
// corners (exactly the region drawSweep fills) instead of forcing
// a viewport-wide clear. Rare impact effects (full flashes, rings,
// chroma) still set curFull, so the machinery stays exercised.
const half = Math.max(rect?.width ?? 0, rect?.height ?? 0) / 2 + 14;
const bandS = 26;
const k = e.age < 0 ? 0 : Math.min(1, e.age / e.ttl);
const travel = (k - 0.5) * 2 * half * 1.2;
const cx = (rect?.left ?? 0) + (rect?.width ?? w) / 2;
const cy = (rect?.top ?? 0) + (rect?.height ?? 0) / 2;
const len = Math.hypot(e.dx, e.dy) || 1;
const ux = e.dx / len, uy = e.dy / len;
for (const sx of [-1, 1]) {
for (const sy of [-1, 1]) {
const px = cx + ux * (travel + sx * bandS) - uy * sy * half;
const py = cy + uy * (travel + sx * bandS) + ux * sy * half;
grow(px, py, px, py);
}
}
continue;
}
if ('ax' in e) {
if (rect) grow(rect.left - 8, rect.top - 8, rect.right + 8, rect.bottom + 8);
continue;
}
const k = e.age / e.ttl;
if ('vx' in e) {
const s = e.size * (1 - k * 0.5) + 1; // arc radius ≤ size
grow(e.x - s, e.y - s, e.x + s, e.y + s);
} else {
// ring: eased radius + current stroke half-width (+1 AA slop)
const rr = e.r0 + (e.r1 - e.r0) * (1 - Math.pow(1 - k, 2))
+ (e.width * (1 - k * 0.6)) / 2 + 1;
grow(e.x - rr, e.y - rr, e.x + rr, e.y + rr);
}
}
if (curFull || this.bboxFull) {
ctx.clearRect(this.boxX, this.boxY, w, h); // CSS coords of the canvas itself
} else if (cx1 > cx0 && cy1 > cy0) {
let ux0 = Math.min(cx0, this.prevBBox?.x0 ?? cx0);
let uy0 = Math.min(cy0, this.prevBBox?.y0 ?? cy0);
let ux1 = Math.max(cx1, this.prevBBox?.x1 ?? cx1);
let uy1 = Math.max(cy1, this.prevBBox?.y1 ?? cy1);
// C2: clamp to the canvas's own CSS footprint (viewport coords),
// not [0, w] — board mode has boxX/Y > 0.
ux0 = Math.max(this.boxX, Math.floor(ux0)); uy0 = Math.max(this.boxY, Math.floor(uy0));
ux1 = Math.min(this.boxX + w, Math.ceil(ux1)); uy1 = Math.min(this.boxY + h, Math.ceil(uy1));
if (ux1 > ux0 && uy1 > uy0) ctx.clearRect(ux0, uy0, ux1 - ux0, uy1 - uy0);
}
// P2.3: chromatic ghosts AND the flashes share one `screen` pass —
// `globalCompositeOperation` is toggled at most once per frame instead
// of mid-frame. Without active chroma (e.g. every reduced-motion event,
// and the post-chroma tail of a normal boom) flashes stay plain
// source-over exactly as before.
const screenPass = this.chroma > 0.2;
if (screenPass) {
ctx.globalCompositeOperation = 'screen';
this.drawChromaGhosts(ctx);
for (const e of this.effects) {
if (e.age >= 0 && 'peak' in e) this.drawFlash(ctx, e, rect);
}
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
}
for (const e of this.effects) {
if (e.age < 0) continue;
const k = e.age / e.ttl;
if ('vx' in e) {
const alpha = 1 - k;
ctx.globalAlpha = alpha;
ctx.fillStyle = e.color;
ctx.beginPath();
ctx.arc(e.x, e.y, e.size * (1 - k * 0.5), 0, TAU);
ctx.fill();
} else if ('r1' in e) {
ctx.globalAlpha = (1 - k) * 0.9;
ctx.strokeStyle = e.color;
ctx.lineWidth = e.width * (1 - k * 0.6);
ctx.beginPath();
ctx.arc(e.x, e.y, e.r0 + (e.r1 - e.r0) * (1 - Math.pow(1 - k, 2)), 0, TAU);
ctx.stroke();
} else if ('dx' in e) {
this.drawSweep(ctx, e, k, rect);
} else if ('ax' in e) {
this.drawBand(ctx, e, k, rect);
} else if (!screenPass) {
this.drawFlash(ctx, e, rect);
}
}
if (this.wind !== null && !this.calm) this.drawWind(ctx, rect);
ctx.globalAlpha = 1;
// E1: record what THIS draw could have painted, for the next frame's
// clear. On an empty-cur frame (all effects pre-born) keep the pending
// bookkeeping — the dirty region must survive until a frame clears it.
if (curFull) {
this.bboxFull = true;
this.prevBBox = null;
} else if (cx1 > cx0 && cy1 > cy0) {
this.bboxFull = false;
this.prevBBox = {
x0: Math.max(this.boxX, Math.floor(cx0)),
y0: Math.max(this.boxY, Math.floor(cy0)),
x1: Math.min(this.boxX + w, Math.ceil(cx1)),
y1: Math.min(this.boxY + h, Math.ceil(cy1)),
};
}
}
/** Full-viewport wash (320 ms on boom peak) — the one intentional
* viewport-wide fill, coalesced into the screen pass when chroma is live. */
private drawFlash(ctx: CanvasRenderingContext2D, f: Flash, r: DOMRect | null): void {
ctx.globalAlpha = f.peak * (1 - f.age / f.ttl);
ctx.fillStyle = f.color;
// B5 (audit 2026-10-28): the recurring shift-warning wash paints the
// board rect, not the whole viewport — the per-frame clear then stays
// board-sized instead of viewport-sized (B1's P3 clone pattern). The
// impact flashes (boom/shield/death) keep the viewport-wide wash:
// those are rare one-offs the partial-clear machinery must survive.
if (f.board && r) ctx.fillRect(r.left, r.top, r.width, r.height);
else ctx.fillRect(0, 0, this.canvas.width / this.dpr, this.canvas.height / this.dpr);
}
/** P2.2: R/B ghosts as radial halos clamped to `CHROMA_RADIUS` around the
* blast center (offset by ±chroma px), instead of two viewport-sized
* `fillRect`s. Rect is clipped to the canvas so off-edge halos don't
* request out-of-bounds raster. */
private drawChromaGhosts(ctx: CanvasRenderingContext2D): void {
const a = Math.min(0.35, this.chroma / 30);
const r = Fx.CHROMA_RADIUS;
const w = this.canvas.width / this.dpr;
const h = this.canvas.height / this.dpr;
// E2: the two radial gradients are cached per blast center with
// alpha-1 stops; the decaying alpha rides globalAlpha and the ±chroma
// R/B separation is applied by translating the paint (the closing-split
// motion costs nothing per frame). 2 gradient allocations per boom
// instead of ~2/frame across the 520 ms window.
let cg = this.chromaGrads;
if (!cg || cg.x !== this.chromaX || cg.y !== this.chromaY) {
const mk = (rgb: string): CanvasGradient => {
const grad = ctx.createRadialGradient(this.chromaX, this.chromaY, 0, this.chromaX, this.chromaY, r);
grad.addColorStop(0, `rgba(${rgb}, 1)`);
grad.addColorStop(0.55, `rgba(${rgb}, 0.45)`);
grad.addColorStop(1, `rgba(${rgb}, 0)`);
return grad;
};
cg = { x: this.chromaX, y: this.chromaY, g: [mk('255, 40, 60'), mk('40, 120, 255')] };
this.chromaGrads = cg;
}
const offs = [this.chroma, -this.chroma];
for (let i = 0; i < 2; i++) {
const off = offs[i];
const gx = this.chromaX + off;
// clamp in canvas space, then shift back into the translated frame
const x0 = Math.max(0, gx - r) - off;
const x1 = Math.min(w, gx + r) - off;
const y0 = Math.max(0, this.chromaY - r);
const y1 = Math.min(h, this.chromaY + r);
if (x1 <= x0 || y1 <= y0) continue;
ctx.save();
ctx.translate(off, 0);
ctx.globalAlpha = a;
ctx.fillStyle = cg.g[i];
ctx.fillRect(x0, y0, x1 - x0, y1 - y0);
ctx.restore();
}
}
/** A luminous band sweeping from the trailing edge of the board toward the
* shift direction — makes the toroidal slide readable as MOTION. */
private drawSweep(ctx: CanvasRenderingContext2D, e: Sweep, k: number, r: DOMRect | null): void {
if (!r) return;
const pad = 14;
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const half = Math.max(r.width, r.height) / 2 + pad;
const travel = (k - 0.5) * 2 * half * 1.2;
const band = 26;
const alpha = Math.sin(Math.PI * k) * 0.5;
ctx.save();
ctx.translate(cx + e.dx * travel, cy + e.dy * travel);
ctx.rotate(Math.atan2(e.dy, e.dx));
// B4 (audit 2026-10-28): one cached gradient per ctx — it lives in this
// translated/rotated space and `band` is constant, so every sweep frame
// reuses it (was: 1 allocation per sweep per frame). The animated alpha
// rides globalAlpha with stops pinned at alpha 1 (E2 pattern). Side
// benefit: sweep alpha is now DETERMINISTIC — previously it multiplied
// the gradient's alpha into whatever globalAlpha a preceding effect in
// the draw loop had left behind.
if (!this.sweepGrad) {
const grad = ctx.createLinearGradient(-band, 0, band, 0);
grad.addColorStop(0, 'rgba(143, 212, 255, 0)');
grad.addColorStop(0.5, 'rgba(143, 212, 255, 1)');
grad.addColorStop(1, 'rgba(143, 212, 255, 0)');
this.sweepGrad = grad;
}
ctx.globalAlpha = alpha;
ctx.fillStyle = this.sweepGrad;
ctx.fillRect(-band, -half, band * 2, half * 2);
ctx.restore();
}
/** Toroidal wrap tell (ruling 2026-10-28): amber bands on the edges mines
* EXIT (full strength) and the opposite edges they RE-ENTER (fainter) —
* teaches the wrap while the slide itself sweeps through the field. */
private drawBand(ctx: CanvasRenderingContext2D, e: Band, k: number, r: DOMRect | null): void {
if (!r) return;
const band = 22;
const a = Math.sin(Math.PI * k) * 0.55 * (e.strength ?? 1);
// B4 (audit 2026-10-28): strip gradients are pure functions of the board
// rect — cache them keyed by its edges (was: up to 8 createLinearGradient
// allocations per band per frame). Rebuild only on resize/scroll. The
// animated alpha rides globalAlpha; stops pin alpha 1 at the edge.
let bg = this.bandGrads;
if (!bg || bg.left !== r.left || bg.top !== r.top || bg.right !== r.right || bg.bottom !== r.bottom) {
const mk = (x0: number, y0: number, x1: number, y1: number): CanvasGradient => {
const grad = ctx.createLinearGradient(x0, y0, x1, y1);
grad.addColorStop(0, 'rgba(232, 177, 60, 0)');
grad.addColorStop(1, 'rgba(232, 177, 60, 1)');
return grad;
};
bg = {
left: r.left, top: r.top, right: r.right, bottom: r.bottom,
// edge index: 0 right, 1 left, 2 bottom, 3 top (transparent inner →
// opaque outer, matching each strip's original gradient direction)
g: [
mk(r.right - band, 0, r.right, 0),
mk(r.left + band, 0, r.left, 0),
mk(0, r.bottom - band, 0, r.bottom),
mk(0, r.top + band, 0, r.top),
],
};
this.bandGrads = bg;
}
const grads = bg.g;
const strip = (edge: number, x0: number, y0: number, x1: number, y1: number, mul: number): void => {
ctx.globalAlpha = a * mul;
ctx.fillStyle = grads[edge];
ctx.fillRect(x0, y0, x1 - x0, y1 - y0);
};
// D3 (audit 2026-10-28): the single-entry loop wrapper left over from
// the old multi-edge design flattened to straight-line code (mul was 1).
if (e.ax > 0) strip(0, r.right - band, r.top, r.right, r.bottom, 1);
if (e.ax < 0) strip(1, r.left, r.top, r.left + band, r.bottom, 1);
if (e.ay > 0) strip(2, r.left, r.bottom - band, r.right, r.bottom, 1);
if (e.ay < 0) strip(3, r.left, r.top, r.right, r.top + band, 1);
const back = 0.55; // re-entry edges: fainter
if (e.ax > 0) strip(1, r.left, r.top, r.left + band, r.bottom, back);
if (e.ax < 0) strip(0, r.right - band, r.top, r.right, r.bottom, back);
if (e.ay > 0) strip(3, r.left, r.top, r.right, r.top + band, back);
if (e.ay < 0) strip(2, r.left, r.bottom - band, r.right, r.bottom, back);
}
/** The always-on telegraph wind: short streaks drifting across the board in
* the announced shift direction, wrapping like the mines themselves. Alpha,
* length and speed ramp with countdown urgency; a Stabilizer hold freezes
* the drift and turns it ice-blue (the pause is felt ON the board). */
private drawWind(ctx: CanvasRenderingContext2D, r: DOMRect | null): void {
const w = this.wind;
if (!w || !r) return;
const len = Math.hypot(w.dx, w.dy) || 1;
const ux = w.dx / len;
const uy = w.dy / len;
const streak = w.frozen ? 9 : 10 + w.urgency * 14;
// Visibility floor raised (user ruling 2026-10-28): at depth 6 the first
// shift is ~28 turns out, so urgency starts near 0 — the old 0.12-alpha
// hairlines were invisible there, making the telegraph read as "missing".
const alpha = w.frozen ? 0.42 : 0.26 + w.urgency * 0.34;
ctx.save();
ctx.beginPath();
ctx.rect(r.left - 4, r.top - 4, r.width + 8, r.height + 8);
ctx.clip();
ctx.strokeStyle = w.frozen ? 'rgb(79, 210, 255)' : 'rgb(232, 177, 60)';
ctx.lineWidth = w.frozen ? 2.6 : 2 + w.urgency * 1.1;
ctx.lineCap = 'round';
ctx.globalAlpha = alpha;
ctx.beginPath();
for (const d of w.dots) {
const x = r.left + d.u * r.width;
const y = r.top + d.v * r.height;
ctx.moveTo(x - (ux * streak) / 2, y - (uy * streak) / 2);
ctx.lineTo(x + (ux * streak) / 2, y + (uy * streak) / 2);
}
ctx.stroke();
ctx.restore();
}
}
