Add Trestle's first-foreign-planet intro comm pair and ACK pulse cue
- Introduce the `firstForeignPlanet` trigger: when the player discovers a planet in a non-home system, Trestle (new out-of-system mentor character) delivers a two-part ack comm pair (meetup + quest claim), with dedicated video and speech assets. - Make audio cache keys folder-scoped so characters with identically-named clips (e.g. both having `intro-01.mp3`) don't collide in the loader. - Add an amber pulsing attention cue (halo + staggered sonar rings) on the ACKNOWLEDGE button for ack comms, driven per-frame from CommsBox.update(). - Put the ActionBar deck into a standby state while an ack comm holds: slots go inert (no hover/press), dimmed visually; re-arms when the comm ends. GameScene wires this via `actionBar.setEnabled()` each frame based on `comms.paused`. - Extend MenuButton to accept a custom border color so the ACK button can wear the box's amber against the cyan UI. - Add dev self-check harnesses (`comms-trestle.mjs`, `comms-trestle-hold.mjs`) and update existing tests to cover the new pair, folder-scoped keys, deck standby behavior, and pulse animation. - Disable browser module caching in CDP probe/shot scripts to avoid stale ES modules shadowing on-disk changes during dev testing.
This commit is contained in:
parent
881a94a1b6
commit
8afe4c78c6
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.
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"$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 (live): 'newGame' — the fresh-run tutorial welcome (oac-intro → oac-tether → oac-scan; the hub's FIFO plays each right after the previous is acknowledged); 'firstScan' — oac-upgrade reacts to the player's first SCAN press (GameScene.startScan), 5 s after the click; 'tetherResearch' — oac-minerals → oac-fly-mine react to the player committing to the Tether Level 2 research (GameScene.beginResearch, held by the busy() contract until the console closes); 'firstCluster' — oac-first-cluster celebrates the run's first asteroid-cluster discovery (GameScene.celebrateDiscovery), 1.5 s after the chime so the discovery SFX lands first; 'minerals200' — oac-tether2 confirms the moment the run's lifetime mining first crosses 200 (the 'Mine 200 Minerals' quest line and the tether-l2 build cost; GameScene's ore event); 'tetherL2Launch' — oac-relaunch → oac-explore, the launch-back pair: SurfaceScene's take-off that puts the player in space after the home world's level-2 tether is installed (the run's build record is the witness); the hub's FIFO plays the pair back-to-back. 'researchComplete' — oac-research-complete, the AUDIO-ONLY sign-off on EVERY research completion (GameScene._completeResearch; once:false, held by the busy() contract while the research console is still open). Reserved for future comms: researchNew, questNew, questComplete, discovery, landing, jump, buildComplete. 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.",
|
||||
"$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 (live): 'newGame' — the fresh-run tutorial welcome (oac-intro → oac-tether → oac-scan; the hub's FIFO plays each right after the previous is acknowledged); 'firstScan' — oac-upgrade reacts to the player's first SCAN press (GameScene.startScan), 5 s after the click; 'tetherResearch' — oac-minerals → oac-fly-mine react to the player committing to the Tether Level 2 research (GameScene.beginResearch, held by the busy() contract until the console closes); 'firstCluster' — oac-first-cluster celebrates the run's first asteroid-cluster discovery (GameScene.celebrateDiscovery), 1.5 s after the chime so the discovery SFX lands first; 'minerals200' — oac-tether2 confirms the moment the run's lifetime mining first crosses 200 (the 'Mine 200 Minerals' quest line and the tether-l2 build cost; GameScene's ore event); 'tetherL2Launch' — oac-relaunch → oac-explore, the launch-back pair: SurfaceScene's take-off that puts the player in space after the home world's level-2 tether is installed (the run's build record is the witness); the hub's FIFO plays the pair back-to-back; 'firstForeignPlanet' — trestle-meetup → trestle-quest: the FIRST planet discovered in a system that is NOT the player's home (GameScene.celebrateDiscovery; the scene flag fires once, the comm's once-ledger dedupes across saves), the hub's FIFO plays the pair back-to-back; 'researchComplete' — oac-research-complete, the AUDIO-ONLY sign-off on EVERY research completion (GameScene._completeResearch; once:false, held by the busy() contract while the research console is still open). Reserved for future comms: researchNew, questNew, questComplete, discovery, landing, jump, buildComplete. 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.",
|
||||
|
|
@ -12,6 +12,12 @@
|
|||
"label": "O.A.C.",
|
||||
"role": "ONBOARD A.I. COMPUTER",
|
||||
"video": "assets/videos/characters/oac.mp4"
|
||||
},
|
||||
"trestle": {
|
||||
"$comment": "Trestle — the out-of-system mentor (the 'A Friend' behind the starter quest, data/quests.json). Same box, same contracts; the clip + speech are his own (the speech key is folder-scoped, so his intro-01.mp3 never collides with OAC's).",
|
||||
"label": "TRESTLE",
|
||||
"role": "OUT-OF-SYSTEM MENTOR",
|
||||
"video": "assets/videos/characters/trestle.mp4"
|
||||
}
|
||||
},
|
||||
"comms": [
|
||||
|
|
@ -130,6 +136,28 @@
|
|||
"audio": "assets/speech/oac/intro-10.mp3",
|
||||
"once": true,
|
||||
"delayMs": 0
|
||||
},
|
||||
{
|
||||
"$comment": "Trestle's introduction (trigger 'firstForeignPlanet' — GameScene.celebrateDiscovery, the FIRST planet the player discovers in a system that is NOT home: the scene flag fires the event once per scene life, the comm's once-ledger dedupes across saves). Trestle is out of system, sees the player has arrived, and sets up the meet-up. delayMs beats the discovery chime (1.5 s, sfx.discovery) so the SFX lands before he speaks.",
|
||||
"id": "trestle-meetup",
|
||||
"from": "trestle",
|
||||
"type": "ack",
|
||||
"trigger": "firstForeignPlanet",
|
||||
"text": "Hey Kid. Not bad.. not bad at all.. You're getting the hang of this quicker than I thought. Listen, I'm just out of system, but I think it's time we meet up. Finish exploring this system so you can unlock the jump gates. I'll ping you once you're ready.",
|
||||
"audio": "assets/speech/trestle/intro-01.mp3",
|
||||
"once": true,
|
||||
"delayMs": 1500
|
||||
},
|
||||
{
|
||||
"$comment": "Chained right after trestle-meetup is acknowledged (same trigger, config order, delayMs 0 — the FIFO starts it the moment the previous is ACKed). Trestle points at the completed starter quest (data/quests.json → start_your_journey, the 200-mineral reward) and how to claim it.",
|
||||
"id": "trestle-quest",
|
||||
"from": "trestle",
|
||||
"type": "ack",
|
||||
"trigger": "firstForeignPlanet",
|
||||
"text": "Oh and by the way, you just completed your first quest. If you click on the quest in the upper left, or click the quests button below, you can complete the quest and claim 200 minerals I set aside just for you. See you soon kid!",
|
||||
"audio": "assets/speech/trestle/intro-02.mp3",
|
||||
"once": true,
|
||||
"delayMs": 0
|
||||
}
|
||||
],
|
||||
"reservedTriggers": [
|
||||
|
|
@ -162,6 +190,12 @@
|
|||
"paddingY": 7,
|
||||
"letterSpacing": 2
|
||||
},
|
||||
"pulse": {
|
||||
"$comment": "The ACKNOWLEDGE button's attention pulse (ack comms only): the button wears the box's amber, an amber halo breathes under it, and two staggered sonar rings expand out — the primary action is unmistakable while the comm holds (the bottom deck is on standby until then).",
|
||||
"enabled": true,
|
||||
"color": "#ffd84d",
|
||||
"periodMs": 1300
|
||||
},
|
||||
"animation": {
|
||||
"inMs": 260,
|
||||
"outMs": 420,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,16 @@ ws.onmessage = (m) => {
|
|||
|
||||
await send('Page.enable');
|
||||
await send('Runtime.enable');
|
||||
// The dev static server sends no cache headers, and the browser heuristically
|
||||
// caches ES modules — a stale module shadows the file on disk (bit the comms
|
||||
// self-check: a cached ActionBar.js without the new method). Disable the
|
||||
// cache so the harness always sees the current source.
|
||||
await send('Network.enable');
|
||||
await send('Network.setCacheDisabled', { cacheDisabled: true });
|
||||
// A same-URL navigation can restore the tab from bfcache (the frozen old
|
||||
// page) — bounce through about:blank so the load is always a real one.
|
||||
await send('Page.navigate', { url: 'about:blank' });
|
||||
await new Promise((res) => setTimeout(res, 300));
|
||||
await send('Page.navigate', { url });
|
||||
const loaded = new Promise((res) => setTimeout(res, 3000)); // let it settle
|
||||
await loaded;
|
||||
|
|
|
|||
|
|
@ -59,6 +59,14 @@ async function main() {
|
|||
|
||||
await send('Page.enable');
|
||||
await send('Runtime.enable');
|
||||
// The dev static server sends no cache headers, and the browser heuristically
|
||||
// caches ES modules — a stale module shadows the file on disk. Disable the
|
||||
// cache (and bounce through about:blank: a same-URL navigation can restore
|
||||
// the tab from bfcache) so the shot always sees the current source.
|
||||
await send('Network.enable');
|
||||
await send('Network.setCacheDisabled', { cacheDisabled: true });
|
||||
await send('Page.navigate', { url: 'about:blank' });
|
||||
await new Promise((res) => setTimeout(res, 300));
|
||||
await send('Page.navigate', { url });
|
||||
const loaded = new Promise((res) => {
|
||||
const t = setInterval(() => {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@
|
|||
*
|
||||
* 1. oac-intro (ack) — full contract: box + muted looping clip + voice
|
||||
* (full volume, music ducked per-sound) + the
|
||||
* action paused; ACKNOWLEDGE ends it and the duck
|
||||
* restores.
|
||||
* action paused; the bottom deck is on STANDBY
|
||||
* (dim, hover/press dead — the pause contract's
|
||||
* face) and the ACKNOWLEDGE button pulses (the
|
||||
* primary-action cue); ACKNOWLEDGE ends it and
|
||||
* the duck + the deck restore.
|
||||
* 2. oac-tether (ack) — chains right after (the FIFO queue), voice is
|
||||
* intro-02.
|
||||
* 3. oac-scan (ack) — chains right after that, voice is intro-03.
|
||||
|
|
@ -148,7 +151,7 @@ setTimeout(async () => {
|
|||
el.muted === true && // the video is silent (the voice is the speech)
|
||||
el.loop === true && // the ack clip loops until acknowledged
|
||||
el.paused === false &&
|
||||
voiceKey === 'comms_au_intro-01' &&
|
||||
voiceKey === 'comms_au_oac-intro-01' &&
|
||||
snd.isPlaying(voiceKey) === true && // the speech is talking
|
||||
s.comms._ducked === true && // the music duck was issued
|
||||
cfgVol(vs) === 1.0 && // the voice is FULL volume
|
||||
|
|
@ -156,7 +159,38 @@ setTimeout(async () => {
|
|||
s.ship.body.velocity.length() < 1; // the ship is parked
|
||||
d.push(`oac-intro: ${introOk ? 'contract ✔' : 'contract ✘'}`);
|
||||
|
||||
// Press ACKNOWLEDGE; the duck must RESTORE (the music's own volume).
|
||||
// The pause contract's FACE: the deck is on STANDBY (dim + inert —
|
||||
// no hover, no press, the slots' input disabled) and the
|
||||
// ACKNOWLEDGE button is pulsing (the primary-action cue).
|
||||
const ab = s.actionBar;
|
||||
const liveSlots = ab ? ab.slots.filter((sl) => sl.live) : [];
|
||||
const deckStandby = ab && ab.enabled === false && liveSlots.every((sl) => sl.panel?.input?.enabled === false);
|
||||
d.push(`deck standby: enabled=${ab?.enabled} (expect false) slotsInert=${liveSlots.every((sl) => sl.panel?.input?.enabled === false)} (expect true)`);
|
||||
const liveSlot = liveSlots.find((sl) => sl.id === 'scan') ?? liveSlots[0];
|
||||
let deckInertOk = true;
|
||||
if (liveSlot) {
|
||||
liveSlot.panel.emit('pointerover'); // direct emit — the guards must still swallow it
|
||||
const hoverDead = liveSlot.hoverOn === false;
|
||||
let pressed = false;
|
||||
const origAction = ab.onAction;
|
||||
ab.onAction = () => {
|
||||
pressed = true;
|
||||
};
|
||||
liveSlot.panel.emit('pointerdown');
|
||||
ab.onAction = origAction;
|
||||
deckInertOk = hoverDead && !pressed;
|
||||
d.push(`deck inert: hoverDead=${hoverDead} (expect true) pressSwallowed=${!pressed} (expect true)`);
|
||||
}
|
||||
const pulseUp = !!(box?._ringA && box?._ringB && box?._halo);
|
||||
const pa1 = box?._ringA?.alpha;
|
||||
await sleep(260);
|
||||
const pa2 = box?._ringA?.alpha;
|
||||
const pulseMoving = pulseUp && typeof pa1 === 'number' && typeof pa2 === 'number' && Math.abs(pa2 - pa1) > 0.001;
|
||||
d.push(`ack pulse: live=${pulseUp} (expect true) ringA.alpha ${pa1?.toFixed?.(3)} → ${pa2?.toFixed?.(3)} (expect moving)`);
|
||||
|
||||
// Press ACKNOWLEDGE; the duck must RESTORE. (The deck re-arms too —
|
||||
// but oac-tether starts the moment this one ends, so it stands by
|
||||
// again: the re-arm is asserted at step 4, when the comms are idle.)
|
||||
box.button.panel.emit('pointerdown');
|
||||
await until(() => (s.comms.active === false ? s : null), 5000);
|
||||
const msAfter = ms ? (snd.getAll(ms.key) ?? [])[0] : null;
|
||||
|
|
@ -165,19 +199,23 @@ setTimeout(async () => {
|
|||
await sleep(300); // > the endedWithin(200) race window before the next step
|
||||
|
||||
// ---- 2) + 3) the chain — tether, then the scan brief --------------
|
||||
const tetherOk = await ackStep('oac-tether', 'comms_au_intro-02');
|
||||
const tetherOk = await ackStep('oac-tether', 'comms_au_oac-intro-02');
|
||||
await sleep(300);
|
||||
const scanOk = await ackStep('oac-scan', 'comms_au_intro-03');
|
||||
const scanOk = await ackStep('oac-scan', 'comms_au_oac-intro-03');
|
||||
|
||||
// ---- 4) QUIET — nothing else arms until the player acts -----------
|
||||
await sleep(700);
|
||||
const quiet = s.comms.active === false && s.comms.queue.length === 0;
|
||||
d.push(`quiet after chain: active=${s.comms.active} (expect false) queue=${s.comms.queue.length} (expect 0)`);
|
||||
// The comms are idle now — the deck must be LIVE again (re-armed the
|
||||
// frame the last hold ended: hover + press back, slots interactive).
|
||||
const deckArmed = ab && ab.enabled === true && liveSlots.every((sl) => sl.panel?.input?.enabled === true);
|
||||
d.push(`deck re-armed: enabled=${ab?.enabled} (expect true) slotsLive=${liveSlots.every((sl) => sl.panel?.input?.enabled === true)} (expect true)`);
|
||||
|
||||
// ---- 5) the player clicks SCAN on the deck — the real path --------
|
||||
s.deckAction('scan'); // the comm guard must let it through (the scene is free)
|
||||
d.push(`scan: ${s.scanPulse.busy ? 'sweep is out (busy)' : 'NO SWEEP (busy=false — the comm guard swallowed it?)'}`);
|
||||
const upgradeOk = await ackStep('oac-upgrade', 'comms_au_intro-04'); // the 5 s beat + the sweep settle
|
||||
const upgradeOk = await ackStep('oac-upgrade', 'comms_au_oac-intro-04'); // the 5 s beat + the sweep settle
|
||||
await sleep(400); // > the endedWithin(200) race window before the next input
|
||||
|
||||
// ---- 6) Tether Level 2 research — the pair + the busy() hold ------
|
||||
|
|
@ -192,9 +230,9 @@ setTimeout(async () => {
|
|||
d.push(`busy-hold: ${holdOk ? 'contract ✔' : 'contract ✘'}`);
|
||||
s.researchWindow.close(); // the player steps away from the console
|
||||
await until(() => (s.researchWindow?.isOpen === false), 5000);
|
||||
const mineralsOk = await ackStep('oac-minerals', 'comms_au_intro-05');
|
||||
const mineralsOk = await ackStep('oac-minerals', 'comms_au_oac-intro-05');
|
||||
await sleep(300);
|
||||
const flyMineOk = await ackStep('oac-fly-mine', 'comms_au_intro-06');
|
||||
const flyMineOk = await ackStep('oac-fly-mine', 'comms_au_oac-intro-06');
|
||||
await sleep(400);
|
||||
|
||||
// ---- 6b) the project completes — the AUDIO-ONLY sign-off ----------
|
||||
|
|
@ -203,7 +241,7 @@ setTimeout(async () => {
|
|||
// comm pause, so it lands on a live run exactly as in play).
|
||||
const projDone = s.researchState.getActive();
|
||||
if (projDone) projDone.startedAt = s.time.now - projDone.durationMs - 1000; // the clock expires on the next tick
|
||||
const rcKey = 'comms_au_research-complete';
|
||||
const rcKey = 'comms_au_oac-research-complete';
|
||||
const rcUp = await until(() => (s.comms.active && s.comms.current?.id === 'oac-research-complete' ? s : null), 12000);
|
||||
const rcOk = !!rcUp && s.comms.box === null && s.comms.paused === false && s.comms._voiceKey === rcKey && s.sound.isPlaying(rcKey);
|
||||
d.push(`research-complete: active=${s.comms.active} box=${String(s.comms.box)} (expect null — audio only) paused=${s.comms.paused} (expect false — no pause) voice=${s.comms._voiceKey ?? '—'} playing=${s.sound.isPlaying(rcKey)}`);
|
||||
|
|
@ -225,7 +263,7 @@ setTimeout(async () => {
|
|||
s.ship.x = cluster.x;
|
||||
s.ship.y = cluster.y + cluster.bound + 100; // 100px off the rim (< the 540px discovery distance)
|
||||
d.push(`discover: ship at ${Math.round(s.ship.x)},${Math.round(s.ship.y)} — ${cluster.discoveryId} is ${Math.round(Math.hypot(s.ship.x - cluster.x, s.ship.y - cluster.y) - cluster.bound)}px off its rim`);
|
||||
const clusterOk = await ackStep('oac-first-cluster', 'comms_au_intro-07'); // the 1.5 s chime beat
|
||||
const clusterOk = await ackStep('oac-first-cluster', 'comms_au_oac-intro-07'); // the 1.5 s chime beat
|
||||
|
||||
// ---- 8) the first 200 minerals — the build-it-home beat ------------
|
||||
// The real ore event (Mining.js loads the hold, then the scene's
|
||||
|
|
@ -233,7 +271,7 @@ setTimeout(async () => {
|
|||
const added = s.ship.addMinerals(200); // the beam's 'take' (capped to the hold)
|
||||
s.mining.onOre?.(added, s.ship?.minerals ?? 0);
|
||||
d.push(`mine: hold=${s.ship.minerals} (expect >= 200) totalMined=${s.totalMined}`);
|
||||
const tether2Ok = await ackStep('oac-tether2', 'comms_au_intro-08'); // the 1.5 s beat
|
||||
const tether2Ok = await ackStep('oac-tether2', 'comms_au_oac-intro-08'); // the 1.5 s beat
|
||||
await sleep(400);
|
||||
|
||||
// ---- 9) the level-2 tether + the launch-back pair ------------------
|
||||
|
|
@ -252,9 +290,9 @@ setTimeout(async () => {
|
|||
if (s.homeWorldName && s.buildState.isBuilt(s.homeWorldName, 'tether-l2')) {
|
||||
s.comms.trigger('tetherL2Launch');
|
||||
}
|
||||
const relaunchOk = await ackStep('oac-relaunch', 'comms_au_intro-09'); // the 1.5 s beat
|
||||
const relaunchOk = await ackStep('oac-relaunch', 'comms_au_oac-intro-09'); // the 1.5 s beat
|
||||
await sleep(300);
|
||||
const exploreOk = await ackStep('oac-explore', 'comms_au_intro-10'); // IMMEDIATE after the ACK (delayMs 0)
|
||||
const exploreOk = await ackStep('oac-explore', 'comms_au_oac-intro-10'); // IMMEDIATE after the ACK (delayMs 0)
|
||||
await sleep(400);
|
||||
|
||||
// ---- 10) the ledger holds what the run heard -----------------------
|
||||
|
|
@ -274,7 +312,7 @@ setTimeout(async () => {
|
|||
const armed7 = s.comms.trigger('tetherL2Launch');
|
||||
d.push(`re-arm: newGame=${armed2} firstScan=${armed3} tetherResearch=${armed4} firstCluster=${armed5} minerals200=${armed6} tetherL2Launch=${armed7} (expect 0 0 0 0 0 0 — the ledger holds)`);
|
||||
|
||||
const allOk = introOk && duckRestoreOk && tetherOk && scanOk && quiet && upgradeOk && holdOk && mineralsOk && flyMineOk && rcOk && rcArmed === 1 && rc2Up && clusterOk && tether2Ok && buildOk && relaunchOk && exploreOk && noReplay && playedOk && armed2 === 0 && armed3 === 0 && armed4 === 0 && armed5 === 0 && armed6 === 0 && armed7 === 0;
|
||||
const allOk = introOk && duckRestoreOk && deckStandby && deckInertOk && pulseMoving && tetherOk && scanOk && quiet && deckArmed && upgradeOk && holdOk && mineralsOk && flyMineOk && rcOk && rcArmed === 1 && rc2Up && clusterOk && tether2Ok && buildOk && relaunchOk && exploreOk && noReplay && playedOk && armed2 === 0 && armed3 === 0 && armed4 === 0 && armed5 === 0 && armed6 === 0 && armed7 === 0;
|
||||
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]);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Orbit — dev: the Trestle pair (screenshot hold)</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-trestle-hold.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* Dev-only: HOLD Trestle's intro comm open (no ACK) for a clean
|
||||
* screenshot — the pair armed through the REAL discovery path
|
||||
* (foreign system + a planet), the box live (video rolling, text
|
||||
* decoded, button armed + pulsing).
|
||||
*
|
||||
* node dev/cdp-shot.mjs "http://127.0.0.1:3000/dev/comms-trestle-hold.html" out.png \
|
||||
* "window.__trestleHold" 90000 "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();
|
||||
const shipped = data.comms;
|
||||
data.comms = { ...shipped, comms: (shipped?.comms ?? []).filter((c) => c.trigger === 'firstForeignPlanet') };
|
||||
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);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 : null;
|
||||
});
|
||||
if (!ready) { report.textContent = 'FAIL — scene never ready'; window.__trestleHold = { ok: false }; return; }
|
||||
const planet = (s().systemPlanets ?? [])[0];
|
||||
if (!planet) { report.textContent = 'FAIL — no planet'; window.__trestleHold = { ok: false }; return; }
|
||||
// The real arm: a foreign system, a planet discovery.
|
||||
s().isHomeSystem = false;
|
||||
s().celebrateDiscovery({
|
||||
id: planet.discoveryId,
|
||||
x: planet.x,
|
||||
y: planet.y,
|
||||
radius: planet.radius,
|
||||
name: planet.discoveryName,
|
||||
typeLabel: 'Rocky World',
|
||||
});
|
||||
const up = await until(() => {
|
||||
const sc = s();
|
||||
return sc.comms.active && sc.comms.current?.id === 'trestle-meetup' && sc.comms.box?.video && sc.comms.box?._decFinished === true ? sc : null;
|
||||
}, 30000);
|
||||
if (!up) { report.textContent = 'FAIL — trestle-meetup never came up'; window.__trestleHold = { ok: false }; return; }
|
||||
const box = s().comms.box;
|
||||
// Catch a visible pulse phase for the shot.
|
||||
await until(() => (box?._ringA?.alpha ?? 0) > 0.5, 4000);
|
||||
const el = box.video?.video;
|
||||
window.__trestleHold = {
|
||||
ok: true,
|
||||
box: { x: box.x, y: box.y, w: box.W, h: box.H },
|
||||
clip: el ? { paused: el.paused, muted: el.muted, loop: el.loop, src: (el.currentSrc ?? '').split('/').pop() } : null,
|
||||
voice: s().sound?.isPlaying('comms_au_trestle-intro-01'),
|
||||
};
|
||||
report.textContent = 'TRESTLE HOLD: box ' + box.W + '×' + box.H + ' clip=' + (el ? el.currentSrc.split('/').pop() : '—');
|
||||
}, 600);
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Orbit — dev: the Trestle pair 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-trestle.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* Dev-only: exercise TRESTLE's introduction pair in the live GameScene —
|
||||
* the FIRST planet the player discovers in a system that is NOT home
|
||||
* (trigger 'firstForeignPlanet'):
|
||||
*
|
||||
* trestle-meetup → ack: box holds (muted looping video + text + voice)
|
||||
* until ACKNOWLEDGE.
|
||||
* trestle-quest → ack: starts the moment the meetup is ACKed (delayMs 0).
|
||||
*
|
||||
* The roster is overridden at boot so ONLY the pair is live (the newGame
|
||||
* welcome stays out of the FIFO) — but everything else is the shipped
|
||||
* code: the real character entry (his clip + his speech keys), the real
|
||||
* GameScene.celebrateDiscovery arm (the scene flag + the witness check),
|
||||
* the real hub (FIFO, the ack contract, the ledger), the real box.
|
||||
*
|
||||
* The witness matrix (all through the real celebrateDiscovery):
|
||||
* 1. home system, a planet discovered → NO arm (home doesn't count)
|
||||
* 2. foreign system, a cluster → NO arm (planets are the witness)
|
||||
* 3. foreign system, a PLANET → the pair arms, in config order
|
||||
*
|
||||
* Headless note: the AudioContext is suspended, so the clips never reach
|
||||
* a natural end — the ACK clicks drive the flow (ack comms hold until
|
||||
* ACKNOWLEDGE), which is exactly the player's path.
|
||||
*
|
||||
* node dev/cdp-probe.mjs "http://127.0.0.1:3000/dev/comms-trestle.html" \
|
||||
* "window.__trestleResult ? JSON.stringify(window.__trestleResult) : null" 180000
|
||||
*/
|
||||
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: ONLY the trestle pair is live (the welcome stays out) ----
|
||||
const shipped = data.comms;
|
||||
data.comms = {
|
||||
...shipped,
|
||||
comms: (shipped?.comms ?? []).filter((c) => c.trigger === 'firstForeignPlanet'),
|
||||
};
|
||||
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 setReport = (lines) => { report.textContent = lines.join('\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 = [];
|
||||
const errors = [];
|
||||
const origErr = console.error;
|
||||
console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); };
|
||||
try {
|
||||
const s = game.scene.getScene('GameScene');
|
||||
const ready = await until(() => (s && s.ship && s.comms ? s : null));
|
||||
if (!ready) throw new Error('scene never ready');
|
||||
|
||||
// The scene booted in the HOME system (isHomeSystem true). The roster
|
||||
// override keeps newGame quiet — verify the deck is live + nothing armed.
|
||||
await sleep(600);
|
||||
const quiet0 = s.comms.active === false && s.comms.queue.length === 0;
|
||||
d.push(`boot: active=${s.comms.active} (expect false) queue=${s.comms.queue.length} (expect 0)`);
|
||||
|
||||
// A real planet of this system (the arm's witness: discoveryId match).
|
||||
const planet = (s.systemPlanets ?? [])[0];
|
||||
const cluster = (s.asteroidClusters ?? [])[0];
|
||||
if (!planet) throw new Error('no system planet to discover');
|
||||
const disc = (o, name, typeLabel) => ({
|
||||
id: o.discoveryId,
|
||||
x: o.x,
|
||||
y: o.y,
|
||||
radius: o.radius ?? o.bound ?? 30,
|
||||
name,
|
||||
typeLabel,
|
||||
});
|
||||
|
||||
// ---- witness 1 — home system: a planet does NOT count -------------
|
||||
if (s.isHomeSystem !== true) throw new Error('scene should boot in the home system');
|
||||
s.celebrateDiscovery(disc(planet, planet.discoveryName, 'Rocky World'));
|
||||
await sleep(400);
|
||||
const homeGuard = s.comms.active === false && s.comms.queue.length === 0 && s._firstForeignPlanetSeen === false;
|
||||
d.push(`witness 1 (home planet): armed=${s.comms.queue.length} (expect 0 — home doesn't count) flag=${s._firstForeignPlanetSeen} (expect false)`);
|
||||
|
||||
// ---- witness 2 — foreign system: a CLUSTER does not count ---------
|
||||
s.isHomeSystem = false; // simulate the jump: the scene now holds a foreign system
|
||||
if (cluster) {
|
||||
s.celebrateDiscovery(disc(cluster, cluster.discoveryName, 'Asteroid Cluster'));
|
||||
await sleep(400);
|
||||
const clusterGuard = s.comms.active === false && s.comms.queue.length === 0 && s._firstForeignPlanetSeen === false;
|
||||
d.push(`witness 2 (foreign cluster): armed=${s.comms.queue.length} (expect 0 — planets are the witness) flag=${s._firstForeignPlanetSeen} (expect false)`);
|
||||
if (!clusterGuard) throw new Error('a cluster armed the planet comm');
|
||||
} else {
|
||||
d.push('witness 2 (foreign cluster): skipped (no cluster in this system)');
|
||||
}
|
||||
|
||||
// ---- witness 3 — foreign system: the first PLANET — the pair ------
|
||||
s.celebrateDiscovery(disc(planet, planet.discoveryName, 'Rocky World'));
|
||||
const armOk = s._firstForeignPlanetSeen === true && s.comms.queue.length + (s.comms.active ? 1 : 0) === 2;
|
||||
d.push(`witness 3 (foreign planet): flag=${s._firstForeignPlanetSeen} (expect true) armed=${s.comms.queue.length + (s.comms.active ? 1 : 0)} (expect 2 — the pair)`);
|
||||
if (!armOk) throw new Error('the planet did not arm the pair');
|
||||
|
||||
const ackStep = async (id, voiceKey, videoKey) => {
|
||||
const live = await until(() => (s.comms.active && s.comms.current?.id === id ? s : null), 20000);
|
||||
if (!live) return false;
|
||||
const box = s.comms.box;
|
||||
const el = box?.video?.video;
|
||||
const clipIsHim =
|
||||
box.videoSpec?.key === videoKey && // his loader key (not OAC's)
|
||||
(!el || (el.currentSrc ?? '').includes('trestle.mp4')); // his file on the wire
|
||||
const contract =
|
||||
s.comms.paused === true && // the ack pauses the action
|
||||
!!box &&
|
||||
!!box.button && // the ACKNOWLEDGE button
|
||||
clipIsHim &&
|
||||
!!el && el.muted === true && el.loop === true && el.paused === false && // muted, looping, playing
|
||||
s.sound.isPlaying(voiceKey) === true; // his voice
|
||||
d.push(`${id}: paused=${s.comms.paused} box=${!!box} ackBtn=${!!box?.button} clip=${el ? el.paused === false && el.muted : '—'} loop=${el?.loop ?? '—'} videoKey=${box?.videoSpec?.key ?? '—'} elSrc=${el ? (el.currentSrc ?? '').split('/').pop() : '—'} voice=${voiceKey} playing=${s.sound.isPlaying(voiceKey)}`);
|
||||
// Press ACKNOWLEDGE (the player's one move while it holds).
|
||||
box.button.panel.emit('pointerdown');
|
||||
await until(() => (s.comms.current?.id !== id ? true : null), 6000);
|
||||
return contract;
|
||||
};
|
||||
|
||||
const meetupOk = await ackStep('trestle-meetup', 'comms_au_trestle-intro-01', 'comms_vid_trestle');
|
||||
await sleep(300);
|
||||
const questOk = await ackStep('trestle-quest', 'comms_au_trestle-intro-02', 'comms_vid_trestle');
|
||||
|
||||
// ---- the ledger holds; the pair re-arms nothing --------------------
|
||||
await sleep(700); // > the endedWithin(200) race window
|
||||
const quiet = s.comms.active === false && s.comms.queue.length === 0;
|
||||
const ledger = s.registry.get('commsPlayed');
|
||||
d.push(`ledger: played=${JSON.stringify(ledger ? Array.from(ledger) : [])}`);
|
||||
const playedOk = !!ledger && ledger.has('trestle-meetup') && ledger.has('trestle-quest');
|
||||
const rearm = s.comms.trigger('firstForeignPlanet');
|
||||
d.push(`re-arm: firstForeignPlanet=${rearm} (expect 0 — the ledger holds)`);
|
||||
|
||||
const allOk = quiet0 && homeGuard && armOk && meetupOk && questOk && quiet && playedOk && rearm === 0 && errors.length === 0;
|
||||
const errLine = errors.length ? ['console errors:', ...errors.slice(0, 4)] : [];
|
||||
window.__trestleResult = { ok: allOk, details: [...d, ...errLine] };
|
||||
setReport(['TRESTLE PAIR SELF-CHECK ' + (allOk ? 'PASS ✔' : 'FAIL ✘'), '', ...d, ...errLine]);
|
||||
} catch (err) {
|
||||
window.__trestleResult = { ok: false, details: ['EXCEPTION: ' + String(err?.stack ?? err), ...d] };
|
||||
setReport(['TRESTLE PAIR SELF-CHECK FAIL ✘', String(err?.stack ?? err), ...d]);
|
||||
}
|
||||
}, 600);
|
||||
|
|
@ -10,7 +10,8 @@
|
|||
* on firstScan, oac-minerals → oac-fly-mine on tetherResearch, and
|
||||
* oac-first-cluster on firstCluster, oac-tether2 on minerals200,
|
||||
* oac-relaunch → oac-explore on tetherL2Launch, and
|
||||
* oac-research-complete (audio-only, once:false) on researchComplete);
|
||||
* oac-research-complete (audio-only, once:false) on researchComplete,
|
||||
* and trestle-meetup → trestle-quest on firstForeignPlanet);
|
||||
* - 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),
|
||||
|
|
@ -67,8 +68,11 @@ check('COMM_TYPES = ack | visual | audio', JSON.stringify(COMM_TYPES) === JSON.s
|
|||
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 trestle = charEntry(cfg, 'trestle');
|
||||
check('Trestle is registered (label + role + his own clip)', !!trestle && trestle.label === 'TRESTLE' && !!trestle.role && trestle.video === 'assets/videos/characters/trestle.mp4');
|
||||
|
||||
const roster = listComms(cfg);
|
||||
check('the roster holds exactly the shipped comms', roster.length === 11);
|
||||
check('the roster holds exactly the shipped comms', roster.length === 13);
|
||||
const byId = Object.fromEntries(roster.map((c) => [c.id, c]));
|
||||
const intro = byId['oac-intro'];
|
||||
check(
|
||||
|
|
@ -195,6 +199,31 @@ check(
|
|||
check('oac-explore: the send-off (the brief, verbatim)', byId['oac-explore']?.text === 'This should be everything you need to get around and explore. Once you discover all the objects in a solar system, you can research that systems jump gates and then use them to jump into other solar systems. I\'ll let you do some exploring for a while on your own now... Good luck.');
|
||||
check('oac-explore: speaks intro-10.mp3', byId['oac-explore']?.audio === 'assets/speech/oac/intro-10.mp3');
|
||||
|
||||
// The foreign-planet pair — Trestle meets the player the first time they
|
||||
// find a planet OUTSIDE the home system (data/comms.json: firstForeignPlanet).
|
||||
check(
|
||||
'trestle-meetup: the foreign-planet intro (ack, firstForeignPlanet, trestle, once)',
|
||||
!!byId['trestle-meetup'] &&
|
||||
byId['trestle-meetup'].from === 'trestle' &&
|
||||
byId['trestle-meetup'].type === 'ack' &&
|
||||
byId['trestle-meetup'].trigger === 'firstForeignPlanet' &&
|
||||
byId['trestle-meetup'].once === true,
|
||||
);
|
||||
check('trestle-meetup: the meet-up brief (the brief, verbatim)', byId['trestle-meetup']?.text === 'Hey Kid. Not bad.. not bad at all.. You\'re getting the hang of this quicker than I thought. Listen, I\'m just out of system, but I think it\'s time we meet up. Finish exploring this system so you can unlock the jump gates. I\'ll ping you once you\'re ready.');
|
||||
check('trestle-meetup: speaks trestle/intro-01.mp3', byId['trestle-meetup']?.audio === 'assets/speech/trestle/intro-01.mp3');
|
||||
check('trestle-meetup: beats the discovery chime (delayMs > 0)', (byId['trestle-meetup']?.delayMs ?? 0) > 0);
|
||||
check(
|
||||
'trestle-quest: chained after trestle-meetup (ack, firstForeignPlanet, once, immediate)',
|
||||
!!byId['trestle-quest'] &&
|
||||
byId['trestle-quest'].from === 'trestle' &&
|
||||
byId['trestle-quest'].type === 'ack' &&
|
||||
byId['trestle-quest'].trigger === 'firstForeignPlanet' &&
|
||||
byId['trestle-quest'].once === true &&
|
||||
(byId['trestle-quest']?.delayMs ?? 0) === 0,
|
||||
);
|
||||
check('trestle-quest: the first-quest/claim brief (the brief, verbatim)', byId['trestle-quest']?.text === 'Oh and by the way, you just completed your first quest. If you click on the quest in the upper left, or click the quests button below, you can complete the quest and claim 200 minerals I set aside just for you. See you soon kid!');
|
||||
check('trestle-quest: speaks trestle/intro-02.mp3', byId['trestle-quest']?.audio === 'assets/speech/trestle/intro-02.mp3');
|
||||
|
||||
check(
|
||||
'tetherResearch arms the pair in config order (minerals → fly-mine)',
|
||||
JSON.stringify(commsForTrigger(cfg, 'tetherResearch').map((c) => c.id)) === JSON.stringify(['oac-minerals', 'oac-fly-mine']),
|
||||
|
|
@ -203,6 +232,7 @@ check('firstCluster arms the rock-field comm', commsForTrigger(cfg, 'firstCluste
|
|||
check('researchComplete arms the sign-off', commsForTrigger(cfg, 'researchComplete').length === 1 && commsForTrigger(cfg, 'researchComplete')[0].id === 'oac-research-complete');
|
||||
check('minerals200 arms the 200-mineral beat', commsForTrigger(cfg, 'minerals200').length === 1 && commsForTrigger(cfg, 'minerals200')[0].id === 'oac-tether2');
|
||||
check('tetherL2Launch arms the launch-back pair in config order', JSON.stringify(commsForTrigger(cfg, 'tetherL2Launch').map((c) => c.id)) === JSON.stringify(['oac-relaunch', 'oac-explore']));
|
||||
check('firstForeignPlanet arms the trestle pair in config order', JSON.stringify(commsForTrigger(cfg, 'firstForeignPlanet').map((c) => c.id)) === JSON.stringify(['trestle-meetup', 'trestle-quest']));
|
||||
|
||||
check(
|
||||
'the chain arms in config order (intro → tether → scan)',
|
||||
|
|
@ -220,10 +250,13 @@ check(
|
|||
// 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'));
|
||||
check('video key is per character', videoKeyFor('oac') === 'comms_vid_oac' && videoKeyFor('trestle') === 'comms_vid_trestle');
|
||||
check('audio key is per clip (dedupes shared clips)', audioKeyFor('assets/speech/oac/intro-01.mp3') === 'comms_au_oac-intro-01' && audioKeyFor('assets/speech/oac/intro-01.mp3') === audioKeyFor('./assets/speech/oac/intro-01.mp3'));
|
||||
check("audio keys are folder-scoped (Trestle's intro-01 is NOT OAC's)", audioKeyFor('assets/speech/oac/intro-01.mp3') !== audioKeyFor('assets/speech/trestle/intro-01.mp3') && audioKeyFor('assets/speech/trestle/intro-01.mp3') === 'comms_au_trestle-intro-01');
|
||||
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');
|
||||
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_oac-intro-01');
|
||||
const tAssets = commAssets(cfg, byId['trestle-meetup']);
|
||||
check('trestle-meetup assets: his clip + his speech, keyed', tAssets.video?.key === 'comms_vid_trestle' && tAssets.video.url === 'assets/videos/characters/trestle.mp4' && tAssets.audio?.key === 'comms_au_trestle-intro-01');
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// normalizeComm — the drops (what can't be shown doesn't ship)
|
||||
|
|
@ -253,9 +286,9 @@ 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 === 11 && ghostList[0].id === 'oac-intro');
|
||||
check('drop: unknown sender (the roster keeps the rest)', ghostList.length === 13 && ghostList[0].id === 'oac-intro');
|
||||
const dupList = listComms({ ...cfg, comms: [...cfg.comms, { ...cfg.comms[0] }] });
|
||||
check('drop: duplicate ids (first wins)', dupList.length === 11 && dupList[0].id === 'oac-intro');
|
||||
check('drop: duplicate ids (first wins)', dupList.length === 13 && dupList[0].id === 'oac-intro');
|
||||
warnOn = true;
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
|
@ -274,6 +307,9 @@ check('a played oac-research-complete RE-arms (once:false — every completion s
|
|||
check('a played oac-tether2 does not re-arm minerals200', commsForTrigger(cfg, 'minerals200', new Set(['oac-tether2'])).length === 0);
|
||||
check('a played half of the launch-back pair still arms the other', JSON.stringify(commsForTrigger(cfg, 'tetherL2Launch', new Set(['oac-relaunch'])).map((c) => c.id)) === JSON.stringify(['oac-explore']));
|
||||
check('a fully played launch-back pair re-arms nothing', commsForTrigger(cfg, 'tetherL2Launch', new Set(['oac-relaunch', 'oac-explore'])).length === 0);
|
||||
check('firstForeignPlanet arms the trestle pair on a fresh run', commsForTrigger(cfg, 'firstForeignPlanet').length === 2);
|
||||
check('a played trestle-meetup does not re-arm its half (the other still does)', JSON.stringify(commsForTrigger(cfg, 'firstForeignPlanet', new Set(['trestle-meetup'])).map((c) => c.id)) === JSON.stringify(['trestle-quest']));
|
||||
check('a fully played trestle pair re-arms nothing', commsForTrigger(cfg, 'firstForeignPlanet', new Set(['trestle-meetup', 'trestle-quest'])).length === 0);
|
||||
check('an unknown trigger arms nothing', commsForTrigger(cfg, 'launchParty').length === 0);
|
||||
check('a blank trigger arms nothing', commsForTrigger(cfg, ' ').length === 0);
|
||||
|
||||
|
|
|
|||
|
|
@ -31,16 +31,20 @@ export function videoKeyFor(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').
|
||||
* The scene-loader cache key for a speech clip, derived from the FILE.
|
||||
* The key is the folder + basename (two comms sharing a clip share one
|
||||
* key; 'assets/speech/oac/intro-01.mp3' → 'comms_au_oac-intro-01'). The
|
||||
* folder is part of the key because TWO CHARACTERS may both ship an
|
||||
* 'intro-01.mp3' — the basename alone would make Trestle speak OAC's
|
||||
* clip (the loader would see the key as already loaded).
|
||||
*/
|
||||
export function audioKeyFor(file) {
|
||||
const base = String(file ?? '')
|
||||
const parts = String(file ?? '')
|
||||
.split('/')
|
||||
.pop()
|
||||
.replace(/\.[a-z0-9]+$/i, '');
|
||||
const safe = base.replace(/[^a-z0-9_-]+/gi, '-');
|
||||
.filter(Boolean);
|
||||
const base = (parts.pop() ?? '').replace(/\.[a-z0-9]+$/i, '');
|
||||
const dir = parts.length ? parts[parts.length - 1] : null;
|
||||
const safe = `${dir ? dir + '-' : ''}${base}`.replace(/[^a-z0-9_-]+/gi, '-');
|
||||
return `comms_au_${safe || 'speech'}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -811,6 +811,7 @@ export class GameScene extends Phaser.Scene {
|
|||
// authoritative dedupe across saves (a flag reset on load can never
|
||||
// re-play a comm the ledger already holds).
|
||||
this._firstClusterSeen = false;
|
||||
this._firstForeignPlanetSeen = false; // the first FOREIGN planet (Trestle's intro)
|
||||
|
||||
// The run's FIRST crossing of the 200-mineral line (the 'minerals200'
|
||||
// comm beat — see onOre): same one-shot shape; the threshold check
|
||||
|
|
@ -1753,6 +1754,12 @@ export class GameScene extends Phaser.Scene {
|
|||
// breathing — starfield, tether, the comm's own clip — only the
|
||||
// player's action is paused.
|
||||
const _commsPaused = this.comms?.paused === true;
|
||||
// The deck goes to STANDBY while an 'ack' comm holds (the pause
|
||||
// contract's face): the slots dim and stop hovering/pressing, so the
|
||||
// pulsing ACKNOWLEDGE button is the only live control on screen.
|
||||
// setEnabled is a no-op when the state is unchanged (per-frame free),
|
||||
// and re-arms the frame the comm ends.
|
||||
this.actionBar?.setEnabled(!_commsPaused);
|
||||
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
|
||||
|
|
@ -2851,6 +2858,22 @@ export class GameScene extends Phaser.Scene {
|
|||
this.comms?.trigger('firstCluster');
|
||||
}
|
||||
}
|
||||
|
||||
// The comms beat (data/comms.json: firstForeignPlanet — the trestle
|
||||
// pair): Trestle meets the player the first time they discover a
|
||||
// planet in a system that is NOT home — they have made it out, and
|
||||
// he has seen. A PLANET is the witness (not a cluster/station/gate,
|
||||
// and not the home world — it exists only in home). The scene flag
|
||||
// fires the event once; the comm's once-ledger dedupes across saves.
|
||||
if (!this._firstForeignPlanetSeen) {
|
||||
const isForeignPlanet =
|
||||
this.isHomeSystem !== true &&
|
||||
(this.systemPlanets ?? []).some((p) => p.discoveryId === o.id);
|
||||
if (isForeignPlanet) {
|
||||
this._firstForeignPlanetSeen = true;
|
||||
this.comms?.trigger('firstForeignPlanet');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showTargetMarker(x, y) {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
|
|||
? { ...config.section('actionbar', {}), buttons: o.buttons }
|
||||
: config.section('actionbar', {});
|
||||
this.dead = false;
|
||||
this.enabled = true; // the deck is live (an 'ack' comm's pause drops it to standby)
|
||||
this.onAction = typeof o.onAction === 'function' ? o.onAction : null;
|
||||
|
||||
const { width: W, height: H } = scene.scale;
|
||||
|
|
@ -284,11 +285,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
|
|||
|
||||
// Hit-test the whole slot rect with an explicit area (independent
|
||||
// of the Graphics' draw state, so repainting never breaks input).
|
||||
s.panel.setInteractive({
|
||||
useHandCursor: true,
|
||||
hitArea: new Phaser.Geom.Rectangle(-bw / 2, -bh / 2, bw, bh),
|
||||
hitAreaCallback: (p, px, py) => Phaser.Geom.Rectangle.Contains(p, px, py),
|
||||
});
|
||||
s.panel.setInteractive(this.slotHit());
|
||||
s.panel.on('pointerover', () => this.setHover(s, true));
|
||||
s.panel.on('pointerout', () => this.setHover(s, false));
|
||||
s.panel.on('pointerdown', () => this.press(s));
|
||||
|
|
@ -343,7 +340,50 @@ export class ActionBar extends Phaser.GameObjects.Container {
|
|||
// Slot states
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Repaint a slot for a visual state: 'base' | 'hover' | 'press'. */
|
||||
/** The slot's hit config — one source for the initial wiring and the
|
||||
* standby re-arm (setEnabled), so the rect can never drift. */
|
||||
slotHit() {
|
||||
const bw = this.style.bw;
|
||||
const bh = this.style.bh;
|
||||
return {
|
||||
useHandCursor: true,
|
||||
hitArea: new Phaser.Geom.Rectangle(-bw / 2, -bh / 2, bw, bh),
|
||||
hitAreaCallback: (p, px, py) => Phaser.Geom.Rectangle.Contains(p, px, py),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* setEnabled(on) — the deck's STANDBY state (the 'ack' comm's pause
|
||||
* contract, GameScene wires it on `comms.paused`): the slots go inert
|
||||
* (no hover, no press, clicks swallowed) and paint dim like the
|
||||
* reserved sockets — so the ACKNOWLEDGE button is the only live
|
||||
* control on the screen while the comm holds. Idempotent (a no-op
|
||||
* when the state is unchanged) — the scene may call it every frame.
|
||||
*/
|
||||
setEnabled(on) {
|
||||
const want = on !== false;
|
||||
if (this.dead || want === this.enabled) return;
|
||||
this.enabled = want;
|
||||
for (const s of this.slots) {
|
||||
if (!s.live) continue;
|
||||
if (want) {
|
||||
s.panel.setInteractive(this.slotHit()); // re-arm (v4: flips input.enabled)
|
||||
} else {
|
||||
s.hoverOn = false; // hover is over (the paint below is the truth)
|
||||
s.sweep.setAlpha(0);
|
||||
if (s._sweep) {
|
||||
s._sweep.remove();
|
||||
s._sweep = null;
|
||||
}
|
||||
if (s._scaleTw) s._scaleTw.stop(); // the hover scale punch is a tween
|
||||
s.slot.setScale(1); // land any in-flight hover scale
|
||||
s.panel.disableInteractive(); // no more pointer events, cursor reset
|
||||
}
|
||||
this.paintSlot(s, want ? 'base' : 'disabled');
|
||||
}
|
||||
}
|
||||
|
||||
/** Repaint a slot for a visual state: 'base' | 'hover' | 'press' | 'disabled'. */
|
||||
paintSlot(s, state) {
|
||||
const st = this.style;
|
||||
const { bw, bh, notch } = st;
|
||||
|
|
@ -359,6 +399,21 @@ export class ActionBar extends Phaser.GameObjects.Container {
|
|||
strokeAlpha: 1,
|
||||
lineWidth: 1.5,
|
||||
});
|
||||
} else if (state === 'disabled') {
|
||||
// Standby (the ack hold): the reserved-socket language — dim panel,
|
||||
// faint edge, the accent rail barely there, the label grayed.
|
||||
CyberShape.draw(g, bw, bh, {
|
||||
notch,
|
||||
fill: st.slotBg,
|
||||
fillAlpha: 0.45,
|
||||
stroke: st.slotBorder,
|
||||
strokeAlpha: 0.35,
|
||||
lineWidth: 1.5,
|
||||
});
|
||||
g.fillStyle(st.reserved, 0.25);
|
||||
g.fillRect(-bw / 2 + notch, -bh / 2 + 1.5, bw - notch * 2, 1.5);
|
||||
if (s.label) s.label.setColor('#5a6478');
|
||||
return;
|
||||
} else if (s.live) {
|
||||
CyberShape.draw(g, bw, bh, {
|
||||
notch,
|
||||
|
|
@ -399,7 +454,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
|
|||
}
|
||||
|
||||
setHover(s, on) {
|
||||
if (!s.live || this.dead) return;
|
||||
if (!s.live || this.dead || this.enabled === false) return; // standby: inert
|
||||
if (on) this.scene.playSfx?.('ui_hover'); // the hover tick (the scene is the voice)
|
||||
s.hoverOn = on;
|
||||
this.paintSlot(s, on ? 'hover' : 'base');
|
||||
|
|
@ -433,7 +488,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
|
|||
|
||||
/** Click feedback: white flash + scale punch, then restore. Fires onAction. */
|
||||
press(s) {
|
||||
if (!s.live || s.pressing || this.dead) return;
|
||||
if (!s.live || s.pressing || this.dead || this.enabled === false) return; // standby: inert
|
||||
// SCAN plays its own sonar ping (the scene's onAction) — no double tick.
|
||||
if (s.id !== 'scan') this.scene.playSfx?.('ui_click');
|
||||
s.pressing = true;
|
||||
|
|
|
|||
|
|
@ -32,6 +32,14 @@ import { MenuButton } from './MenuButton.js';
|
|||
* Research/Build feeds, the landing stage). 'ack' loops it silently
|
||||
* while the box holds; 'visual' plays it once.
|
||||
*
|
||||
* The ACKNOWLEDGE button is the PRIMARY action while a comm holds: it
|
||||
* wears the box's amber (against the cyan UI) and — for 'ack' only —
|
||||
* a pulsing attention cue (config: box.pulse): an amber halo breathing
|
||||
* under it plus two staggered sonar rings expanding out. The pulse is
|
||||
* driven per-frame from update() (no tween bookkeeping — it lives
|
||||
* exactly as long as the box does, and the hub stops pumping when the
|
||||
* box closes).
|
||||
*
|
||||
* 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
|
||||
|
|
@ -100,6 +108,16 @@ export class CommsBox extends Phaser.GameObjects.Container {
|
|||
this.voiceOn = false;
|
||||
this._textValue = String(spec.text ?? '');
|
||||
|
||||
// The ACK pulse (box.pulse config) — built with the button (below);
|
||||
// nulls for 'visual' comms (no button, no pulse).
|
||||
const pulseCfg = config.section('comms.box.pulse', {});
|
||||
this._pulseOn = this.showAck && pulseCfg.enabled !== false;
|
||||
this._pulseColor = toColor(pulseCfg.color, 0xffd84d);
|
||||
this._pulsePeriod = Math.max(600, pulseCfg.periodMs ?? 1300);
|
||||
this._halo = null;
|
||||
this._ringA = null;
|
||||
this._ringB = null;
|
||||
|
||||
const fam = fontStack('body');
|
||||
|
||||
// ---- measure the text (its height sizes the box) ------------------
|
||||
|
|
@ -149,6 +167,9 @@ export class CommsBox extends Phaser.GameObjects.Container {
|
|||
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) {
|
||||
// The button wears the box's AMBER — the primary action reads
|
||||
// against the cyan UI (the pulse below makes it unmistakable).
|
||||
const amberCss = toCss(this.amberInt);
|
||||
this.button = new MenuButton(
|
||||
scene,
|
||||
0, 0, // repositioned below (right-aligned in the text column)
|
||||
|
|
@ -161,6 +182,10 @@ export class CommsBox extends Phaser.GameObjects.Container {
|
|||
letterSpacing: buttonCfg.letterSpacing ?? 2,
|
||||
upper: true,
|
||||
screenFixed: true, // the box lives in a scrolling-cam scene
|
||||
bgColor: '#181104', // warm dark panel (the box's amber family)
|
||||
hoverColor: '#2f2409', // hover warms it
|
||||
borderColor: amberCss, // the box's amber — the primary-action edge
|
||||
textColor: '#ffe9b8', // warm ink on the amber
|
||||
},
|
||||
);
|
||||
// Right-aligned in the text column: the action anchors the box's
|
||||
|
|
@ -171,6 +196,7 @@ export class CommsBox extends Phaser.GameObjects.Container {
|
|||
textX + Math.min(this.button.width, textMaxW) - this.button.width / 2,
|
||||
contentTop + textH + (buttonCfg.gap ?? 10) + buttonH / 2,
|
||||
);
|
||||
if (this._pulseOn) this._buildPulse(); // the halo + rings go BEHIND the button
|
||||
this.add(this.button);
|
||||
}
|
||||
|
||||
|
|
@ -331,6 +357,73 @@ export class CommsBox extends Phaser.GameObjects.Container {
|
|||
this.add(ns);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// the ACK pulse (the primary-action cue — ack comms only)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** The amber halo (breathing) + two staggered sonar rings (expanding
|
||||
* out from the button), behind it in the container. Sized off the
|
||||
* button's final geometry — called after its position is set. */
|
||||
_buildPulse() {
|
||||
const s = this.scene;
|
||||
const bw = this.button.width;
|
||||
const bh = this.button.height;
|
||||
const notch = this.button.style?.notch ?? 8;
|
||||
|
||||
// The halo — a soft amber fill hugging the button (additive: light
|
||||
// from behind, not a solid slab).
|
||||
const halo = s.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD);
|
||||
halo.clear();
|
||||
CyberShape.draw(halo, bw + 8, bh + 8, {
|
||||
notch: notch + 2,
|
||||
fill: this._pulseColor,
|
||||
fillAlpha: 1, // the alpha is the breathing (update drives it)
|
||||
});
|
||||
halo.setPosition(this.button.x, this.button.y);
|
||||
this._halo = halo;
|
||||
|
||||
// The sonar rings — a plain outline a touch larger than the button;
|
||||
// update() scales/fades them on a half-period stagger, so one is
|
||||
// always leaving while the other sets.
|
||||
const mkRing = () => {
|
||||
const ring = s.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD);
|
||||
ring.clear();
|
||||
CyberShape.draw(ring, bw + 12, bh + 12, {
|
||||
notch: notch + 3,
|
||||
stroke: this._pulseColor,
|
||||
strokeAlpha: 1,
|
||||
lineWidth: 2.5,
|
||||
});
|
||||
ring.setPosition(this.button.x, this.button.y);
|
||||
return ring;
|
||||
};
|
||||
this._ringA = mkRing();
|
||||
this._ringB = mkRing();
|
||||
|
||||
this.add(this._halo);
|
||||
this.add(this._ringA);
|
||||
this.add(this._ringB);
|
||||
}
|
||||
|
||||
/** Per-frame (the hub pumps while the comm is in play): the pulse. A
|
||||
* pure function of `time` — nothing to stop, and the hub stops
|
||||
* calling it the moment the box closes. */
|
||||
_pulse(time) {
|
||||
if (!this._pulseOn || !this._ringA || !this._halo) return;
|
||||
const P = this._pulsePeriod;
|
||||
const ring = (r, off) => {
|
||||
const u = ((time + off) % P) / P; // 0..1 — the ring's age this cycle
|
||||
// Never fully gone: the floor keeps the button framed so the cue
|
||||
// reads even in the "between waves" moment.
|
||||
r.setScale(1 + 0.3 * u).setAlpha(0.18 + (1 - u) * 0.67);
|
||||
};
|
||||
ring(this._ringA, 0);
|
||||
ring(this._ringB, P / 2);
|
||||
// The halo breathes once per cycle — the button's own soft glow.
|
||||
const b = 0.5 + 0.5 * Math.sin(((time % P) / P) * Math.PI * 2);
|
||||
this._halo.setAlpha(0.12 + 0.26 * b);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// the hub's handles
|
||||
// ------------------------------------------------------------------
|
||||
|
|
@ -343,8 +436,9 @@ export class CommsBox extends Phaser.GameObjects.Container {
|
|||
this.text.setText('');
|
||||
}
|
||||
|
||||
/** Per-frame (the hub pumps it): the text's decode. */
|
||||
/** Per-frame (the hub pumps it): the ACK pulse, then the text's decode. */
|
||||
update(time) {
|
||||
this._pulse(time);
|
||||
if (!this._dec || this._decFinished) return;
|
||||
if (!this._dec.started(time)) return;
|
||||
this.text.setText(this._dec.display(time));
|
||||
|
|
@ -424,6 +518,12 @@ export class CommsBox extends Phaser.GameObjects.Container {
|
|||
_destroyVideo(this.video);
|
||||
this.video = null;
|
||||
}
|
||||
this._halo?.destroy();
|
||||
this._halo = null;
|
||||
this._ringA?.destroy();
|
||||
this._ringA = null;
|
||||
this._ringB?.destroy();
|
||||
this._ringB = null;
|
||||
this.button?.destroy();
|
||||
this.button = null;
|
||||
this.dot?.destroy();
|
||||
|
|
|
|||
|
|
@ -41,7 +41,9 @@ export class MenuButton extends Phaser.GameObjects.Container {
|
|||
|
||||
const fill = toColor(o.bgColor ?? menuColors.buttonBg ?? '#0a1120');
|
||||
const hoverFill = toColor(o.hoverColor ?? menuColors.buttonHoverBg ?? '#13233f');
|
||||
const stroke = toColor(menuColors.buttonBorder ?? '#2aa9c9');
|
||||
// o.borderColor (optional): the base-edge color (the comm box's amber
|
||||
// ACKNOWLEDGE button — the primary action reads against the cyan UI).
|
||||
const stroke = toColor(o.borderColor ?? menuColors.buttonBorder ?? '#2aa9c9');
|
||||
const neon = themeColor('neon', 0x00e5ff);
|
||||
const neon2 = themeColor('neon2', 0xff2d6f);
|
||||
const ink = toColor(o.textColor ?? menuColors.buttonText ?? '#eaf6ff');
|
||||
|
|
|
|||
Loading…
Reference in New Issue