Add READY-RESEARCH attention cue on deck slot and console tabs

- New ShimmerCue module: pure shimmerPhase/drawShimmer cycle (yellow band sweeps face, comet runs cut-corner edge), shared by both surfaces
- ActionBar gains setShimmer + a dedicated additive layer; the RESEARCH slot wears the full face+edge recipe while a tech can start and the console is closed
- ResearchWindow._paintCues paints the edge-only shimmer on every category tab whose tree still holds an available tech, plus the ACK pulse on the "Unlock Jumpgates" plate (or SYSTEM tab when off-screen)
- GameScene._applyResearchCue switches one cue at a time: deck slot when console closed, console cues when open; rests while a project runs (maxConcurrent 1), under standby, or under the tutorial finger
- ResearchModel.hasAvailable and SystemCategory.gatesReady/gatesNodeId encode the ready rules
- data: actionbar.json animation.shimmer, research.json fx.tabShimmer (#ffd84d, comm-pulse yellow family)
- dev: Node test (research-cue.test.mjs) pinning the pure cycle model + slot/tab behavior; browser verify page (research-cue-shot.html/.mjs) with CDP capture poses
- assets: arid-surface-03 / arid-takeoff-03 videos
This commit is contained in:
Brian Fertig 2026-09-13 19:48:53 -06:00
parent 4334d8cc93
commit 1f39c25c2f
14 changed files with 1011 additions and 4 deletions

Binary file not shown.

Binary file not shown.

View File

@ -72,6 +72,16 @@
"intervalMs": [3500, 9000],
"durationMs": [240, 480],
"slices": [3, 6]
},
"shimmer": {
"_comment": "The RESEARCH slot's READY cue (GameScene._applyResearchCue arms it while a tech can start — one cue on screen at a time; the console's category tabs wear the same recipe, data/research.json → fx.tabShimmer): a yellow band sweeps the face, then a comet runs the cut-corner edge (js/ui/ShimmerCue.js). color is the comm pulse's yellow family (data/comms.json → box.pulse); periodMs = faceMs + gapMs + edgeMs + the silent rest.",
"enabled": true,
"color": "#ffd84d",
"periodMs": 4200,
"faceMs": 700,
"gapMs": 140,
"edgeMs": 1500,
"dashFrac": 0.3
}
}
}

View File

@ -75,7 +75,7 @@
{ "land": "lava-land-03.mp4", "surface": "lava-surface-03.mp4", "takeoff": "lava-takeoff-03.mp4", "shop": "ice-shop-03.mp4" },
{ "land": "arid-land-01.mp4", "surface": "arid-surface-01.mp4", "takeoff": "arid-takeoff-01.mp4", "shop": "ice-shop-01.mp4" },
{ "land": "arid-land-02.mp4", "surface": "arid-surface-02.mp4", "takeoff": "arid-takeoff-02.mp4", "shop": "ice-shop-02.mp4" },
{ "land": "ice-land-03.mp4", "surface": "ice-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "ice-shop-03.mp4" },
{ "land": "arid-land-03.mp4", "surface": "arid-surface-03.mp4", "takeoff": "arid-takeoff-03.mp4", "shop": "ice-shop-03.mp4" },
{ "land": "capital-land-neutral.mp4", "surface": "capital-surface-neutral.mp4", "takeoff": "capital-takeoff-neutral.mp4", "shop": "ice-shop-01.mp4", "bar": "capital-bar-neutral.mp4" },
{ "land": "lava-land-02.mp4", "surface": "lava-surface-02.mp4", "takeoff": "lava-takeoff-02.mp4", "shop": "ice-shop-02.mp4" },
{ "land": "ice-land-03.mp4", "surface": "ice-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "ice-shop-03.mp4" },

View File

@ -16,9 +16,19 @@
"aspect": [2, 3]
},
"fx": {
"_comment": "Window dressing. glitch = occasional RGB-split slice bursts; sweep = the scan band that crawls down the video feed.",
"_comment": "Window dressing. glitch = occasional RGB-split slice bursts; sweep = the scan band that crawls down the video feed; tabShimmer = the READY-RESEARCH cue on the category tabs (the deck's RESEARCH-slot shimmer's twin — data/actionbar.json → animation.shimmer, js/ui/ShimmerCue.js).",
"glitch": { "enabled": true, "intervalMs": [6000, 13000], "durationMs": [220, 420] },
"sweep": { "enabled": true, "everyMs": [4200, 8600], "durationMs": 1500 }
"sweep": { "enabled": true, "everyMs": [4200, 8600], "durationMs": 1500 },
"tabShimmer": {
"_comment": "Per frame while the console is open + no project in flight (maxConcurrent 1): every category tab whose tree still holds an available tech wears the edge comet (ResearchWindow._paintCues). The face sweep is deck-only (faceMs/gapMs 0 — the comet owns its window); tabs are staggered i*420 ms. color is the comm pulse's yellow family.",
"enabled": true,
"color": "#ffd84d",
"periodMs": 4200,
"faceMs": 0,
"gapMs": 0,
"edgeMs": 1500,
"dashFrac": 0.3
}
},
"_nodeTemplate": {
"label": "Tech name",

View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="../" />
<title>Orbit — READY-RESEARCH cue (dev verify)</title>
<style>
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
</style>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<div id="game"></div>
<script type="module" src="dev/research-cue-shot.mjs"></script>
</body>
</html>

225
dev/research-cue-shot.mjs Normal file
View File

@ -0,0 +1,225 @@
/**
* Dev-only: boot GameScene and VERIFY the READY-RESEARCH attention cue
* end-to-end in a real browser (the node tests pin the model + the deck;
* this drives the live scene + the console):
*
* 1. deck (window closed) — the RESEARCH slot's cue is armed (a tech
* can start), and the cycle paints: face band → edge comet → rest;
* 2. console (window open) — the deck's slot rests, the ready category
* tabs wear the edge shimmer, and the system's "Unlock Jumpgates"
* tech wears the ACK pulse (forced chart-complete): on its plate
* while the SYSTEM tree is on screen, on the SYSTEM tab otherwise;
* 3. in-flight — starting a project rests BOTH halves (maxConcurrent 1).
*
* The report (window.__CUE_SHOT + the #report <pre>) lists every check;
* the two screenshots pin the two faces of the cue (each capture freezes
* its cycle mid-window so the live clock paints the same frame):
*
* node dev/server.mjs 8091
* /…/chrome --headless --disable-gpu --remote-debugging-port=9333 about:blank
* node dev/cdp-shot.mjs http://127.0.0.1:8091/dev/research-cue-shot.html \
* /tmp/cue-deck.png "window.__CUE_SHOT && window.__CUE_SHOT.ready" 90000 \
* "window.__CUE_SHOT.captureDeck()"
* node dev/cdp-shot.mjs http://127.0.0.1:8091/dev/research-cue-shot.html \
* /tmp/cue-console.png "window.__CUE_SHOT && window.__CUE_SHOT.ready" 90000 \
* "window.__CUE_SHOT.captureConsole()"
*/
import Phaser from '../js/vendor/phaser.js';
import { config } from '../js/config/Config.js';
import { ConfigLoader } from '../js/config/ConfigLoader.js';
import { createGameConfig } from '../js/config/GameConfig.js';
import { GameScene } from '../js/scenes/GameScene.js';
import { gatesReady } from '../js/research/SystemCategory.js';
const data = await ConfigLoader.load();
config.init(data);
const errors = [];
const origErr = console.error.bind(console);
console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); };
window.addEventListener('error', (e) => errors.push(String(e.message)));
window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`));
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene];
const game = new Phaser.Game(gameConfig);
window.game = game;
const report = document.createElement('pre');
report.id = 'report';
report.style.cssText = 'position:fixed;left:10px;top:10px;z-index:9999;max-width:75%;margin:0;padding:8px 12px;font:13px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.92);border:1px solid #1b3a5a;white-space:pre-wrap;';
document.body.appendChild(report);
const setReport = (lines) => { report.textContent = (Array.isArray(lines) ? lines : [lines]).join('\n'); };
setReport('booting…');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function waitScene() {
for (let i = 0; i < 100; i++) {
const s = game.scene.getScene('GameScene');
if (s && s.ship && s.researchWindow) return s;
await sleep(100);
}
throw new Error('GameScene never booted');
}
// Recorder over a Phaser Graphics (counts the cue's own call surface).
const recorder = (g) => {
const r = { fillRect: 0, strokePoints: 0, fillPoints: 0, clear: 0 };
const orig = {
fillRect: g.fillRect.bind(g),
strokePoints: g.strokePoints.bind(g),
fillPoints: g.fillPoints.bind(g),
clear: g.clear.bind(g),
};
g.fillRect = (...a) => { r.fillRect++; return orig.fillRect(...a); };
g.strokePoints = (...a) => { r.strokePoints++; return orig.strokePoints(...a); };
g.fillPoints = (...a) => { r.fillPoints++; return orig.fillPoints(...a); };
g.clear = (...a) => { r.clear++; return orig.clear(...a); };
r.reset = () => { r.fillRect = 0; r.strokePoints = 0; r.fillPoints = 0; r.clear = 0; };
return r;
};
try {
const s = await waitScene();
await sleep(900); // let the boot settle
const lines = [];
const check = (label, cond) => lines.push(`${cond ? '✔' : '✘ FAIL'} ${label}`);
const bar = s.actionBar;
const win = s.researchWindow;
const st = s.researchState;
const slot = bar.slots.find((x) => x.id === 'research');
// ---- 1. the deck's RESEARCH slot (the window is closed) --------------
s._applyResearchCue(); // the scene's per-frame beat
check('deck: cue armed on the RESEARCH slot (fresh run — a tech can start)', bar.shimmerId === 'research');
const r = recorder(bar.shimmerG);
const cfg = bar.cfgShimmer;
const off = slot.phase * 350; // the bar's per-slot stagger
const P = cfg.periodMs;
const at = (u) => (((u % P) + P) % P);
const step = (t) => { r.reset(); bar.update(t, 16); return { ...r }; };
let a = step(at(cfg.faceMs / 2 - off));
check(`deck: face window — the band paints (fillRects ${a.fillRect} ≥ 3)`, a.fillRect >= 3 && a.strokePoints === 0);
a = step(at(cfg.faceMs + cfg.gapMs + cfg.edgeMs / 2 - off));
check(`deck: edge window — the comet runs (stroke passes ${a.strokePoints} ≥ 4)`, a.strokePoints >= 4 && a.fillRect === 0);
a = step(at(P - 1 - off));
check(`deck: rest — the layer idles (cleared=${a.clear})`, a.fillRect === 0 && a.strokePoints === 0 && a.clear >= 1);
// ---- 2. the console takes the cue over --------------------------------
s.deckAction('research');
for (let i = 0; i < 50 && win.openState !== 'open'; i++) await sleep(60);
await sleep(800); // the boot reveal finishes
s._applyResearchCue();
check('deck: the slot rests while the console is open', bar.shimmerId === null);
// Force the SYSTEM chart complete (the scene's isNavComplete hook) so
// the "Unlock Jumpgates" tech is ready to be researched.
s.systemTree.available = () => true;
s.systemTree.lockNote = () => null; // the readout agrees (AVAILABLE, not LOCKED)
win.refresh();
const gid = gatesReady(s.systemTree, st);
check('console: the gates tech is READY (chart forced complete)', !!gid);
const rw = recorder(win.cueG);
const wcfg = win.cfgShimmer;
const W = (t) => { rw.reset(); win.update(t); return { ...rw }; };
// A time inside the comet window (the tabs stagger i*420 — the first
// tab's window is [0, edgeMs); every tab's is within a few hundred ms).
const tw = Math.min(200, wcfg.edgeMs);
let w = W(s.time.now + tw);
check(`console: the ready tabs wear the edge shimmer (strokes ${w.strokePoints} ≥ 6)`, w.strokePoints >= 6);
w = W(s.time.now + tw);
check(`console: the gates plate wears the ACK pulse (halo fills ${w.fillPoints} ≥ 1)`, w.fillPoints >= 1);
// Off the SYSTEM tab → the pulse moves to the SYSTEM tab itself.
win.switchCategory('exploration');
await sleep(30);
w = W(s.time.now + tw);
check(`console: on another tab the pulse moves to the SYSTEM tab (halo fills ${w.fillPoints} ≥ 1)`, w.fillPoints >= 1);
win.switchCategory('system');
await sleep(30);
// ---- 3. in-flight — nothing can start, the cue rests everywhere ------
s.beginResearch('exploration', 'tether_l2');
s._applyResearchCue();
await sleep(30);
check('deck: in flight — the slot rests (maxConcurrent 1)', bar.shimmerId === null);
w = W(s.time.now + tw);
check(`console: in flight — the cues rest (strokes ${w.strokePoints}, fills ${w.fillPoints})`,
w.strokePoints === 0 && w.fillPoints === 0);
st.active = null; // dev poke — put the run back to "a tech can start"
win.refresh();
// ---- the capture poses (beforeCaptureExpr) ---------------------------
// The scene's live clock keeps redrawing, so each capture widens the
// cycle's window around the live time — every frame paints the same
// (frozen-mid-window) pose until the restore lands well after the shot.
window.__CUE_SHOT = {
ready: true,
lines,
errors,
captureDeck: async () => {
// The deck's face: the comms quiet (the intro's ACK queue is
// dev-drained — in play the hold owns the screen until the player
// presses through, and the cue rests under it by design), the
// console closed, the face window frozen mid-sweep.
const h = s.comms;
if (h) {
for (const q of h.queue) if (q?.def?.id) h.played.add(q.def.id);
h.queue.length = 0;
if (h.current) h._finish(); // the one in play: end it (the pause lifts)
await sleep(60);
}
if (win.isOpen) win.close(); // straight to the window's API (deckAction's input guard may still hold the ACK click)
for (let i = 0; i < 30 && win.isOpen; i++) await sleep(60);
const now = s.time.now;
const saved = { ...cfg };
Object.assign(cfg, { periodMs: now * 6, faceMs: now * 1.5, gapMs: 40, edgeMs: 500 });
await sleep(120); // a couple of scene frames at the pose
window.__CUE_SHOT.dbg = {
shimmerId: bar.shimmerId,
enabled: bar.enabled,
slotDisabled: slot?.disabled,
highlightId: bar.highlightId,
anyAvail: s.researchAnyAvailable(),
commsPaused: s.comms?.paused === true,
winOpen: win.openState,
now,
cfg: { ...cfg },
layer: typeof bar.shimmerG.geometry?.commands?.length,
};
document.getElementById('report').style.display = 'none';
setTimeout(() => Object.assign(cfg, saved), 2500);
return 'deck pose set';
},
captureConsole: async () => {
// The console's face: open on SYSTEM, gates ready, comet mid-lap.
if (!win.isOpen) win.open();
for (let i = 0; i < 50 && win.openState !== 'open'; i++) await sleep(60);
await sleep(900); // the reveal
s.systemTree.available = () => true;
s.systemTree.lockNote = () => null;
win.refresh();
win.switchCategory('system');
const now = s.time.now;
const savedW = { ...wcfg };
// edgeMs = 2·now → u = now/(2·now) = 0.5 (the comet mid-lap, full
// envelope) for every staggered tab; periodMs > now keeps ct = now.
Object.assign(wcfg, { periodMs: now * 4, faceMs: 0, gapMs: 0, edgeMs: Math.max(400, now * 2) });
await sleep(120);
document.getElementById('report').style.display = 'none';
setTimeout(() => Object.assign(wcfg, savedW), 2500);
return 'console pose set';
},
};
setReport(lines);
console.info('research-cue-shot: report painted');
} catch (err) {
errors.push(`FATAL: ${err.message}`);
setReport(['FATAL: ' + err.message, ...errors.slice(0, 3)]);
window.__CUE_SHOT = { ready: true, fatal: String(err.message), errors };
console.error('research-cue-shot: fatal', err);
}

335
dev/research-cue.test.mjs Normal file
View File

@ -0,0 +1,335 @@
/**
* READY-RESEARCH attention-cue test (dev tool, run with Node — no browser):
*
* node dev/research-cue.test.mjs
*
* Pins the contract behind the "research is ready" cue (one cue on screen
* at a time — GameScene._applyResearchCue picks the surface):
* - the deck's RESEARCH slot (ActionBar.setShimmer): a periodic yellow
* band sweeps the face, then a comet runs the cut-corner edge — the
* cycle model (js/ui/ShimmerCue.js: shimmerPhase/drawShimmer) is pure
* and is checked here, and the slot's draw/hold behavior through the
* REAL ActionBar with the headless scene (standby / slot lock / the
* tutorial's finger all suppress the shimmer; unknown ids no-op);
* - the console's half (ResearchWindow._paintCues): the category tabs'
* shimmers ask ResearchModel.hasAvailable (the any-ready rule), and
* the jump-gate tech's ACK pulse asks SystemCategory.gatesReady (the
* chart gate + the in-flight gate) — both checked here against the
* real ResearchState. (The window's per-frame paint is the same
* ShimmerCue recipe + the existing ACK-pulse painter; its Phaser
* wiring is exercised in the browser.)
*/
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import fs from 'node:fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const dataDir = join(root, 'data');
const read = (name) => JSON.parse(fs.readFileSync(join(dataDir, name), 'utf8'));
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
// ----------------------------------------------------------------------
// Config — the cue's knobs exist in the right files, yellow family
// ----------------------------------------------------------------------
const comms = read('comms.json');
const actionbar = read('actionbar.json');
const research = read('research.json');
check('comms: the ACK pulse color is the cue yellow (the family both surfaces borrow)',
comms.box?.pulse?.color === '#ffd84d');
check('actionbar: animation.shimmer exists (the RESEARCH slot ready cue)',
!!actionbar.animation?.shimmer && typeof actionbar.animation.shimmer === 'object');
const abShim = actionbar.animation?.shimmer ?? {};
check('actionbar: the shimmer is enabled + yellow + a sane cycle',
abShim.enabled === true && abShim.color === '#ffd84d' &&
abShim.periodMs > 0 && abShim.faceMs > 0 && abShim.edgeMs > 0 &&
abShim.faceMs + abShim.gapMs + abShim.edgeMs < abShim.periodMs);
check('research: fx.tabShimmer exists (the console tabs ready cue)',
!!research.fx?.tabShimmer && typeof research.fx.tabShimmer === 'object');
const rwShim = research.fx?.tabShimmer ?? {};
check('research: the tab shimmer is enabled + yellow (deck-only face → faceMs 0)',
rwShim.enabled === true && rwShim.color === '#ffd84d' && rwShim.faceMs === 0 && rwShim.edgeMs > 0);
// ----------------------------------------------------------------------
// The pure cycle model (js/ui/ShimmerCue.js)
// ----------------------------------------------------------------------
import { shimmerPhase, drawShimmer } from '../js/ui/ShimmerCue.js';
import { config } from '../js/config/Config.js';
config.init({ research, gates: read('gates.json'), exploration: read('research/exploration.json') });
const { categories, loadCategory, isAvailable, hasAvailable } = await import('../js/research/ResearchModel.js');
const { buildSystemTree, gatesReady, gatesNodeId, SYSTEM_CATEGORY } = await import('../js/research/SystemCategory.js');
const { ResearchState } = await import('../js/research/ResearchState.js');
const { ActionBar } = await import('../js/ui/ActionBar.js');
// -- shimmerPhase: the windows + envelopes ------------------------------
{
const cfg = abShim; // the deck's cycle (face → edge → rest)
const P = cfg.periodMs, F = cfg.faceMs, G = cfg.gapMs, E = cfg.edgeMs;
const at = (t) => shimmerPhase(t, cfg);
check('phase: t=0 is the face start (envelope 0 — the band is born)',
at(0).faceU === 0 && at(0).faceA === 0);
check('phase: the face window runs [0, faceMs)',
at(1).faceU !== null && at(F - 1).faceU !== null && at(F).faceU === null);
check('phase: the face envelope peaks mid-window (sine)',
Math.abs(at(F / 2).faceA - 1) < 1e-9);
check('phase: the gap is silent (no face, no edge)',
at(F).faceA === 0 && at(F).edgeA === 0 &&
at(F + G / 2).faceA === 0 && at(F + G / 2).edgeA === 0 &&
at(F + G - 1).edgeA === 0);
check('phase: the edge window runs [faceMs+gapMs, +edgeMs)',
at(F + G).edgeU === 0 && at(F + G).edgeA === 0 &&
at(F + G + E - 1).edgeU !== null && at(F + G + E).edgeU === null);
check('phase: the edge envelope peaks mid-lap (sine)',
Math.abs(at(F + G + E / 2).edgeA - 1) < 1e-9);
const rest = F + G + E;
check('phase: the rest of the period is silence',
at(rest).faceA === 0 && at(rest).edgeA === 0 &&
at(P - 1).faceA === 0 && at(P - 1).edgeA === 0 &&
at(P).faceU === 0, `rest ${rest}..${P}`);
check('phase: the cycle is periodic (pure in time)',
JSON.stringify(at(123)) === JSON.stringify(at(123 + P)));
check('phase: an offset staggers the cycle (per-slot / per-tab phase)',
JSON.stringify(at(0)) !== JSON.stringify(shimmerPhase(0, cfg, 500)));
// The tab cycle: faceMs 0 — the comet owns the window from t=0.
const tab = shimmerPhase(1, rwShim);
check('phase: faceMs 0 (the tabs) → no face, the edge runs from t=0',
tab.faceU === null && tab.edgeU !== null && tab.edgeA > 0);
}
// -- drawShimmer: the call surface (a recorder stands in for Graphics) ---
{
const calls = [];
const g = {
fillStyle: (c, a) => calls.push(['fillStyle', c, a]),
fillRect: (x, y, w, h) => calls.push(['fillRect', x, y, w, h]),
lineStyle: (w, c, a) => calls.push(['lineStyle', w, c, a]),
strokePoints: (pts, close) => calls.push(['strokePoints', pts.length, close]),
};
const cfg = abShim;
const COLOR = 0xffd84d;
calls.length = 0;
drawShimmer(g, 100, 50, 160, 44, 10, COLOR, shimmerPhase(cfg.faceMs / 2, cfg), {});
const faces = calls.filter((c) => c[0] === 'fillRect');
const edges = calls.filter((c) => c[0] === 'strokePoints');
check('draw: the face pass paints band + core + white line (3 fillRects), no edge',
faces.length === 3 && edges.length === 0);
check('draw: the face rects stay clamped to the plate face',
faces.every(([, x, y, w, h]) => x >= 100 - 0.5 && x + w <= 100 + 160 + 0.5 && y === 50 && h === 44));
calls.length = 0;
drawShimmer(g, 100, 50, 160, 44, 10, COLOR, shimmerPhase(cfg.faceMs + cfg.gapMs + cfg.edgeMs / 2, cfg), {});
const rim = calls.filter((c) => c[0] === 'strokePoints' && c[2] === true);
const bands = calls.filter((c) => c[0] === 'strokePoints' && c[2] === false);
check('draw: the edge pass paints the rim glow (1 closed stroke) + the comet (3 bands × halo+core)',
rim.length === 1 && bands.length === 6);
check('draw: the face is absent during the edge window',
calls.filter((c) => c[0] === 'fillRect').length === 0);
calls.length = 0;
drawShimmer(g, 100, 50, 160, 44, 10, COLOR, shimmerPhase(cfg.periodMs - 1, cfg), {});
check('draw: the rest of the cycle paints nothing', calls.length === 0);
calls.length = 0;
drawShimmer(g, 100, 50, 160, 44, 10, COLOR, shimmerPhase(cfg.faceMs / 2, cfg), { face: false });
check('draw: face:false (the tabs) skips the band even mid-face-window',
calls.filter((c) => c[0] === 'fillRect').length === 0);
}
// ----------------------------------------------------------------------
// The any-ready rule (ResearchModel.hasAvailable — the deck cue + the
// tabs' shimmers ask it)
// ----------------------------------------------------------------------
{
const state = new ResearchState();
const tree = loadCategory('exploration');
check('hasAvailable: fresh run — the first tech is ready (true)', hasAvailable(tree, state) === true);
state.unlock('exploration', tree.order[0]);
check('hasAvailable: after the first tech, its child is ready (true)', hasAvailable(tree, state) === true);
for (const id of tree.order) state.unlock('exploration', id);
check('hasAvailable: everything researched — nothing ready (false)', hasAvailable(tree, state) === false);
check('hasAvailable: null/missing state is a safe false', hasAvailable(tree, null) === false);
}
// ----------------------------------------------------------------------
// The jump-gate rule (SystemCategory.gatesReady — the ACK pulse target)
// ----------------------------------------------------------------------
{
const mk = () => buildSystemTree({ id: 'testsys', name: 'Testys' });
const state = new ResearchState();
const tree = mk();
check('gatesReady: fresh system (no map) — not ready (null)', gatesReady(tree, state) === null);
state.unlock(SYSTEM_CATEGORY, tree.order[0]); // the map tech (granted on entry)
check('gatesReady: map owned but chart incomplete — not ready (null)', gatesReady(tree, state) === null);
check('gatesReady: no gates node on a foreign tree — safe null',
gatesReady({ id: 'x', order: ['a'], nodes: { a: {} } }, state) === null);
// Chart complete: the availability hook passes (the scene's isNavComplete).
const done = { ...mk(), available: () => true };
state.unlock(SYSTEM_CATEGORY, done.order[0]);
check('gatesReady: chart complete + map owned → the gates node id',
gatesReady(done, state) === gatesNodeId(done));
check('hasAvailable: the ready gates count as an available tech', hasAvailable(done, state) === true);
// In flight — maxConcurrent 1: the pulse must rest.
state.start(SYSTEM_CATEGORY, gatesNodeId(done), 60000, 0);
check('gatesReady: the gates tech IN FLIGHT — not ready (null)', gatesReady(done, state) === null);
// A different project in flight (the same gate, through the deck).
const state2 = new ResearchState();
const done2 = { ...mk(), available: () => true };
state2.unlock(SYSTEM_CATEGORY, done2.order[0]);
const ex = loadCategory('exploration');
state2.start('exploration', ex.order[0], 60000, 0);
check('gatesReady: ANOTHER project in flight — not ready (null)', gatesReady(done2, state2) === null);
}
// ----------------------------------------------------------------------
// The deck's RESEARCH slot (ActionBar.setShimmer, real bar + headless scene)
// ----------------------------------------------------------------------
{
const stubObject = (extra = {}) => ({
x: 0, y: 0, width: 60, height: 18, alpha: 1,
setOrigin() { return this; },
setAlpha(a) { this.alpha = a; return this; },
setScale() { return this; },
setTint() { return this; },
setPosition(x, y) { this.x = x; this.y = y; return this; },
setX(v) { this.x = v; return this; },
setY(v) { this.y = v; return this; },
setStrokeStyle() { return this; },
setSize(w, h) { this.width = w; this.height = h; return this; },
setBlendMode() { return this; },
setScrollFactor() { return this; },
setDepth() { return this; },
setInteractive(opts) { this._interactive = opts; return this; },
disableInteractive() { this._interactive = null; return this; },
setDisplaySize() { return this; },
on() { return this; },
once() { return this; },
off() { return this; },
setText(s) { this.text = String(s); return this; },
setColor(c) { this.color = c; return this; },
setFontSize() { return this; },
clear() { this._clears = (this._clears ?? 0) + 1; return this; },
fillStyle() { return this; },
lineStyle() { return this; },
fillPoints() { return this; },
strokePoints() { return this; },
fillRect() { return this; },
strokeRect() { return this; },
lineBetween() { return this; },
destroy() { this.destroyed = true; },
...extra,
});
const scene = {
scale: { width: 1280, height: 720 },
add: {
existing: (o) => o,
text: (x, y, str) => stubObject({ x, y, text: String(str) }),
image: (x, y, key) => stubObject({ x, y, key }),
graphics: () => stubObject({}),
rectangle: (x, y, w, h) => stubObject({ x, y, width: w, height: h }),
tileSprite: (x, y, w, h) => stubObject({ x, y, width: w, height: h }),
circle: (x, y, r) => stubObject({ x, y, radius: r }),
},
tweens: {
add: (t) => {
const apply = (o) => { for (const k of ['x', 'y', 'scale', 'alpha']) if (typeof t?.[k] === 'number' && o) o[k] = t[k]; };
if (Array.isArray(t?.targets)) t.targets.forEach(apply);
else if (t?.targets) apply(t.targets);
return { destroy() {}, stop() {} };
},
killTweensOf() {},
},
time: {
now: 1000,
delayedCall: (_ms, fn) => { if (typeof fn === 'function') fn(); return { stop() {} }; },
},
textures: { exists: () => true },
};
// The FLIGHT deck (the default buttons — it has the RESEARCH slot).
const bar = new ActionBar(scene, {});
const slot = bar.slots.find((s) => s.id === 'research');
check('bar: the flight deck has a live RESEARCH slot', !!slot && slot.live === true);
// A recorder over the shimmer layer (the bar's own — the real methods,
// wrapped to count).
const g = bar.shimmerG;
let rects = 0, strokes = 0, clears = 0;
const orig = { fillRect: g.fillRect, strokePoints: g.strokePoints, clear: g.clear };
g.fillRect = (...a) => { rects += 1; return orig.fillRect.apply(g, a); };
g.strokePoints = (...a) => { strokes += 1; return orig.strokePoints.apply(g, a); };
g.clear = (...a) => { clears += 1; return orig.clear.apply(g, a); };
const step = (t) => { rects = 0; strokes = 0; const before = clears; bar.update(t, 16); return { rects, strokes, cleared: clears > before }; };
// The bar staggers the cycle per slot (phase * 350 ms — the RGB idiom);
// the test times are the cycle windows shifted by that same offset.
const P = abShim.periodMs;
const off = (slot.phase ?? 0) * 350;
const at = (u) => (((u % P) + P) % P);
const T_FACE = at(abShim.faceMs / 2 - off);
const T_EDGE = at(abShim.faceMs + abShim.gapMs + abShim.edgeMs / 2 - off);
const T_REST = at(abShim.periodMs - 1 - off);
check('bar: no shimmer at construction (nothing drawn, no live flag)',
bar.shimmerId === null && bar._shimLive === false);
let r = step(100);
check('bar: update draws nothing on the shimmer layer when disarmed', r.rects === 0 && r.strokes === 0);
// Arm the cue — like GameScene._applyResearchCue does per frame.
bar.setShimmer('research');
check('bar: setShimmer arms the RESEARCH slot', bar.shimmerId === 'research');
bar.setShimmer('research');
check('bar: setShimmer is idempotent (the scene re-applies per frame)', bar.shimmerId === 'research');
r = step(T_FACE);
check('bar: the face window paints the band (fillRects) on the shimmer layer', r.rects >= 2 && r.strokes === 0);
r = step(T_EDGE);
check('bar: the edge window paints the comet (strokes) on the shimmer layer', r.strokes >= 4 && r.rects === 0);
r = step(T_REST);
check('bar: the rest of the cycle is quiet (the layer idles)', r.rects === 0 && r.strokes === 0);
// The holds: standby (the 'ack' comm), a locked slot, the tutorial's finger.
bar.setEnabled(false);
r = step(T_FACE);
check('bar: the deck standby suppresses the shimmer (the ACK hold owns the screen)', r.rects === 0 && r.strokes === 0);
bar.setEnabled(true);
bar.setSlotEnabled('research', false);
r = step(T_FACE);
check('bar: a locked slot suppresses the shimmer', r.rects === 0 && r.strokes === 0);
bar.setSlotEnabled('research', true);
bar.setHighlight('research');
r = step(T_FACE);
check('bar: the tutorial finger wins the shared slot (no double cue)', r.rects === 0 && r.strokes === 0);
bar.setHighlight(null);
r = step(T_FACE);
check('bar: the shimmer returns once the finger lifts', r.rects >= 2);
// Clearing + unknown ids.
bar.setShimmer(null);
check('bar: null clears the shimmer', bar.shimmerId === null);
r = step(T_FACE);
check('bar: …and the layer goes quiet', r.rects === 0 && r.strokes === 0);
bar.setShimmer('nope');
check('bar: an unknown id (not on this deck) is a safe no-op', bar.shimmerId === null);
// The SURFACE deck — the slot is 'shop', not 'research': the scene's
// cue must degrade to a clear (SurfaceScene replaces Research).
const surface = [
{ id: 'shop', label: 'Shop' },
{ id: 'build', label: 'Build' },
{ id: 'quests', label: 'Quests' },
{ id: 'menu', label: 'Menu' },
];
const bar2 = new ActionBar(scene, { buttons: surface });
bar2.setShimmer('research');
check('bar: the surface deck has no RESEARCH slot — the cue degrades to clear',
bar2.shimmerId === null);
}
console.log(failures === 0 ? '\nAll research-cue checks passed ✔' : `\n${failures} check(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -182,6 +182,23 @@ export function isAvailable(tree, state, id) {
return true;
}
/**
* Does this tree hold ANY tech ready to research right now — an
* unresearched node whose requires are all met and whose extra gate
* (the `available` hook) holds? The scene's "any research available"
* cue (the deck's RESEARCH-slot shimmer) and the console's per-tab
* shimmers ask this. The caller may additionally demand
* `!state.getActive()` — with maxConcurrent 1 nothing can START while a
* project runs (isAvailable doesn't see the in-flight slot).
*/
export function hasAvailable(tree, state) {
if (!tree || !Array.isArray(tree.order) || typeof state?.isUnlocked !== 'function') return false;
for (const id of tree.order) {
if (isAvailable(tree, state, id)) return true;
}
return false;
}
/** The parents this node is still missing (for the LOCKED readout). */
export function missingRequires(tree, state, id) {
return (tree.nodes[id]?.requires ?? []).filter(

View File

@ -40,6 +40,7 @@
* Pure (no Phaser) — Node-testable (dev/system-category.test.mjs).
*/
import { config } from '../config/Config.js';
import { isAvailable } from './ResearchModel.js';
/** The category id (data/research.json → categories[]). */
export const SYSTEM_CATEGORY = 'system';
@ -67,6 +68,39 @@ export function systemIdOfGatesNode(nodeId) {
return String(nodeId ?? '').replace(/_gates$/, '');
}
/**
* The tree's "Unlock Jumpgates" node id, or null (its
* `effects.activateGates` flag is the marker — the id otherwise carries
* no shape the tree can introspect, its key is the id).
* @param {object} tree a SystemCategory tree
* @returns {string|null}
*/
export function gatesNodeId(tree) {
for (const id of tree?.order ?? []) {
if (tree.nodes[id]?.effects?.activateGates === true) return id;
}
return null;
}
/**
* Is this system's jump-gate tech ready to research right now? Returns
* the node id when yes — the map tech owned, the chart complete (the
* tree's `available` hook), the tech not yet researched, and NOTHING
* in flight (maxConcurrent 1 — nothing can start while a project runs) —
* else null. The console's attention cue (the ACK pulse on the gates
* plate, ResearchWindow._paintCues) asks this.
* @param {object} tree a SystemCategory tree (buildSystemTree's shape)
* @param {object} state a ResearchState
* @returns {string|null} the gates node id, or null
*/
export function gatesReady(tree, state) {
if (!tree || typeof state?.isUnlocked !== 'function') return null;
if (typeof state.getActive === 'function' && state.getActive()) return null;
const id = gatesNodeId(tree);
if (!id) return null;
return isAvailable(tree, state, id) ? id : null;
}
const fillTemplate = (template, systemName) =>
String(template ?? '').split('{system}').join(systemName);

View File

@ -57,7 +57,7 @@ import { navDiscoveryStats, resourceStats, systemChartSnapshot } from '../galaxy
import { buildGalaxySnapshot, edgeKey } from '../galaxy/GalaxyChart.js';
import { planRoute } from '../galaxy/Route.js';
import { ResearchState } from '../research/ResearchState.js';
import { categories, loadCategory, isAvailable, buildDefs } from '../research/ResearchModel.js';
import { categories, loadCategory, isAvailable, hasAvailable, buildDefs } from '../research/ResearchModel.js';
import {
SYSTEM_CATEGORY,
MAP_NODE,
@ -2090,6 +2090,7 @@ export class GameScene extends Phaser.Scene {
this.mapWindow?.update(_time); // the map console: reveal, decodes, sweep, ship marker, chart poll
this.questWindow?.update(_time); // the quests console: reveal, decodes, sweep, dossier poll
this._deckResearchBar(_time); // the RESEARCH slot's progress bar
this._applyResearchCue(); // the RESEARCH slot's ready-shimmer (the deck's half)
// Builds: a build started on a surface keeps running in space if the
// player took off mid-build (game-loop clock — see beginBuild); when
// its deadline passes HERE we apply the effect (the world change is
@ -4869,6 +4870,46 @@ export class GameScene extends Phaser.Scene {
return loadCategory(catId);
}
/**
* Any tech in ANY category ready to research right now — none in
* flight (maxConcurrent 1 — nothing can START while a project runs)
* and at least one node with its requires met (the SYSTEM chart's
* extra gate included — the tree's availability hook says so).
* Drives the READY-RESEARCH cue: the deck's RESEARCH slot's shimmer
* (this scene) and the console's per-tab shimmers + gates pulse
* (ResearchWindow._paintCues).
*/
researchAnyAvailable() {
const state = this.researchState;
if (!state || typeof state.getActive !== 'function' || state.getActive()) return false;
for (const cat of categories()) {
const tree = this._treeFor(cat.id);
if (tree && hasAvailable(tree, state)) return true;
}
return false;
}
/**
* The READY-RESEARCH cue's scene-side switch (per frame, update): one
* cue on screen at a time —
* - console CLOSED: the deck's RESEARCH slot wears the periodic
* yellow shimmer (face sweep → edge run, js/ui/ShimmerCue.js);
* - console OPEN: the console wears it instead (its tabs' edge
* shimmers + the jump-gate tech's ACK pulse — _paintCues), and
* the deck's slot rests.
* setShimmer is idempotent, so the per-frame call is a no-op when
* the beat is unchanged. The cue also rests while a project runs
* (researchAnyAvailable) and under the deck's standby (the ActionBar
* owns that hold — the 'ack' comm) and over the tutorial's finger
* (same slot — the finger already owns the eyes).
*/
_applyResearchCue() {
const bar = this.actionBar;
if (!bar || typeof bar.setShimmer !== 'function') return;
const on = this.researchAnyAvailable() && this.researchWindow?.isOpen !== true;
bar.setShimmer(on ? 'research' : null);
}
/** Entering a system begins its NAV chart: the SYSTEM category's
* '{System} Map' tech is granted (duration 0 — never researched via the
* console). Idempotent — create() calls it for the starting system (and

View File

@ -4,6 +4,7 @@ import { toColor, toCss } from '../utils/Color.js';
import { fontStack, themeColor } from '../utils/Theme.js';
import { canvasTexture } from '../utils/Textures.js';
import { CyberShape } from './CyberShape.js';
import { shimmerPhase, drawShimmer } from './ShimmerCue.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
@ -102,6 +103,21 @@ export class ActionBar extends Phaser.GameObjects.Container {
slices: [3, 6],
...(anim.glitch ?? {}),
};
// The READY-RESEARCH cue (GameScene arms the RESEARCH slot while a
// tech can start): a yellow band sweeps the face, then a comet runs
// the cut-corner edge (js/ui/ShimmerCue.js — the console's tabs wear
// the same recipe). The yellow is the comm pulse's family (#ffd84d).
this.cfgShimmer = {
enabled: true,
color: 0xffd84d,
periodMs: 4200,
faceMs: 700,
gapMs: 140,
edgeMs: 1500,
dashFrac: 0.3,
...(anim.shimmer ?? {}),
};
this.cfgShimmer.color = toColor(this.cfgShimmer.color, 0xffd84d);
this.bootStagger = anim.bootStagger ?? 70;
this.railTop = null;
@ -128,6 +144,14 @@ export class ActionBar extends Phaser.GameObjects.Container {
this.highlightG = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD);
this.add(this.highlightG);
this._hlLive = false;
// The READY-RESEARCH shimmer (setShimmer — the scene arms the RESEARCH
// slot while a tech can start): drawn every frame in update(), above
// the highlight rim (both are additive cues, the finger wins on a
// shared slot — see the update() gate).
this.shimmerId = null;
this.shimmerG = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD);
this.add(this.shimmerG);
this._shimLive = false;
this.boot();
}
@ -598,6 +622,22 @@ export class ActionBar extends Phaser.GameObjects.Container {
this.highlightId = want ? want.id : null;
}
/**
* setShimmer(id) — the READY-RESEARCH cue on one slot (GameScene's
* _applyResearchCue arms the RESEARCH slot while a tech can start
* and the console is closed): a periodic yellow band sweeps the face,
* then a comet runs the cut-corner edge (js/ui/ShimmerCue.js — the
* console's category tabs wear the same recipe). `null` clears it.
* Idempotent (the scene applies its beat per frame). The shimmer rests
* under the deck's standby (setEnabled(false) — the 'ack' comm's hold),
* over a locked slot, and under the tutorial's pointing finger (which
* already owns that slot's eyes).
*/
setShimmer(id) {
const want = id != null ? (this.slots ?? []).find((s) => s.live && s.id === id) : null;
this.shimmerId = want ? want.id : null;
}
// ------------------------------------------------------------------
// Boot
// ------------------------------------------------------------------
@ -790,6 +830,32 @@ export class ActionBar extends Phaser.GameObjects.Container {
this._hlLive = false;
}
// The READY-RESEARCH shimmer (setShimmer) — the yellow band across
// the face, then the comet around the edge (js/ui/ShimmerCue.js,
// the console's tab recipe). Rests under the deck's standby, over a
// locked slot, and under the tutorial's finger on the same slot.
const shimSlot = this.shimmerId ? this.slots.find((s) => s.live && s.id === this.shimmerId) : null;
if (
this.cfgShimmer.enabled !== false &&
shimSlot &&
shimSlot.disabled !== true &&
this.enabled !== false &&
this.highlightId !== shimSlot.id
) {
const { bw, bh, notch } = this.style;
this.shimmerG.clear(); // a fresh frame — the rest window paints nothing, and a stale comet must never linger
const ph = shimmerPhase(time, this.cfgShimmer, shimSlot.phase * 350);
drawShimmer(this.shimmerG, shimSlot.slot.x - bw / 2, shimSlot.slot.y - bh / 2, bw, bh, notch, this.cfgShimmer.color, ph, {
face: true,
edge: true,
dashFrac: this.cfgShimmer.dashFrac,
});
this._shimLive = true;
} else if (this._shimLive) {
this.shimmerG.clear();
this._shimLive = false;
}
// The bar itself: the panel outline pulled apart in magenta/cyan while
// the burst is live — the RGB separation on the bar, not just the text.
if (L > 0.02) {

View File

@ -46,10 +46,13 @@ import {
loadCategory,
layoutTree,
isAvailable,
hasAvailable,
missingRequires,
unlocksOf,
buildDefs,
} from '../research/ResearchModel.js';
import { gatesReady } from '../research/SystemCategory.js';
import { shimmerPhase, drawShimmer } from './ShimmerCue.js';
import { ensureIcon } from '../research/ResearchIcons.js';
const HEADER = fontStack('header');
@ -386,6 +389,26 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
this.highlight = null; // { catId, nodeId }
this._hlLive = false;
// The READY-RESEARCH cues (_paintCues, per frame while open — the
// console takes the scene's cue over while it is open):
// - the edge shimmer on every category tab whose tree still holds
// an available tech (js/ui/ShimmerCue.js — the deck's RESEARCH
// slot's recipe, edge only),
// - the ACK pulse (comms.box.pulse — the same one the tutorial's
// finger paints) on the system's "Unlock Jumpgates" tech when
// its chart is complete: its plate while the SYSTEM tree is on
// screen, the SYSTEM tab otherwise.
// Both rest while a project runs (nothing can start — maxConcurrent
// 1) and while the tutorial's finger is on the same target.
this.cfgShimmer = config.section('research.fx.tabShimmer', {});
this.cueG = s
.graphics()
.setScrollFactor(0)
.setDepth(8)
.setBlendMode(Phaser.BlendModes.ADD);
this.add(this.cueG);
this._cueLive = false;
// glitch layers (top of the window)
this.glitchG = G(10);
this.titleGhostA = this._ghost();
@ -1489,6 +1512,8 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
this.video?.pause?.();
this.bursts.length = 0; // no ignitions over a closing window
this.burstG?.clear();
this.cueG?.clear(); // the READY cues rest with the window (the deck's slot takes over)
this._cueLive = false;
this.highlightG?.clear(); // the pointing finger rests with the window
this._hlLive = false;
this.sfx('ui_close');
@ -1723,6 +1748,96 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
this._hlLive = true;
}
/**
* The console's READY-RESEARCH cues — per frame (update), the window's
* half of the scene's cue (GameScene._applyResearchCue rests the
* deck's slot while the console is open). While a tech can start (none
* in flight — maxConcurrent 1):
* - every category tab whose tree still holds an available tech
* wears the edge shimmer (the deck's RESEARCH-slot recipe —
* js/ui/ShimmerCue.js, edge only; staggered per tab so the row
* doesn't pulse in lockstep),
* - the system's "Unlock Jumpgates" tech — its chart complete and
* not yet owned — wears the ACKNOWLEDGE pulse (comms.box.pulse —
* the same one the tutorial's finger paints): on its plate while
* the SYSTEM tree is on screen, on the SYSTEM tab otherwise.
* The tutorial's finger wins a shared target (no double pulse). All of
* it rests while closed / revealing / a project runs.
*/
_paintCues(time) {
const g = this.cueG;
if (!g) return;
const state = this.state;
const on =
this.openState === 'open' &&
!this.reveal.length &&
!!state &&
typeof state.getActive === 'function' &&
!state.getActive();
if (!on) {
if (this._cueLive) {
g.clear();
this._cueLive = false;
}
return;
}
g.clear();
this._cueLive = true;
// The tabs' edge shimmers (the ready categories light at a glance).
if (this.cfgShimmer.enabled !== false) {
const color = toColor(this.cfgShimmer.color, 0xffd84d);
this.tabs.forEach((t, i) => {
const entry = this.trees.get(t.id);
if (!entry || !hasAvailable(entry.tree, state)) return;
const { bodyY, tabH } = this.geo;
const ph = shimmerPhase(time, this.cfgShimmer, i * 420);
drawShimmer(g, t.x, bodyY + 1, t.w, tabH - 2, 8, color, ph, {
face: false, // the tabs are the button in miniature — edge only
dashFrac: this.cfgShimmer.dashFrac,
});
});
}
// The jump-gate tech's ACK pulse (the gates' "ready to be
// researched" — chart complete, not yet owned, nothing in flight).
const gid = this.systemTree ? gatesReady(this.systemTree, state) : null;
if (gid) {
const target = this._gatesCueRect(gid);
if (target && !this._highlightSameTarget(target)) {
this._paintAckPulse(g, target.cx, target.cy, target.w, target.h, target.notch, time);
}
}
}
/** The gates cue's target rect: the node's plate (SYSTEM on screen)
* or the SYSTEM tab (otherwise) — the finger's own two choices. */
_gatesCueRect(gid) {
const catId = this.systemTree?.id;
if (!catId) return null;
const entry = this.trees.get(catId);
if (!entry) return null;
if (this.activeCat === catId) {
const node = entry.nodes.get(gid);
if (!node) return null;
return { kind: 'node', catId, nodeId: gid, cx: node.cont.x, cy: node.cont.y, w: node.w, h: node.h, notch: 12 };
}
const tab = this.tabs.find((t) => t.id === catId);
if (!tab) return null;
const { bodyY, tabH } = this.geo;
const y = bodyY + 1;
return { kind: 'tab', catId, nodeId: null, cx: tab.x + tab.w / 2, cy: y + (tabH - 2) / 2, w: tab.w, h: tabH - 2, notch: 10 };
}
/** True when the tutorial's pointing finger already paints this target
* (same node plate, or same category tab) — the finger wins then. */
_highlightSameTarget(target) {
const h = this.highlight;
if (!h) return false;
if (h.nodeId) return h.nodeId === target.nodeId && h.catId === target.catId;
return target.kind === 'tab' && h.catId === target.catId;
}
/** Tier lines for one category tree (re-issuable). */
_paintRows(entry) {
const { rightX, rightW, treeTop, treeH } = this.geo;
@ -1915,6 +2030,9 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
this._paintBursts(time);
// the tutorial's pointing finger (the tab's or the node's rim)
this._paintHighlight(time);
// the READY-RESEARCH cues: the tabs' edge shimmers + the gates' ACK
// pulse (the console wears them while open — the deck's slot rests)
this._paintCues(time);
// tab pips: the in-progress one pulses (the rest are static — painted
// in _paintTabs on state flips)

134
js/ui/ShimmerCue.js Normal file
View File

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