orbit/js/ui/PerimeterShimmer.js

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 */
}
}
}
}