230 lines
8.3 KiB
JavaScript
230 lines
8.3 KiB
JavaScript
/**
|
|
* 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 */
|
|
}
|
|
}
|
|
}
|
|
}
|