Add dual-shimmer rim to READY quest rows in the tracker HUD

- New js/ui/PerimeterShimmer.js: constant amber base rim + glow, and two comets in counter-streams (amber CW, neon cyan CCW) tracing the cut-corner plate perimeter; pure arc math (perimeterPath / arcPoint / arcPolyline) exported for bare-Node tests
- QuestTrackerHud promotes READY rows to the console's cut-corner plate (CyberShape.rectPoints), drives PerimeterShimmer per-frame on the absolute clock so repaints never reset comet phase, and fires a one-shot rim flash when a row is claimed; shimmers are torn down with each paint
- data/quests.json gains tracker.shimmer config (enabled, notch, periodMs, dashFrac, phaseOffsetPerRow, claimFlashMs, base, comets)
- CyberShape.rectPoints added for absolute-coord cut-corner polygons
- New dev/perimeter-shimmer.test.mjs covering arc math, counter-streams, absolute-phase invariance, degenerate configs; quest-tracker tests extended for shimmer lifecycle and claim flash
This commit is contained in:
Brian Fertig 2026-09-12 11:42:56 -06:00
parent 54989d3a3a
commit 338a45e782
7 changed files with 735 additions and 15 deletions

View File

@ -305,6 +305,7 @@ node dev/saves.test.mjs # the save bank + capture/restore hand-off (in
node dev/decode.test.mjs # the shared decode scramble (menu seed + system dossier)
node dev/system-hud.test.mjs # real GameScene dossier: layout + staggered decode to final report
node dev/quest-tracker.test.mjs # the quest tracker HUD: slide in/out, live checklist, priority, SHOW MORE, the popup contract
node dev/perimeter-shimmer.test.mjs # the READY row's dual-shimmer rim: perimeter arc math + the counter-streaming comets
node dev/quests.test.mjs # the quest ledger: grant/claim + the ONE priority (set/clear/sticks/auto-assign) + save round-trip
node dev/sfx.test.mjs # the shared SFX voice: guards + sfx.json keys/files
node dev/music.test.mjs # the shared music voice: guards + music.json contract

View File

@ -66,6 +66,20 @@
"showMore": "SHOW MORE",
"maxVisible": 5,
"slideMs": 320,
"shimmer": {
"_comment": "The READY row's DUAL-SHIMMER rim (js/ui/PerimeterShimmer.js): a constant amber base rim + soft glow, and two comets in counter-streams around the plate's cut-corner perimeter (comets[0] amber clockwise, comets[1] neon cyan counter-clockwise) — they cross twice per cycle. Runs until the reward is claimed: the row drops off with a one-shot rim flash (claimFlashMs). periodMs = one full lap; dashFrac = a comet's dash as a fraction of the perimeter; phaseOffsetPerRow staggers simultaneous READY rows; notch = the READY plate's corner-cut (the console's CLAIM plate in miniature).",
"enabled": true,
"notch": 6,
"periodMs": 2400,
"dashFrac": 0.3,
"phaseOffsetPerRow": 0.25,
"claimFlashMs": 150,
"base": { "color": "#ffc94d", "alpha": 0.55, "glowAlpha": 0.14, "width": 1.5 },
"comets": [
{ "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 }
]
},
"priorityToast": {
"glyph": "◆",
"color": "#67e863",

View File

@ -0,0 +1,296 @@
/**
* PerimeterShimmer test (dev tool, run with Node — no browser):
*
* node dev/perimeter-shimmer.test.mjs
*
* Runs the REAL shimmer (js/ui/PerimeterShimmer.js) — its pure arc math
* (perimeterPath / arcPoint / arcPolyline, no Phaser) and the class
* against a stubbed scene (recording FakeGraphics — the
* quest-tracker.test.mjs pattern) — and pins the READY row's
* dual-shimmer rim contract:
*
* - the perimeter is the CUT-CORNER polygon (6 segments) when notched,
* a plain rectangle (4) when not — and the notch is clamped (it can
* never eat the whole plate);
* - arcPoint / arcPolyline sample the perimeter in arc order,
* wrap-safe, with consecutive duplicates removed;
* - the base rim is drawn ONCE (a glow pass under a core pass);
* - two comets in counter-streams (opposite dirs, half a lap apart —
* the data/quests.json → tracker.shimmer defaults survive the map);
* - update(t) re-streams the comets every frame, on the ABSOLUTE
* clock (a repaint mid-flight never resets the phase — phase0 +
* t/period give the same head at the same absolute time);
* - a zero-alpha comet draws nothing; destroy() is idempotent.
*/
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import fs from 'node:fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
// --- Stub just enough of Phaser for the Color.js import chain ----------
const PhaserStub = {
Display: { Color: { ValueToColor: (v) => ({ color: parseInt(v.slice(1), 16) }) } },
};
globalThis.window = { Phaser: PhaserStub }; // js/vendor/phaser.js reads this
const {
PerimeterShimmer,
perimeterPath,
arcPoint,
arcPolyline,
} = await import(pathToFileURL(join(__dirname, '../js/ui/PerimeterShimmer.js')).href);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
const approx = (a, b, eps = 1e-6) => Math.abs(a - b) < eps;
const near = (p, x, y, eps = 1e-6) => approx(p.x, x, eps) && approx(p.y, y, eps);
// ===========================================================================
// 1) Pure: the perimeter — cut-corner vs plain, notch clamped
// ===========================================================================
{
const rect = perimeterPath(0, 0, 100, 20, 0);
check('plain rect: 4 perimeter segments, P = 2*(w+h)',
rect.segs.length === 4 && approx(rect.P, 240));
const cut = perimeterPath(0, 0, 100, 20, 6);
const diag = Math.hypot(6, 6);
check('cut-corner: 6 perimeter segments (4 edges + 2 diagonals)', cut.segs.length === 6);
check('…and P = 2*(w-n) + 2*(h-n) + 2*diag',
approx(cut.P, 2 * 94 + 2 * 14 + 2 * diag, 1e-9));
check('the diagonals sit at top-left + bottom-right (the CyberShape corners)',
near({ x: cut.segs[2].ax, y: cut.segs[2].ay }, 100, 14) &&
near({ x: cut.segs[2].bx, y: cut.segs[2].by }, 94, 20) &&
near({ x: cut.segs[5].ax, y: cut.segs[5].ay }, 0, 6) &&
near({ x: cut.segs[5].bx, y: cut.segs[5].by }, 6, 0));
const big = perimeterPath(0, 0, 100, 20, 1000);
check('a runaway notch is CLAMPED (n <= h/2 — never eats the plate)',
big.segs.length === 6 && Number.isFinite(big.P) && approx(big.segs[0].ax, 10));
const off = perimeterPath(30, 40, 100, 20, 0);
check('absolute coords: the rect starts at (x, y)',
near({ x: off.segs[0].ax, y: off.segs[0].ay }, 30, 40));
}
// ===========================================================================
// 2) Pure: arcPoint — arc order, corners, wrap, negatives
// ===========================================================================
{
const { segs, P } = perimeterPath(0, 0, 100, 20, 0);
check('arcPoint: along the top edge', near(arcPoint(segs, P, 10), 10, 0));
check('arcPoint: the top-right corner (d = w)', near(arcPoint(segs, P, 100), 100, 0));
check('arcPoint: down the right edge', near(arcPoint(segs, P, 110), 100, 10));
check('arcPoint: bottom-left corner (d = 2w + h)', near(arcPoint(segs, P, 220), 0, 20));
check('arcPoint: d == P wraps to the start', near(arcPoint(segs, P, 240), 0, 0));
check('arcPoint: negative d wraps the long way (−10 ≡ 230)', near(arcPoint(segs, P, -10), 0, 10));
}
// ===========================================================================
// 3) Pure: arcPolyline — in-segment, corner-crossing, wrap-safe, deduped
// ===========================================================================
{
const { segs, P } = perimeterPath(0, 0, 100, 20, 0);
const flat = (pl) => pl.map((p) => `${p.x},${p.y}`).join(' → ');
const a = arcPolyline(segs, P, 10, 30);
check('in one segment: a straight 2-point dash',
a.length === 2 && near(a[0], 10, 0) && near(a[1], 40, 0));
const b = arcPolyline(segs, P, 95, 30);
check('crossing the top-right corner: [95,0] → [100,0] → [100,20] → [95,20]',
b.length === 4 && near(b[0], 95, 0) && near(b[1], 100, 0) && near(b[2], 100, 20) && near(b[3], 95, 20));
const c = arcPolyline(segs, P, 230, 30);
check('WRAP-SAFE (crosses d = P): [0,10] → [0,0] → [20,0] (up the left edge, over the corner, 20 units along)',
c.length === 3 && near(c[0], 0, 10) && near(c[1], 0, 0) && near(c[2], 20, 0));
const deduped = (pl) => pl.every((p, i) => i === 0 || !near(p, pl[i - 1]));
check('no consecutive duplicates (shared boundary points are removed)',
deduped(a) && deduped(b) && deduped(c));
check('zero length → an empty polyline', arcPolyline(segs, P, 50, 0).length === 0);
// The notched plate: the dash rides the diagonal cleanly.
const cut = perimeterPath(0, 0, 100, 20, 6);
const { P: Pc } = cut;
const topLen = 94;
const d = arcPolyline(cut.segs, Pc, topLen + 6, 20);
check('notched: the dash crosses the bottom-right diagonal (right edge → cut → bottom edge)',
d.length === 4 && near(d[0], 100, 6, 1e-6) && near(d[1], 100, 14) && near(d[2], 94, 20) &&
approx(d[3].y, 20) && near(d[3], ...Object.values(arcPoint(cut.segs, Pc, topLen + 26))));
console.log(` (debug) ${flat(d)}`);
}
// ===========================================================================
// 4) Class: the rim is drawn once; two counter-streaming comets
// ===========================================================================
function makeScene() {
const glist = [];
const container = {
children: [],
add(o) {
this.children.push(o);
return this;
},
};
return {
time: { now: 0 },
add: {
graphics: () => {
const g = { calls: [], destroyed: false, alpha: 1 };
for (const fn of ['clear', 'lineStyle', 'fillStyle', 'fillRect', 'fillPoints', 'strokePoints', 'lineBetween']) {
g[fn] = (...args) => {
if (fn === 'clear') g.calls.length = 0;
else g.calls.push({ fn, args });
return g;
};
}
g.setScrollFactor = () => g;
g.destroy = () => {
g.destroyed = true;
return g;
};
glist.push(g);
return g;
},
existing: (o) => o,
},
glist,
container,
};
}
const linePts = (g) => g.calls.filter((c) => c.fn === 'lineBetween').map((c) => `${c.args[0]},${c.args[1]}→${c.args[2]},${c.args[3]}`);
{
const scene = makeScene();
const sh = new PerimeterShimmer(scene, 14, 72, 250, 20, { container: scene.container, notch: 6 });
check('the rim + the comet layer join the container (they slide with the tracker)',
scene.container.children.length === 2 && scene.container.children.includes(sh.rim) && scene.container.children.includes(sh.cometLayer));
const rimLines = sh.rim.calls.filter((c) => c.fn === 'lineStyle');
check('the base rim is drawn ONCE: a glow pass + a core pass (2 lineStyle + 2 strokePoints)',
rimLines.length === 2 && sh.rim.calls.filter((c) => c.fn === 'strokePoints').length === 2);
const [glow, core] = rimLines.map((c) => c.args);
check('the glow is the WIDER, fainter pass (width+3.5, low alpha)',
glow[0] > core[0] && glow[2] < core[2] && glow[1] === 0xffc94d);
check('the rim is the AMBER claimable color (0xffc94d)', core[1] === 0xffc94d);
check('TWO comets in COUNTER-STREAMS: amber clockwise + cyan counter-clockwise',
sh.comets.length === 2 &&
sh.comets[0].dir === 1 && sh.comets[1].dir === -1 &&
sh.comets[0].color === 0xffc94d && sh.comets[1].color === 0x00e5ff);
check('…half a lap apart (the crossing lands twice per cycle)',
Math.abs(sh.comets[0].phase - sh.comets[1].phase) === 0.5);
check('the rim traces the CUT-CORNER plate (6 segments)', sh.segs.length === 6);
// Per-frame: the comets re-stream (line passes land) and MOVE.
sh.update(0);
const at0 = linePts(sh.cometLayer);
sh.update(sh.periodMs / 4);
const atQuarter = linePts(sh.cometLayer);
check('update(t) re-streams the comets (line passes on the comet layer)',
at0.length > 0 && atQuarter.length > 0);
check('…and they MOVE with the clock (a quarter lap shifts the dashes)',
JSON.stringify(at0) !== JSON.stringify(atQuarter));
// ABSOLUTE phase: phase0 + t/period is the head — a "repaint" (a fresh
// instance at the same absolute time) draws the SAME dashes.
const A = new PerimeterShimmer(makeScene(), 14, 72, 250, 20, { notch: 6, phase0: 0, periodMs: 2400 });
A.update(1200); // half a lap by the clock
const B = new PerimeterShimmer(makeScene(), 14, 72, 250, 20, { notch: 6, phase0: 0.5, periodMs: 2400 });
B.update(0); // the SAME head, reached by phase0 instead of the clock
check('the phase is ABSOLUTE: phase0 0 @ t = T/2 ≡ phase0 0.5 @ t = 0',
JSON.stringify(linePts(A.cometLayer)) === JSON.stringify(linePts(B.cometLayer)));
// destroy() — idempotent, and update() after it is a safe no-op.
sh.destroy();
sh.destroy();
check('destroy(): rim + comet layer gone, idempotent',
sh.destroyed === true && sh.rim.destroyed === true && sh.cometLayer.destroyed === true);
sh.update(100); // must not throw
check('update() after destroy(): a safe no-op', true);
}
// ===========================================================================
// 5) Config-driven: data/quests.json → tracker.shimmer survives the map
// ===========================================================================
{
const quests = JSON.parse(fs.readFileSync(join(__dirname, '../data/quests.json'), 'utf8'));
const cfg = quests.tracker.shimmer;
check('the config exists and is enabled (with the tuned defaults)',
cfg !== null && cfg.enabled === true && cfg.notch === 6 && cfg.periodMs === 2400 &&
cfg.dashFrac === 0.3 && cfg.claimFlashMs === 150);
check('the config defines the dual comets (amber CW, neon CCW, half a lap apart)',
cfg.comets.length === 2 && cfg.comets[0].dir === 1 && cfg.comets[1].dir === -1 &&
cfg.comets[0].phase === 0 && cfg.comets[1].phase === 0.5);
const scene = makeScene();
const sh = new PerimeterShimmer(scene, 14, 72, 250, 20, {
container: scene.container,
notch: cfg.notch,
periodMs: cfg.periodMs,
dashFrac: cfg.dashFrac,
base: cfg.base,
comets: cfg.comets,
});
check('the tracker.shimmer base rim lands (amber, the config alpha)',
(() => {
const lines = sh.rim.calls.filter((c) => c.fn === 'lineStyle');
const core = lines[lines.length - 1].args;
return core[1] === 0xffc94d && approx(core[2], 0.55);
})());
sh.update(100);
check('the tracker.shimmer comets stream (both colors drawn on the comet layer)',
(() => {
const colors = new Set(sh.cometLayer.calls.filter((c) => c.fn === 'lineStyle').map((c) => c.args[1]));
return colors.has(0xffc94d) && colors.has(0x00e5ff);
})());
sh.destroy();
}
// ===========================================================================
// 6) Degenerate configs stay safe
// ===========================================================================
{
const scene = makeScene();
// A zero-alpha comet draws nothing (the rim still stands).
const sh0 = new PerimeterShimmer(scene, 0, 0, 100, 20, {
comets: [
{ color: '#ffc94d', dir: 1, phase: 0, width: 2, alpha: 0, haloAlpha: 0.3 },
{ color: '#00e5ff', dir: -1, phase: 0.5, width: 2, alpha: 0, haloAlpha: 0.3 },
],
});
sh0.update(0);
check('zero-alpha comets draw NOTHING (the rim is untouched)',
sh0.cometLayer.calls.filter((c) => c.fn === 'lineBetween').length === 0 &&
sh0.rim.calls.filter((c) => c.fn === 'strokePoints').length === 2);
sh0.destroy();
// Missing comets → the built-in dual pair (amber + neon).
const sh1 = new PerimeterShimmer(scene, 0, 0, 100, 20, {});
check('no comet config → the built-in dual pair (2 comets, opposite dirs)',
sh1.comets.length === 2 && sh1.comets[0].dir === 1 && sh1.comets[1].dir === -1);
sh1.destroy();
// A degenerate rect throws loudly (never a silent NaN perimeter).
let threw = false;
try {
new PerimeterShimmer(scene, 0, 0, 0, 0, {});
} catch {
threw = true;
}
check('a degenerate rect (0x0) throws (no silent NaN perimeter)', threw === true);
}
if (failures > 0) {
console.error(`\n${failures} perimeter-shimmer test(s) FAILED`);
process.exit(1);
}
console.log('\nperimeter-shimmer: all checks passed');

View File

@ -900,10 +900,10 @@ const T0 = 100000;
trk.show(scene.time.now);
trk.update(scene.time.now);
const rowTints = () => trk._kids.filter((k) => k instanceof FakeGraphics && !k.input).length;
check('an open row stays plain: 0% readout, no READY label, no tint',
check('an open row stays plain: 0% readout, no READY label, no tint, no shimmer rim',
scene.add.texts.some((t) => t.text === '0%') &&
!scene.add.texts.some((t) => t.text === 'READY') &&
rowTints() === 0);
rowTints() === 0 && trk._shimmers.length === 0);
// The quest COMPLETES (a check flips) — the same-frame repaint goes READY.
snap.quests[0].checks[0].done = true;
@ -919,6 +919,14 @@ const T0 = 100000;
return !!t && t.color === '#eaf6ff';
})());
check('READY row: the breathing tint is up (a plate + a tint per row)', rowTints() === 1);
check('READY row: the DUAL-SHIMMER rim is up, tracing the CUT-CORNER plate (6 perimeter segments)',
trk._shimmers.length === 1 && trk._shimmers[0].segs.length === 6);
check('…two comets in COUNTER-STREAMS (opposite dirs, half a lap apart)',
(() => {
const c = trk._shimmers[0].comets;
return c.length === 2 && c[0].dir === 1 && c[1].dir === -1 &&
Math.abs(c[0].phase - c[1].phase) === 0.5;
})());
check('READY row: the tint breathes (a slow yoyo sine, 0.1 → 0.3)',
tweens.some((t) => t.targets instanceof FakeGraphics && !t.targets.input && t.alpha === 0.3 && t.repeat === -1 && t.yoyo === true));
check('the MOMENT fired: a row-flash + four ◆ glyph bursts (tweened)',
@ -946,6 +954,18 @@ const T0 = 100000;
trk._popup.btns[0].panel.fire('pointerdown');
check('CLAIM REWARD → onClaim(q_open) in place + the popup closes',
claimed.length === 1 && claimed[0] === 'q_open' && trk.popupOpen === false);
// The ledger flips claimed (GameScene.claimQuest's repaint trigger) —
// the row drops off with a one-shot RIM FLASH (the reward pouring into
// the button) and the shimmer is torn down with the paint.
tweens.length = 0;
snap.quests[0].claimed = true;
trk.update(scene.time.now);
check('CLAIMED → the row drops off + a one-shot rim flash (~150 ms fade — distinct from the 420 ms moment)',
trk._rowRects.length === 0 &&
tweens.some((t) => t.targets instanceof FakeGraphics && !t.targets.input && t.alpha === 0 && t.duration === 150));
check('…and the DUAL-SHIMMER rim is torn down with the paint (no leak)',
trk._shimmers.length === 0);
trk.destroy();
// While SLID OFF, the flip is silent (the scene\u2019s toast + chime carry it)
@ -972,6 +992,18 @@ const T0 = 100000;
glyphs(tweensH).length === 0 && !tweensH.some((t) => t.targets instanceof FakeGraphics && t.alpha === 0));
check('…and the row is READY-styled when it re-shows',
sceneH.add.texts.some((t) => t.text === 'READY' && t.color === '#ffc94d'));
check('…with the shimmer rim up (it streams when the tracker re-shows)',
trkH._shimmers.length === 1 && trkH._shimmers[0].segs.length === 6);
// The claim while slid off — the rim flash is SKIPPED (nothing renders
// offscreen; the toast + chime + the console carry it), and the rim is
// torn down anyway.
tweensH.length = 0;
snapH.quests[0].claimed = true;
trkH.update(sceneH.time.now);
check('a claim while slid off: NO rim flash + the shimmer is torn down anyway',
!tweensH.some((t) => t.targets instanceof FakeGraphics && t.alpha === 0) &&
trkH._shimmers.length === 0);
trkH.destroy();
}

View File

@ -27,6 +27,24 @@ export class CyberShape {
];
}
/**
* The cut-corner polygon in ABSOLUTE coordinates over the rect
* (x, y, w, h) — for Graphics drawn at screen/world coordinates (the
* tracker's READY row plate: CyberShape.rectPoints(rx, ry, rw, rh, 6)).
* Same corners as points() (top-left + bottom-right).
*/
static rectPoints(x, y, w, h, notch) {
const n = Math.max(0, Math.min(notch ?? 0, w / 2, h / 2));
return [
{ x: x + n, y },
{ x: x + w, y },
{ x: x + w, y: y + h - n },
{ x: x + w - n, y: y + h },
{ x, y: y + h },
{ x, y: y + n },
];
}
/**
* Draw a cut-corner panel into an existing Graphics (at its origin).
*

229
js/ui/PerimeterShimmer.js Normal file
View File

@ -0,0 +1,229 @@
/**
* 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 */
}
}
}
}

View File

@ -18,9 +18,16 @@
*
* A COMPLETE-but-unclaimed quest is READY — the reward is sitting there:
* its row turns AMBER (the console's READY color — amber = claimable, the
* green accent stays the tracker's), the percent reads READY, and the
* plate breathes (a slow ~1.2 s sine on an amber tint — a pulse, never a
* strobe). The flip is a one-shot MOMENT: a bright row-flash + a small ◆
* green accent stays the tracker's), is promoted to the console's
* CUT-CORNER plate (the CLAIM plate in miniature), the percent reads
* READY, the plate breathes (a slow ~1.2 s sine on an amber tint — a
* pulse, never a strobe), and the rim SHIMMERS — a dual-shimmer rim
* (js/ui/PerimeterShimmer.js, data/quests.json → tracker.shimmer): a
* constant amber base rim + glow, and two comets in counter-streams
* around the perimeter (amber clockwise, neon cyan counter-clockwise),
* crossing twice per cycle — until the reward is CLAIMED: the row drops
* off with a one-shot rim flash (the reward pouring into the button). The
* flip is a one-shot MOMENT: a bright row-flash + a small ◆
* burst off the row's right edge (when the row is on screen — the
* scene's toast + 'discovery' chime carry it when the tracker is slid
* off), and the row's popup gains a CLAIM REWARD button (the console's
@ -56,10 +63,11 @@
*/
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toCss } from '../utils/Color.js';
import { toCss, toColor } from '../utils/Color.js';
import { fontStack, themeColor } from '../utils/Theme.js';
import { CyberShape } from './CyberShape.js';
import { MenuButton } from './MenuButton.js';
import { PerimeterShimmer } from './PerimeterShimmer.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
const HEADER = fontStack('header', FONT_FALLBACK);
@ -80,6 +88,12 @@ function catRank(q) {
return q?.category === 'main' ? 0 : q?.category === 'side' ? 1 : 2;
}
/** `Number(v)` when finite, else 0 (config-safe). */
function numCfg(v) {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
/**
* The tracker's visible rows: the first `max` in display order — except
* the priority quest, which is ALWAYS in the list (its checklist is the
@ -136,6 +150,13 @@ export class QuestTrackerHud {
this.rowW = 250; // the row plates' width (the percent sits at their right edge)
this._offX = -(this.rowW + 60); // offscreen-left (the slide's start/end)
// The READY row's DUAL-SHIMMER rim (data/quests.json →
// tracker.shimmer) + the plate's cut-corner promotion (notch px).
this._shimmerCfg = cfg.shimmer && typeof cfg.shimmer === 'object' ? cfg.shimmer : null;
this._shimmerOn = this._shimmerCfg !== null && this._shimmerCfg.enabled !== false;
this._notch = this._shimmerOn ? Math.max(0, Math.min(12, Math.floor(numCfg(this._shimmerCfg.notch)))) : 0;
this._shimmers = []; // the current paint's PerimeterShimmer instances
// Everything lives in ONE container — the slide is a single x
// translate of the container (at (0,0) at rest, so local == screen).
this.root = new Phaser.GameObjects.Container(scene, 0, 0);
@ -249,6 +270,12 @@ export class QuestTrackerHud {
* claimed/granted). The slide state is tween-driven, not polled.
*/
update(_time) {
// The READY rows' DUAL-SHIMMER rims (PerimeterShimmer) — per-frame,
// on the ABSOLUTE clock (a repaint mid-flight never resets the
// comets' phase). Skipped while hidden (invisible — nothing to burn).
if (this.state !== 'hidden' && this._shimmers.length > 0) {
for (const sh of this._shimmers) sh.update(_time);
}
let snap = null;
try {
snap = this.getSnapshot ? this.getSnapshot() : null;
@ -272,6 +299,20 @@ export class QuestTrackerHud {
}
const visible = pickVisible(active, priorityId, this.maxVisible);
const overflowFirst = active.length > visible.length ? active[visible.length] : null;
const readyNow = new Set(active.filter((q) => q.complete === true).map((q) => q.id));
// READY on the last paint, gone now = the reward was CLAIMED — grab
// the row's rect BEFORE the repaint tears it down (the exit flash is
// fired after the repaint, on top — the reward pouring into the
// button).
let claimFlashes = null;
if (this._readyBaseline !== null) {
const gone = [...this._readyBaseline].filter((id) => !readyNow.has(id));
if (gone.length > 0) {
claimFlashes = gone
.map((id) => this._rowRectById.get(id))
.filter((r) => r !== null && r !== undefined);
}
}
const fp = JSON.stringify({
p: priorityId,
a: ids,
@ -289,6 +330,9 @@ export class QuestTrackerHud {
this._fp = fp;
this._priorityId = priorityId;
this._paint(active, visible, priorityId, overflowFirst);
if (claimFlashes && this.shown) {
for (const r of claimFlashes) this._claimFlash(r);
}
this._readyTransitions(active); // the one-shot moment for any newly-READY row
}
@ -376,8 +420,78 @@ export class QuestTrackerHud {
}
}
/**
* Map data/quests.json → tracker.shimmer onto PerimeterShimmer's
* options (the config carries its own hex colors — no theme lookup).
* `rowIndex` staggers simultaneous READY rows, so their comets cross
* on different beats instead of in unison down the stack.
*/
_shimmerOptions(rowIndex) {
const c = this._shimmerCfg ?? {};
return {
container: this.root,
notch: this._notch,
periodMs: c.periodMs,
dashFrac: c.dashFrac,
phase0: rowIndex * (numCfg(c.phaseOffsetPerRow) || 0.25),
base: c.base,
comets: c.comets,
};
}
/**
* The CLAIM exit moment: a READY row just dropped off the paint (its
* reward claimed) — one bright rim flash over its last rect
* (~150 ms fade, tracker.shimmer.claimFlashMs), the reward pouring
* into the CLAIM button. Transient (NOT in _kids — it self-destroys),
* added after the repaint so it renders on top.
*/
_claimFlash(r) {
const s = this.scene;
const c = this._shimmerCfg ?? {};
const ms = Math.max(80, Math.floor(numCfg(c.claimFlashMs)) || 150);
const notch = this._notch;
const pts = notch > 0
? CyberShape.rectPoints(r.x, r.y, r.w, r.h, notch)
: [
{ x: r.x, y: r.y },
{ x: r.x + r.w, y: r.y },
{ x: r.x + r.w, y: r.y + r.h },
{ x: r.x, y: r.y + r.h },
];
const col = toColor(c.base?.color, C.amber);
const g = s.add.graphics().setScrollFactor(0);
g.clear();
g.lineStyle(6, col, 0.5);
g.strokePoints(pts, true);
g.lineStyle(2, 0xffe9b0, 0.95);
g.strokePoints(pts, true);
this.root.add(g);
s.tweens.add({
targets: g,
alpha: 0,
duration: ms,
ease: 'Sine.easeOut',
onComplete: () => {
try {
g.destroy();
} catch {
/* already gone */
}
},
});
}
/** Tear down the dynamic content (the title + rule are permanent). */
_teardown() {
for (const sh of this._shimmers) {
try {
sh.destroy();
} catch {
/* already gone */
}
}
this._shimmers = [];
for (const k of this._kids) {
try {
this.scene.tweens.killTweensOf(k); // the READY row's breathing tint
@ -423,7 +537,8 @@ export class QuestTrackerHud {
return;
}
for (const q of visible) {
for (let i = 0; i < visible.length; i++) {
const q = visible[i];
const isPrio = q.id === priorityId;
const isReady = q.complete === true; // ACTIVE quests only — claimed are filtered out above
const doneN = (q.checks ?? []).filter((c) => c?.done).length;
@ -434,15 +549,22 @@ export class QuestTrackerHud {
const rw = this.rowW;
const rh = 20;
// The row plate (a VISIBLE object — the v4 hit-test rule) + the
// left tick (amber = READY / claimable, green = the priority quest,
// faint = the rest).
// The row plate (a VISIBLE object — the v4 hit-test rule). A READY
// row is promoted to the console's CUT-CORNER plate (the CLAIM
// plate in miniature — the shimmer rim below traces the same
// polygon); the left tick (green = priority, faint = the rest) is
// for the plain rows — the READY row carries the rim instead.
const notch = isReady ? this._notch : 0;
const platePts = notch > 0 ? CyberShape.rectPoints(rx, ry, rw, rh, notch) : null;
const plate = add(s.add.graphics().setScrollFactor(0));
plate.clear();
plate.fillStyle(C.panel, 0.35);
plate.fillRect(rx, ry, rw, rh);
plate.fillStyle(isReady ? C.amber : isPrio ? C.green : C.faint, isReady ? 1 : isPrio ? 0.95 : 0.7);
plate.fillRect(rx, ry + 3, isPrio ? 2 : 1, rh - 6);
if (platePts) plate.fillPoints(platePts, true);
else plate.fillRect(rx, ry, rw, rh);
if (!isReady) {
plate.fillStyle(isPrio ? C.green : C.faint, isPrio ? 0.95 : 0.7);
plate.fillRect(rx, ry + 3, isPrio ? 2 : 1, rh - 6);
}
plate.setInteractive({
useHandCursor: true,
hitArea: new Phaser.Geom.Rectangle(rx, ry, rw, rh),
@ -458,12 +580,17 @@ export class QuestTrackerHud {
});
// The READY tint — an amber wash OVER the plate, UNDER the text,
// breathing on a slow sine (a pulse, never a strobe).
// breathing on a slow sine (a pulse, never a strobe) — and the
// DUAL-SHIMMER RIM around the cut-corner plate (a constant amber
// rim + two counter-streaming comets; data/quests.json →
// tracker.shimmer) — it runs until the claim repaints the row
// away (the one-shot claim flash in update() marks the exit).
if (isReady) {
const tint = add(s.add.graphics().setScrollFactor(0));
tint.clear();
tint.fillStyle(C.amber, 1);
tint.fillRect(rx, ry, rw, rh);
if (platePts) tint.fillPoints(platePts, true);
else tint.fillRect(rx, ry, rw, rh);
tint.setAlpha(0.1);
s.tweens.add({
targets: tint,
@ -473,6 +600,9 @@ export class QuestTrackerHud {
yoyo: true,
repeat: -1,
});
if (this._shimmerOn) {
this._shimmers.push(new PerimeterShimmer(s, rx, ry, rw, rh, this._shimmerOptions(i)));
}
}
add(