/** * PerimeterShimmer — the DUAL-SHIMMER rim of a READY quest row * (js/ui/QuestTrackerHud.js): * * - a CONSTANT base rim: a thin stroke + soft glow pass around the * plate's whole perimeter — the "always claimable" edge; * - TWO comets in counter-streams: each a bright dash with a fading * tail, one per direction around the perimeter, phase-offset half a * lap. They cross twice per cycle — the crossing is the shimmer. * * The rim and both comets trace the SAME cut-corner polygon as the plate * (CyberShape — top-left + bottom-right notched), so a READY row reads as * the console's CLAIM plate in miniature. * * The comet phase is derived from the ABSOLUTE clock passed to * update(time), so a repaint mid-flight (the tracker repaints on * fingerprint change) never makes a comet jump. Plain Graphics — no * textures, no tweens; the per-frame cost is a couple dozen line passes * over a 250x20 rect. * * The arc math (perimeterPath / arcPolyline) is pure — testable in bare * Node (dev/perimeter-shimmer.test.mjs). */ import { toColor } from '../utils/Color.js'; import { CyberShape } from './CyberShape.js'; /** The comet's dash: 3 tail→head bands of falling alpha (the fade). */ const BANDS = [0.16, 0.45, 1.0]; /** `Number(v)` when finite, else `fallback` (config-safe). */ function num(v, fallback) { const n = Number(v); return Number.isFinite(n) ? n : fallback; } /** * The rect's perimeter as arc-length segments (the cut-corner polygon * when `notch > 0`, a plain rectangle otherwise), clockwise from the * top-left: { segs: [{ax,ay,bx,by,len,start}], P } — P = total perimeter. * Pure (no Phaser). */ export function perimeterPath(x, y, w, h, notch = 0) { const n = Math.max(0, Math.min(notch ?? 0, w / 2, h / 2)); const pts = n > 0 ? CyberShape.rectPoints(x, y, w, h, n) : [ { x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h }, ]; const segs = []; let P = 0; for (let i = 0; i < pts.length; i++) { const a = pts[i]; const b = pts[(i + 1) % pts.length]; const len = Math.hypot(b.x - a.x, b.y - a.y); segs.push({ ax: a.x, ay: a.y, bx: b.x, by: b.y, len, start: P }); P += len; } return { segs, P }; } /** The point at arc distance `d` (mod P) along `segs`. Pure. */ export function arcPoint(segs, P, d) { d = ((d % P) + P) % P; for (const s of segs) { if (d <= s.start + s.len) { const t = s.len > 0 ? (d - s.start) / s.len : 0; return { x: s.ax + (s.bx - s.ax) * t, y: s.ay + (s.by - s.ay) * t }; } } return { x: segs[0].ax, y: segs[0].ay }; } /** * The polyline tracing the perimeter from `d0` for `len` — arc order, * wrap-safe, consecutive duplicates removed. A comet's dash. Pure. */ export function arcPolyline(segs, P, d0, len) { d0 = ((d0 % P) + P) % P; const total = Math.min(Math.max(0, len), P * 0.999); const parts = d0 + total <= P ? [[d0, d0 + total]] : [[d0, P], [0, d0 + total - P]]; const out = []; for (const [lo, hi] of parts) { for (const s of segs) { const sEnd = s.start + s.len; if (s.len <= 1e-9 || sEnd <= lo || s.start >= hi) continue; const a = Math.max(lo, s.start); const b = Math.min(hi, sEnd); if (b - a < 1e-9) continue; out.push(arcPoint(segs, P, a), arcPoint(segs, P, b)); } } const clean = []; for (const p of out) { const last = clean[clean.length - 1]; if (!last || Math.hypot(p.x - last.x, p.y - last.y) > 1e-4) clean.push(p); } return clean; } /** * @param {Phaser.Scene} scene the owning scene * @param {number} x the rect's left edge * @param {number} y the rect's top edge * @param {number} w * @param {number} h * @param {object} o * @param {object} [o.container] the tracker's root — the rim + comets join * it (so they slide with the tracker) * @param {number} [o.notch] the corner-cut size (0 = a plain rectangle) * @param {number} [o.periodMs] one full lap (~2400) * @param {number} [o.dashFrac] a comet's dash, as a fraction of the perimeter (~0.3) * @param {number} [o.phase0] this row's base phase (staggered per row) * @param {object} [o.base] the constant rim { color, alpha, glowAlpha, width } * @param {object[]} [o.comets] [{ color, dir (+1|-1), phase (0..1), width, alpha, haloAlpha }] */ export class PerimeterShimmer { constructor(scene, x, y, w, h, o = {}) { const { segs, P } = perimeterPath(x, y, w, h, Math.max(0, Math.min(12, num(o.notch, 0)))); if (P <= 0) throw new Error('PerimeterShimmer: degenerate rect'); this.segs = segs; this.P = P; this.periodMs = Math.max(600, num(o.periodMs, 2400)); this.dashFrac = Math.min(0.5, Math.max(0.05, num(o.dashFrac, 0.3))); this.phase0 = (((num(o.phase0, 0)) % 1) + 1) % 1; this.destroyed = false; // The default pair (when the config gives none): amber clockwise + // neon cyan counter-clockwise, half a lap apart. const FALLBACK = [ { color: '#ffc94d', dir: 1, phase: 0, width: 2, alpha: 1, haloAlpha: 0.32 }, { color: '#00e5ff', dir: -1, phase: 0.5, width: 2, alpha: 0.9, haloAlpha: 0.32 }, ]; const raw = Array.isArray(o.comets) && o.comets.length > 0 ? o.comets : FALLBACK; this.comets = raw.map((c, i) => { const cm = (c && typeof c === 'object') ? c : {}; const f = FALLBACK[i % FALLBACK.length]; return { color: toColor(cm.color, toColor(f.color, 0xffffff)), dir: num(cm.dir, f.dir) < 0 ? -1 : 1, phase: (((num(cm.phase, f.phase)) % 1) + 1) % 1, width: Math.max(0.5, num(cm.width, f.width)), alpha: Math.max(0, Math.min(1, num(cm.alpha, f.alpha))), haloAlpha: Math.max(0, Math.min(1, num(cm.haloAlpha, f.haloAlpha))), }; }); const join = (g) => { g.setScrollFactor(0); if (o.container) o.container.add(g); // the tracker's root — slides with it return g; }; // The CONSTANT base rim (drawn once — the glow pass under the core). const base = (o.base && typeof o.base === 'object') ? o.base : {}; const rimColor = toColor(base.color, 0xffc94d); const rimWidth = Math.max(0.5, num(base.width, 1.5)); const rimAlpha = Math.max(0, Math.min(1, num(base.alpha, 0.55))); const rimGlow = Math.max(0, Math.min(1, num(base.glowAlpha, 0.14))); const pts = [ { x: segs[0].ax, y: segs[0].ay }, ...this.segs.map((s) => ({ x: s.bx, y: s.by })), ]; this.rim = join(scene.add.graphics()); this.rim.clear(); this.rim.lineStyle(rimWidth + 3.5, rimColor, rimGlow); this.rim.strokePoints(pts, true); this.rim.lineStyle(rimWidth, rimColor, rimAlpha); this.rim.strokePoints(pts, true); // The comets — redrawn every frame in update() (the phase is absolute, // so a repaint mid-flight never resets them). this.cometLayer = join(scene.add.graphics()); this._drawAt(num(scene?.time?.now, 0)); // initial paint (hidden at birth) } /** Per-frame (QuestTrackerHud.update): re-stream both comets. */ update(time) { if (this.destroyed) return; this._drawAt(num(time, 0)); } _drawAt(time) { const g = this.cometLayer; g.clear(); const P = this.P; const t = (((time / this.periodMs) % 1) + 1) % 1; const dashLen = this.dashFrac * P; for (const c of this.comets) { const head = (((this.phase0 + c.phase) * P + t * c.dir * P) % P + P) % P; this._drawComet(g, c, head, dashLen); } } /** One comet: 3 tail→head bands, each a halo pass + a core pass. */ _drawComet(g, c, head, dashLen) { const bandLen = dashLen / BANDS.length; for (let i = 0; i < BANDS.length; i++) { const d0 = head - dashLen + i * bandLen; // tail → head const poly = arcPolyline(this.segs, this.P, d0, bandLen + 1e-6); if (poly.length < 2) continue; const a = c.alpha * BANDS[i]; if (a < 0.004) continue; const pass = (width, alpha) => { g.lineStyle(width, c.color, alpha); for (let j = 0; j < poly.length - 1; j++) { g.lineBetween(poly[j].x, poly[j].y, poly[j + 1].x, poly[j + 1].y); } }; pass(c.width + 3, a * c.haloAlpha); // the halo (soft width, low alpha) pass(c.width, a); // the core } } destroy() { if (this.destroyed) return; this.destroyed = true; for (const obj of [this.rim, this.cometLayer]) { try { obj.destroy(); } catch { /* already gone */ } } } }