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