Added Comms Feature
This commit is contained in:
parent
71cbea2fab
commit
aaa953d654
Binary file not shown.
|
After Width: | Height: | Size: 576 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,80 @@
|
||||||
|
{
|
||||||
|
"$comment": "COMMS — the characters of the ship speaking to the player (OAC first). Three comm types: 'ack' pauses the action and holds the box open (muted looping video + text + voice) until the player clicks ACKNOWLEDGE; 'visual' shows the box (video plays once + text + voice) and fades out when the voice ends; 'audio' is voice only — no box, no pause. Comms are EVENTS: each one binds a trigger (when) + a sender (who) + text + a speech clip (what). Trigger vocabulary: 'newGame' is live (the fresh-run welcome); the reservedTriggers list below is the proposed event set to discuss and assign comms to (researchComplete, researchNew, questNew, questComplete, discovery, landing, jump, buildComplete) — none of them fire yet. Assets are loaded ON DEMAND by the hub (js/comms/CommsHub.js) when a comm is due — never in a scene's preload, so the initial load stays lean.",
|
||||||
|
"enabled": true,
|
||||||
|
"audio": {
|
||||||
|
"$comment": "The comm's voice plays at its OWN volume (volume) — the manager's master is left untouched (in v4 `sound.setVolume` IS the master, so ducking it would quiet the voice too). While the voice talks, the other playing sounds (the game music) are ducked PER-SOUND by musicDuck (a fraction of each one's own volume) and restored exactly when the voice ends.",
|
||||||
|
"volume": 1.0,
|
||||||
|
"musicDuck": 0.3
|
||||||
|
},
|
||||||
|
"characters": {
|
||||||
|
"$comment": "The senders. label = the box's header; role = the small line beside it; video = the character clip (left panel of the box, ALWAYS played muted — the voice is the speech clip, never the video).",
|
||||||
|
"oac": {
|
||||||
|
"label": "O.A.C.",
|
||||||
|
"role": "ONBOARD A.I. COMPUTER",
|
||||||
|
"video": "assets/videos/characters/oac.mp4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"comms": [
|
||||||
|
{
|
||||||
|
"$comment": "The fresh-run welcome: OAC introduces the ship. type 'ack' — the action pauses and the box holds (video loops silently) until ACKNOWLEDGE. delayMs beats the opening toast so the signal is clean.",
|
||||||
|
"id": "oac-intro",
|
||||||
|
"from": "oac",
|
||||||
|
"type": "ack",
|
||||||
|
"trigger": "newGame",
|
||||||
|
"text": "So you want to explore the galaxy do you?... Welcome to your ship... she's not much but she's got room to grow. I'm O.A.C. your Onboard A I Computer, and I'm here to help you get around.",
|
||||||
|
"audio": "assets/speech/oac/intro-01.mp3",
|
||||||
|
"once": true,
|
||||||
|
"delayMs": 1400
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"reservedTriggers": [
|
||||||
|
"researchComplete",
|
||||||
|
"researchNew",
|
||||||
|
"questNew",
|
||||||
|
"questComplete",
|
||||||
|
"discovery",
|
||||||
|
"landing",
|
||||||
|
"jump",
|
||||||
|
"buildComplete"
|
||||||
|
],
|
||||||
|
"box": {
|
||||||
|
"$comment": "The wide comm box, docked just below the mineral bar (its right edge sticks to the bar's right edge; topPad px of padding below the bar). Left: the sender's clip in a bracketed feed panel (muted, contain-fit). Right: the comm text decoding in, and — for 'ack' only — the ACKNOWLEDGE button below it. The height grows to the content; long comms just get taller.",
|
||||||
|
"width": 560,
|
||||||
|
"topPad": 12,
|
||||||
|
"pad": 14,
|
||||||
|
"headerGap": 8,
|
||||||
|
"videoSize": 150,
|
||||||
|
"videoGap": 14,
|
||||||
|
"text": {
|
||||||
|
"fontSize": 13,
|
||||||
|
"lineHeight": 18,
|
||||||
|
"letterSpacing": 0.4
|
||||||
|
},
|
||||||
|
"button": {
|
||||||
|
"label": "ACKNOWLEDGE",
|
||||||
|
"gap": 10,
|
||||||
|
"fontSize": 11,
|
||||||
|
"paddingX": 16,
|
||||||
|
"paddingY": 7,
|
||||||
|
"letterSpacing": 2
|
||||||
|
},
|
||||||
|
"animation": {
|
||||||
|
"inMs": 260,
|
||||||
|
"outMs": 420,
|
||||||
|
"visualHoldMs": 350,
|
||||||
|
"$comment": "visualHoldMs = the beat the box holds after the voice ends (visual comms) before it fades."
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"panel": "#0a1120",
|
||||||
|
"panelAlpha": 0.94,
|
||||||
|
"border": "#22405f",
|
||||||
|
"borderAlpha": 0.9,
|
||||||
|
"notch": 10,
|
||||||
|
"neon": "#00e5ff",
|
||||||
|
"ink": "#eaf6ff",
|
||||||
|
"dim": "#7d92c4",
|
||||||
|
"faint": "#3d4c74",
|
||||||
|
"amber": "#ffc94d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
"save.json",
|
"save.json",
|
||||||
"mineralhud.json",
|
"mineralhud.json",
|
||||||
"map.json",
|
"map.json",
|
||||||
"quests.json"
|
"quests.json",
|
||||||
|
"comms.json"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
// Dev: evaluate one expression in a page (brave CDP on :9333) and print
|
||||||
|
// the full result. Usage: node dev/cdp-probe.mjs <url> <expr> [timeoutMs]
|
||||||
|
const [url, expr, timeoutMs = '20000'] = process.argv.slice(2);
|
||||||
|
if (!url || !expr) {
|
||||||
|
console.error('usage: node dev/cdp-probe.mjs <url> <expr> [timeoutMs]');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const CDP = '127.0.0.1:9333';
|
||||||
|
const deadline = Date.now() + Number(timeoutMs);
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
let target = await (await fetch(`http://${CDP}/json/list`)).json()
|
||||||
|
.then((ts) => ts.find((t) => t.type === 'page'));
|
||||||
|
if (!target) {
|
||||||
|
target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json();
|
||||||
|
}
|
||||||
|
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||||
|
let id = 0;
|
||||||
|
const pending = new Map();
|
||||||
|
const send = (method, params = {}) => new Promise((res, rej) => {
|
||||||
|
const mid = ++id;
|
||||||
|
pending.set(mid, { res, rej });
|
||||||
|
ws.send(JSON.stringify({ id: mid, method, params }));
|
||||||
|
});
|
||||||
|
await new Promise((res, rej) => {
|
||||||
|
ws.onopen = res;
|
||||||
|
ws.onerror = (e) => rej(new Error('ws error: ' + (e?.message ?? '?')));
|
||||||
|
});
|
||||||
|
ws.onmessage = (m) => {
|
||||||
|
const msg = JSON.parse(m.data);
|
||||||
|
if (msg.id && pending.has(msg.id)) {
|
||||||
|
const { res, rej } = pending.get(msg.id);
|
||||||
|
pending.delete(msg.id);
|
||||||
|
msg.error ? rej(new Error(msg.error.message)) : res(msg.result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await send('Page.enable');
|
||||||
|
await send('Runtime.enable');
|
||||||
|
await send('Page.navigate', { url });
|
||||||
|
const loaded = new Promise((res) => setTimeout(res, 3000)); // let it settle
|
||||||
|
await loaded;
|
||||||
|
|
||||||
|
const evaluate = async (e) => {
|
||||||
|
const r = await send('Runtime.evaluate', { expression: e, returnByValue: true, awaitPromise: true });
|
||||||
|
if (r.exceptionDetails) throw new Error('page: ' + (r.exceptionDetails.exception?.description ?? r.exceptionDetails.text));
|
||||||
|
return r.result?.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
let out = null;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
out = await evaluate(expr).catch(() => null);
|
||||||
|
if (out !== null && out !== undefined) break;
|
||||||
|
await sleep(300);
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify(out, null, 2));
|
||||||
|
ws.close();
|
||||||
|
process.exit(0);
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Orbit — dev: the comms intro self-check</title>
|
||||||
|
<!-- This page lives in /dev, but the game's relative asset paths are
|
||||||
|
rooted at the project root — resolve them against it. -->
|
||||||
|
<base href="../" />
|
||||||
|
<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/comms-hold.mjs"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
/**
|
||||||
|
* Dev-only: HOLD the comms intro open (no ACK) for a clean screenshot of
|
||||||
|
* the box in its live state (video rolling, text decoded, button armed).
|
||||||
|
* node dev/cdp-shot.mjs "http://127.0.0.1:8090/dev/comms-hold.html" out.png \
|
||||||
|
* "window.__commsHold" 60000 "document.getElementById('report')?.remove()"
|
||||||
|
*/
|
||||||
|
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 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;margin:0;padding:8px 12px;font:12px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.9);border:1px solid #1b3a5a;max-width:50%;';
|
||||||
|
document.body.appendChild(report);
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const until = async (fn, ms = 30000) => {
|
||||||
|
const t0 = Date.now();
|
||||||
|
for (;;) {
|
||||||
|
const v = fn();
|
||||||
|
if (v) return v;
|
||||||
|
if (Date.now() - t0 > ms) return null;
|
||||||
|
await sleep(150);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
const s = () => game.scene.getScene('GameScene');
|
||||||
|
const ready = await until(() => {
|
||||||
|
const sc = s();
|
||||||
|
return sc && sc.ship && sc.comms && sc.comms.active && sc.comms.box?.video ? sc : null;
|
||||||
|
});
|
||||||
|
if (!ready) { report.textContent = 'FAIL — comm never came up'; window.__commsHold = { ok: false }; return; }
|
||||||
|
// Let the text finish decoding + the clip settle, then signal.
|
||||||
|
await until(() => (s().comms.box._decFinished === true), 8000);
|
||||||
|
await sleep(700);
|
||||||
|
const sc = s();
|
||||||
|
const box = sc.comms.box;
|
||||||
|
const el = box.video?.video;
|
||||||
|
window.__commsHold = {
|
||||||
|
ok: true,
|
||||||
|
box: { x: Math.round(box.x), y: Math.round(box.y), w: box.W, h: box.H },
|
||||||
|
clip: el ? { paused: el.paused, muted: el.muted, t: el.currentTime?.toFixed(1) } : null,
|
||||||
|
voice: sc.comms._voiceKey ? sc.sound.isPlaying(sc.comms._voiceKey) : null,
|
||||||
|
};
|
||||||
|
report.textContent = 'COMMS HOLD: box ' + box.W + '×' + box.H +
|
||||||
|
' @ ' + Math.round(box.x) + ',' + Math.round(box.y) +
|
||||||
|
' · clip ' + (el ? (el.paused ? 'PAUSED' : 'rolling') : '—') +
|
||||||
|
' · voice ' + (sc.comms._voiceKey ? 'talking' : 'done');
|
||||||
|
}, 600);
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Orbit — dev: the comms intro self-check</title>
|
||||||
|
<!-- This page lives in /dev, but the game's relative asset paths are
|
||||||
|
rooted at the project root — resolve them against it. -->
|
||||||
|
<base href="../" />
|
||||||
|
<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/comms-intro.mjs"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
/**
|
||||||
|
* Dev-only: the COMMS intro self-check — boots the game straight into
|
||||||
|
* the GameScene (a FRESH run, so the 'newGame' trigger arms OAC's
|
||||||
|
* welcome) and paints a plain-text report answering "did the ack comm
|
||||||
|
* actually happen?":
|
||||||
|
*
|
||||||
|
* 1. Did the comm ARM (trigger → queue) and PLAY (box + voice)?
|
||||||
|
* 2. Is the pause contract live (comms.paused, the ship parked)?
|
||||||
|
* 3. Is the clip muted + rolling (the video's element state)?
|
||||||
|
* 4. Does ACKNOWLEDGE actually end it (button press → finish →
|
||||||
|
* the box gone, the action free, the music volume restored)?
|
||||||
|
*
|
||||||
|
* node dev/slow-server.mjs 8080 # or any static server on the repo
|
||||||
|
* → open http://127.0.0.1:8080/dev/comms-intro.html
|
||||||
|
*
|
||||||
|
* Headless screenshot:
|
||||||
|
* node dev/cdp-shot.mjs "http://127.0.0.1:8080/dev/comms-intro.html" \
|
||||||
|
* out.png "document.getElementById('report').textContent.includes('SELF-CHECK')"
|
||||||
|
*/
|
||||||
|
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); };
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
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:72%;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('COMMS SELF-CHECK RUNNING… (waiting for OAC’s welcome)');
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const until = async (fn, ms = 30000) => {
|
||||||
|
const t0 = Date.now();
|
||||||
|
for (;;) {
|
||||||
|
const v = fn();
|
||||||
|
if (v) return v;
|
||||||
|
if (Date.now() - t0 > ms) return null;
|
||||||
|
await sleep(150);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scene = () => game.scene.getScene('GameScene');
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
const d = [];
|
||||||
|
try {
|
||||||
|
const s = scene();
|
||||||
|
const ready = await until(() => (s && s.ship && s.comms ? s : null));
|
||||||
|
if (!ready) { setReport(['FAIL — scene never became ready']); window.__commsResult = { ok: false }; return; }
|
||||||
|
|
||||||
|
// 1) The welcome armed + played (the hub's deferred load may take a
|
||||||
|
// moment — the clip + the speech come in on demand).
|
||||||
|
const live = await until(() => (s.comms.active ? s : null));
|
||||||
|
d.push(`arm+play: active=${s.comms.active} paused=${s.comms.paused} queued=${s.comms.queued}`);
|
||||||
|
if (!live) { setReport(['FAIL — the newGame comm never came up', ...d]); window.__commsResult = { ok: false, details: d }; return; }
|
||||||
|
|
||||||
|
// The clip may take a beat to start (v4's autoplay retry — the
|
||||||
|
// browser unlocks muted autoplay on its own schedule); a real
|
||||||
|
// player has the whole voice to see it, so give it 4 s here.
|
||||||
|
const box = s.comms.box;
|
||||||
|
const clip = box?.video;
|
||||||
|
const el = clip?.video;
|
||||||
|
if (el) await until(() => el.paused === false, 4000);
|
||||||
|
const snd = s.sound;
|
||||||
|
const voiceKey = s.comms._voiceKey;
|
||||||
|
d.push(`box: ${box ? `${Math.round(box.x)},${Math.round(box.y)} ${box.W}×${box.H}` : '—'}`);
|
||||||
|
d.push(`clip: present=${!!clip} muted=${el ? el.muted : '—'} paused=${el ? el.paused : '—'} loop=${el ? el.loop : '—'} t=${el && Number.isFinite(el.currentTime) ? el.currentTime.toFixed(2) + 's' : '—'}`);
|
||||||
|
d.push(`voice: key=${voiceKey ?? '—'} playing=${voiceKey ? snd.isPlaying(voiceKey) : '—'} ducked=${s.comms._ducked}`);
|
||||||
|
d.push(`ship: paused-contract=${s.ship.body.velocity.length().toFixed(1)} state=${s.ship.state}`);
|
||||||
|
d.push(`text: "${String(box?.text?.text ?? '').slice(0, 60)}…"`);
|
||||||
|
|
||||||
|
// Per-sound ducking (the volume contract): the VOICE's own sound
|
||||||
|
// stays at FULL volume (1.0); the music's own sound drops by
|
||||||
|
// musicDuck (0.6 → 0.18); the manager's MASTER is untouched. (The
|
||||||
|
// old bug: `sound.setVolume` IS the master in v4, so the voice was
|
||||||
|
// being quieted along with the music — that is why the comms
|
||||||
|
// sounded quiet.) Asserted on currentConfig.volume — the LOGICAL
|
||||||
|
// volume; the gain-node read lags in a suspended (headless) context.
|
||||||
|
const allPlaying = typeof snd.getAllPlaying === 'function' ? snd.getAllPlaying() : [];
|
||||||
|
const vs = allPlaying.find((x) => x.key === voiceKey);
|
||||||
|
const ms = allPlaying.find((x) => typeof x.key === 'string' && x.key.startsWith('music_'));
|
||||||
|
const musicVol = config.get('music.volume', 0.6);
|
||||||
|
const cfgVol = (x) => (x && x.currentConfig && typeof x.currentConfig.volume === 'number' ? x.currentConfig.volume : null);
|
||||||
|
d.push(`duck: voice.volume=${cfgVol(vs)} (expect 1.0) music.volume=${cfgVol(ms)} (expect ${(musicVol * 0.3).toFixed(2)}) master=${snd.masterVolumeNode && snd.masterVolumeNode.gain.value} (expect 1)`);
|
||||||
|
const duckOk =
|
||||||
|
s.comms._ducked === true && // the music duck was issued
|
||||||
|
cfgVol(vs) === 1.0 && // the voice is FULL volume
|
||||||
|
(cfgVol(ms) !== null && Math.abs(cfgVol(ms) - musicVol * 0.3) < 0.001); // music ducked per-sound
|
||||||
|
|
||||||
|
const ok =
|
||||||
|
s.comms.active === true &&
|
||||||
|
s.comms.paused === true && // ack: the action holds
|
||||||
|
!!box &&
|
||||||
|
!!box.button &&
|
||||||
|
!!clip &&
|
||||||
|
!!el &&
|
||||||
|
el.muted === true && // the video is silent (the voice is the speech)
|
||||||
|
el.loop === true && // the ack clip loops until acknowledged
|
||||||
|
el.paused === false && // …and it's rolling
|
||||||
|
voiceKey !== null &&
|
||||||
|
snd.isPlaying(voiceKey) === true && // the speech is talking
|
||||||
|
duckOk &&
|
||||||
|
s.ship.body.velocity.length() < 1; // the ship is parked
|
||||||
|
|
||||||
|
// 2) ACKNOWLEDGE — press the box's button (its own pointerdown, the
|
||||||
|
// save-panel's modal idiom) and the comm must end: box gone,
|
||||||
|
// action free, the voice stopped.
|
||||||
|
const before = { paused: s.comms.paused, ducked: s.comms._ducked };
|
||||||
|
box.button.panel.emit('pointerdown');
|
||||||
|
await until(() => (s.comms.active === false ? s : null), 5000);
|
||||||
|
d.push(`ack: active=${s.comms.active} paused=${before.paused} → ${s.comms.paused} ducked=${before.ducked} → ${s.comms._ducked} voice=${voiceKey ? snd.isPlaying(voiceKey) : '—'}`);
|
||||||
|
// The duck must RESTORE: the music goes back to its own volume.
|
||||||
|
const msAfter = ms ? (snd.getAll(ms.key) ?? [])[0] : null;
|
||||||
|
d.push(`duck-restore: music.volume=${msAfter ? cfgVol(msAfter) : '—'} (expect ${musicVol})`);
|
||||||
|
const duckRestoreOk = !ms || (msAfter !== null && cfgVol(msAfter) !== null && Math.abs(cfgVol(msAfter) - musicVol) < 0.001);
|
||||||
|
// The comm must NOT replay: give the hub several frames to (wrongly)
|
||||||
|
// re-arm the same entry, then assert it stays finished.
|
||||||
|
await sleep(700);
|
||||||
|
const noReplay = s.comms.active === false && s.comms.queue.length === 0;
|
||||||
|
d.push(`no-replay: active=${s.comms.active} (expect false) queue=${s.comms.queue.length} (expect 0)`);
|
||||||
|
const ackOk = s.comms.active === false && s.comms.paused === false && s.comms._ducked === false && duckRestoreOk && (voiceKey ? snd.isPlaying(voiceKey) === false : true) && noReplay;
|
||||||
|
|
||||||
|
// 3) The ledger: the welcome is PLAYED (a reload of this run would
|
||||||
|
// not re-hear it).
|
||||||
|
d.push(`ledger: played=${JSON.stringify(Array.from(s.registry.get('commsPlayed') ?? []))}`);
|
||||||
|
const playedOk = s.registry.get('commsPlayed')?.has('oac-intro') === true;
|
||||||
|
|
||||||
|
// 4) The played filter: re-triggering arms nothing.
|
||||||
|
const armed = s.comms.trigger('newGame');
|
||||||
|
d.push(`re-arm: armed=${armed} (expect 0 — the ledger holds)`);
|
||||||
|
const rearmOk = armed === 0;
|
||||||
|
|
||||||
|
const allOk = ok && ackOk && playedOk && rearmOk;
|
||||||
|
const errLine = errors.length ? ['console errors:', ...errors.slice(0, 4)] : [];
|
||||||
|
window.__commsResult = { ok: allOk, details: [...d, ...errLine] };
|
||||||
|
setReport(['COMMS SELF-CHECK ' + (allOk ? 'PASS ✔' : 'FAIL ✘'), '', ...d, ...errLine]);
|
||||||
|
} catch (err) {
|
||||||
|
window.__commsResult = { ok: false, details: ['EXCEPTION: ' + String(err?.stack ?? err)] };
|
||||||
|
setReport(['COMMS SELF-CHECK FAIL ✘', String(err?.stack ?? err)]);
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Orbit — dev: the comms intro self-check</title>
|
||||||
|
<!-- This page lives in /dev, but the game's relative asset paths are
|
||||||
|
rooted at the project root — resolve them against it. -->
|
||||||
|
<base href="../" />
|
||||||
|
<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/comms-types.mjs"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
/**
|
||||||
|
* Dev-only: exercise the VISUAL + AUDIO comm paths in the live GameScene.
|
||||||
|
* The shipped roster is ack-only (the newGame welcome); this page overrides
|
||||||
|
* the comms config at boot so 'newGame' arms, in FIFO order:
|
||||||
|
*
|
||||||
|
* t-visual → box opens, clip plays ONCE (loop=false), NO ACK button,
|
||||||
|
* NO pause (comms.paused stays false, the ship is not parked),
|
||||||
|
* and the comm FINISHES ON ITS OWN when the voice ends
|
||||||
|
* (the isPlaying poll → visualHoldMs → fade).
|
||||||
|
* t-audio → NO box at all (comms.box is null), voice plays, no pause,
|
||||||
|
* finishes on its own.
|
||||||
|
*
|
||||||
|
* Headless note: the AudioContext is suspended, so the 12.6 s clip never
|
||||||
|
* reaches its natural end — the test stops the voice with stopByKey(),
|
||||||
|
* which is exactly what the hub's isPlaying poll sees as "the voice ended"
|
||||||
|
* (isPlaying → false). The hub must then finish the comm by itself.
|
||||||
|
*
|
||||||
|
* node dev/cdp-shot.mjs "http://127.0.0.1:8090/dev/comms-types.html" o.png \
|
||||||
|
* "window.__commsTypes" 90000
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
|
||||||
|
// --- override: two newGame comms — one visual, one audio ---------------
|
||||||
|
const voice = 'assets/speech/oac/intro-01.mp3';
|
||||||
|
data.comms = {
|
||||||
|
...(data.comms ?? {}),
|
||||||
|
comms: [
|
||||||
|
{
|
||||||
|
id: 't-visual', from: 'oac', type: 'visual', trigger: 'newGame',
|
||||||
|
text: 'A short visual comm — the action should keep running.',
|
||||||
|
audio: voice, once: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 't-audio', from: 'oac', type: 'audio', trigger: 'newGame',
|
||||||
|
audio: voice, once: true, // second in the queue (FIFO behind the visual)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
config.init(data);
|
||||||
|
|
||||||
|
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;margin:0;padding:8px 12px;font:12px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.9);border:1px solid #1b3a5a;max-width:55%;white-space:pre-wrap;';
|
||||||
|
document.body.appendChild(report);
|
||||||
|
const log = (m) => { report.textContent += m + '\n'; };
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const until = async (fn, ms = 20000) => {
|
||||||
|
const t0 = Date.now();
|
||||||
|
for (;;) {
|
||||||
|
const v = fn();
|
||||||
|
if (v) return v;
|
||||||
|
if (Date.now() - t0 > ms) return null;
|
||||||
|
await sleep(120);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
const d = [];
|
||||||
|
try {
|
||||||
|
const s = () => game.scene.getScene('GameScene');
|
||||||
|
const ready = await until(() => {
|
||||||
|
const sc = s();
|
||||||
|
return sc && sc.ship && sc.comms ? sc : null;
|
||||||
|
});
|
||||||
|
if (!ready) throw new Error('scene never ready');
|
||||||
|
|
||||||
|
// ---- VISUAL -------------------------------------------------------
|
||||||
|
const live = await until(() => (s().comms.active && s().comms.current?.id === 't-visual' ? s() : null));
|
||||||
|
if (!live) throw new Error('visual comm never came up');
|
||||||
|
const box = s().comms.box;
|
||||||
|
const el = box?.video?.video;
|
||||||
|
d.push(`visual: active=true paused=${s().comms.paused} (expect false) box=${!!box} button=${!!box?.button} (expect false)`);
|
||||||
|
d.push(`visual: clip loop=${el ? el.loop : '—'} (expect false) playing=${el ? el.paused === false : '—'}`);
|
||||||
|
d.push(`visual: shipFrozen=${s().ship.body.velocity.length() >= 1} (expect false — the comm did NOT park the ship)`);
|
||||||
|
const visualOk =
|
||||||
|
s().comms.paused === false && // NO pause (the visual contract)
|
||||||
|
!!box &&
|
||||||
|
box.button === null && // no ACKNOWLEDGE button
|
||||||
|
!!el &&
|
||||||
|
el.loop === false && // the clip plays ONCE
|
||||||
|
el.paused === false;
|
||||||
|
|
||||||
|
// The voice is talking; end it the way the hub's poll sees an end.
|
||||||
|
const vKey = await until(() => (s().comms._voiceKey && s().sound.isPlaying(s().comms._voiceKey) ? s().comms._voiceKey : null));
|
||||||
|
if (!vKey) throw new Error('visual voice never started');
|
||||||
|
s().sound.stopByKey(vKey);
|
||||||
|
const visualDone = await until(() => (s().comms.current?.id !== 't-visual' ? true : null), 5000);
|
||||||
|
d.push(`visual: voice=${vKey} auto-finished=${!!visualDone} boxGone=${s().comms.box === null} paused=${s().comms.paused}`);
|
||||||
|
const ok = visualOk && !!visualDone && s().comms.box === null;
|
||||||
|
|
||||||
|
// ---- AUDIO --------------------------------------------------------
|
||||||
|
const aLive = await until(() => (s().comms.active && s().comms.current?.id === 't-audio' ? s() : null), 10000);
|
||||||
|
if (!aLive) throw new Error('audio comm never came up');
|
||||||
|
const aNoBox = s().comms.box === null;
|
||||||
|
const aPaused = s().comms.paused;
|
||||||
|
const aVoiceKey = s().comms._voiceKey;
|
||||||
|
const aVoice = aVoiceKey ? s().sound.isPlaying(aVoiceKey) : null;
|
||||||
|
d.push(`audio: active=true box=${!!s().comms.box} (expect false) paused=${aPaused} (expect false) voice=${aVoice} (expect true)`);
|
||||||
|
if (aVoiceKey) s().sound.stopByKey(aVoiceKey);
|
||||||
|
const aDone = await until(() => (s().comms.current?.id !== 't-audio' ? true : null), 5000);
|
||||||
|
d.push(`audio: auto-finished=${!!aDone}`);
|
||||||
|
const okAll = ok && aNoBox && aPaused === false && aVoice === true && !!aDone;
|
||||||
|
|
||||||
|
window.__commsTypes = { ok: okAll, details: d };
|
||||||
|
console.log('[comms-types]', okAll ? 'PASS' : 'FAIL', d.join(' | '));
|
||||||
|
} catch (err) {
|
||||||
|
window.__commsTypes = { ok: false, details: [...d, 'EXCEPTION: ' + String(err?.stack ?? err)] };
|
||||||
|
console.error('[comms-types] FAIL', err);
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
/**
|
||||||
|
* Comms model test (dev tool, run with Node — no browser):
|
||||||
|
*
|
||||||
|
* node dev/comms.test.mjs
|
||||||
|
*
|
||||||
|
* The pure rules of data/comms.json (js/comms/CommsModel.js) — the comm
|
||||||
|
* roster's contract:
|
||||||
|
* - the shipped config is VALID (enabled, OAC registered, the
|
||||||
|
* oac-intro comm wired: ack + newGame + the real assets);
|
||||||
|
* - the reserved triggers are RESERVED (no comm uses them yet);
|
||||||
|
* - normalizeComm drops what can't be shown (bad type / no trigger /
|
||||||
|
* no text for a box / no speech for an audio comm / unknown sender),
|
||||||
|
* and keeps the rest (once defaults true, delayMs clamped);
|
||||||
|
* - commsForTrigger returns the due comms in order, filtering the
|
||||||
|
* played `once` ones (and re-arming `once: false` ones);
|
||||||
|
* - asset keys are stable (per character for the clip, per file for
|
||||||
|
* the speech) and resolve to the shipped files.
|
||||||
|
*/
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = join(__dirname, '..');
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
const check = (label, cond) => {
|
||||||
|
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
|
||||||
|
if (!cond) failures++;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Silence the model's drop warnings during the negative tests (they are
|
||||||
|
// the EXPECTED behavior; the checks below verify the drops).
|
||||||
|
const origWarn = console.warn;
|
||||||
|
let warnOn = true;
|
||||||
|
console.warn = (...a) => {
|
||||||
|
if (warnOn) origWarn(...a);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { config } = await import(pathToFileURL(join(root, 'js/config/Config.js')).href);
|
||||||
|
const cfg = JSON.parse(readFileSync(join(root, 'data/comms.json'), 'utf8'));
|
||||||
|
config.init({ comms: cfg });
|
||||||
|
const {
|
||||||
|
commsEnabled,
|
||||||
|
listComms,
|
||||||
|
commsForTrigger,
|
||||||
|
normalizeComm,
|
||||||
|
commAssets,
|
||||||
|
charEntry,
|
||||||
|
videoKeyFor,
|
||||||
|
audioKeyFor,
|
||||||
|
COMM_TYPES,
|
||||||
|
} = await import(pathToFileURL(join(root, 'js/comms/CommsModel.js')).href);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// the shipped config (the contract the hub + box build on)
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
check('comms.json is enabled', cfg.enabled === true);
|
||||||
|
check('COMM_TYPES = ack | visual | audio', JSON.stringify(COMM_TYPES) === JSON.stringify(['ack', 'visual', 'audio']));
|
||||||
|
|
||||||
|
const oac = charEntry(cfg, 'oac');
|
||||||
|
check('OAC is registered (label + role + clip)', !!oac && oac.label === 'O.A.C.' && !!oac.role && oac.video === 'assets/videos/characters/oac.mp4');
|
||||||
|
|
||||||
|
const roster = listComms(cfg);
|
||||||
|
check('the roster holds exactly the shipped comms', roster.length === 1);
|
||||||
|
const intro = roster[0];
|
||||||
|
check(
|
||||||
|
'oac-intro: the fresh-run welcome (ack, newGame, OAC, once)',
|
||||||
|
!!intro &&
|
||||||
|
intro.id === 'oac-intro' &&
|
||||||
|
intro.from === 'oac' &&
|
||||||
|
intro.type === 'ack' &&
|
||||||
|
intro.trigger === 'newGame' &&
|
||||||
|
intro.once === true,
|
||||||
|
);
|
||||||
|
check('oac-intro: the welcome text (the brief, verbatim)', intro?.text.includes('Welcome to your ship') && intro?.text.includes('O.A.C.'));
|
||||||
|
check('oac-intro: the parked speech clip', intro?.audio === 'assets/speech/oac/intro-01.mp3');
|
||||||
|
check('oac-intro: its delay beats the opening toast', (intro?.delayMs ?? 0) >= 1000);
|
||||||
|
|
||||||
|
check(
|
||||||
|
'the reserved triggers are reserved (no comm uses them yet)',
|
||||||
|
['researchComplete', 'researchNew', 'questNew', 'questComplete', 'discovery', 'landing', 'jump', 'buildComplete'].every(
|
||||||
|
(t) => !roster.some((c) => c.trigger === t),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// the deferred-load keys (stable + deduping)
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
check('video key is per character', videoKeyFor('oac') === 'comms_vid_oac');
|
||||||
|
check('audio key is per file (dedupes shared clips)', audioKeyFor('assets/speech/oac/intro-01.mp3') === 'comms_au_intro-01' && audioKeyFor('assets/speech/oac/intro-01.mp3') === audioKeyFor('./assets/speech/oac/intro-01.mp3'));
|
||||||
|
const assets = commAssets(cfg, intro);
|
||||||
|
check('oac-intro assets: the clip + the speech, keyed', assets.video?.key === 'comms_vid_oac' && assets.video.url === 'assets/videos/characters/oac.mp4' && assets.audio?.key === 'comms_au_intro-01');
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// normalizeComm — the drops (what can't be shown doesn't ship)
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const base = { id: 't', from: 'oac', type: 'ack', trigger: 'newGame', text: 'hi', audio: 'assets/speech/oac/intro-01.mp3' };
|
||||||
|
|
||||||
|
// The drop checks below EXPECT the warnings — mute them (the checks
|
||||||
|
// verify the drops themselves).
|
||||||
|
warnOn = false;
|
||||||
|
check('a clean comm normalizes (defaults: once=true, delayMs=0)', (() => {
|
||||||
|
const c = normalizeComm({ ...base, id: 't2' });
|
||||||
|
return !!c && c.once === true && c.delayMs === 0 && c.id === 't2';
|
||||||
|
})());
|
||||||
|
check('drop: unknown type', normalizeComm({ ...base, id: 'b1', type: 'hologram' }) === null);
|
||||||
|
check('drop: no trigger', normalizeComm({ ...base, id: 'b2', trigger: ' ' }) === null);
|
||||||
|
check('drop: ack/visual without text', normalizeComm({ ...base, id: 'b3', text: '' }) === null);
|
||||||
|
check('drop: audio comm without speech', normalizeComm({ ...base, id: 'b4', type: 'audio', audio: '' }) === null);
|
||||||
|
check('keep: an audio comm with no text (voice only)', (() => {
|
||||||
|
const c = normalizeComm({ ...base, id: 'b5', type: 'audio', text: '' });
|
||||||
|
return !!c && c.text === '' && c.type === 'audio';
|
||||||
|
})());
|
||||||
|
check('keep: once:false survives (the author’s repeat)', normalizeComm({ ...base, id: 'b6', once: false })?.once === false);
|
||||||
|
check('delayMs clamps to >= 0', normalizeComm({ ...base, id: 'b7', delayMs: -50 })?.delayMs === 0);
|
||||||
|
warnOn = true;
|
||||||
|
|
||||||
|
// unknown senders are dropped by the ROSTER (listComms), not normalize
|
||||||
|
const withGhost = { ...cfg, comms: [...cfg.comms, { ...base, id: 'ghost', from: 'wraith' }] };
|
||||||
|
const ghostList = listComms(withGhost);
|
||||||
|
check('drop: unknown sender (the roster keeps the rest)', ghostList.length === 1 && ghostList[0].id === 'oac-intro');
|
||||||
|
const dupList = listComms({ ...cfg, comms: [...cfg.comms, { ...cfg.comms[0] }] });
|
||||||
|
check('drop: duplicate ids (first wins)', dupList.length === 1 && dupList[0].id === 'oac-intro');
|
||||||
|
warnOn = true;
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// commsForTrigger — the due set (order + the played ledger)
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
check('newGame arms the welcome on a fresh run', commsForTrigger(cfg, 'newGame').length === 1 && commsForTrigger(cfg, 'newGame')[0].id === 'oac-intro');
|
||||||
|
check('a played `once` comm does not re-arm', commsForTrigger(cfg, 'newGame', new Set(['oac-intro'])).length === 0);
|
||||||
|
check('an unknown trigger arms nothing', commsForTrigger(cfg, 'launchParty').length === 0);
|
||||||
|
check('a blank trigger arms nothing', commsForTrigger(cfg, ' ').length === 0);
|
||||||
|
|
||||||
|
// an `once: false` comm re-arms on every trigger (the repeat comm)
|
||||||
|
const repeatCfg = {
|
||||||
|
...cfg,
|
||||||
|
comms: [{ id: 'chatter', from: 'oac', type: 'audio', trigger: 'landing', audio: 'assets/speech/oac/intro-01.mp3', once: false }],
|
||||||
|
};
|
||||||
|
check('once:false re-arms after it played', commsForTrigger(repeatCfg, 'landing', new Set(['chatter'])).length === 1);
|
||||||
|
|
||||||
|
// a future roster: several comms on one trigger keep config order
|
||||||
|
const multiCfg = {
|
||||||
|
...cfg,
|
||||||
|
comms: [
|
||||||
|
{ id: 'a', from: 'oac', type: 'audio', trigger: 'researchComplete', audio: 'assets/speech/oac/research-complete.mp3' },
|
||||||
|
{ id: 'b', from: 'oac', type: 'audio', trigger: 'researchComplete', audio: 'assets/speech/oac/quest-new.mp3' },
|
||||||
|
{ id: 'c', from: 'oac', type: 'audio', trigger: 'questNew', audio: 'assets/speech/oac/quest-new.mp3' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
check('a trigger’s comms arm in config order', JSON.stringify(commsForTrigger(multiCfg, 'researchComplete').map((c) => c.id)) === JSON.stringify(['a', 'b']));
|
||||||
|
|
||||||
|
// disabled master: the model still describes the roster (the hub is the
|
||||||
|
// one that honors `enabled`) — keep the two honest.
|
||||||
|
check('a config without the section reads as enabled (pre-comms saves)', commsEnabled({}) === true && commsEnabled({ enabled: false }) === false);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (failures > 0) {
|
||||||
|
console.error(`\n${failures} comms-model check(s) failed`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('\nAll comms-model checks passed.');
|
||||||
|
|
@ -202,6 +202,9 @@ const makeStorage = (fail = false) => {
|
||||||
scene.discovery.check(system.id, 0, 0, [{ id: 'pl:0', x: 500, y: 0, radius: 12 }]);
|
scene.discovery.check(system.id, 0, 0, [{ id: 'pl:0', x: 500, y: 0, radius: 12 }]);
|
||||||
check('setup: discovery has a hit', scene.discovery.isDiscovered(system.id, 'pl:0') === true);
|
check('setup: discovery has a hit', scene.discovery.isDiscovered(system.id, 'pl:0') === true);
|
||||||
|
|
||||||
|
// The comms played-ledger (the hub's registry Set) rides the save.
|
||||||
|
scene.registry.set('commsPlayed', new Set(['oac-intro', 'wraith-parting']));
|
||||||
|
|
||||||
const rec = captureState(scene);
|
const rec = captureState(scene);
|
||||||
check('capture: seed carried', rec.seed === SEED);
|
check('capture: seed carried', rec.seed === SEED);
|
||||||
check('capture: system identity carried', rec.currentSystemId === system.id && rec.systemName === system.name);
|
check('capture: system identity carried', rec.currentSystemId === system.id && rec.systemName === system.name);
|
||||||
|
|
@ -211,6 +214,8 @@ const makeStorage = (fail = false) => {
|
||||||
check('capture: tethers carried (both)', rec.tethers.length === 2 && rec.tethers[1].label === 'Outpost');
|
check('capture: tethers carried (both)', rec.tethers.length === 2 && rec.tethers[1].label === 'Outpost');
|
||||||
check('capture: playtime carried', rec.playTimeMs === 123456);
|
check('capture: playtime carried', rec.playTimeMs === 123456);
|
||||||
check('capture: depleted fields carried', rec.depletedClusters.includes(`${system.id}:c-depleted`));
|
check('capture: depleted fields carried', rec.depletedClusters.includes(`${system.id}:c-depleted`));
|
||||||
|
check('capture: the comms ledger is carried (record.comms.played)',
|
||||||
|
Array.isArray(rec.comms?.played) && rec.comms.played.includes('oac-intro') && rec.comms.played.includes('wraith-parting'));
|
||||||
|
|
||||||
// A FRESH registry = a new browser session: prepareLoad rebuilds the
|
// A FRESH registry = a new browser session: prepareLoad rebuilds the
|
||||||
// galaxy from the seed and restores the discovery state.
|
// galaxy from the seed and restores the discovery state.
|
||||||
|
|
@ -229,6 +234,11 @@ const makeStorage = (fail = false) => {
|
||||||
check('prepare: the live state is staged under the pending key',
|
check('prepare: the live state is staged under the pending key',
|
||||||
staged !== null && staged.ship.x === 123.5 && staged.playTimeMs === 123456);
|
staged !== null && staged.ship.x === 123.5 && staged.playTimeMs === 123456);
|
||||||
check('prepare: the hold rides along with the ship state', staged.ship.minerals === 37);
|
check('prepare: the hold rides along with the ship state', staged.ship.minerals === 37);
|
||||||
|
check('prepare: the comms ledger is restored (a Set, same ids)',
|
||||||
|
reg.get('commsPlayed') instanceof Set
|
||||||
|
&& reg.get('commsPlayed').has('oac-intro')
|
||||||
|
&& reg.get('commsPlayed').has('wraith-parting')
|
||||||
|
&& reg.get('commsPlayed').size === 2);
|
||||||
|
|
||||||
// A LEGACY record (pre-minerals) still loads — no field, the restore
|
// A LEGACY record (pre-minerals) still loads — no field, the restore
|
||||||
// side's `typeof === 'number'` guard keeps the ship at 0.
|
// side's `typeof === 'number'` guard keeps the ship at 0.
|
||||||
|
|
@ -254,6 +264,8 @@ const makeStorage = (fail = false) => {
|
||||||
prepareLoad(regNoBuilds, makeRec()); // pre-build-system save
|
prepareLoad(regNoBuilds, makeRec()); // pre-build-system save
|
||||||
check('prepare: a legacy record (no builds field) stages null builds',
|
check('prepare: a legacy record (no builds field) stages null builds',
|
||||||
regNoBuilds.get(PENDING_RESTORE_KEY).builds === null);
|
regNoBuilds.get(PENDING_RESTORE_KEY).builds === null);
|
||||||
|
check('prepare: a legacy record (no comms field) stages an empty ledger',
|
||||||
|
regNoBuilds.get('commsPlayed') instanceof Set && regNoBuilds.get('commsPlayed').size === 0);
|
||||||
|
|
||||||
// consumeRestore: exactly once.
|
// consumeRestore: exactly once.
|
||||||
const first = consumeRestore(reg);
|
const first = consumeRestore(reg);
|
||||||
|
|
@ -263,11 +275,13 @@ const makeStorage = (fail = false) => {
|
||||||
|
|
||||||
// resetRunState: New Game clears the run-state seams.
|
// resetRunState: New Game clears the run-state seams.
|
||||||
reg.set('discovery', reg.get('discovery'));
|
reg.set('discovery', reg.get('discovery'));
|
||||||
|
reg.set('commsPlayed', new Set(['oac-intro']));
|
||||||
reg.set(PENDING_RESTORE_KEY, { seed: SEED, ship: { x: 0, y: 0, heading: 0 } });
|
reg.set(PENDING_RESTORE_KEY, { seed: SEED, ship: { x: 0, y: 0, heading: 0 } });
|
||||||
resetRunState(reg);
|
resetRunState(reg);
|
||||||
check('reset: discovery cleared (fresh run)', reg.get('discovery') === null);
|
check('reset: discovery cleared (fresh run)', reg.get('discovery') === null);
|
||||||
check('reset: depleted fields cleared (fresh run)', reg.get('depletedClusters') === null);
|
check('reset: depleted fields cleared (fresh run)', reg.get('depletedClusters') === null);
|
||||||
check('reset: staged restore cleared', reg.get(PENDING_RESTORE_KEY) === null);
|
check('reset: staged restore cleared', reg.get(PENDING_RESTORE_KEY) === null);
|
||||||
|
check('reset: the comms ledger is cleared (the welcome arms again)', reg.get('commsPlayed') === null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// prepareLoad must reject a record it can't trust.
|
// prepareLoad must reject a record it can't trust.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,470 @@
|
||||||
|
import { config } from '../config/Config.js';
|
||||||
|
import { CommsBox } from '../ui/CommsBox.js';
|
||||||
|
import { commsEnabled, commsForTrigger, commAssets, charEntry } from './CommsModel.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* COMMS HUB — the scene-facing manager of data/comms.json.
|
||||||
|
*
|
||||||
|
* The hub owns a comm's life end-to-end:
|
||||||
|
*
|
||||||
|
* trigger(name) → the roster's due comms queue up (their delayMs beats
|
||||||
|
* whatever toast the event fired — the voice lands
|
||||||
|
* second, like a reply);
|
||||||
|
* pump(time) → the scene's update() calls it each frame; when the
|
||||||
|
* scene is FREE (the busy() contract) and the comm's
|
||||||
|
* assets are in, the next comm plays;
|
||||||
|
* play → the box (js/ui/CommsBox.js) opens under the mineral
|
||||||
|
* bar — the muted clip loops (ack) or runs once
|
||||||
|
* (visual) while the speech clip plays and the music
|
||||||
|
* ducks under it;
|
||||||
|
* finish → 'ack' waits for the ACKNOWLEDGE press (the action is
|
||||||
|
* frozen the whole time — the pause contract below);
|
||||||
|
* 'visual' holds a beat after the voice and fades;
|
||||||
|
* 'audio' is voice only — no box at all.
|
||||||
|
*
|
||||||
|
* DEFERRED ASSETS — the load is the COMM's, not the scene's (the point:
|
||||||
|
* the game can grow dozens of comms without preloading dozens of clips
|
||||||
|
* into every scene): when a comm is due, its character clip + speech
|
||||||
|
* file are queued on the scene's loader (`scene.load.video` /
|
||||||
|
* `scene.load.audio` → `start()` → 'complete') and played straight off
|
||||||
|
* the cache. The cache is shared game-wide, so the second scene to need
|
||||||
|
* OAC's clip gets it for free.
|
||||||
|
*
|
||||||
|
* THE PAUSE CONTRACT (ack comms only — visual/audio never pause; the
|
||||||
|
* player agreed to that: visual comms are the short ones):
|
||||||
|
* - input: the scene's handler swallows world clicks (GameScene gates
|
||||||
|
* on `comms.paused`) — the ACKNOWLEDGE button presses itself (its
|
||||||
|
* own pointerdown, the save-panel's modal idiom);
|
||||||
|
* - action: the scene's update() gates the ship's steering, the
|
||||||
|
* research/build ticks, and discovery on `comms.paused` — the world
|
||||||
|
* keeps breathing (starfield, tether, the clip), the action holds;
|
||||||
|
* - onPause hook: the scene parks the ship (`ship.stop()`) and drops
|
||||||
|
* the mining arm at pause start.
|
||||||
|
*
|
||||||
|
* PLAYED LEDGER (save-backed): the run's already-played comm ids live
|
||||||
|
* in the shared registry under 'commsPlayed' (a Set) — SaveData
|
||||||
|
* captures/restores it (record.comms.played), and a NEW GAME resets it
|
||||||
|
* (the welcome plays on every fresh run; a loaded run doesn't re-hear
|
||||||
|
* it). Marked PLAYED when the comm starts, so a comm the run never
|
||||||
|
* heard (abandoned mid-queue) can still arm on its next trigger.
|
||||||
|
*
|
||||||
|
* Config: data/comms.json (enabled, audio, characters, comms, box).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PLAYED_KEY = 'commsPlayed';
|
||||||
|
/** A deferred asset that hasn't landed in 15 s: play the comm anyway
|
||||||
|
* (NO SIGNAL plate / voice-less) — the queue never holds a comm forever. */
|
||||||
|
const LOAD_VALVE_MS = 15000;
|
||||||
|
|
||||||
|
export class CommsHub {
|
||||||
|
/**
|
||||||
|
* @param {Phaser.Scene} scene
|
||||||
|
* @param {{
|
||||||
|
* busy?: () => boolean, the scene isn't free (a console is up, mining
|
||||||
|
* is live, a clip is in flight) — the comm waits
|
||||||
|
* anchor?: () => ({right:number, top:number}) | null,
|
||||||
|
* the mineral bar's lower-right (the box docks
|
||||||
|
* below it; null → the standard corner fallback)
|
||||||
|
* onPause?: (on: boolean) => void, the ack pause's scene-side effects
|
||||||
|
* }} [o]
|
||||||
|
*/
|
||||||
|
constructor(scene, o = {}) {
|
||||||
|
this.scene = scene;
|
||||||
|
this.cfg = config.section('comms', {});
|
||||||
|
this.enabled = commsEnabled(this.cfg);
|
||||||
|
this.busy = typeof o.busy === 'function' ? o.busy : null;
|
||||||
|
this.anchor = typeof o.anchor === 'function' ? o.anchor : null;
|
||||||
|
this.onPause = typeof o.onPause === 'function' ? o.onPause : null;
|
||||||
|
|
||||||
|
this.queue = []; // { def, dueAt, assets, loadingDone }
|
||||||
|
this.current = null; // the comm in play (its def)
|
||||||
|
this.box = null; // the live CommsBox (ack/visual comms)
|
||||||
|
this.played = this._playedSet();
|
||||||
|
|
||||||
|
this._loading = false; // a deferred asset pass is in flight
|
||||||
|
this._voiceKey = null; // the speech clip talking (or null)
|
||||||
|
this._voiceTimer = null; // the isPlaying poll (v4 has no complete event)
|
||||||
|
this._voiceDone = false; // this comm's voice has ended (or never existed)
|
||||||
|
this._ducked = false;
|
||||||
|
this._duckedSounds = null; // [{s, vol}] — the per-sound music duck (restore map)
|
||||||
|
this._paused = false;
|
||||||
|
this._endedAt = 0; // the last _finish (scene.time.now) — the ACK-click race guard
|
||||||
|
this._down = false; // torn down (scene shutdown) — all quiet
|
||||||
|
|
||||||
|
scene.events.once('shutdown', () => this.destroy());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// state the scene reads
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** True while an 'ack' comm holds the box open (the pause contract). */
|
||||||
|
get paused() {
|
||||||
|
return this._paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True while a comm is in play (box open or voice talking). */
|
||||||
|
get active() {
|
||||||
|
return this.current !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many comms are waiting behind the current one. */
|
||||||
|
get queued() {
|
||||||
|
return this.queue.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if a comm just ended (the ACK press / the fade-out). The scene
|
||||||
|
* uses this as a RACE guard: the click that ACKNOWLEDGED (or the fade
|
||||||
|
* that closed a visual comm) is the comm's own — whatever the input
|
||||||
|
* dispatch order (button handler vs. scene handler), that click is
|
||||||
|
* never a world click. A deliberate NEXT action is always >200ms out.
|
||||||
|
*/
|
||||||
|
endedWithin(ms) {
|
||||||
|
return this._endedAt > 0 && (this.scene.time.now - this._endedAt) < ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// the scene's two calls
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arm the comms due for an event ('newGame', 'researchComplete', …).
|
||||||
|
* Each one queues with its delayMs and starts its deferred asset load
|
||||||
|
* immediately (while the event's toast is still up, the network is
|
||||||
|
* warm) — by the time the scene is free they are in.
|
||||||
|
*
|
||||||
|
* @returns {number} how many comms armed
|
||||||
|
*/
|
||||||
|
trigger(name) {
|
||||||
|
if (this._down || !this.enabled) return 0;
|
||||||
|
const due = commsForTrigger(this.cfg, name, this.played);
|
||||||
|
for (const def of due) {
|
||||||
|
this.queue.push({
|
||||||
|
def,
|
||||||
|
dueAt: this.scene.time.now + def.delayMs,
|
||||||
|
assets: commAssets(this.cfg, def),
|
||||||
|
loadingDone: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return due.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-frame (the scene's update() drives it — the TimeClock is scene-
|
||||||
|
* driven in v4): step the live comm (the box's text decode) and, when
|
||||||
|
* idle, start the next due comm if the scene is free and the assets
|
||||||
|
* are in.
|
||||||
|
*/
|
||||||
|
pump(time) {
|
||||||
|
if (this._down) return;
|
||||||
|
if (this.current) {
|
||||||
|
this.box?.update(time);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._loading) return; // the asset pass re-pumps itself on 'complete'
|
||||||
|
const entry = this.queue[0];
|
||||||
|
if (!entry) return;
|
||||||
|
if (time < entry.dueAt) return;
|
||||||
|
if (this.busy && this.busy()) return; // the console owns the screen — wait
|
||||||
|
this.queue.shift(); // COMMIT — a comm plays at most once (the played
|
||||||
|
// ledger then bars its next trigger; without this the entry would
|
||||||
|
// sit here and re-play every time the hub went idle)
|
||||||
|
this._start(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// the comm's life
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Start the entry: defer-load whatever's missing, then play. */
|
||||||
|
_start(entry) {
|
||||||
|
const s = this.scene;
|
||||||
|
const missing = [];
|
||||||
|
if (entry.assets.video && !_hasVideo(s, entry.assets.video.key)) {
|
||||||
|
missing.push({ kind: 'video', spec: entry.assets.video });
|
||||||
|
}
|
||||||
|
if (entry.assets.audio && !_hasAudio(s, entry.assets.audio.key)) {
|
||||||
|
missing.push({ kind: 'audio', spec: entry.assets.audio });
|
||||||
|
}
|
||||||
|
if (missing.length === 0) {
|
||||||
|
this._play(entry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._loading = true;
|
||||||
|
for (const { kind, spec } of missing) {
|
||||||
|
if (kind === 'video') s.load.video(spec.key, spec.url);
|
||||||
|
else s.load.audio(spec.key, spec.url);
|
||||||
|
}
|
||||||
|
let done = false;
|
||||||
|
let valve = null;
|
||||||
|
const finish = () => {
|
||||||
|
if (done || this._down) return;
|
||||||
|
done = true;
|
||||||
|
entry.loadingDone = true;
|
||||||
|
this._loading = false;
|
||||||
|
if (valve) {
|
||||||
|
valve.remove(false);
|
||||||
|
valve = null;
|
||||||
|
}
|
||||||
|
s.load.off('complete', finish); // the other finish path — keep it clean
|
||||||
|
// Whatever landed, the comm plays (a missing clip degrades to the
|
||||||
|
// NO SIGNAL plate / a voice-less box — the comm is never lost).
|
||||||
|
this._play(entry);
|
||||||
|
};
|
||||||
|
s.load.once('complete', finish);
|
||||||
|
s.load.start();
|
||||||
|
// The stall valve (the project's idiom — a clip that never starts
|
||||||
|
// must not hold the player): the load must resolve on its own.
|
||||||
|
valve = s.time.delayedCall(LOAD_VALVE_MS, finish);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The comm is in play: the box (ack/visual) + the voice + the pause. */
|
||||||
|
_play(entry) {
|
||||||
|
const s = this.scene;
|
||||||
|
const def = entry.def;
|
||||||
|
this.current = def;
|
||||||
|
this._voiceDone = false;
|
||||||
|
this.played.add(def.id); // the run has heard it (or is hearing it)
|
||||||
|
// The ack comm freezes the action FIRST (before the box slides in,
|
||||||
|
// before the voice) — the ship is already parked as the box lands.
|
||||||
|
// Visual/audio comms never pause (the agreed contract — they are the
|
||||||
|
// short ones).
|
||||||
|
if (def.type === 'ack') this._setPaused(true);
|
||||||
|
|
||||||
|
if (def.type !== 'audio') {
|
||||||
|
const char = charEntry(this.cfg, def.from) ?? {};
|
||||||
|
const video = entry.assets.video && _hasVideo(s, entry.assets.video.key)
|
||||||
|
? { key: entry.assets.video.key }
|
||||||
|
: null;
|
||||||
|
this.box = new CommsBox(s, this._anchor(), {
|
||||||
|
label: char.label ?? def.from,
|
||||||
|
role: char.role ?? '',
|
||||||
|
text: def.text,
|
||||||
|
loop: def.type === 'ack', // the clip loops silently until acknowledged
|
||||||
|
video,
|
||||||
|
showAck: def.type === 'ack',
|
||||||
|
onAck: () => this._ack(),
|
||||||
|
});
|
||||||
|
this.box.open();
|
||||||
|
}
|
||||||
|
this._speak(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The comm's voice (the speech clip), with the music ducked under it. */
|
||||||
|
_speak(entry) {
|
||||||
|
const s = this.scene;
|
||||||
|
const spec = entry.assets.audio;
|
||||||
|
const snd = s.sound;
|
||||||
|
if (!spec || !_hasAudio(s, spec.key) || !snd || typeof snd.play !== 'function') {
|
||||||
|
// Voice-less comm (missing clip, or no sound manager — headless):
|
||||||
|
// the comm still shows; the finish path just runs early.
|
||||||
|
this._voiceKey = null;
|
||||||
|
this._voiceEnded();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._voiceKey = spec.key;
|
||||||
|
this._voiceDone = false;
|
||||||
|
this._duck(true);
|
||||||
|
this.box?.setVoice(true);
|
||||||
|
// The comm's voice is the comm's CONTENT (not an SFX — it plays even
|
||||||
|
// with sfx.enabled off; the cache/manager guards above are the
|
||||||
|
// failure path, per the project's sound rule).
|
||||||
|
try {
|
||||||
|
snd.play(spec.key, { volume: config.get('comms.audio.volume', 1.0) });
|
||||||
|
} catch (e) {
|
||||||
|
// The engine refused (autoplay policy, the manager mid-shutdown):
|
||||||
|
// the comm still shows — finish on the voice-less path, never hang.
|
||||||
|
console.warn('[comms] voice refused by the engine', e);
|
||||||
|
this._voiceKey = null;
|
||||||
|
this._voiceEnded();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// v4 (Giedi) has no 'complete' event on the sound manager (checked —
|
||||||
|
// see Music.js) — poll isPlaying() on the shuffle's time base.
|
||||||
|
this._voiceTimer = s.time.addEvent({
|
||||||
|
delay: 250,
|
||||||
|
loop: true,
|
||||||
|
callback: () => {
|
||||||
|
if (this._down || !this.current || this._voiceKey !== spec.key) return;
|
||||||
|
if (snd.isPlaying(spec.key) !== true) {
|
||||||
|
this._voiceKey = null;
|
||||||
|
this._voiceEnded();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The voice ended: ack waits for the button; the rest fade out. */
|
||||||
|
_voiceEnded() {
|
||||||
|
if (this._voiceDone || !this.current) return;
|
||||||
|
this._voiceDone = true;
|
||||||
|
this._stopVoice();
|
||||||
|
this.box?.setVoice(false);
|
||||||
|
this._duck(false);
|
||||||
|
const def = this.current;
|
||||||
|
if (def.type === 'ack') return; // the box holds (video looping) until ACKNOWLEDGE
|
||||||
|
const hold =
|
||||||
|
def.type === 'visual'
|
||||||
|
? Math.max(0, config.get('comms.box.animation.visualHoldMs', 350))
|
||||||
|
: 0;
|
||||||
|
this.scene.time.delayedCall(hold, () => {
|
||||||
|
if (this.current === def) this._finish();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ACKNOWLEDGE press (the box's button — the scene is the voice). */
|
||||||
|
_ack() {
|
||||||
|
if (!this.current || this.current.type !== 'ack') return;
|
||||||
|
this._finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The comm is over: tear down, unpause, hand the next one its turn. */
|
||||||
|
_finish() {
|
||||||
|
if (!this.current) return;
|
||||||
|
this._stopVoice();
|
||||||
|
this._duck(false);
|
||||||
|
this._setPaused(false);
|
||||||
|
this._endedAt = this.scene.time.now; // the ACK-click race guard (the scene swallows it)
|
||||||
|
const box = this.box;
|
||||||
|
this.box = null;
|
||||||
|
this.current = null;
|
||||||
|
this._voiceDone = true;
|
||||||
|
if (box) box.close(); // the fade-out; the hub moves on (the next pump)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tear everything down (scene shutdown / destroy). Idempotent. */
|
||||||
|
destroy() {
|
||||||
|
if (this._down) return;
|
||||||
|
this._down = true;
|
||||||
|
this._stopVoice();
|
||||||
|
this._duck(false);
|
||||||
|
this._setPaused(false);
|
||||||
|
this.queue.length = 0;
|
||||||
|
this.current = null;
|
||||||
|
if (this.box) {
|
||||||
|
this.box.destroy();
|
||||||
|
this.box = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// internals
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
_stopVoice() {
|
||||||
|
if (this._voiceTimer) {
|
||||||
|
this._voiceTimer.remove(false);
|
||||||
|
this._voiceTimer = null;
|
||||||
|
}
|
||||||
|
if (this._voiceKey) {
|
||||||
|
const snd = this.scene.sound;
|
||||||
|
if (snd && typeof snd.stopByKey === 'function') {
|
||||||
|
try {
|
||||||
|
snd.stopByKey(this._voiceKey);
|
||||||
|
} catch {
|
||||||
|
/* the manager is gone (shutdown) — fine */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._voiceKey = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_duck(on) {
|
||||||
|
const snd = this.scene && this.scene.sound;
|
||||||
|
if (!snd) return;
|
||||||
|
if (on && !this._ducked) {
|
||||||
|
// The duck is PER-SOUND, never the manager's master: `sound.setVolume`
|
||||||
|
// is the MASTER volume node (v4), and it would quiet the comm's OWN
|
||||||
|
// voice along with the music — the voice is the point. Music is
|
||||||
|
// played per-sound at music.volume (Music.js), so lower the other
|
||||||
|
// playing sounds' own volume by the factor and restore it exactly.
|
||||||
|
this._ducked = true;
|
||||||
|
this._duckedSounds = [];
|
||||||
|
const f = config.get('comms.audio.musicDuck', 0.3);
|
||||||
|
const playing = typeof snd.getAllPlaying === 'function' ? snd.getAllPlaying() : [];
|
||||||
|
for (const s of playing) {
|
||||||
|
if (!s || s.key === this._voiceKey) continue; // the voice is exempt
|
||||||
|
if (typeof s.setVolume !== 'function') continue;
|
||||||
|
// The LOGICAL volume (currentConfig) is the source of truth — the
|
||||||
|
// gain node's read can lag in a suspended context (headless tabs),
|
||||||
|
// so the restore map is keyed off the config, not the node.
|
||||||
|
const vol = s.currentConfig && typeof s.currentConfig.volume === 'number'
|
||||||
|
? s.currentConfig.volume
|
||||||
|
: s.volume;
|
||||||
|
if (typeof vol !== 'number') continue;
|
||||||
|
this._duckedSounds.push({ s, vol });
|
||||||
|
try {
|
||||||
|
s.setVolume(vol * f);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[comms] duck failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!on && this._ducked) {
|
||||||
|
this._ducked = false;
|
||||||
|
for (const { s, vol } of this._duckedSounds) {
|
||||||
|
try {
|
||||||
|
s.setVolume(vol);
|
||||||
|
} catch {
|
||||||
|
/* it ended mid-duck (a track rolled on) — fine */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._duckedSounds = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_setPaused(on) {
|
||||||
|
const want = on === true;
|
||||||
|
if (want === this._paused) return;
|
||||||
|
this._paused = want;
|
||||||
|
if (this.onPause) {
|
||||||
|
try {
|
||||||
|
this.onPause(want);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[comms] pause hook failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The box's dock: the mineral bar's lower-right, or the standard corner. */
|
||||||
|
_anchor() {
|
||||||
|
if (this.anchor) {
|
||||||
|
try {
|
||||||
|
const a = this.anchor();
|
||||||
|
if (a && Number.isFinite(a.right) && Number.isFinite(a.top)) return a;
|
||||||
|
} catch {
|
||||||
|
/* the hud is gone (shutdown race) — fall through */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const W = this.scene.scale?.width ?? 1280;
|
||||||
|
return { right: W - 16, top: 47 }; // the hud's standard geometry
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The run's played ledger (the shared registry — SaveData captures it
|
||||||
|
* and a new game resets it). A record predating comms has none → a
|
||||||
|
* fresh Set (the welcome plays; old saves load).
|
||||||
|
*/
|
||||||
|
_playedSet() {
|
||||||
|
const reg = this.scene.registry;
|
||||||
|
if (!reg) return new Set();
|
||||||
|
const cur = reg.get(PLAYED_KEY);
|
||||||
|
if (cur instanceof Set) return cur;
|
||||||
|
const set = new Set(Array.isArray(cur) ? cur.filter((id) => typeof id === 'string') : []);
|
||||||
|
reg.set(PLAYED_KEY, set);
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// the v4 cache guards (the Sfx.js/ResearchWindow idiom)
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _hasVideo(scene, key) {
|
||||||
|
const c = scene.cache?.video;
|
||||||
|
return !!(c && typeof c.has === 'function' && c.has(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _hasAudio(scene, key) {
|
||||||
|
const c = scene.cache?.audio;
|
||||||
|
return !!(c && typeof c.has === 'function' && c.has(key));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
/**
|
||||||
|
* COMMS MODEL — the pure, Node-testable rules of data/comms.json.
|
||||||
|
*
|
||||||
|
* A comm is an EVENT with a sender: { id, from, type, trigger, text,
|
||||||
|
* audio, once, delayMs }. This module normalizes the config (dropping —
|
||||||
|
* with a console warning — anything that can't be shown) and answers
|
||||||
|
* the two questions the hub asks:
|
||||||
|
*
|
||||||
|
* - `commsForTrigger(cfg, trigger, played)` — which comms are DUE for
|
||||||
|
* an event (already-played `once` comms don't re-fire; a fresh run's
|
||||||
|
* empty played set is what lets the new-game welcome play);
|
||||||
|
* - `commAssets(cfg, comm)` — the deferred-load spec: the sender's
|
||||||
|
* character clip (by character id, so every comm from OAC shares one
|
||||||
|
* cached clip) and the speech clip (by file name, so a clip shared
|
||||||
|
* by several comms loads once).
|
||||||
|
*
|
||||||
|
* Pure: it takes the `comms` config SECTION (the object from
|
||||||
|
* data/comms.json) — never the `config` singleton — so the dev tests
|
||||||
|
* (dev/comms.test.mjs) can feed it plain JSON.
|
||||||
|
*
|
||||||
|
* import { config } from '../config/Config.js';
|
||||||
|
* const cfg = config.section('comms', {});
|
||||||
|
* const due = commsForTrigger(cfg, 'newGame', playedSet);
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const COMM_TYPES = ['ack', 'visual', 'audio'];
|
||||||
|
|
||||||
|
/** The scene-loader cache key for a character's clip: comms_vid_<id>. */
|
||||||
|
export function videoKeyFor(charId) {
|
||||||
|
return `comms_vid_${String(charId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scene-loader cache key for a speech clip, derived from the FILE
|
||||||
|
* (two comms sharing a clip share one key; 'assets/speech/oac/intro-01.mp3'
|
||||||
|
* → 'comms_au_intro-01').
|
||||||
|
*/
|
||||||
|
export function audioKeyFor(file) {
|
||||||
|
const base = String(file ?? '')
|
||||||
|
.split('/')
|
||||||
|
.pop()
|
||||||
|
.replace(/\.[a-z0-9]+$/i, '');
|
||||||
|
const safe = base.replace(/[^a-z0-9_-]+/gi, '-');
|
||||||
|
return `comms_au_${safe || 'speech'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} the master switch (a config predating comms defaults on). */
|
||||||
|
export function commsEnabled(cfg) {
|
||||||
|
return cfg?.enabled !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sender's entry (characters.<from>) or null (a comm with an unknown
|
||||||
|
* sender is dropped by listComms — the box has no face to put on it).
|
||||||
|
*/
|
||||||
|
export function charEntry(cfg, from) {
|
||||||
|
const chars = cfg?.characters;
|
||||||
|
if (!chars || typeof chars !== 'object' || from == null) return null;
|
||||||
|
const entry = chars[from];
|
||||||
|
return entry && typeof entry === 'object' ? entry : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize one comm entry: returns a clean object, or null when the
|
||||||
|
* entry can't be shown (with a console warning — the rest of the list
|
||||||
|
* still plays).
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - type in ack|visual|audio (else the comm is unplayable);
|
||||||
|
* - trigger: a non-empty string (a comm that can't fire is dead weight);
|
||||||
|
* - from: must resolve to a character (the box needs its face);
|
||||||
|
* - text: required for ack|visual (a box with no words is noise) —
|
||||||
|
* optional for 'audio' (voice only);
|
||||||
|
* - audio: required for 'audio' (voice is the whole comm) — optional
|
||||||
|
* for ack|visual (a text-only comm is a valid comm);
|
||||||
|
* - once defaults true (comms don't repeat unless the author says so);
|
||||||
|
* - delayMs defaults 0 (clamped to >= 0).
|
||||||
|
*/
|
||||||
|
export function normalizeComm(raw) {
|
||||||
|
if (!raw || typeof raw !== 'object') return null;
|
||||||
|
const id = String(raw.id ?? '').trim();
|
||||||
|
if (!id) return null;
|
||||||
|
const type = String(raw.type ?? '').trim();
|
||||||
|
if (!COMM_TYPES.includes(type)) {
|
||||||
|
console.warn(`[comms] "${id}" has unknown type "${type}" (expected ${COMM_TYPES.join(' | ')}) — dropped`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trigger = String(raw.trigger ?? '').trim();
|
||||||
|
if (!trigger) {
|
||||||
|
console.warn(`[comms] "${id}" has no trigger — dropped`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const from = String(raw.from ?? '').trim();
|
||||||
|
const text = String(raw.text ?? '').trim();
|
||||||
|
const audio = String(raw.audio ?? '').trim();
|
||||||
|
if (!text && type !== 'audio') {
|
||||||
|
console.warn(`[comms] "${id}" (${type}) has no text — dropped`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!audio && type === 'audio') {
|
||||||
|
console.warn(`[comms] "${id}" (audio) has no speech clip — dropped`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
from,
|
||||||
|
type,
|
||||||
|
trigger,
|
||||||
|
text,
|
||||||
|
audio: audio || null,
|
||||||
|
once: raw.once !== false,
|
||||||
|
delayMs: Math.max(0, Number(raw.delayMs) || 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The playable roster: the normalized comms in config order, minus the
|
||||||
|
* ones whose sender has no character entry (warned per id).
|
||||||
|
* @returns {Array<object>}
|
||||||
|
*/
|
||||||
|
export function listComms(cfg) {
|
||||||
|
const list = Array.isArray(cfg?.comms) ? cfg.comms : [];
|
||||||
|
const out = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const raw of list) {
|
||||||
|
const comm = normalizeComm(raw);
|
||||||
|
if (!comm) continue;
|
||||||
|
if (!charEntry(cfg, comm.from)) {
|
||||||
|
console.warn(`[comms] "${comm.id}" speaks for unknown sender "${comm.from}" — dropped`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (seen.has(comm.id)) {
|
||||||
|
console.warn(`[comms] duplicate id "${comm.id}" — the first entry wins`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(comm.id);
|
||||||
|
out.push(comm);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The comm's deferred-load spec. The character clip is keyed BY CHARACTER
|
||||||
|
* (every OAC comm shares one 185 KB clip); the speech clip BY FILE (a clip
|
||||||
|
* reused across comms loads once).
|
||||||
|
*
|
||||||
|
* @returns {{video: {key:string,url:string}|null, audio: {key:string,url:string}|null}}
|
||||||
|
*/
|
||||||
|
export function commAssets(cfg, comm) {
|
||||||
|
const entry = charEntry(cfg, comm.from);
|
||||||
|
const videoUrl = String(entry?.video ?? '').trim();
|
||||||
|
const audioUrl = String(comm.audio ?? '').trim();
|
||||||
|
return {
|
||||||
|
video: videoUrl ? { key: videoKeyFor(comm.from), url: videoUrl } : null,
|
||||||
|
audio: audioUrl ? { key: audioKeyFor(audioUrl), url: audioUrl } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The comms due for a trigger: the roster entries bound to it, in config
|
||||||
|
* order, minus the ones already played (`once` comms the run has heard;
|
||||||
|
* `once: false` comms re-arm on every trigger — the author's repeat).
|
||||||
|
*
|
||||||
|
* @param {object} cfg the comms config section
|
||||||
|
* @param {string} trigger e.g. 'newGame'
|
||||||
|
* @param {Set<string>} [played] the run's already-played ids (a save's ledger)
|
||||||
|
* @returns {Array<object>} the normalized comms
|
||||||
|
*/
|
||||||
|
export function commsForTrigger(cfg, trigger, played = new Set()) {
|
||||||
|
const name = String(trigger ?? '').trim();
|
||||||
|
if (!name) return [];
|
||||||
|
return listComms(cfg).filter((c) => c.trigger === name && !(c.once && played.has(c.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The set of trigger names the roster uses (the dev tests + docs). */
|
||||||
|
export function triggersUsed(cfg) {
|
||||||
|
return new Set(listComms(cfg).map((c) => c.trigger));
|
||||||
|
}
|
||||||
|
|
@ -120,6 +120,13 @@ export function captureState(scene, now) {
|
||||||
// A save predating quests has no field; the restore stages null and
|
// A save predating quests has no field; the restore stages null and
|
||||||
// the starter quest is granted onto the fresh ledger (old saves load).
|
// the starter quest is granted onto the fresh ledger (old saves load).
|
||||||
quests: scene.questState ? scene.questState.toJSON() : null,
|
quests: scene.questState ? scene.questState.toJSON() : null,
|
||||||
|
// Comms — the run's ALREADY-PLAYED ids (data/comms.json: the `once`
|
||||||
|
// comms the run has heard, so a load doesn't re-fire them and a new
|
||||||
|
// game re-plays the welcome). The ledger lives in the registry
|
||||||
|
// ('commsPlayed' — js/comms/CommsHub.js owns it). A save predating
|
||||||
|
// comms has no field; the restore stages an empty set (old saves
|
||||||
|
// load).
|
||||||
|
comms: { played: Array.from(scene.registry.get('commsPlayed') ?? []) },
|
||||||
// Lifetime minerals mined from asteroids (the 'Mine 200 Minerals'
|
// Lifetime minerals mined from asteroids (the 'Mine 200 Minerals'
|
||||||
// requirement's input — the hold is cargo, this is the record).
|
// requirement's input — the hold is cargo, this is the record).
|
||||||
// A save predating it has no field; the restore stages 0.
|
// A save predating it has no field; the restore stages 0.
|
||||||
|
|
@ -231,6 +238,17 @@ export function prepareLoad(registry, record) {
|
||||||
'depletedClusters',
|
'depletedClusters',
|
||||||
new Set(Array.isArray(record.depletedClusters) ? record.depletedClusters : []),
|
new Set(Array.isArray(record.depletedClusters) ? record.depletedClusters : []),
|
||||||
);
|
);
|
||||||
|
// The run's COMMS played-ledger (a save predating comms has no field →
|
||||||
|
// an empty set — old saves load; a fresh run's welcome arms on its own
|
||||||
|
// 'newGame' trigger in GameScene.create()).
|
||||||
|
registry.set(
|
||||||
|
'commsPlayed',
|
||||||
|
new Set(
|
||||||
|
Array.isArray(record.comms?.played)
|
||||||
|
? record.comms.played.filter((id) => typeof id === 'string')
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
);
|
||||||
registry.set(PENDING_RESTORE_KEY, {
|
registry.set(PENDING_RESTORE_KEY, {
|
||||||
ship: record.ship,
|
ship: record.ship,
|
||||||
tethers: Array.isArray(record.tethers) ? record.tethers : [],
|
tethers: Array.isArray(record.tethers) ? record.tethers : [],
|
||||||
|
|
@ -267,6 +285,7 @@ export function resetRunState(registry) {
|
||||||
registry.set('quests', null);
|
registry.set('quests', null);
|
||||||
registry.set('totalMined', null);
|
registry.set('totalMined', null);
|
||||||
registry.set('depletedClusters', null);
|
registry.set('depletedClusters', null);
|
||||||
|
registry.set('commsPlayed', null); // the welcome arms again on the new run
|
||||||
registry.set(PENDING_RESTORE_KEY, null);
|
registry.set(PENDING_RESTORE_KEY, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import { MenuSubBar } from '../ui/MenuSubBar.js';
|
||||||
import { SavePanel } from '../ui/SavePanel.js';
|
import { SavePanel } from '../ui/SavePanel.js';
|
||||||
import { SaveManager } from '../save/SaveManager.js';
|
import { SaveManager } from '../save/SaveManager.js';
|
||||||
import { consumeRestore, captureState, prepareLoad } from '../save/SaveData.js';
|
import { consumeRestore, captureState, prepareLoad } from '../save/SaveData.js';
|
||||||
|
import { CommsHub } from '../comms/CommsHub.js';
|
||||||
import { TetherField } from '../tether/TetherField.js';
|
import { TetherField } from '../tether/TetherField.js';
|
||||||
import { Mining } from '../mining/Mining.js';
|
import { Mining } from '../mining/Mining.js';
|
||||||
import { MiningPopup } from '../ui/MiningPopup.js';
|
import { MiningPopup } from '../ui/MiningPopup.js';
|
||||||
|
|
@ -348,6 +349,12 @@ export class GameScene extends Phaser.Scene {
|
||||||
// playtime) in the registry (js/save/SaveData.js) — the ship and the
|
// playtime) in the registry (js/save/SaveData.js) — the ship and the
|
||||||
// tether field don't exist yet, so apply it once they do (below).
|
// tether field don't exist yet, so apply it once they do (below).
|
||||||
this._pendingRestore = consumeRestore(this.registry);
|
this._pendingRestore = consumeRestore(this.registry);
|
||||||
|
// A FRESH run (no staged restore) is what the 'newGame' comms fire
|
||||||
|
// for (the welcome plays on a new game, never on a load) — and a
|
||||||
|
// jump-cut restart (scene.restart, no resetRunState) is NOT one: its
|
||||||
|
// played ledger (the registry) already holds the welcome's id, so
|
||||||
|
// the trigger below re-arms nothing (the model's played filter).
|
||||||
|
this._freshRun = this._pendingRestore === null;
|
||||||
this.systemRecord = this.galaxy.currentSystem();
|
this.systemRecord = this.galaxy.currentSystem();
|
||||||
this.systemContent = this.galaxy.ensureContent(this.systemRecord.id);
|
this.systemContent = this.galaxy.ensureContent(this.systemRecord.id);
|
||||||
// DEPLETED FIELDS — asteroid clusters the run has mined to nothing:
|
// DEPLETED FIELDS — asteroid clusters the run has mined to nothing:
|
||||||
|
|
@ -758,6 +765,46 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.mineralHud = new MineralHud(this);
|
this.mineralHud = new MineralHud(this);
|
||||||
this.mineralHud.set(this.ship.minerals, this.ship.stats.mineralStorage);
|
this.mineralHud.set(this.ship.minerals, this.ship.stats.mineralStorage);
|
||||||
|
|
||||||
|
// ---- COMMS (the characters speak to the player — data/comms.json) --
|
||||||
|
// The hub (js/comms/CommsHub.js) owns the comms' lives: the queue
|
||||||
|
// (events arm them; pump() below plays the next due one), the
|
||||||
|
// DEFERRED asset loads (the comm's character clip + speech file load
|
||||||
|
// when the comm is due — never in preload, so a big comm roster
|
||||||
|
// doesn't bloat the initial load), the voice (with the music ducked
|
||||||
|
// under it), and the ACK pause contract (below: update() gates the
|
||||||
|
// action on comms.paused; the input seams gate on it too). The box
|
||||||
|
// itself (js/ui/CommsBox.js) docks just below the mineral bar — the
|
||||||
|
// hud's lower-right is the anchor.
|
||||||
|
this.comms = new CommsHub(this, {
|
||||||
|
// The scene isn't free while any of these owns the screen — the
|
||||||
|
// comm waits (re-pumped each frame) until the screen is clear.
|
||||||
|
busy: () =>
|
||||||
|
(this.savePanel && this.savePanel.isOpen) ||
|
||||||
|
(this.researchWindow && this.researchWindow.isOpen) ||
|
||||||
|
(this.mapWindow && this.mapWindow.isOpen) ||
|
||||||
|
(this.questWindow && this.questWindow.isOpen) ||
|
||||||
|
(this.commsPanel && this.commsPanel.isOpen) ||
|
||||||
|
(this.miningPopup && this.miningPopup.isOpen) ||
|
||||||
|
(this.menuSubBar && this.menuSubBar.isOpen) ||
|
||||||
|
(this.mining && this.mining.isActive) ||
|
||||||
|
(this.scanPulse && this.scanPulse.busy) ||
|
||||||
|
!!this._jumping,
|
||||||
|
anchor: () => {
|
||||||
|
const hud = this.mineralHud;
|
||||||
|
if (!hud) return null;
|
||||||
|
return { right: this.scale.width - hud.pad, top: hud.barY + hud.barH };
|
||||||
|
},
|
||||||
|
// The ack pause's scene-side effects: the action holds — the ship
|
||||||
|
// parks dead (target + throttle cleared, velocity zeroed) and the
|
||||||
|
// mining arm drops. Resuming needs no work (the ship stays parked;
|
||||||
|
// the player's next click steers it again).
|
||||||
|
onPause: (on) => {
|
||||||
|
if (!on) return;
|
||||||
|
this.ship.stop();
|
||||||
|
if (this.mining && this.mining.isActive) this.mining.stop();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Hint (pinned just ABOVE the command deck, not under it)
|
// Hint (pinned just ABOVE the command deck, not under it)
|
||||||
this.hint = this.add
|
this.hint = this.add
|
||||||
.text(this.scale.width / 2, this.scale.height - deckReserve - (deckReserve ? 20 : 26), config.get('game.hintText', ''), {
|
.text(this.scale.width / 2, this.scale.height - deckReserve - (deckReserve ? 20 : 26), config.get('game.hintText', ''), {
|
||||||
|
|
@ -1069,6 +1116,12 @@ export class GameScene extends Phaser.Scene {
|
||||||
// A jump is in flight (between the clip's end and the restart): the
|
// A jump is in flight (between the clip's end and the restart): the
|
||||||
// old scene is already gone — swallow anything that lands in the gap.
|
// old scene is already gone — swallow anything that lands in the gap.
|
||||||
if (this._jumping) return;
|
if (this._jumping) return;
|
||||||
|
// An ACK comm is up — it owns the whole screen (the box + its
|
||||||
|
// ACKNOWLEDGE button press itself; the world holds still — the
|
||||||
|
// CommsHub's pause contract; no skip, the player reads + clicks).
|
||||||
|
// _commInputBlocked also swallows the click that ACKNOWLEDGED (the
|
||||||
|
// endedWithin race window) — that click is the comm's own.
|
||||||
|
if (this._commInputBlocked()) return;
|
||||||
// The save pop-up is MODAL — while it's up it owns all input
|
// The save pop-up is MODAL — while it's up it owns all input
|
||||||
// (its scrim / cards / dialog eat the click; the world stays put).
|
// (its scrim / cards / dialog eat the click; the world stays put).
|
||||||
if (this.savePanel && this.savePanel.isOpen) return;
|
if (this.savePanel && this.savePanel.isOpen) return;
|
||||||
|
|
@ -1276,6 +1329,16 @@ export class GameScene extends Phaser.Scene {
|
||||||
// to the world camera.
|
// to the world camera.
|
||||||
this.systemEffects = new SystemEffects(this);
|
this.systemEffects = new SystemEffects(this);
|
||||||
this.systemEffects.apply(this.systemRecord.type);
|
this.systemEffects.apply(this.systemRecord.type);
|
||||||
|
|
||||||
|
// ---- COMMS: the fresh-run welcome ----------------------------------
|
||||||
|
// The 'newGame' trigger (data/comms.json) arms the run's opening
|
||||||
|
// comm — OAC introducing the ship (ack: the action pauses, the box
|
||||||
|
// holds, the player reads + clicks ACKNOWLEDGE). It fires on a FRESH
|
||||||
|
// start only (above: _freshRun) — a LOAD's played ledger already
|
||||||
|
// holds its id, so a resumed run doesn't re-hear the welcome. The
|
||||||
|
// hub's delayMs beats the opening toast, and its deferred load pulls
|
||||||
|
// OAC's clip + speech on demand (the scene's preload stays lean).
|
||||||
|
if (this._freshRun) this.comms.trigger('newGame');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1653,10 +1716,19 @@ export class GameScene extends Phaser.Scene {
|
||||||
// fire. (Same quirk as MenuScene.update; verified Sept 2026.)
|
// fire. (Same quirk as MenuScene.update; verified Sept 2026.)
|
||||||
this.time.update(_time, delta);
|
this.time.update(_time, delta);
|
||||||
this.tweens.update();
|
this.tweens.update();
|
||||||
|
this.comms?.pump(_time); // comms: queue → play (and its pause contract)
|
||||||
|
// The ACK pause contract (the CommsHub froze the action): the ship
|
||||||
|
// holds station, the research/build ticks wait (their deadlines are
|
||||||
|
// time-based — a project that finishes while the box holds completes
|
||||||
|
// on the first tick after, its toast landing when the action resumes),
|
||||||
|
// and discovery is off (the ship isn't moving). The world KEEPS
|
||||||
|
// breathing — starfield, tether, the comm's own clip — only the
|
||||||
|
// player's action is paused.
|
||||||
|
const _commsPaused = this.comms?.paused === true;
|
||||||
this.updateHud(_time); // the dossier: decode, caret, auto-fold, toggle
|
this.updateHud(_time); // the dossier: decode, caret, auto-fold, toggle
|
||||||
this.systemEffects?.update(_time); // the star's character (wave phase + star's screen UV)
|
this.systemEffects?.update(_time); // the star's character (wave phase + star's screen UV)
|
||||||
this.questTracker?.update(_time); // the tracker: live checklist + priority
|
this.questTracker?.update(_time); // the tracker: live checklist + priority
|
||||||
this.ship.update(_time, delta);
|
if (!_commsPaused) this.ship.update(_time, delta);
|
||||||
// Session time (saved with the game) — capped so a backgrounded tab
|
// Session time (saved with the game) — capped so a backgrounded tab
|
||||||
// can't fast-forward it.
|
// can't fast-forward it.
|
||||||
this.playTimeMs = (this.playTimeMs ?? 0) + Math.min(delta, 100);
|
this.playTimeMs = (this.playTimeMs ?? 0) + Math.min(delta, 100);
|
||||||
|
|
@ -1667,7 +1739,9 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.commsPanel?.update(_time); // the name decode, the bar draw-in, the cursor blink, the flicker
|
this.commsPanel?.update(_time); // the name decode, the bar draw-in, the cursor blink, the flicker
|
||||||
// Research: tick the in-flight project (time-based; tick() reports any
|
// Research: tick the in-flight project (time-based; tick() reports any
|
||||||
// completion and already unlocked it — the scene applies effects/SFX).
|
// completion and already unlocked it — the scene applies effects/SFX).
|
||||||
const _researchDone = this.researchState?.tick(_time) ?? null;
|
// Paused by an ACK comm (above) — the tick resumes after, and the
|
||||||
|
// completed project's effects land then (the toast, the deck bar).
|
||||||
|
const _researchDone = !_commsPaused && this.researchState ? this.researchState.tick(_time) : null;
|
||||||
if (_researchDone?.length) {
|
if (_researchDone?.length) {
|
||||||
for (const d of _researchDone) this._completeResearch(d.category, d.id);
|
for (const d of _researchDone) this._completeResearch(d.category, d.id);
|
||||||
}
|
}
|
||||||
|
|
@ -1679,7 +1753,7 @@ export class GameScene extends Phaser.Scene {
|
||||||
// player took off mid-build (game-loop clock — see beginBuild); when
|
// player took off mid-build (game-loop clock — see beginBuild); when
|
||||||
// its deadline passes HERE we apply the effect (the world change is
|
// its deadline passes HERE we apply the effect (the world change is
|
||||||
// world state: the tether range outlives the stay).
|
// world state: the tether range outlives the stay).
|
||||||
if (this.buildState) {
|
if (this.buildState && !_commsPaused) {
|
||||||
const _buildDone = this.buildState.tick(this.game.loop.now);
|
const _buildDone = this.buildState.tick(this.game.loop.now);
|
||||||
if (_buildDone.length) {
|
if (_buildDone.length) {
|
||||||
for (const c of _buildDone) this.completeBuild(c.planet, c.build);
|
for (const c of _buildDone) this.completeBuild(c.planet, c.build);
|
||||||
|
|
@ -1708,7 +1782,7 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.tetherField.draw(_time); // the barrier (on-screen dots only)
|
this.tetherField.draw(_time); // the barrier (on-screen dots only)
|
||||||
this.actionBar?.update(_time, delta); // the deck's living details
|
this.actionBar?.update(_time, delta); // the deck's living details
|
||||||
this._checkDestinationReached(); // last leg done? clear the destination (REACHED)
|
this._checkDestinationReached(); // last leg done? clear the destination (REACHED)
|
||||||
this.updateDiscovery(_time, delta); // last: sees this frame's final camera view
|
if (!_commsPaused) this.updateDiscovery(_time, delta); // last: sees this frame's final camera view
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1986,6 +2060,7 @@ export class GameScene extends Phaser.Scene {
|
||||||
* click wins).
|
* click wins).
|
||||||
*/
|
*/
|
||||||
autopilotTo(id) {
|
autopilotTo(id) {
|
||||||
|
if (this._commInputBlocked()) return; // the comm owns the screen (the ship holds)
|
||||||
// While the sub-bar is open (or this very click just closed it — the
|
// While the sub-bar is open (or this very click just closed it — the
|
||||||
// scene's handler and the chip's own listener race on event order),
|
// scene's handler and the chip's own listener race on event order),
|
||||||
// the chip's click is the menu-dismiss, not a navigation command.
|
// the chip's click is the menu-dismiss, not a navigation command.
|
||||||
|
|
@ -2768,12 +2843,27 @@ export class GameScene extends Phaser.Scene {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while the ACK comm owns the input — it's up (paused), or it just
|
||||||
|
* ended on a click (the endedWithin race window, so the click that
|
||||||
|
* ACKNOWLEDGED never leaks into a world/deck/ESC action). The scene's
|
||||||
|
* input seams gate on this; the pause's action-freeze gates on
|
||||||
|
* comms.paused alone (strict).
|
||||||
|
*/
|
||||||
|
_commInputBlocked() {
|
||||||
|
const c = this.comms;
|
||||||
|
return !!(c && (c.paused || c.endedWithin(200)));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The deck's button presses (js/ui/ActionBar.js → onAction).
|
* The deck's button presses (js/ui/ActionBar.js → onAction).
|
||||||
* 'menu' is the save system's door (menuAction below); the other
|
* 'menu' is the save system's door (menuAction below); the other
|
||||||
* slots are still seams for the player's loop.
|
* slots are still seams for the player's loop.
|
||||||
*/
|
*/
|
||||||
deckAction(id) {
|
deckAction(id) {
|
||||||
|
// The ACK comm owns the input (no deck while the box holds — and the
|
||||||
|
// click that ACKNOWLEDGED is the comm's, never the deck's).
|
||||||
|
if (this._commInputBlocked()) return;
|
||||||
// The research console (full-screen, depth 80) — the deck's main feature.
|
// The research console (full-screen, depth 80) — the deck's main feature.
|
||||||
if (id === 'research') {
|
if (id === 'research') {
|
||||||
if (this.researchWindow && this.researchWindow.isOpen) {
|
if (this.researchWindow && this.researchWindow.isOpen) {
|
||||||
|
|
@ -4088,6 +4178,7 @@ export class GameScene extends Phaser.Scene {
|
||||||
|
|
||||||
/** ESC: topmost open thing first — confirm dialog → pop-up → research → mining → sub-bar. */
|
/** ESC: topmost open thing first — confirm dialog → pop-up → research → mining → sub-bar. */
|
||||||
escAction() {
|
escAction() {
|
||||||
|
if (this._commInputBlocked()) return; // the comm owns the input (no skip)
|
||||||
if (this.savePanel) {
|
if (this.savePanel) {
|
||||||
if (this.savePanel.confirm.isOpen) {
|
if (this.savePanel.confirm.isOpen) {
|
||||||
this.savePanel.confirm.cancel();
|
this.savePanel.confirm.cancel();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,483 @@
|
||||||
|
import Phaser from '../vendor/phaser.js';
|
||||||
|
import { config } from '../config/Config.js';
|
||||||
|
import { toColor, toCss } from '../utils/Color.js';
|
||||||
|
import { fontStack } from '../utils/Theme.js';
|
||||||
|
import { ScrambleDecode, decodeDur } from '../utils/Decode.js';
|
||||||
|
import { CyberShape } from './CyberShape.js';
|
||||||
|
import { MenuButton } from './MenuButton.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* COMMS BOX — the wide box a comm shows in, docked just below the mineral
|
||||||
|
* bar (upper-right): the sender's clip in a bracketed feed panel on the
|
||||||
|
* LEFT, the comm text decoding in on the RIGHT, and — for 'ack' comms —
|
||||||
|
* the ACKNOWLEDGE button below the text.
|
||||||
|
*
|
||||||
|
* ┌──────────────────────────────────────────────────────┐
|
||||||
|
* │ ● O.A.C. // ONBOARD A.I. COMPUTER │
|
||||||
|
* │ ┌──────┐ So you want to explore the galaxy do │
|
||||||
|
* │ │ O A C │ you?... Welcome to your ship... she's │
|
||||||
|
* │ │ clip │ not much but she's got room to grow. [ACK]│
|
||||||
|
* │ └──────┘ │
|
||||||
|
* └──────────────────────────────────────────────────────┘
|
||||||
|
*
|
||||||
|
* The box is a PASSIVE view (the project rule): the CommsHub
|
||||||
|
* (js/comms/CommsHub.js) owns the comm's life — it builds a box per
|
||||||
|
* comm, drives `open()` / `update(time)` / `setVoice(on)` / `close()`,
|
||||||
|
* and tears it down. The box never plays the voice, never touches the
|
||||||
|
* scene's action; it only shows the comm and reports the button press
|
||||||
|
* (`onAck` — the hub is the ear, the scene is the voice).
|
||||||
|
*
|
||||||
|
* The clip is ALWAYS muted: the voice is the comm's speech clip, and
|
||||||
|
* the video is the sender's face (the project's video rule — the
|
||||||
|
* Research/Build feeds, the landing stage). 'ack' loops it silently
|
||||||
|
* while the box holds; 'visual' plays it once.
|
||||||
|
*
|
||||||
|
* v4 quirks honored (see the ResearchWindow/SurfaceScene notes):
|
||||||
|
* - add.video(x, y, key) — the key is LAST;
|
||||||
|
* - the element is muted via el.muted (setVolume(0) alone isn't the
|
||||||
|
* browser's autoplay check);
|
||||||
|
* - the bookkeeping size is a placeholder until the first presented
|
||||||
|
* frame — fit now with the best known dimensions, refit on
|
||||||
|
* 'created', keep the clip hidden until ready (no black flash);
|
||||||
|
* - resume() not play() after a pause (v4's play() is a no-op then).
|
||||||
|
*
|
||||||
|
* Config: data/comms.json → box.
|
||||||
|
*/
|
||||||
|
export class CommsBox extends Phaser.GameObjects.Container {
|
||||||
|
/**
|
||||||
|
* @param {Phaser.Scene} scene
|
||||||
|
* @param {{right:number, top:number}} anchor the mineral bar's lower-right
|
||||||
|
* (the box's right edge sticks to anchor.right, its top sits
|
||||||
|
* `box.topPad` below anchor.top)
|
||||||
|
* @param {{
|
||||||
|
* label: string, the sender's display name (the header)
|
||||||
|
* role: string, the small line beside it
|
||||||
|
* text: string, the comm's words (decode in)
|
||||||
|
* loop: boolean, the clip loops silently (ack) or plays once (visual)
|
||||||
|
* video: {key:string}|null, the sender's cached clip (null → NO SIGNAL plate)
|
||||||
|
* showAck: boolean, the ACKNOWLEDGE button (ack comms)
|
||||||
|
* onAck: () => void, the button press (the hub finishes the comm)
|
||||||
|
* }} spec
|
||||||
|
*/
|
||||||
|
constructor(scene, anchor, spec) {
|
||||||
|
super(scene, 0, 0);
|
||||||
|
this.scene.add.existing(this);
|
||||||
|
this.setScrollFactor(0); // UI — pinned to the screen, not the world
|
||||||
|
this.setDepth(55); // above the mineral bar (30) and the deck (50); under the full-screen consoles (80)
|
||||||
|
|
||||||
|
const boxCfg = config.section('comms.box', {});
|
||||||
|
const colors = boxCfg.colors ?? {};
|
||||||
|
const textCfg = boxCfg.text ?? {};
|
||||||
|
const buttonCfg = boxCfg.button ?? {};
|
||||||
|
const anim = boxCfg.animation ?? {};
|
||||||
|
|
||||||
|
this.W = Math.max(320, boxCfg.width ?? 560);
|
||||||
|
this.pad = boxCfg.pad ?? 14;
|
||||||
|
this.topPad = boxCfg.topPad ?? 12;
|
||||||
|
this.videoSize = Math.max(80, boxCfg.videoSize ?? 150);
|
||||||
|
this.videoGap = boxCfg.videoGap ?? 14;
|
||||||
|
this.headerH = 20;
|
||||||
|
|
||||||
|
// Palette (data/comms.json → box.colors, theme underneath).
|
||||||
|
this.panelInt = toColor(colors.panel, 0x0a1120);
|
||||||
|
this.panelAlpha = Math.max(0, Math.min(1, colors.panelAlpha ?? 0.94));
|
||||||
|
this.borderInt = toColor(colors.border, 0x22405f);
|
||||||
|
this.borderAlpha = colors.borderAlpha ?? 0.9;
|
||||||
|
this.notch = boxCfg.notch ?? Math.min(14, 20);
|
||||||
|
this.neonInt = toColor(colors.neon, 0x00e5ff);
|
||||||
|
this.inkCss = toCss(toColor(colors.ink, 0xeaf6ff));
|
||||||
|
this.dimCss = toCss(toColor(colors.dim, 0x7d92c4));
|
||||||
|
this.faintCss = toCss(toColor(colors.faint, 0x3d4c74));
|
||||||
|
this.amberInt = toColor(colors.amber, 0xffc94d);
|
||||||
|
|
||||||
|
this.inMs = Math.max(0, anim.inMs ?? 260);
|
||||||
|
this.outMs = Math.max(0, anim.outMs ?? 420);
|
||||||
|
|
||||||
|
this.onAck = typeof spec.onAck === 'function' ? spec.onAck : null;
|
||||||
|
this.showAck = spec.showAck === true;
|
||||||
|
this.loopVideo = spec.loop === true; // the clip loops silently (ack) or plays once (visual)
|
||||||
|
this.videoSpec = spec.video && spec.video.key ? spec.video : null;
|
||||||
|
this.voiceOn = false;
|
||||||
|
this._textValue = String(spec.text ?? '');
|
||||||
|
|
||||||
|
const fam = fontStack('body');
|
||||||
|
|
||||||
|
// ---- measure the text (its height sizes the box) ------------------
|
||||||
|
const textMaxW = Math.max(120, this.W - 2 * this.pad - this.videoSize - this.videoGap);
|
||||||
|
this.text = scene.add
|
||||||
|
.text(0, 0, spec.text, {
|
||||||
|
fontFamily: fam,
|
||||||
|
fontSize: `${textCfg.fontSize ?? 13}px`,
|
||||||
|
lineHeight: { value: textCfg.lineHeight ?? 18 },
|
||||||
|
color: this.inkCss,
|
||||||
|
letterSpacing: textCfg.letterSpacing ?? 0.4,
|
||||||
|
wordWrap: { width: textMaxW, useAdvancedWrap: true },
|
||||||
|
})
|
||||||
|
.setOrigin(0, 0)
|
||||||
|
.setScrollFactor(0);
|
||||||
|
const textW = this.text.width;
|
||||||
|
const textH = this.text.height;
|
||||||
|
|
||||||
|
// ---- the button (ack comms) — measured too (it adds a row) ---------
|
||||||
|
this.button = null;
|
||||||
|
let buttonH = 0;
|
||||||
|
const buttonLabel = String(buttonCfg.label ?? 'ACKNOWLEDGE');
|
||||||
|
if (this.showAck) {
|
||||||
|
// Measure first (a throwaway) so the box height is final before the
|
||||||
|
// real one is built at its slot.
|
||||||
|
const probe = new Phaser.GameObjects.Text(scene, 0, 0, buttonLabel.toUpperCase(), {
|
||||||
|
fontFamily: fam,
|
||||||
|
fontSize: `${buttonCfg.fontSize ?? 11}px`,
|
||||||
|
});
|
||||||
|
buttonH = probe.height + (buttonCfg.paddingY ?? 7) * 2;
|
||||||
|
probe.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentTop = this.pad + this.headerH + (boxCfg.headerGap ?? 8);
|
||||||
|
const rightColH = textH + (this.showAck ? (buttonCfg.gap ?? 10) + buttonH : 0);
|
||||||
|
this.H = contentTop + Math.max(this.videoSize, rightColH) + this.pad;
|
||||||
|
|
||||||
|
// ---- placement: just below the mineral bar, right edge aligned -----
|
||||||
|
this.finalX = anchor.right - this.W;
|
||||||
|
this.finalY = anchor.top + this.topPad;
|
||||||
|
this.setPosition(this.finalX, this.finalY - 16); // starts 16px high — it slides in
|
||||||
|
this.setAlpha(0);
|
||||||
|
|
||||||
|
this._buildBackplate();
|
||||||
|
this._buildHeader(spec.label, spec.role);
|
||||||
|
this._buildVideoPanel(contentTop);
|
||||||
|
this.text.setPosition(this.pad + this.videoSize + this.videoGap, contentTop);
|
||||||
|
this.add(this.text); // v4: a directly-built Text is off the display list until added
|
||||||
|
if (this.showAck) {
|
||||||
|
this.button = new MenuButton(
|
||||||
|
scene,
|
||||||
|
0, 0, // repositioned below (right-aligned in the text column)
|
||||||
|
buttonLabel,
|
||||||
|
() => this._onAckPress(),
|
||||||
|
{
|
||||||
|
fontSize: buttonCfg.fontSize ?? 11,
|
||||||
|
paddingX: buttonCfg.paddingX ?? 16,
|
||||||
|
paddingY: buttonCfg.paddingY ?? 7,
|
||||||
|
letterSpacing: buttonCfg.letterSpacing ?? 2,
|
||||||
|
upper: true,
|
||||||
|
screenFixed: true, // the box lives in a scrolling-cam scene
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Right-aligned in the text column: the action anchors the box's
|
||||||
|
// lower-right (the menu's button idiom, the console's balance).
|
||||||
|
const textX = this.pad + this.videoSize + this.videoGap;
|
||||||
|
const textMaxW = Math.max(120, this.W - 2 * this.pad - this.videoSize - this.videoGap);
|
||||||
|
this.button.setPosition(
|
||||||
|
textX + Math.min(this.button.width, textMaxW) - this.button.width / 2,
|
||||||
|
contentTop + textH + (buttonCfg.gap ?? 10) + buttonH / 2,
|
||||||
|
);
|
||||||
|
this.add(this.button);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The decode-in (the console's type-out): the comm's words pull
|
||||||
|
// themselves out of static over the canonical window.
|
||||||
|
this._dec = null;
|
||||||
|
this._decT0 = null;
|
||||||
|
this._decFinished = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// the chrome
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
_buildBackplate() {
|
||||||
|
const g = this.scene.add.graphics().setScrollFactor(0);
|
||||||
|
g.clear();
|
||||||
|
CyberShape.draw(g, this.W, this.H, {
|
||||||
|
notch: this.notch,
|
||||||
|
fill: this.panelInt,
|
||||||
|
fillAlpha: this.panelAlpha,
|
||||||
|
stroke: this.borderInt,
|
||||||
|
strokeAlpha: this.borderAlpha,
|
||||||
|
lineWidth: 1.5,
|
||||||
|
glow: this.neonInt,
|
||||||
|
glowAlpha: 0.14,
|
||||||
|
});
|
||||||
|
g.setPosition(this.W / 2, this.H / 2);
|
||||||
|
this.backplate = g;
|
||||||
|
this.add(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildHeader(label, role) {
|
||||||
|
const fam = fontStack('body');
|
||||||
|
// The voice indicator — amber while the comm's voice is talking
|
||||||
|
// (setVoice drives its pulse), faint when the voice is done.
|
||||||
|
this.dot = this.scene.add.circle(0, 0, 3.5, this.amberInt, 0.35).setScrollFactor(0);
|
||||||
|
this.add(this.dot);
|
||||||
|
const dx = this.pad + 3.5 + 5;
|
||||||
|
const name = this.scene.add
|
||||||
|
.text(0, 0, String(label ?? '').toUpperCase(), {
|
||||||
|
fontFamily: fam,
|
||||||
|
fontSize: '11px',
|
||||||
|
color: this.inkCss,
|
||||||
|
letterSpacing: 2,
|
||||||
|
})
|
||||||
|
.setOrigin(0, 0.5)
|
||||||
|
.setScrollFactor(0);
|
||||||
|
name.setPosition(dx, this.pad + 8);
|
||||||
|
this.add(name);
|
||||||
|
const roleText = this.scene.add
|
||||||
|
.text(0, 0, String(role ?? '').toUpperCase(), {
|
||||||
|
fontFamily: fam,
|
||||||
|
fontSize: '9px',
|
||||||
|
color: this.faintCss,
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
})
|
||||||
|
.setOrigin(0, 0.5)
|
||||||
|
.setScrollFactor(0);
|
||||||
|
roleText.setPosition(dx + name.width + 10, this.pad + 8);
|
||||||
|
this.add(roleText);
|
||||||
|
this.dot.setPosition(this.pad + 3.5, this.pad + 8);
|
||||||
|
// A faint hairline under the header, across the box width.
|
||||||
|
const line = this.scene.add.graphics().setScrollFactor(0);
|
||||||
|
line.lineStyle(1, this.borderInt, 0.45);
|
||||||
|
line.lineBetween(this.pad, this.pad + this.headerH, this.W - this.pad, this.pad + this.headerH);
|
||||||
|
this.add(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildVideoPanel(contentTop) {
|
||||||
|
const s = this.scene;
|
||||||
|
const vx = this.pad;
|
||||||
|
const vy = contentTop;
|
||||||
|
const vs = this.videoSize;
|
||||||
|
|
||||||
|
// The feed frame: bracket corners + the side ticks (the
|
||||||
|
// ResearchWindow archive-feed idiom).
|
||||||
|
const frame = s.add.graphics().setScrollFactor(0);
|
||||||
|
frame.clear();
|
||||||
|
_brackets(frame, vx - 5, vy - 5, vs + 10, vs + 10, { length: 12, color: this.neonInt, alpha: 0.7 });
|
||||||
|
frame.lineStyle(1, this.borderInt, 0.55);
|
||||||
|
frame.lineBetween(vx - 5, vy + vs / 2, vx - 1, vy + vs / 2);
|
||||||
|
frame.lineBetween(vx + vs + 1, vy + vs / 2, vx + vs + 5, vy + vs / 2);
|
||||||
|
this.add(frame);
|
||||||
|
|
||||||
|
const cx = vx + vs / 2;
|
||||||
|
const cy = vy + vs / 2;
|
||||||
|
|
||||||
|
this.video = null;
|
||||||
|
const key = this.videoSpec?.key;
|
||||||
|
if (key && _hasVideo(s, key)) {
|
||||||
|
// v4: add.video(x, y, key) — the key is the LAST argument (the
|
||||||
|
// factory creates AND displays the element).
|
||||||
|
const v = s.add.video(0, 0, key);
|
||||||
|
v.setOrigin(0.5);
|
||||||
|
v.setScrollFactor(0);
|
||||||
|
// The feed is the sender's face — muted. The voice is the comm's
|
||||||
|
// speech clip (the hub plays it); v4's setVolume() only sets
|
||||||
|
// el.volume, so mute the element too (the autoplay-policy check).
|
||||||
|
v.setVolume(0);
|
||||||
|
if (v.video) v.video.muted = true;
|
||||||
|
v.setLoop(this.loopVideo);
|
||||||
|
// Fire it straight away (the SurfaceScene landing-clip idiom): if
|
||||||
|
// the browser's autoplay policy holds it, v4 retries internally
|
||||||
|
// until it's allowed — and the game only runs after a gesture
|
||||||
|
// (the menu click), so muted autoplay is permitted from the start.
|
||||||
|
v.play();
|
||||||
|
// Fit: contain (the face is never cropped), refit on 'created'
|
||||||
|
// (the true dimensions), hidden until ready (no black flash).
|
||||||
|
const fit = (vv, iw = 0, ih = 0) => {
|
||||||
|
const el = vv.video;
|
||||||
|
const vw = iw || (el && (el.videoWidth || el.width)) || (vv.frame && vv.frame.realWidth) || 256;
|
||||||
|
const vh = ih || (el && (el.videoHeight || el.height)) || (vv.frame && vv.frame.realHeight) || 256;
|
||||||
|
const sc = Math.min(vs / vw, vs / vh);
|
||||||
|
vv.setPosition(cx, cy);
|
||||||
|
vv.setScale(sc);
|
||||||
|
};
|
||||||
|
fit(v);
|
||||||
|
const ready = (vv, w, h) => {
|
||||||
|
if (vv !== v || !vv.active) return;
|
||||||
|
fit(vv, w, h);
|
||||||
|
vv.setVisible(true);
|
||||||
|
if (!vv.video?.paused) return; // already rolling (v4 autoplay retry)
|
||||||
|
vv.play(); // the first start raced 'created' — nudge it again
|
||||||
|
};
|
||||||
|
v.on('created', ready);
|
||||||
|
if (v.video && v.video.readyState >= 1) ready(v, 0, 0);
|
||||||
|
this.add(v); // into the box's container
|
||||||
|
this.video = v;
|
||||||
|
} else {
|
||||||
|
this._noSignal(cx, cy, vx, vy, vs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_noSignal(cx, cy, vx, vy, vs) {
|
||||||
|
const s = this.scene;
|
||||||
|
const ph = s.add.graphics().setScrollFactor(0);
|
||||||
|
ph.clear();
|
||||||
|
CyberShape.draw(ph, vs, vs, {
|
||||||
|
notch: 6,
|
||||||
|
fill: 0x050a12,
|
||||||
|
fillAlpha: 0.9,
|
||||||
|
stroke: this.borderInt,
|
||||||
|
strokeAlpha: 0.5,
|
||||||
|
lineWidth: 1,
|
||||||
|
});
|
||||||
|
ph.setPosition(cx, cy);
|
||||||
|
this.add(ph);
|
||||||
|
const ns = s.add
|
||||||
|
.text(cx, cy, 'NO SIGNAL', {
|
||||||
|
fontFamily: fontStack('header'),
|
||||||
|
fontSize: '12px',
|
||||||
|
color: this.faintCss,
|
||||||
|
letterSpacing: 3,
|
||||||
|
})
|
||||||
|
.setOrigin(0.5)
|
||||||
|
.setScrollFactor(0);
|
||||||
|
this.add(ns);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// the hub's handles
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The comm's words decoding in (the hub calls this once, at open). */
|
||||||
|
beginText() {
|
||||||
|
const dur = decodeDur(this._textValue.length);
|
||||||
|
this._decT0 = this.scene.time.now;
|
||||||
|
this._dec = new ScrambleDecode(this._textValue, this._decT0, dur);
|
||||||
|
this.text.setText('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-frame (the hub pumps it): the text's decode. */
|
||||||
|
update(time) {
|
||||||
|
if (!this._dec || this._decFinished) return;
|
||||||
|
if (!this._dec.started(time)) return;
|
||||||
|
this.text.setText(this._dec.display(time));
|
||||||
|
if (this._dec.finished(time)) {
|
||||||
|
this._decFinished = true;
|
||||||
|
this.text.setText(this._textValue); // land exactly
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The voice is live / done — the header dot pulses while it talks. */
|
||||||
|
setVoice(on) {
|
||||||
|
this.voiceOn = !!on;
|
||||||
|
if (this._dotTween) {
|
||||||
|
this._dotTween.stop();
|
||||||
|
this._dotTween = null;
|
||||||
|
}
|
||||||
|
if (this.voiceOn) {
|
||||||
|
this.dot.setFillStyle(this.amberInt, 1);
|
||||||
|
this._dotTween = this.scene.tweens.add({
|
||||||
|
targets: this.dot,
|
||||||
|
scale: 1.5,
|
||||||
|
duration: 420,
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1,
|
||||||
|
ease: 'Sine.easeInOut',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.dot.setFillStyle(this.amberInt, 0.35).setScale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The slide-in (the hub opens it, then begins the text). */
|
||||||
|
open() {
|
||||||
|
this.beginText();
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: this,
|
||||||
|
y: this.finalY,
|
||||||
|
alpha: 1,
|
||||||
|
duration: this.inMs,
|
||||||
|
ease: 'Cubic.easeOut',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fade-out → destroy. `cb` runs after the teardown (or now). */
|
||||||
|
close(cb) {
|
||||||
|
if (this._closing) {
|
||||||
|
if (typeof cb === 'function') cb();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._closing = true;
|
||||||
|
if (typeof cb === 'function') {
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: this,
|
||||||
|
y: this.finalY + 10,
|
||||||
|
alpha: 0,
|
||||||
|
duration: this.outMs,
|
||||||
|
ease: 'Cubic.easeIn',
|
||||||
|
onComplete: () => {
|
||||||
|
this.destroy();
|
||||||
|
cb();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tear the box down (the hub's shutdown / close path). */
|
||||||
|
destroy() {
|
||||||
|
if (this._closing === 'done') return;
|
||||||
|
this._closing = 'done';
|
||||||
|
if (this._dotTween) {
|
||||||
|
this._dotTween.stop();
|
||||||
|
this._dotTween = null;
|
||||||
|
}
|
||||||
|
if (this.video) {
|
||||||
|
_destroyVideo(this.video);
|
||||||
|
this.video = null;
|
||||||
|
}
|
||||||
|
this.button?.destroy();
|
||||||
|
this.button = null;
|
||||||
|
this.dot?.destroy();
|
||||||
|
this.dot = null;
|
||||||
|
this.text?.destroy();
|
||||||
|
this.text = null;
|
||||||
|
this.backplate?.destroy();
|
||||||
|
this.backplate = null;
|
||||||
|
super.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
_onAckPress() {
|
||||||
|
if (this._closing) return;
|
||||||
|
// The press plays its own tick (the MenuButton calls the scene's
|
||||||
|
// voice); the hub is the ear — it finishes the comm from here.
|
||||||
|
if (this.onAck) this.onAck();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// small local helpers (the ResearchWindow/SurfaceScene video idiom)
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _hasVideo(scene, key) {
|
||||||
|
const c = scene.cache?.video;
|
||||||
|
return !!(c && typeof c.has === 'function' && c.has(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bracket corners framing a rect (the viewfinder chrome). */
|
||||||
|
function _brackets(g, x, y, w, h, o = {}) {
|
||||||
|
const L = o.length ?? 12;
|
||||||
|
g.lineStyle(o.lineWidth ?? 2, o.color ?? 0x00e5ff, o.alpha ?? 0.7);
|
||||||
|
g.lineBetween(x, y + L, x, y);
|
||||||
|
g.lineBetween(x, y, x + L, y);
|
||||||
|
g.lineBetween(x + w - L, y, x + w, y);
|
||||||
|
g.lineBetween(x + w, y, x + w, y + L);
|
||||||
|
g.lineBetween(x + w, y + h - L, x + w, y + h);
|
||||||
|
g.lineBetween(x + w, y + h, x + w - L, y + h);
|
||||||
|
g.lineBetween(x + L, y + h, x, y + h);
|
||||||
|
g.lineBetween(x, y + h, x, y + h - L);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Destroy a video element the project-safe way (the SurfaceScene idiom). */
|
||||||
|
function _destroyVideo(v) {
|
||||||
|
if (!v) return;
|
||||||
|
try {
|
||||||
|
v.off();
|
||||||
|
if (typeof v.stop === 'function') v.stop(false);
|
||||||
|
} catch {
|
||||||
|
/* the element may already be gone */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
v.destroy();
|
||||||
|
} catch {
|
||||||
|
/* already destroyed */
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue