Add research completion celebration and progress pips to console
- Fire a one-shot ignition (expanding rings, edge pulse, child flares) when a project completes; queue it if the console is closed or on another tab, dropping stale events after 15s - Add per-tech progress pips on each category tab showing done/in-progress/not-yet states, with a pulse on the active one - Light up unlocked nodes with an accent glow ring, icon halo, and a stamped seal glyph replacing the plain checkmark - Add dev harnesses for screenshotting the live completion treatment and verifying the queued-celebration path
This commit is contained in:
parent
f470250721
commit
fa3e0cb1f8
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<base href="../" />
|
||||||
|
<title>Orbit — queued celebration check (dev)</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
|
||||||
|
</style>
|
||||||
|
<script src="lib/phaser.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="game"></div>
|
||||||
|
<script type="module" src="dev/queue-celebrate.mjs"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
/**
|
||||||
|
* Dev check — the QUEUED celebration path: research completes while the
|
||||||
|
* console is closed, and the ignition still plays when the player reopens
|
||||||
|
* the console within the fresh window (a stale one is dropped, not played).
|
||||||
|
* Drives itself and exposes `window.__QUEUE_CHECK__`:
|
||||||
|
*
|
||||||
|
* node dev/server.mjs 8090
|
||||||
|
* node dev/cdp-shot.mjs "http://127.0.0.1:8090/dev/queue-celebrate.html" \
|
||||||
|
* /tmp/q.png "window.__QUEUE_CHECK__"
|
||||||
|
* # then read the report via CDP eval: window.__QUEUE_CHECK__.lines
|
||||||
|
*/
|
||||||
|
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';
|
||||||
|
|
||||||
|
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 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
try {
|
||||||
|
const s = await waitScene();
|
||||||
|
await sleep(800);
|
||||||
|
const win = s.researchWindow;
|
||||||
|
|
||||||
|
// 1 — open, start a short project, CLOSE the console while in flight
|
||||||
|
s.deckAction('research');
|
||||||
|
await sleep(900); // boot reveal
|
||||||
|
s.researchState.start('exploration', 'tether_l2', 400, s.time.now);
|
||||||
|
await sleep(150);
|
||||||
|
s.deckAction('research'); // toggle → close (fade ~150 ms)
|
||||||
|
await sleep(700); // completion happens HERE, console closed → QUEUED
|
||||||
|
|
||||||
|
const q1 = win.celebration;
|
||||||
|
lines.push(`queued at close: ${q1 ? `cat=${q1.catId} played=${q1.played}` : 'NONE'}`);
|
||||||
|
if (!q1 || q1.played) errors.push('CELEBRATE not queued while console closed');
|
||||||
|
|
||||||
|
// 2 — reopen within the fresh window → the ignition must play
|
||||||
|
s.deckAction('research');
|
||||||
|
await sleep(200);
|
||||||
|
lines.push(`after reopen: played=${win.celebration?.played} bursts=${win.bursts.length} open=${win.openState}`);
|
||||||
|
if (!win.celebration?.played) errors.push('CELEBRATE did not play after reopen');
|
||||||
|
if (win.bursts.length === 0) errors.push('no bursts alive after reopen');
|
||||||
|
|
||||||
|
// 3 — stale-drop: a completion older than the fresh window is dropped,
|
||||||
|
// not played (simulate by backdating `at`). Let step 2's bursts expire
|
||||||
|
// first so the count below is meaningful.
|
||||||
|
await sleep(1400);
|
||||||
|
win.celebration = { catId: 'exploration', id: 'tether_l3', at: s.time.now - 60000, played: false };
|
||||||
|
win._maybeCelebrate();
|
||||||
|
lines.push(`stale drop: played=${win.celebration.played} bursts=${win.bursts.length}`);
|
||||||
|
if (!win.celebration.played) errors.push('stale celebration was not dropped');
|
||||||
|
if (win.bursts.length !== 0) errors.push('stale celebration still pushed bursts');
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`FATAL: ${err.message}`);
|
||||||
|
}
|
||||||
|
lines.unshift(errors.length === 0 ? 'QUEUE CHECK OK — no console errors' : 'FAILURES:');
|
||||||
|
window.__QUEUE_CHECK__ = { ok: errors.length === 0, lines, errors };
|
||||||
|
console.info('queue-celebrate: done', errors.length === 0);
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<base href="../" />
|
||||||
|
<title>Orbit — Research completion (dev shot)</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-complete-shot.mjs"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
/**
|
||||||
|
* Dev-only: boot GameScene, open the RESEARCH console, start a SHORT project
|
||||||
|
* and let it COMPLETE LIVE — capturing the completion treatment: the lit
|
||||||
|
* node (accent fill + glow ring + icon halo + stamped seal), the tab
|
||||||
|
* progress pips (filled = researched / hollow = not yet), and the one-shot
|
||||||
|
* ignition mid-flight (ring shells, the fast pulse down the newly-powered
|
||||||
|
* edge, the child flare) with the status strip's PROJECT COMPLETE decode.
|
||||||
|
*
|
||||||
|
* node dev/server.mjs 8090
|
||||||
|
* node dev/cdp-shot.mjs http://127.0.0.1:8090/dev/research-complete-shot.html \
|
||||||
|
* /tmp/research-complete.png "window.__RESEARCH_SHOT && window.__RESEARCH_SHOT.ready" \
|
||||||
|
* 90000 "document.getElementById('report') && (document.getElementById('report').style.display='none')"
|
||||||
|
*
|
||||||
|
* The page sets window.__RESEARCH_SHOT (report + errors) when ready.
|
||||||
|
*/
|
||||||
|
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';
|
||||||
|
|
||||||
|
const data = await ConfigLoader.load();
|
||||||
|
config.init(data);
|
||||||
|
|
||||||
|
// Capture console errors + uncaught exceptions for the report.
|
||||||
|
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;
|
||||||
|
|
||||||
|
// The report lives OUTSIDE the canvas — a DOM <pre> the screenshot can
|
||||||
|
// always read (headless canvases don't have to cooperate).
|
||||||
|
const report = document.createElement('pre');
|
||||||
|
report.id = 'report';
|
||||||
|
report.style.cssText = 'position:fixed;left:10px;top:10px;z-index:9999;max-width:70%;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');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const s = await waitScene();
|
||||||
|
await sleep(800); // let the boot settle (dossier decode, deck flicker)
|
||||||
|
|
||||||
|
// Open the console (the deck RESEARCH button does exactly this).
|
||||||
|
s.deckAction('research');
|
||||||
|
await sleep(900); // the boot reveal finishes
|
||||||
|
|
||||||
|
// Start a project with a SHORT duration and let it complete live — the
|
||||||
|
// completion tick fires _completeResearchFx → the console's ignition.
|
||||||
|
s.researchState.start('exploration', 'tether_l2', 500, s.time.now);
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
if (!s.researchState.getActive() && s.researchState.isUnlocked('exploration', 'tether_l2')) break;
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
const win = s.researchWindow;
|
||||||
|
// The natural ignition already played (and will be over by the time the
|
||||||
|
// capture harness lands ~0.5 s after ready). Re-fire it right before the
|
||||||
|
// handoff so the capture lands MID-FLIGHT: the soft white ring expanding,
|
||||||
|
// the ignition head riding down the edge, the child flare in bloom.
|
||||||
|
win.celebrate('exploration', 'tether_l2');
|
||||||
|
const burstsLive = win.bursts.length;
|
||||||
|
if (burstsLive === 0) errors.push('CELEBRATE MISS — no bursts queued by win.celebrate()');
|
||||||
|
|
||||||
|
const st = s.researchState;
|
||||||
|
const entry = win.trees.get('exploration');
|
||||||
|
const nodeStates = entry
|
||||||
|
? entry.tree.order.map((id) => `${id}=${win._nodeState(id)}`).join(' ')
|
||||||
|
: 'NO TREE';
|
||||||
|
const pips = win.tabs
|
||||||
|
.map((t) => `${t.id}:${t.pips.map((p) => p.state[0]).join('')}`)
|
||||||
|
.join(' ');
|
||||||
|
const lines = [
|
||||||
|
errors.length === 0 ? 'SMOKE OK — no console errors' : `ERRORS:\n${errors.slice(0, 3).join('\n')}`,
|
||||||
|
`window: ${win.openState} · cat=${win.activeCat} · sel=${win.selected ? win.selected.category + '/' + win.selected.id : '—'}`,
|
||||||
|
`state: ${st.unlocked.size} unlocked · active=${st.getActive() ? st.getActive().category + '/' + st.getActive().id : '—'}`,
|
||||||
|
`nodes: ${nodeStates}`,
|
||||||
|
`pips: ${pips}`,
|
||||||
|
`bursts: ${win.bursts.length} live (handoff) · ${burstsLive} at celebrate · celebration=${win.celebration ? (win.celebration.played ? 'played' : 'queued') : 'none'}`,
|
||||||
|
];
|
||||||
|
setReport(lines);
|
||||||
|
window.__RESEARCH_SHOT = { ready: true, lines, errors };
|
||||||
|
console.info('research-complete-shot: report painted');
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`FATAL: ${err.message}`);
|
||||||
|
setReport(['FATAL: ' + err.message, ...errors.slice(0, 3)]);
|
||||||
|
window.__RESEARCH_SHOT = { ready: true, fatal: String(err.message), errors };
|
||||||
|
console.error('research-complete-shot: fatal', err);
|
||||||
|
}
|
||||||
|
|
@ -3659,6 +3659,7 @@ export class GameScene extends Phaser.Scene {
|
||||||
glyph: '✓',
|
glyph: '✓',
|
||||||
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
|
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
|
||||||
});
|
});
|
||||||
|
this.researchWindow?.celebrate(catId, id); // the console's ignition (ring / pulse / flare) — queues if it's closed
|
||||||
this.researchWindow?.refresh();
|
this.researchWindow?.refresh();
|
||||||
this._questRefresh(); // a 'Research …' requirement may have just landed
|
this._questRefresh(); // a 'Research …' requirement may have just landed
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,27 @@ const mixWhite = (color, amt) => {
|
||||||
return (ch(16) << 16) | (ch(8) << 8) | ch(0);
|
return (ch(16) << 16) | (ch(8) << 8) | ch(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Radial halo texture (white → tintable per node) — the "powered" glow
|
||||||
|
* behind a lit node's icon. Built once per scene (the scanline/sweep idiom).
|
||||||
|
*/
|
||||||
|
function ensureHaloTexture(scene) {
|
||||||
|
const key = 'research_halo';
|
||||||
|
if (scene.textures?.exists(key)) return key;
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = 64;
|
||||||
|
c.height = 64;
|
||||||
|
const ctx = c.getContext('2d');
|
||||||
|
const grad = ctx.createRadialGradient(32, 32, 2, 32, 32, 32);
|
||||||
|
grad.addColorStop(0, 'rgba(255,255,255,0.9)');
|
||||||
|
grad.addColorStop(0.4, 'rgba(255,255,255,0.28)');
|
||||||
|
grad.addColorStop(1, 'rgba(255,255,255,0)');
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.fillRect(0, 0, 64, 64);
|
||||||
|
scene.textures.addCanvas(key, c);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tech-tree edge styling — thick enough to read at a glance: a 3 px core
|
* Tech-tree edge styling — thick enough to read at a glance: a 3 px core
|
||||||
* over a wide, low-alpha accent glow (the panel-glow idiom). Powered edges
|
* over a wide, low-alpha accent glow (the panel-glow idiom). Powered edges
|
||||||
|
|
@ -169,6 +190,9 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
this.glitch = { until: 0, next: 0, level: 0.8 };
|
this.glitch = { until: 0, next: 0, level: 0.8 };
|
||||||
this.sweep = { t0: 0, next: 0 };
|
this.sweep = { t0: 0, next: 0 };
|
||||||
this._lastPct = undefined;
|
this._lastPct = undefined;
|
||||||
|
this.bursts = []; // one-shot completion ignitions ({ kind, t0, dur, … })
|
||||||
|
this.celebration = null; // { catId, id, at, played } — pending ignition
|
||||||
|
this._burstLive = false;
|
||||||
|
|
||||||
this._build();
|
this._build();
|
||||||
this.setVisible(true);
|
this.setVisible(true);
|
||||||
|
|
@ -331,6 +355,15 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
});
|
});
|
||||||
this._paintClose();
|
this._paintClose();
|
||||||
|
|
||||||
|
// one-shot ignition layer — completion rings / edge pulses / child
|
||||||
|
// flares (additive; above tree + detail, under the glitch dressing)
|
||||||
|
this.burstG = s
|
||||||
|
.graphics()
|
||||||
|
.setScrollFactor(0)
|
||||||
|
.setDepth(9)
|
||||||
|
.setBlendMode(Phaser.BlendModes.ADD);
|
||||||
|
this.add(this.burstG);
|
||||||
|
|
||||||
// glitch layers (top of the window)
|
// glitch layers (top of the window)
|
||||||
this.glitchG = G(10);
|
this.glitchG = G(10);
|
||||||
this.titleGhostA = this._ghost();
|
this.titleGhostA = this._ghost();
|
||||||
|
|
@ -550,10 +583,19 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
})
|
})
|
||||||
.setScrollFactor(0);
|
.setScrollFactor(0);
|
||||||
const w = txt.width + 44;
|
// progress pips — one per tech in this category: the console's
|
||||||
|
// at-a-glance "done vs available" summary (filled = researched,
|
||||||
|
// pulsing = in progress, hollow = not yet). _paintTabs repaints
|
||||||
|
// them on state flips; update() only pulses the in-progress one.
|
||||||
|
const tree = this._treeFor(cat.id);
|
||||||
|
const nPips = tree ? tree.order.length : 0;
|
||||||
|
const pipS = 5;
|
||||||
|
const pipGap = 4;
|
||||||
|
const pipSpan = nPips ? nPips * pipS + (nPips - 1) * pipGap : 0;
|
||||||
|
const w = txt.width + 44 + (pipSpan ? pipSpan + 14 : 0);
|
||||||
const y = bodyY + tabH / 2;
|
const y = bodyY + tabH / 2;
|
||||||
const g = this.scene.add.graphics().setScrollFactor(0);
|
const g = this.scene.add.graphics().setScrollFactor(0);
|
||||||
const tab = { id: cat.id, x, y, w, h: tabH, txt, g, accent, hover: false };
|
const tab = { id: cat.id, x, y, w, h: tabH, txt, g, accent, hover: false, pips: [] };
|
||||||
const tabRect = new Phaser.Geom.Rectangle(x, y - tabH / 2, w, tabH);
|
const tabRect = new Phaser.Geom.Rectangle(x, y - tabH / 2, w, tabH);
|
||||||
g.setInteractive({
|
g.setInteractive({
|
||||||
useHandCursor: true,
|
useHandCursor: true,
|
||||||
|
|
@ -574,6 +616,20 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
this.switchCategory(cat.id);
|
this.switchCategory(cat.id);
|
||||||
});
|
});
|
||||||
txt.setPosition(x + 26, y - 4);
|
txt.setPosition(x + 26, y - 4);
|
||||||
|
const pipX0 = x + 26 + txt.width + 14;
|
||||||
|
const pipY = bodyY + tabH / 2 - 1;
|
||||||
|
for (let i = 0; i < nPips; i++) {
|
||||||
|
const pg = this.scene.add.graphics().setScrollFactor(0).setDepth(2);
|
||||||
|
this.add(pg);
|
||||||
|
tab.pips.push({
|
||||||
|
g: pg,
|
||||||
|
node: tree.order[i],
|
||||||
|
x: pipX0 + i * (pipS + pipGap),
|
||||||
|
y: pipY,
|
||||||
|
s: pipS,
|
||||||
|
state: 'todo',
|
||||||
|
});
|
||||||
|
}
|
||||||
g.setDepth(2);
|
g.setDepth(2);
|
||||||
txt.setDepth(3);
|
txt.setDepth(3);
|
||||||
this.add(g);
|
this.add(g);
|
||||||
|
|
@ -612,6 +668,32 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
t.g.fillTriangle(dx, dy - 4.5, dx + 4.5, dy, dx, dy + 4.5, dx - 4.5, dy);
|
t.g.fillTriangle(dx, dy - 4.5, dx + 4.5, dy, dx, dy + 4.5, dx - 4.5, dy);
|
||||||
t.txt.setColor(active ? toCss(t.accent) : toCss(C.ink));
|
t.txt.setColor(active ? toCss(t.accent) : toCss(C.ink));
|
||||||
t.txt.setAlpha(active ? 1 : t.hover ? 0.9 : 0.62);
|
t.txt.setAlpha(active ? 1 : t.hover ? 0.9 : 0.62);
|
||||||
|
|
||||||
|
// progress pips — state from ResearchState (a cheap repaint: a few
|
||||||
|
// 5 px squares each; update() only pulses the in-progress one)
|
||||||
|
const run = this.state?.getActive();
|
||||||
|
for (const p of t.pips) {
|
||||||
|
p.g.clear();
|
||||||
|
const st = this.state?.isUnlocked(t.id, p.node)
|
||||||
|
? 'done'
|
||||||
|
: run && run.category === t.id && run.id === p.node
|
||||||
|
? 'progress'
|
||||||
|
: 'todo';
|
||||||
|
p.state = st;
|
||||||
|
if (st === 'done') {
|
||||||
|
p.g.fillStyle(t.accent, 1);
|
||||||
|
p.g.fillRect(p.x, p.y - p.s / 2, p.s, p.s);
|
||||||
|
p.g.setAlpha(active ? 1 : 0.8);
|
||||||
|
} else if (st === 'progress') {
|
||||||
|
p.g.fillStyle(t.accent, 1);
|
||||||
|
p.g.fillRect(p.x, p.y - p.s / 2, p.s, p.s);
|
||||||
|
p.g.setAlpha(0.35); // update() drives the pulse
|
||||||
|
} else {
|
||||||
|
p.g.lineStyle(1, active ? 0x6284b0 : 0x3d4c74, active ? 0.95 : 0.6);
|
||||||
|
p.g.strokeRect(p.x, p.y - p.s / 2, p.s, p.s);
|
||||||
|
p.g.setAlpha(active ? 1 : 0.65);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -704,6 +786,14 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
const s = this.scene.add;
|
const s = this.scene.add;
|
||||||
const cont = new Phaser.GameObjects.Container(this.scene, px, py);
|
const cont = new Phaser.GameObjects.Container(this.scene, px, py);
|
||||||
const g = s.graphics().setScrollFactor(0);
|
const g = s.graphics().setScrollFactor(0);
|
||||||
|
// the lit glow behind the icon — "powered" states (unlocked/active)
|
||||||
|
// drive its alpha in _paintNode / update(); locked nodes keep it at 0
|
||||||
|
const halo = s
|
||||||
|
.image(-w / 2 + 22, 0, ensureHaloTexture(this.scene))
|
||||||
|
.setDisplaySize(48, 48)
|
||||||
|
.setScrollFactor(0)
|
||||||
|
.setBlendMode(Phaser.BlendModes.ADD)
|
||||||
|
.setAlpha(0);
|
||||||
const img = s.image(-w / 2 + 22, 0, ensureIcon(this.scene, icon, accent)).setDisplaySize(24, 24).setScrollFactor(0);
|
const img = s.image(-w / 2 + 22, 0, ensureIcon(this.scene, icon, accent)).setDisplaySize(24, 24).setScrollFactor(0);
|
||||||
const txt = s
|
const txt = s
|
||||||
.text(-w / 2 + 40, 0, label, {
|
.text(-w / 2 + 40, 0, label, {
|
||||||
|
|
@ -716,10 +806,11 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
.setOrigin(0, 0.5)
|
.setOrigin(0, 0.5)
|
||||||
.setScrollFactor(0);
|
.setScrollFactor(0);
|
||||||
cont.add(g);
|
cont.add(g);
|
||||||
|
cont.add(halo);
|
||||||
cont.add(img);
|
cont.add(img);
|
||||||
cont.add(txt);
|
cont.add(txt);
|
||||||
|
|
||||||
const node = { id, w, h, cont, g, img, txt, hover: false };
|
const node = { id, w, h, cont, g, halo, img, txt, hover: false };
|
||||||
const nodeRect = new Phaser.Geom.Rectangle(-w / 2, -h / 2, w, h);
|
const nodeRect = new Phaser.Geom.Rectangle(-w / 2, -h / 2, w, h);
|
||||||
g.setInteractive({
|
g.setInteractive({
|
||||||
useHandCursor: true,
|
useHandCursor: true,
|
||||||
|
|
@ -1002,6 +1093,7 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
let fillA = 0.5;
|
let fillA = 0.5;
|
||||||
let stroke = 0x22405f;
|
let stroke = 0x22405f;
|
||||||
let strokeA = 0.55;
|
let strokeA = 0.55;
|
||||||
|
let glowA = 0;
|
||||||
let labelColor = toCss(C.faint);
|
let labelColor = toCss(C.faint);
|
||||||
let iconTint = 0x445566;
|
let iconTint = 0x445566;
|
||||||
let glyph = 'x';
|
let glyph = 'x';
|
||||||
|
|
@ -1016,21 +1108,37 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
fillA = 0.2;
|
fillA = 0.2;
|
||||||
stroke = accent;
|
stroke = accent;
|
||||||
strokeA = 1;
|
strokeA = 1;
|
||||||
|
glowA = 0.25;
|
||||||
labelColor = toCss(0xffffff);
|
labelColor = toCss(0xffffff);
|
||||||
iconTint = 0xffffff;
|
iconTint = 0xffffff;
|
||||||
glyph = 'bar';
|
glyph = 'bar';
|
||||||
} else if (st === 'unlocked') {
|
} else if (st === 'unlocked') {
|
||||||
fillA = 0.18;
|
// LIT — the plate holds a soft accent fill + a glow ring ("powered",
|
||||||
|
// the node-side of the edge idiom) and the icon sits in a halo.
|
||||||
|
// The stamped seal below replaces the bare check: it reads as
|
||||||
|
// "acquired" at a glance, distinct from the ◆ available marker.
|
||||||
|
fill = accent;
|
||||||
|
fillA = 0.13;
|
||||||
stroke = accent;
|
stroke = accent;
|
||||||
strokeA = 0.95;
|
strokeA = 0.95;
|
||||||
|
glowA = 0.38;
|
||||||
labelColor = toCss(C.ink);
|
labelColor = toCss(C.ink);
|
||||||
iconTint = 0xffffff;
|
iconTint = 0xffffff;
|
||||||
glyph = 'check';
|
glyph = 'seal';
|
||||||
}
|
}
|
||||||
if (node.hover) fillA = Math.min(0.9, fillA + 0.12);
|
if (node.hover) fillA = Math.min(0.9, fillA + 0.12);
|
||||||
|
|
||||||
panel(g, -w / 2, -h / 2, w, h, { notch: 9, fill, fillAlpha: fillA, stroke, strokeAlpha: strokeA });
|
panel(g, -w / 2, -h / 2, w, h, {
|
||||||
|
notch: 9,
|
||||||
|
fill,
|
||||||
|
fillAlpha: fillA,
|
||||||
|
stroke,
|
||||||
|
strokeAlpha: strokeA,
|
||||||
|
...(glowA ? { glow: accent, glowAlpha: glowA } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
node.halo.setTint(accent);
|
||||||
|
node.halo.setAlpha(st === 'unlocked' ? 0.5 : st === 'active' ? 0.32 : 0);
|
||||||
node.img.setTint(iconTint);
|
node.img.setTint(iconTint);
|
||||||
node.img.setAlpha(st === 'locked' ? 0.55 : 1);
|
node.img.setAlpha(st === 'locked' ? 0.55 : 1);
|
||||||
node.txt.setColor(labelColor);
|
node.txt.setColor(labelColor);
|
||||||
|
|
@ -1045,10 +1153,14 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
} else if (glyph === 'diamond') {
|
} else if (glyph === 'diamond') {
|
||||||
g.fillStyle(glyphColor, 1);
|
g.fillStyle(glyphColor, 1);
|
||||||
g.fillTriangle(gx, -5.5, gx + 5.5, 0, gx, 5.5, gx - 5.5, 0);
|
g.fillTriangle(gx, -5.5, gx + 5.5, 0, gx, 5.5, gx - 5.5, 0);
|
||||||
} else if (glyph === 'check') {
|
} else if (glyph === 'seal') {
|
||||||
g.lineStyle(2, glyphColor, 1);
|
// the stamp: an accent diamond with the check knocked out in the
|
||||||
g.lineBetween(gx - 5, 0, gx - 1, 4);
|
// plate — "certified", not just a tick
|
||||||
g.lineBetween(gx - 1, 4, gx + 5, -4);
|
g.fillStyle(glyphColor, 1);
|
||||||
|
g.fillTriangle(gx, -6, gx + 6, 0, gx, 6, gx - 6, 0);
|
||||||
|
g.lineStyle(1.8, 0x050b14, 1);
|
||||||
|
g.lineBetween(gx - 3.2, 0.8, gx - 0.9, 3.2);
|
||||||
|
g.lineBetween(gx - 0.9, 3.2, gx + 3.2, -2.8);
|
||||||
} else if (glyph === 'bar') {
|
} else if (glyph === 'bar') {
|
||||||
g.fillStyle(0x0a1424, 0.9);
|
g.fillStyle(0x0a1424, 0.9);
|
||||||
g.fillRect(gx - 8, -2, 16, 4);
|
g.fillRect(gx - 8, -2, 16, 4);
|
||||||
|
|
@ -1110,6 +1222,7 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
_paintAll(catId) {
|
_paintAll(catId) {
|
||||||
const entry = this.trees.get(catId);
|
const entry = this.trees.get(catId);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
|
this._paintTabs(); // the tab pips track the same state (a run started here flips them)
|
||||||
for (const node of entry.nodes.values()) this._paintNode(node);
|
for (const node of entry.nodes.values()) this._paintNode(node);
|
||||||
this._paintEdges(entry);
|
this._paintEdges(entry);
|
||||||
this._paintDetailBtn();
|
this._paintDetailBtn();
|
||||||
|
|
@ -1194,6 +1307,7 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
this._paintAll(category);
|
this._paintAll(category);
|
||||||
this._paintDetail(true);
|
this._paintDetail(true);
|
||||||
this._lastSelSt = this._nodeState(id);
|
this._lastSelSt = this._nodeState(id);
|
||||||
|
this._maybeCelebrate();
|
||||||
}
|
}
|
||||||
|
|
||||||
switchCategory(catId) {
|
switchCategory(catId) {
|
||||||
|
|
@ -1213,6 +1327,7 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
this._paintDetail(false);
|
this._paintDetail(false);
|
||||||
this._lastSelSt = null;
|
this._lastSelSt = null;
|
||||||
}
|
}
|
||||||
|
this._maybeCelebrate();
|
||||||
}
|
}
|
||||||
|
|
||||||
_defaultSelection(catId) {
|
_defaultSelection(catId) {
|
||||||
|
|
@ -1325,6 +1440,7 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
if (id && this.selected?.id !== id) this.select(cat, id);
|
if (id && this.selected?.id !== id) this.select(cat, id);
|
||||||
this._paintAll(this.activeCat);
|
this._paintAll(this.activeCat);
|
||||||
this._paintStatusStrip();
|
this._paintStatusStrip();
|
||||||
|
this._maybeCelebrate();
|
||||||
|
|
||||||
// resume() not play(): v4's play() is a no-op after pause() (its
|
// resume() not play(): v4's play() is a no-op after pause() (its
|
||||||
// _playCalled flag stays true, so the native play() is never
|
// _playCalled flag stays true, so the native play() is never
|
||||||
|
|
@ -1340,6 +1456,8 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
if (this.openState === 'closed' || this.openState === 'closing') return;
|
if (this.openState === 'closed' || this.openState === 'closing') return;
|
||||||
this.openState = 'closing';
|
this.openState = 'closing';
|
||||||
this.video?.pause?.();
|
this.video?.pause?.();
|
||||||
|
this.bursts.length = 0; // no ignitions over a closing window
|
||||||
|
this.burstG?.clear();
|
||||||
this.sfx('ui_close');
|
this.sfx('ui_close');
|
||||||
this.scene.tweens.add({
|
this.scene.tweens.add({
|
||||||
targets: this,
|
targets: this,
|
||||||
|
|
@ -1373,6 +1491,113 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
this._paintStatusStrip();
|
this._paintStatusStrip();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot ignition for a completed project (GameScene's
|
||||||
|
* _completeResearchFx calls it): a light ring on the node, a fast pulse
|
||||||
|
* down each newly-powered edge, a flare on each newly-woken child, and
|
||||||
|
* the status strip decoding the completion line. If the console is
|
||||||
|
* closed or on another category the event is QUEUED — it plays if the
|
||||||
|
* player returns to the category while it is still fresh (the toast
|
||||||
|
* already carried the news, so a stale event is just dropped).
|
||||||
|
*/
|
||||||
|
celebrate(catId, id) {
|
||||||
|
this.celebration = { catId, id, at: this.scene.time.now, played: false };
|
||||||
|
this._maybeCelebrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
_maybeCelebrate() {
|
||||||
|
const c = this.celebration;
|
||||||
|
if (!c || c.played) return;
|
||||||
|
if (this.scene.time.now - c.at > 15000) {
|
||||||
|
c.played = true; // stale — the toast already told the story
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 'open' only — NOT isOpen: the close-fade counts as open there, and a
|
||||||
|
// burst into a fading window is lost to the player (queue it instead).
|
||||||
|
// Also wait out the boot reveal — the ring shouldn't race the pop-in —
|
||||||
|
// and the tab must be the one the research finished on (switching tabs
|
||||||
|
// re-checks, and the 15 s fresh window covers the trip).
|
||||||
|
if (this.openState !== 'open' || this.reveal.length || this.activeCat !== c.catId) return;
|
||||||
|
const entry = this.trees.get(c.catId);
|
||||||
|
if (!entry || !entry.nodes.has(c.id)) {
|
||||||
|
c.played = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
c.played = true;
|
||||||
|
const node = entry.nodes.get(c.id);
|
||||||
|
const accent = entry.accent;
|
||||||
|
const t0 = this.scene.time.now;
|
||||||
|
// the ring — two expanding shells (the accent one + a soft white)
|
||||||
|
this.bursts.push({ kind: 'ring', x: node.cont.x, y: node.cont.y, t0, dur: 620, color: accent, r0: 14, r1: 62 });
|
||||||
|
this.bursts.push({ kind: 'ring', x: node.cont.x, y: node.cont.y, t0: t0 + 130, dur: 720, color: 0xffffff, r0: 8, r1: 92, soft: true });
|
||||||
|
// the ignition pulse down each newly-powered edge, then the child
|
||||||
|
// flares as the pulse lands
|
||||||
|
for (const e of entry.edges) {
|
||||||
|
if (e.parent.id !== c.id) continue;
|
||||||
|
this.bursts.push({ kind: 'edge', e, t0: t0 + 60, dur: 540 });
|
||||||
|
this.bursts.push({ kind: 'flash', node: e.child, t0: t0 + 400, dur: 420, color: accent });
|
||||||
|
}
|
||||||
|
// the status strip decodes the completion
|
||||||
|
const label = String(entry.tree.nodes[c.id]?.label ?? c.id).toUpperCase();
|
||||||
|
this.statusTxt.setColor(toCss(accent));
|
||||||
|
this.decodeTo(this.statusTxt, `PROJECT COMPLETE — ${label}`, t0, 560);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advance + draw the one-shot completion bursts (additive layer). */
|
||||||
|
_paintBursts(time) {
|
||||||
|
const bg = this.burstG;
|
||||||
|
if (!bg) return;
|
||||||
|
if (!this.bursts.length) {
|
||||||
|
if (this._burstLive) {
|
||||||
|
bg.clear();
|
||||||
|
this._burstLive = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bg.clear();
|
||||||
|
this._burstLive = true;
|
||||||
|
for (let i = this.bursts.length - 1; i >= 0; i--) {
|
||||||
|
const b = this.bursts[i];
|
||||||
|
const u = (time - b.t0) / b.dur;
|
||||||
|
if (u < 0) continue; // scheduled for a later frame
|
||||||
|
if (u >= 1) {
|
||||||
|
this.bursts.splice(i, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const a = 1 - u;
|
||||||
|
if (b.kind === 'ring') {
|
||||||
|
const r = b.r0 + (b.r1 - b.r0) * easeIO(u);
|
||||||
|
bg.lineStyle(2.5, b.color, (b.soft ? 0.55 : 0.9) * a);
|
||||||
|
bg.strokeCircle(b.x, b.y, r);
|
||||||
|
bg.fillStyle(b.color, 0.1 * a);
|
||||||
|
bg.fillCircle(b.x, b.y, r);
|
||||||
|
} else if (b.kind === 'edge') {
|
||||||
|
// the fast bright ride parent → child (a one-shot of the ambient
|
||||||
|
// comet — quicker, whiter head, fuller trail)
|
||||||
|
const head = this._pointAt(b.e, u);
|
||||||
|
for (let k = 3; k >= 1; k--) {
|
||||||
|
const uu = u - k * 0.055;
|
||||||
|
if (uu < 0) continue;
|
||||||
|
const p = this._pointAt(b.e, uu);
|
||||||
|
bg.fillStyle(b.e.accent, 0.5 * (1 - k / 4));
|
||||||
|
bg.fillCircle(p.x, p.y, 5 - k * 0.7);
|
||||||
|
}
|
||||||
|
bg.fillStyle(b.e.accent, 0.55);
|
||||||
|
bg.fillCircle(head.x, head.y, 10);
|
||||||
|
bg.fillStyle(0xffffff, 1);
|
||||||
|
bg.fillCircle(head.x, head.y, 3.8);
|
||||||
|
} else if (b.kind === 'flash') {
|
||||||
|
// the newly-woken child plate flares as the pulse lands
|
||||||
|
const nw = b.node.w;
|
||||||
|
const nh = b.node.h;
|
||||||
|
bg.fillStyle(0xffffff, 0.2 * a);
|
||||||
|
bg.fillRect(b.node.cont.x - nw / 2, b.node.cont.y - nh / 2, nw, nh);
|
||||||
|
bg.lineStyle(2, b.color, 0.7 * a);
|
||||||
|
bg.strokeCircle(b.node.cont.x, b.node.cont.y, 8 + 26 * easeIO(u));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Tier lines for one category tree (re-issuable). */
|
/** Tier lines for one category tree (re-issuable). */
|
||||||
_paintRows(entry) {
|
_paintRows(entry) {
|
||||||
const { rightX, rightW, treeTop, treeH } = this.geo;
|
const { rightX, rightW, treeTop, treeH } = this.geo;
|
||||||
|
|
@ -1427,6 +1652,7 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
this.tabs.forEach((t, i) => {
|
this.tabs.forEach((t, i) => {
|
||||||
push(t.g, 300 + i * 70, 240, 'fade');
|
push(t.g, 300 + i * 70, 240, 'fade');
|
||||||
push(t.txt, 300 + i * 70, 240, 'fade');
|
push(t.txt, 300 + i * 70, 240, 'fade');
|
||||||
|
for (const p of t.pips) push(p.g, 300 + i * 70, 240, 'fade');
|
||||||
});
|
});
|
||||||
const entry = this.activeCat ? this.trees.get(this.activeCat) : null;
|
const entry = this.activeCat ? this.trees.get(this.activeCat) : null;
|
||||||
if (entry) {
|
if (entry) {
|
||||||
|
|
@ -1460,6 +1686,10 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
update(time) {
|
update(time) {
|
||||||
if (!this.isOpen) return;
|
if (!this.isOpen) return;
|
||||||
|
|
||||||
|
// completion ignition — a project finished and the console is on its
|
||||||
|
// category (or the player just came back to it): fire the one-shot
|
||||||
|
this._maybeCelebrate();
|
||||||
|
|
||||||
// reveal timeline
|
// reveal timeline
|
||||||
if (this.reveal.length) {
|
if (this.reveal.length) {
|
||||||
let done = true;
|
let done = true;
|
||||||
|
|
@ -1483,6 +1713,7 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
if (r.mode === 'pop') r.o.setScale(1);
|
if (r.mode === 'pop') r.o.setScale(1);
|
||||||
}
|
}
|
||||||
this.reveal = [];
|
this.reveal = [];
|
||||||
|
this._paintTabs(); // the reveal forced alphas to 1 — restore the pip states
|
||||||
this._paintStatusStrip();
|
this._paintStatusStrip();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1555,6 +1786,18 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
// REC dot pulse
|
// REC dot pulse
|
||||||
this.recDot.setFillStyle(C.amber, 0.45 + 0.4 * Math.sin(time * 0.006));
|
this.recDot.setFillStyle(C.amber, 0.45 + 0.4 * Math.sin(time * 0.006));
|
||||||
|
|
||||||
|
// one-shot completion ignitions (rings / edge pulses / child flares)
|
||||||
|
this._paintBursts(time);
|
||||||
|
|
||||||
|
// tab pips: the in-progress one pulses (the rest are static — painted
|
||||||
|
// in _paintTabs on state flips)
|
||||||
|
const run = this.state?.getActive();
|
||||||
|
if (run) {
|
||||||
|
const tab = this.tabs.find((t) => t.id === run.category);
|
||||||
|
const pip = tab?.pips.find((p) => p.node === run.id && p.state === 'progress');
|
||||||
|
if (pip) pip.g.setAlpha(0.3 + 0.45 * Math.abs(Math.sin(time * 0.005)));
|
||||||
|
}
|
||||||
|
|
||||||
// tree life (only while fully revealed — the reveal owns alpha then)
|
// tree life (only while fully revealed — the reveal owns alpha then)
|
||||||
const entry = this.activeCat ? this.trees.get(this.activeCat) : null;
|
const entry = this.activeCat ? this.trees.get(this.activeCat) : null;
|
||||||
if (entry && !this.reveal.length) {
|
if (entry && !this.reveal.length) {
|
||||||
|
|
@ -1566,6 +1809,17 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
} else {
|
} else {
|
||||||
node.cont.setAlpha(1);
|
node.cont.setAlpha(1);
|
||||||
}
|
}
|
||||||
|
// lit nodes keep breathing (the available plate already breathes
|
||||||
|
// above; locked stays dead)
|
||||||
|
if (node.halo) {
|
||||||
|
node.halo.setAlpha(
|
||||||
|
st === 'unlocked'
|
||||||
|
? 0.4 + 0.16 * Math.sin(time * 0.002 + node.cont.x * 0.01)
|
||||||
|
: st === 'active'
|
||||||
|
? 0.26 + 0.16 * Math.sin(time * 0.0045)
|
||||||
|
: 0
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// …and the detail readout follows the selected node's flips (its
|
// …and the detail readout follows the selected node's flips (its
|
||||||
// button turns LOCKED → RESEARCH as the system's chart completes).
|
// button turns LOCKED → RESEARCH as the system's chart completes).
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue