From 338a45e782bf057878f12adea08d1f6d07fdb689 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sat, 12 Sep 2026 11:42:56 -0600 Subject: [PATCH] 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 --- README.md | 1 + data/quests.json | 14 ++ dev/perimeter-shimmer.test.mjs | 296 +++++++++++++++++++++++++++++++++ dev/quest-tracker.test.mjs | 36 +++- js/ui/CyberShape.js | 18 ++ js/ui/PerimeterShimmer.js | 229 +++++++++++++++++++++++++ js/ui/QuestTrackerHud.js | 156 +++++++++++++++-- 7 files changed, 735 insertions(+), 15 deletions(-) create mode 100644 dev/perimeter-shimmer.test.mjs create mode 100644 js/ui/PerimeterShimmer.js diff --git a/README.md b/README.md index 141154d..670a075 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/data/quests.json b/data/quests.json index 9fe2814..4a531d7 100644 --- a/data/quests.json +++ b/data/quests.json @@ -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", diff --git a/dev/perimeter-shimmer.test.mjs b/dev/perimeter-shimmer.test.mjs new file mode 100644 index 0000000..354c29d --- /dev/null +++ b/dev/perimeter-shimmer.test.mjs @@ -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'); diff --git a/dev/quest-tracker.test.mjs b/dev/quest-tracker.test.mjs index 1f402ce..5c27621 100644 --- a/dev/quest-tracker.test.mjs +++ b/dev/quest-tracker.test.mjs @@ -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(); } diff --git a/js/ui/CyberShape.js b/js/ui/CyberShape.js index 573b396..0cf5deb 100644 --- a/js/ui/CyberShape.js +++ b/js/ui/CyberShape.js @@ -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). * diff --git a/js/ui/PerimeterShimmer.js b/js/ui/PerimeterShimmer.js new file mode 100644 index 0000000..f62bd3f --- /dev/null +++ b/js/ui/PerimeterShimmer.js @@ -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 */ + } + } + } +} diff --git a/js/ui/QuestTrackerHud.js b/js/ui/QuestTrackerHud.js index 8b82352..161037e 100644 --- a/js/ui/QuestTrackerHud.js +++ b/js/ui/QuestTrackerHud.js @@ -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(