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",
|
||||
"mineralhud.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 }]);
|
||||
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);
|
||||
check('capture: seed carried', rec.seed === SEED);
|
||||
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: playtime carried', rec.playTimeMs === 123456);
|
||||
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
|
||||
// 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',
|
||||
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 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
|
||||
// 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
|
||||
check('prepare: a legacy record (no builds field) stages null builds',
|
||||
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.
|
||||
const first = consumeRestore(reg);
|
||||
|
|
@ -263,11 +275,13 @@ const makeStorage = (fail = false) => {
|
|||
|
||||
// resetRunState: New Game clears the run-state seams.
|
||||
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 } });
|
||||
resetRunState(reg);
|
||||
check('reset: discovery cleared (fresh run)', reg.get('discovery') === 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: the comms ledger is cleared (the welcome arms again)', reg.get('commsPlayed') === null);
|
||||
}
|
||||
|
||||
// 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
|
||||
// the starter quest is granted onto the fresh ledger (old saves load).
|
||||
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'
|
||||
// requirement's input — the hold is cargo, this is the record).
|
||||
// A save predating it has no field; the restore stages 0.
|
||||
|
|
@ -231,6 +238,17 @@ export function prepareLoad(registry, 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, {
|
||||
ship: record.ship,
|
||||
tethers: Array.isArray(record.tethers) ? record.tethers : [],
|
||||
|
|
@ -267,6 +285,7 @@ export function resetRunState(registry) {
|
|||
registry.set('quests', null);
|
||||
registry.set('totalMined', null);
|
||||
registry.set('depletedClusters', null);
|
||||
registry.set('commsPlayed', null); // the welcome arms again on the new run
|
||||
registry.set(PENDING_RESTORE_KEY, null);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { MenuSubBar } from '../ui/MenuSubBar.js';
|
|||
import { SavePanel } from '../ui/SavePanel.js';
|
||||
import { SaveManager } from '../save/SaveManager.js';
|
||||
import { consumeRestore, captureState, prepareLoad } from '../save/SaveData.js';
|
||||
import { CommsHub } from '../comms/CommsHub.js';
|
||||
import { TetherField } from '../tether/TetherField.js';
|
||||
import { Mining } from '../mining/Mining.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
|
||||
// tether field don't exist yet, so apply it once they do (below).
|
||||
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.systemContent = this.galaxy.ensureContent(this.systemRecord.id);
|
||||
// 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.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)
|
||||
this.hint = this.add
|
||||
.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
|
||||
// old scene is already gone — swallow anything that lands in the gap.
|
||||
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
|
||||
// (its scrim / cards / dialog eat the click; the world stays put).
|
||||
if (this.savePanel && this.savePanel.isOpen) return;
|
||||
|
|
@ -1276,6 +1329,16 @@ export class GameScene extends Phaser.Scene {
|
|||
// to the world camera.
|
||||
this.systemEffects = new SystemEffects(this);
|
||||
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.)
|
||||
this.time.update(_time, delta);
|
||||
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.systemEffects?.update(_time); // the star's character (wave phase + star's screen UV)
|
||||
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
|
||||
// can't fast-forward it.
|
||||
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
|
||||
// Research: tick the in-flight project (time-based; tick() reports any
|
||||
// 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) {
|
||||
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
|
||||
// its deadline passes HERE we apply the effect (the world change is
|
||||
// world state: the tether range outlives the stay).
|
||||
if (this.buildState) {
|
||||
if (this.buildState && !_commsPaused) {
|
||||
const _buildDone = this.buildState.tick(this.game.loop.now);
|
||||
if (_buildDone.length) {
|
||||
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.actionBar?.update(_time, delta); // the deck's living details
|
||||
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).
|
||||
*/
|
||||
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
|
||||
// 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.
|
||||
|
|
@ -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).
|
||||
* 'menu' is the save system's door (menuAction below); the other
|
||||
* slots are still seams for the player's loop.
|
||||
*/
|
||||
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.
|
||||
if (id === 'research') {
|
||||
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. */
|
||||
escAction() {
|
||||
if (this._commInputBlocked()) return; // the comm owns the input (no skip)
|
||||
if (this.savePanel) {
|
||||
if (this.savePanel.confirm.isOpen) {
|
||||
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