orbit/js/ui/ShimmerCue.js

135 lines
5.6 KiB
JavaScript

/**
* ShimmerCue — the READY-RESEARCH attention cue, shared by the two
* surfaces that wear it (the scene arms exactly one at a time — GameScene
*._applyResearchCue keeps the cue on one screen):
*
* - the command deck's RESEARCH slot (ActionBar.setShimmer) — while a
* tech can start, a yellow band sweeps the button's FACE, then a
* comet runs its cut-corner EDGE (face + edge);
* - the research console's category tabs (ResearchWindow._paintCues) —
* the same edge comet on every tab whose tree still holds an
* available tech (edge only; the tabs are the deck's button in
* miniature, without the face sweep).
*
* The cycle is a PURE function of the absolute clock (the PerimeterShimmer
* idiom — a repaint mid-cycle never makes the cue jump):
*
* periodMs = faceMs + gapMs + edgeMs + rest (the rest is silence);
* face — a soft yellow band + white-hot core crossing the face L→R;
* edge — a faint full-rim glow + a bright dash with a fading tail
* running one full lap around the cut-corner outline.
*
* `shimmerPhase` is pure (node-testable, dev/research-cue.test.mjs). The
* draw helpers take ANY object with the Phaser Graphics surface they use
* (fillStyle/fillRect/lineStyle/strokePoints) — a recorder suffices for
* the tests; the arc math is PerimeterShimmer's own (perimeterPath /
* arcPolyline), so the comet traces the SAME cut-corner polygon as the
* plates (top-left + bottom-right notched).
*/
import { perimeterPath, arcPolyline } from './PerimeterShimmer.js';
/** `Number(v)` when finite, else `fallback` (config-safe). */
const num = (v, fallback) => {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
};
/**
* The cycle's phase at `time` (see the header). Pure.
* Returns { faceU, faceA, edgeU, edgeA } — `*U` the 0..1 position,
* `*A` the 0..1 envelope (sine — zero at both ends of its window);
* the inactive half carries a null position + zero envelope.
* @param {number} time ms (the scene clock)
* @param {object} cfg { periodMs, faceMs, gapMs, edgeMs }
* @param {number} [offsetMs] a per-target phase offset (staggered rows)
*/
export function shimmerPhase(time, cfg = {}, offsetMs = 0) {
const P = Math.max(400, num(cfg.periodMs, 4200));
const faceMs = Math.max(0, num(cfg.faceMs, 700));
const gapMs = Math.max(0, num(cfg.gapMs, 140));
const edgeMs = Math.max(200, num(cfg.edgeMs, 1500));
const ct = (((num(time, 0) + num(offsetMs, 0)) % P) + P) % P;
const out = { faceU: null, faceA: 0, edgeU: null, edgeA: 0 };
if (faceMs > 0 && ct < faceMs) {
out.faceU = ct / faceMs;
out.faceA = Math.sin(Math.PI * out.faceU);
}
const e0 = faceMs + gapMs;
if (ct >= e0 && ct < e0 + edgeMs) {
out.edgeU = (ct - e0) / edgeMs;
out.edgeA = Math.sin(Math.PI * out.edgeU);
}
return out;
}
/**
* One shimmer pass over the plate rect (x, y = top-left; w, h; `notch`
* the corner-cut size, 0 = plain rectangle) into `g` (a Graphics, or a
* recorder with fillStyle/fillRect/lineStyle/strokePoints).
* @param {object} o { face?: boolean (default true — pass false for the
* tabs), dashFrac?: the comet's dash as a fraction of the perimeter }
*/
export function drawShimmer(g, x, y, w, h, notch, color, ph, o = {}) {
if (!ph || w <= 0 || h <= 0) return;
// FACE — the sweep band: a soft wide pass + a hot core + a 2 px white
// line at the band's centre. The band travels from just off the left
// edge to just off the right; its visible part is clamped to the face
// (a Graphics has no clip — clamping IS the clip).
if (o.face !== false && ph.faceA > 0.01) {
const margin = Math.max(18, w * 0.25);
const bx = x - margin + (w + 2 * margin) * ph.faceU;
const band = Math.max(10, w * 0.18);
let lo = Math.max(x, bx - band);
let hi = Math.min(x + w, bx + band);
if (hi > lo) {
g.fillStyle(color, 0.16 * ph.faceA);
g.fillRect(lo, y, hi - lo, h);
lo = Math.max(x, bx - 3);
hi = Math.min(x + w, bx + 3);
if (hi > lo) {
g.fillStyle(color, 0.5 * ph.faceA);
g.fillRect(lo, y, hi - lo, h);
}
const c0 = Math.max(x, Math.min(x + w, bx - 1));
const cw = Math.min(2, x + w - c0);
if (cw > 0) {
g.fillStyle(0xffffff, 0.55 * ph.faceA);
g.fillRect(c0, y, cw, h);
}
}
}
// EDGE — the faint full-rim glow (the rim is "armed" while the comet
// passes) + the comet itself: 3 tail→head bands of falling alpha, the
// same comet as PerimeterShimmer's READY rim, one lap per cycle.
if (o.edge !== false && ph.edgeA > 0.01) {
const n = Math.max(0, Math.min(num(notch, 0), w / 2, h / 2));
const { segs, P } = perimeterPath(x, y, w, h, n);
const pts = [{ x: segs[0].ax, y: segs[0].ay }];
for (const s of segs) pts.push({ x: s.bx, y: s.by });
g.lineStyle(2, color, 0.28 * ph.edgeA);
g.strokePoints(pts, true);
const dashFrac = Math.min(0.5, Math.max(0.08, num(o.dashFrac, 0.3)));
const head = (((ph.edgeU * P) % P) + P) % P;
drawComet(g, segs, P, head, dashFrac * P, color, ph.edgeA);
}
}
/** The comet: 3 tail→head bands, each a halo pass + a core pass. */
const BANDS = [0.16, 0.45, 1.0];
function drawComet(g, segs, P, head, dashLen, color, env) {
const bandLen = dashLen / BANDS.length;
for (let i = 0; i < BANDS.length; i++) {
const d0 = head - dashLen + i * bandLen;
const poly = arcPolyline(segs, P, d0, bandLen + 1e-6);
if (poly.length < 2) continue;
const a = env * BANDS[i];
if (a < 0.004) continue;
g.lineStyle(3.5, color, a * 0.3); // the halo (soft width, low alpha)
g.strokePoints(poly, false);
g.lineStyle(1.8, color, a); // the core
g.strokePoints(poly, false);
}
}