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:
parent
4334d8cc93
commit
1f39c25c2f
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue