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:
parent
54989d3a3a
commit
338a45e782
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue