From 81a1589307f15ae5544ece3ab5619268db85d93c Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Tue, 22 Sep 2026 20:12:41 -0600 Subject: [PATCH] Add neutral-only encounter comms and soundtrack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduce a NEUTRAL-ONLY encounter path: when escorts are live with zero hostiles, the combat seam now carries a `neutral` flag so the scene can swap to a dedicated neutral hum instead of the combat shuffle. - Fire two new pilot comms once per neutral encounter — `neutralScan` (pilot on screen) and `neutralScanComplete` (first pilot break-off) — via a new `onComm` seam in CombatSystem; hostile-only beats stay silent during neutral encounters. - Add `neutral` music context to data/music.json with its own shuffle playlist, load it in GameScene, and resume the correct context on wake. - Ship a `?neutral` dev boot (two pilots, 8 s escort window) for testing the full beat at home, plus tests covering the comm seam, mixed→neutral re-resolution, and data validation. --- README.md | 7 +++ data/comms.json | 38 ++++++++++++++- data/music.json | 7 ++- dev/comms.test.mjs | 7 ++- dev/neutral.test.mjs | 47 ++++++++++++++++++- js/combat/CombatSystem.js | 71 ++++++++++++++++++++++++++-- js/scenes/GameScene.js | 98 +++++++++++++++++++++++++++++++++------ 7 files changed, 253 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index d8989a2..3c54250 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,13 @@ respawns, the bar — lives in **`data/combat.json`**: a line in the kill feed. Poke the live fight in DevTools via `window.__ORBIT_COMBAT__ = { system, bar, player, kills, spawn, damagePlayer, cast, xp, grantXp }`. +- **Neutral encounter** — **`index.html?neutral`** boots that same normal + game with a NEUTRAL-ONLY encounter live instead of hostiles: two + pilots stream in on a short escort window — the neutral hum replaces + the combat music the moment one is on screen, the pilot's SCAN comm + lands as it flies in, and the SCAN-COMPLETE comm rides the break-off + (the dev stand-in for the neutral encounter beat — safe to run even at + home, where hostiles never go). - **Feel** — the handling is what the range tunes. Every feel number (thrust, top speed, drag, turn rate + turn response, throttle ramp/ diff --git a/data/comms.json b/data/comms.json index 846c77b..8c735d2 100644 --- a/data/comms.json +++ b/data/comms.json @@ -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; 'firstWorld' — trestle-meetup → trestle-quest: the FIRST world the player discovers — the starter quest's own witness (discoveredWorldCount: any planet or space station, the home world never counts, in any system — the comm and the quest can never drift; GameScene.celebrateDiscovery, plus a scene-create arm for loaded saves that already hold discovered worlds), 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), and oac-research-new — the 'new research available' beat that ANSWERS the sign-off: armed the moment the sign-off's voice has FINISHED (the hub's onEnd — GameScene._commEnded) and only if a tech is still ready to research that isn't yet (researchAnyAvailable — nothing in flight, a node whose requires are met); the hub's FIFO plays it right behind the sign-off, so the two voices never overlap; 'systemComplete' — oac-system-complete, the system-complete milestone: the moment a system's NAV chart COMPLETES — its last planet/space station/jump-gate discovery (GameScene.celebrateDiscovery), which is exactly when its 'Unlock Jumpgates' research becomes AVAILABLE (the SYSTEM tree's available hook, js/research/SystemCategory.js) — NOT on the research's completion; once per system (once:false re-arms per system; the scene's arming set keeps it a single fire, so later cluster discoveries in a charted system don't re-arm it). 'questNew' — oac-quest-new: the quest-grant voice (GameScene._grantUnlockedQuests — the starter on the first boot, 'Leaving the Nest' on the starter's claim, every future unlock; once:false re-arms per grant); 'trestleBrief' — trestle-brief-1 → trestle-brief-2 → trestle-brief-3: the 'Leaving the Nest' quest's three back-to-back ACK briefs — armed the moment the home system's jumpgates go ONLINE (the activateGates effect in GameScene._applyResearchEffects — the player can now jump out of the nest; also the quest's grant, and scene create for loaded saves whose gates are already online — GameScene._trestleBeats). The hub's FIFO plays the sequence back-to-back (each 'ack' holds until its own ACKNOWLEDGE, so the 1→2→3 order is guaranteed); ALL THREE in the run's comms played-ledger (registry 'commsPlayed' — marked when a comm starts, saved with the run) are the quest's 'Talk to Trestle' witness (GameScene._questCheckEval), and a quit mid-sequence re-arms only the unplayed beats on the next load; 'trestleArrival' — trestle-arrive-1 → trestle-arrive-2: the 'Leaving the Nest' quest's ARRIVAL PAIR on the NEUTRAL CAPITAL WORLD (the destination trestle-brief-3's acknowledgment sends the player to) — the quest completes on the PLANET, not on entering the neighboring system: armed the moment the ship reaches the capital world (GameScene._checkDestinationReached), plus a scene-create arm in GameScene._trestleBeats for a loaded save parked at the capital's rim. The hub's FIFO plays the pair back-to-back (each 'ack' holds until its own ACKNOWLEDGE); BOTH in the run's comms played-ledger (registry 'commsPlayed' — marked when a comm starts, saved with the run) are the quest's 'Meet Trestle' witness (GameScene._questCheckEval). 'deliveredLaunch' — carol-delivered-1 → carol-delivered-2 → carol-delivered-3 → oac-delivered-1: the 'Special Delivery' quest's PAYOFF — armed the moment the player LAUNCHES after the package's home-world sale (GameScene.onSurfaceDeparture: the run's delivery witness, story.delivered, stands when the shop's CONFIRM sells it — data/goods.json → special-package) — Carol's thanks + the trade sign-off, then OAC's confirmation that buy/sell codes are live on every world (the four 'ack' beats chained by the hub's FIFO, each holding until its own ACKNOWLEDGE; the run's comms played-ledger keeps it a one-shot — a later re-arm is a no-op). 'trestleQ4' — trestle-q4-1 → trestle-q4-2 → trestle-q4-3: the 'Pirate Problems' quest's WARNING TRIO — armed the moment the quest GRANTS (GameScene._questStartBeats — the grant gate: 'Special Delivery' claimed AND its deliveredLaunch chain fully ACKed, data/quests.json → pirate_problems.unlocks), the destination already set to the Neutral Capital world; each 'ack' holds until its own ACKNOWLEDGE, so the warning always lands before the jump into the capital's system (the ambush's midpoint); ALL THREE in the run's comms played-ledger (registry 'commsPlayed') are the quest's 'Speak with Trestle' witness (GameScene._questCheckEval). 'trestleQ4Payoff' — trestle-q4-4 → trestle-q4-5: the 'Pirate Problems' quest's PAYOFF — armed the moment the quest becomes COMPLETE + UNCLAIMED (all its checks standing, the reward claimable — GameScene._questRefresh's once-per-quest READY pass, the same moment the QUEST READY toast + chime fire; the last step to stand is the capital's arrival, story.capitalReturned): the job-board unlock (patrols, bounty hunts, escorts) + the level-5 hook (the open game's next door); the two 'ack' beats chained by the hub's FIFO, each holding until its own ACKNOWLEDGE; the run's comms played-ledger keeps the pair a one-shot — a later re-arm is a no-op (a save loaded with the quest complete + unclaimed hears the unplayed beats on that load). 'combatStart' — oac-enemies-detected: the COMBAT-LIVE beat — the moment CombatSystem's active flag flips ON (GameScene._setCombatBar: the wave run's lead, before the first hostile lands, or the ?combat pack's spawn) — OAC's visual contact call (box + voice, no pause — it rides over the live fight); the voice rolls ONE of the three enemies-detected clips at random per arm (audioPool); once:false — re-arms on every combat start; 'combatEnd' — oac-enemies-defeated: the COMBAT-OVER beat — the moment the last hostile is down (the active flag flips OFF, GameScene._setCombatBar), 1 s after the final kill (delayMs 1000 — the last explosion reads first) — OAC's visual sign-off; the voice rolls ONE of the three enemies-defeated clips at random per arm (audioPool); once:false — re-arms on every combat end; 'pirateAttack' — pirate-attack: the PIRATE'S OWN attack line — the moment a comming hostile (data/combat.json → enemyTypes.raider.comms) flies on-screen (a 50% roll per pirate, js/combat/CombatSystem.js) or a new pirate is sent into a rolling wave (a 20% roll per arrival) — the box's clip is the pirate's face (assets/videos/enemies/pirate.mp4) with the voice rolling ONE of the four attack clips at random per arm (audioPool); 'pirateDefeat' — pirate-defeat: the PIRATE'S OWN defeat line — a 20% roll per pirate killed, the box's clip the dying pirate (assets/videos/enemies/pirate-defeat.mp4) with the voice rolling ONE of the four defeat clips at random per arm (audioPool); 'pirateBountyAttack' — pirate-bounty-attack: the BOUNTY HUNT's MARK's attack line — GameScene._fireBountyEngagements arms it the moment the wanted leader stands up its fight (the dossier's frame at ×2, data/sidequests.json → bounty) — the box's clip the bounty clip (assets/videos/enemies/pirate-bounty.mp4) with the voice rolling ONE of the four bounty clips at random per arm (audioPool). All three once:false — re-arming is the hub's in-flight guard's job (one comm in the queue at a time, never a barrage). Reserved for future comms: 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. A comm's `audioPool` (a list of speech files) speaks one clip at RANDOM per arm — the pick is made when the deferred-load spec is built (js/comms/CommsModel.js → commAssets), so only the chosen clip is fetched.", + "$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; 'firstWorld' — trestle-meetup → trestle-quest: the FIRST world the player discovers — the starter quest's own witness (discoveredWorldCount: any planet or space station, the home world never counts, in any system — the comm and the quest can never drift; GameScene.celebrateDiscovery, plus a scene-create arm for loaded saves that already hold discovered worlds), 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), and oac-research-new — the 'new research available' beat that ANSWERS the sign-off: armed the moment the sign-off's voice has FINISHED (the hub's onEnd — GameScene._commEnded) and only if a tech is still ready to research that isn't yet (researchAnyAvailable — nothing in flight, a node whose requires are met); the hub's FIFO plays it right behind the sign-off, so the two voices never overlap; 'systemComplete' — oac-system-complete, the system-complete milestone: the moment a system's NAV chart COMPLETES — its last planet/space station/jump-gate discovery (GameScene.celebrateDiscovery), which is exactly when its 'Unlock Jumpgates' research becomes AVAILABLE (the SYSTEM tree's available hook, js/research/SystemCategory.js) — NOT on the research's completion; once per system (once:false re-arms per system; the scene's arming set keeps it a single fire, so later cluster discoveries in a charted system don't re-arm it). 'questNew' — oac-quest-new: the quest-grant voice (GameScene._grantUnlockedQuests — the starter on the first boot, 'Leaving the Nest' on the starter's claim, every future unlock; once:false re-arms per grant); 'trestleBrief' — trestle-brief-1 → trestle-brief-2 → trestle-brief-3: the 'Leaving the Nest' quest's three back-to-back ACK briefs — armed the moment the home system's jumpgates go ONLINE (the activateGates effect in GameScene._applyResearchEffects — the player can now jump out of the nest; also the quest's grant, and scene create for loaded saves whose gates are already online — GameScene._trestleBeats). The hub's FIFO plays the sequence back-to-back (each 'ack' holds until its own ACKNOWLEDGE, so the 1→2→3 order is guaranteed); ALL THREE in the run's comms played-ledger (registry 'commsPlayed' — marked when a comm starts, saved with the run) are the quest's 'Talk to Trestle' witness (GameScene._questCheckEval), and a quit mid-sequence re-arms only the unplayed beats on the next load; 'trestleArrival' — trestle-arrive-1 → trestle-arrive-2: the 'Leaving the Nest' quest's ARRIVAL PAIR on the NEUTRAL CAPITAL WORLD (the destination trestle-brief-3's acknowledgment sends the player to) — the quest completes on the PLANET, not on entering the neighboring system: armed the moment the ship reaches the capital world (GameScene._checkDestinationReached), plus a scene-create arm in GameScene._trestleBeats for a loaded save parked at the capital's rim. The hub's FIFO plays the pair back-to-back (each 'ack' holds until its own ACKNOWLEDGE); BOTH in the run's comms played-ledger (registry 'commsPlayed' — marked when a comm starts, saved with the run) are the quest's 'Meet Trestle' witness (GameScene._questCheckEval). 'deliveredLaunch' — carol-delivered-1 → carol-delivered-2 → carol-delivered-3 → oac-delivered-1: the 'Special Delivery' quest's PAYOFF — armed the moment the player LAUNCHES after the package's home-world sale (GameScene.onSurfaceDeparture: the run's delivery witness, story.delivered, stands when the shop's CONFIRM sells it — data/goods.json → special-package) — Carol's thanks + the trade sign-off, then OAC's confirmation that buy/sell codes are live on every world (the four 'ack' beats chained by the hub's FIFO, each holding until its own ACKNOWLEDGE; the run's comms played-ledger keeps it a one-shot — a later re-arm is a no-op). 'trestleQ4' — trestle-q4-1 → trestle-q4-2 → trestle-q4-3: the 'Pirate Problems' quest's WARNING TRIO — armed the moment the quest GRANTS (GameScene._questStartBeats — the grant gate: 'Special Delivery' claimed AND its deliveredLaunch chain fully ACKed, data/quests.json → pirate_problems.unlocks), the destination already set to the Neutral Capital world; each 'ack' holds until its own ACKNOWLEDGE, so the warning always lands before the jump into the capital's system (the ambush's midpoint); ALL THREE in the run's comms played-ledger (registry 'commsPlayed') are the quest's 'Speak with Trestle' witness (GameScene._questCheckEval). 'trestleQ4Payoff' — trestle-q4-4 → trestle-q4-5: the 'Pirate Problems' quest's PAYOFF — armed the moment the quest becomes COMPLETE + UNCLAIMED (all its checks standing, the reward claimable — GameScene._questRefresh's once-per-quest READY pass, the same moment the QUEST READY toast + chime fire; the last step to stand is the capital's arrival, story.capitalReturned): the job-board unlock (patrols, bounty hunts, escorts) + the level-5 hook (the open game's next door); the two 'ack' beats chained by the hub's FIFO, each holding until its own ACKNOWLEDGE; the run's comms played-ledger keeps the pair a one-shot — a later re-arm is a no-op (a save loaded with the quest complete + unclaimed hears the unplayed beats on that load). 'combatStart' — oac-enemies-detected: the COMBAT-LIVE beat — the moment CombatSystem's active flag flips ON (GameScene._setCombatBar: the wave run's lead, before the first hostile lands, or the ?combat pack's spawn) — OAC's visual contact call (box + voice, no pause — it rides over the live fight); the voice rolls ONE of the three enemies-detected clips at random per arm (audioPool); once:false — re-arms on every combat start; 'combatEnd' — oac-enemies-defeated: the COMBAT-OVER beat — the moment the last hostile is down (the active flag flips OFF, GameScene._setCombatBar), 1 s after the final kill (delayMs 1000 — the last explosion reads first) — OAC's visual sign-off; the voice rolls ONE of the three enemies-defeated clips at random per arm (audioPool); once:false — re-arms on every combat end; 'pirateAttack' — pirate-attack: the PIRATE'S OWN attack line — the moment a comming hostile (data/combat.json → enemyTypes.raider.comms) flies on-screen (a 50% roll per pirate, js/combat/CombatSystem.js) or a new pirate is sent into a rolling wave (a 20% roll per arrival) — the box's clip is the pirate's face (assets/videos/enemies/pirate.mp4) with the voice rolling ONE of the four attack clips at random per arm (audioPool); 'pirateDefeat' — pirate-defeat: the PIRATE'S OWN defeat line — a 20% roll per pirate killed, the box's clip the dying pirate (assets/videos/enemies/pirate-defeat.mp4) with the voice rolling ONE of the four defeat clips at random per arm (audioPool); 'pirateBountyAttack' — pirate-bounty-attack: the BOUNTY HUNT's MARK's attack line — GameScene._fireBountyEngagements arms it the moment the wanted leader stands up its fight (the dossier's frame at ×2, data/sidequests.json → bounty) — the box's clip the bounty clip (assets/videos/enemies/pirate-bounty.mp4) with the voice rolling ONE of the four bounty clips at random per arm (audioPool). All three once:false — re-arming is the hub's in-flight guard's job (one comm in the queue at a time, never a barrage). 'neutralScan' — neutral-scan: the NEUTRAL PILOT's SCAN line — the moment a pilot flies ON SCREEN while the encounter is neutral-only (at least one escort in, zero hostiles anywhere — js/combat/CombatSystem.js, once per encounter) — the box's clip is the pilot (assets/videos/allies/neutral-pilot.mp4) with the voice rolling ONE of the three scan clips at random per arm (audioPool); 'neutralScanComplete' — neutral-scan-complete: the PILOT's SCAN-COMPLETE line — the moment a pilot BREAKS OFF (escort window lapsed, peeling off screen) in a neutral-only encounter (js/combat/CombatSystem.js, once per encounter, on the first pilot to break) — the same face with the voice rolling ONE of the three scan-complete clips at random per arm (audioPool). Both once:false (the encounter-level marks in CombatSystem keep them to one per encounter; the hub's in-flight guard keeps one in the queue at a time). Reserved for future comms: 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. A comm's `audioPool` (a list of speech files) speaks one clip at RANDOM per arm — the pick is made when the deferred-load spec is built (js/comms/CommsModel.js → commAssets), so only the chosen clip is fetched.", "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.", @@ -42,6 +42,12 @@ "label": "PIRATE", "role": "WANTED — BOUNTY ACTIVE", "video": "assets/videos/enemies/pirate-bounty.mp4" + }, + "neutral_pilot": { + "$comment": "The NEUTRAL PILOT (data/combat.json → neutralTypes — the escort roster, js/combat/NeutralShip.js). The scan + scan-complete comms' face (triggers 'neutralScan' / 'neutralScanComplete', js/combat/CombatSystem.js): the box's clip is assets/videos/allies/neutral-pilot.mp4 — played muted, once (the voice is the rolled speech clip, never the video).", + "label": "NEUTRAL PILOT", + "role": "CARGO RUNNER — UNDER SCAN", + "video": "assets/videos/allies/neutral-pilot.mp4" } }, "comms": [ @@ -449,6 +455,36 @@ ], "once": false, "delayMs": 0 + }, + { + "$comment": "The NEUTRAL PILOT's SCAN line (trigger 'neutralScan' — js/combat/CombatSystem.js arms it the moment a pilot flies ON SCREEN while the encounter is neutral-only: at least one escort in, zero hostiles anywhere): the box shows the pilot's face (assets/videos/allies/neutral-pilot.mp4 — played once, muted) with the line while the voice rolls ONE of the three scan clips at RANDOM per arm (audioPool — the hub's deferred load fetches only the chosen file); no pause — it rides over the escort window. once:false — re-arms on every neutral encounter (the encounter-level mark in CombatSystem keeps it to one per encounter; the hub's in-flight guard keeps one in the queue at a time).", + "id": "neutral-scan", + "from": "neutral_pilot", + "type": "visual", + "trigger": "neutralScan", + "text": "Hold your heading, runner. You're in a contraband lane — we're running a full scan of your ship before you go any further. Nothing to worry about if you've got nothing to hide.", + "audioPool": [ + "assets/speech/neutral/scan-01.mp3", + "assets/speech/neutral/scan-02.mp3", + "assets/speech/neutral/scan-03.mp3" + ], + "once": false, + "delayMs": 0 + }, + { + "$comment": "The NEUTRAL PILOT's SCAN-COMPLETE line (trigger 'neutralScanComplete' — js/combat/CombatSystem.js arms it the moment a pilot BREAKS OFF (escort window lapsed, peeling off screen) in a neutral-only encounter): the box shows the pilot's face (assets/videos/allies/neutral-pilot.mp4 — played once, muted) with the line while the voice rolls ONE of the three scan-complete clips at RANDOM per arm (audioPool); no pause. once:false — re-arms on every neutral encounter (one per encounter, on the first pilot to break; the hub's in-flight guard keeps one in the queue at a time).", + "id": "neutral-scan-complete", + "from": "neutral_pilot", + "type": "visual", + "trigger": "neutralScanComplete", + "text": "That's a clear scan — nothing contraband aboard. You're free to run, runner. Safe travels out there.", + "audioPool": [ + "assets/speech/neutral/scan-complete-01.mp3", + "assets/speech/neutral/scan-complete-02.mp3", + "assets/speech/neutral/scan-complete-03.mp3" + ], + "once": false, + "delayMs": 0 } ], "reservedTriggers": [ diff --git a/data/music.json b/data/music.json index de8d1dc..6ebf1a4 100644 --- a/data/music.json +++ b/data/music.json @@ -1,8 +1,8 @@ { - "_comment": "Music (assets/music). enabled = master switch (also skips the load). volume = the ONE knob for the mix — the DEFAULT volume for ALL music (menu, game, surface), 0..1. volumes = per-CONTEXT overrides on top of it (see _volumes_comment). menu = the loop that plays while the main menu is up. game = the SHUFFLED soundtrack for the main game scene (open space) — a plain file list, so adding a track is adding one line; one track plays at its natural length and when it ends the next is picked at random (never the same track twice in a row). combat = the SHUFFLED COMBAT soundtrack — the SAME playlist shape as game (a plain file list), swapped in the moment combat goes live and back to game the moment it ends (GameScene.setCombatMusic, on the onActiveChange seam where the combat bar flips; one shuffle per scene, so the swap is a clean restart of the voice). frames = the loop that plays on a world's surface — keyed by the planets.png SHEET FRAME (the same key the landing videos use, data/landing.json: frames 0..2 are the terran worlds, 3..5 the gas giants), so a world hums the track matching the art it is drawn with. The surface loop starts the moment the landing clip starts and stops the moment the takeoff clip ends (or the scene leaves any other way). A frame with no entry (or a null entry) is silent. intro-01.mp3 is parked for the intro sequence that hasn't been built yet.", + "_comment": "Music (assets/music). enabled = master switch (also skips the load). volume = the ONE knob for the mix — the DEFAULT volume for ALL music (menu, game, surface), 0..1. volumes = per-CONTEXT overrides on top of it (see _volumes_comment). menu = the loop that plays while the main menu is up. game = the SHUFFLED soundtrack for the main game scene (open space) — a plain file list, so adding a track is adding one line; one track plays at its natural length and when it ends the next is picked at random (never the same track twice in a row). combat = the SHUFFLED COMBAT soundtrack — the SAME playlist shape as game (a plain file list), swapped in the moment combat goes live and back to game the moment it ends (GameScene.setCombatMusic, on the onActiveChange seam where the combat bar flips; one shuffle per scene, so the swap is a clean restart of the voice). neutral = the SHUFFLED NEUTRAL-PILOT soundtrack — the same shape as combat, swapped in instead of combat for a NEUTRAL-ONLY encounter (at least one escort in, zero hostiles anywhere — the onActiveChange seam's second argument, js/combat/CombatSystem.js → _setActive): the neutral hum plays the whole escort window, and it swaps back to game when the last pilot breaks off (GameScene.setCombatMusic, active + neutral). frames = the loop that plays on a world's surface — keyed by the planets.png SHEET FRAME (the same key the landing videos use, data/landing.json: frames 0..2 are the terran worlds, 3..5 the gas giants), so a world hums the track matching the art it is drawn with. The surface loop starts the moment the landing clip starts and stops the moment the takeoff clip ends (or the scene leaves any other way). A frame with no entry (or a null entry) is silent. intro-01.mp3 is parked for the intro sequence that hasn't been built yet.", "enabled": true, "volume": 0.6, - "_volumes_comment": "volumes = per-CONTEXT overrides on top of volume (volume stays the default for everything else): 'menu' = the main-menu loop, 'game' = the deep-space shuffle, 'combat' = the combat shuffle, 'frames' = the world surface loops, 'stationFrames' = the station surface loops. A NUMBER applies to that whole context; an OBJECT goes one level deeper, so one frame/variant can have its own volume (e.g. \"frames\": { \"0\": 1.0 }). A context with no entry plays at volume. The menu ships at 1.0 — full volume — on purpose.", + "_volumes_comment": "volumes = per-CONTEXT overrides on top of volume (volume stays the default for everything else): 'menu' = the main-menu loop, 'game' = the deep-space shuffle, 'combat' = the combat shuffle, 'neutral' = the neutral-pilot shuffle, 'frames' = the world surface loops, 'stationFrames' = the station surface loops. A NUMBER applies to that whole context; an OBJECT goes one level deeper, so one frame/variant can have its own volume (e.g. \"frames\": { \"0\": 1.0 }). A context with no entry plays at volume. The menu ships at 1.0 — full volume — on purpose.", "volumes": { "menu": 1.0 }, @@ -15,6 +15,9 @@ "assets/music/combat-01.mp3", "assets/music/combat-02.mp3" ], + "neutral": [ + "assets/music/neutral-01.mp3" + ], "frames": { "0": "assets/music/terran-01.mp3", "1": "assets/music/terran-02.mp3", diff --git a/dev/comms.test.mjs b/dev/comms.test.mjs index ca173bc..50e1c30 100644 --- a/dev/comms.test.mjs +++ b/dev/comms.test.mjs @@ -98,6 +98,11 @@ check('the defeat clip ships on disk', existsSync(join(root, 'assets/videos/enem const pirateBounty = charEntry(cfg, 'pirate_bounty'); check('the bounty mark is registered (label + role + the bounty clip)', !!pirateBounty && pirateBounty.label === 'PIRATE' && !!pirateBounty.role && pirateBounty.video === 'assets/videos/enemies/pirate-bounty.mp4'); check('the bounty clip ships on disk', existsSync(join(root, 'assets/videos/enemies/pirate-bounty.mp4'))); +const neutralPilot = charEntry(cfg, 'neutral_pilot'); +check('the neutral pilot is registered (label + role + the pilot clip)', !!neutralPilot && neutralPilot.label === 'NEUTRAL PILOT' && !!neutralPilot.role && neutralPilot.video === 'assets/videos/allies/neutral-pilot.mp4'); +check('the pilot clip ships on disk', existsSync(join(root, 'assets/videos/allies/neutral-pilot.mp4'))); +check('the scan voice ships on disk', existsSync(join(root, 'assets/speech/neutral/scan-01.mp3'))); +check('the scan-complete voice ships on disk', existsSync(join(root, 'assets/speech/neutral/scan-complete-01.mp3'))); const roster = listComms(cfg); // The shipped roster count (the data pins it: a stray unvalidated comm @@ -107,7 +112,7 @@ const roster = listComms(cfg); // quest-complete sign-off) + the combat pair (oac-enemies-detected / // oac-enemies-defeated) + the pirate trio (pirate-attack / pirate-defeat / // pirate-bounty-attack — the comming type's own voice, the mark's too) = 35). -check('the roster holds exactly the shipped comms', roster.length === 35); +check('the roster holds exactly the shipped comms', roster.length === 37); const byId = Object.fromEntries(roster.map((c) => [c.id, c])); const intro = byId['oac-intro']; check( diff --git a/dev/neutral.test.mjs b/dev/neutral.test.mjs index 9b19584..008247e 100644 --- a/dev/neutral.test.mjs +++ b/dev/neutral.test.mjs @@ -179,7 +179,7 @@ const check = (label, cond) => { }; // A fresh harness per block: stub scene + player + real CombatSystem. -function harness() { +function harness(o = {}) { const scene = makeScene(); const ship = new PhaserStub.Physics.Arcade.Sprite(scene, 0, 0, '__ship', 0); scene.physics.add.existing(ship); @@ -189,6 +189,7 @@ function harness() { const combat = new CombatSystem(scene, { ship, onActiveChange: (a) => activeFlips.push(a), + onComm: typeof o.onComm === 'function' ? o.onComm : null, }); return { scene, ship, combat, activeFlips }; } @@ -251,6 +252,50 @@ const ids = neutralTypeIds(); JSON.stringify(activeFlips) === '[true,false]'); } +// ---------------------------------------------------------------- the scan comms +{ + // The NEUTRAL PILOT's comms (data/comms.json → neutralScan / + // neutralScanComplete): armed by the system, delivered through the + // onComm seam — once per encounter, and only while the roster is + // neutral-only (zero hostiles). + const comms = []; + const { ship, combat } = harness({ onComm: (t) => comms.push(t) }); + const a = combat.spawnAlly(300, 0, 0, 'courier', { escortMs: 60000, viewFn: () => null }); + check('seam: a neutral-only encounter stands (active + the neutral flag)', + combat.active === true && combat._neutral === true && combat._neutralOnly() === true); + a.state = 'escort'; // skip the stream-in (approach) + a.x = 80; a.y = 0; + combat.update(1000, 16); + check('scan: the first on-screen moment arms the scan comm (once)', + JSON.stringify(comms) === '["neutralScan"]'); + combat.update(1016, 16); // no barrage on the next frames + check('scan: the mark holds — no repeat arm', JSON.stringify(comms) === '["neutralScan"]'); + // The break-off (escort window lapsed) arms the scan-complete comm. + a.escortMs = 1000; a.escortStart = 0; + combat.update(2000, 16); + check('break: window elapse → break-off', a.state === 'break'); + check('scan-complete: the break-off arms the completion comm (once)', + JSON.stringify(comms) === '["neutralScan","neutralScanComplete"]'); + + // A live hostile anywhere keeps the roster MIXED: no neutral flag, no + // scan comm (the pilot's line is a neutral-only beat). + const comms2 = []; + const h2 = harness({ onComm: (t) => comms2.push(t) }); + const b = h2.combat.spawnEnemy(400, 0, 0, 'raider'); + b.respawn = false; b.nextFireAt = Infinity; b.comms = null; // hold it + no pirate line + const a2 = h2.combat.spawnAlly(300, 0, 1, 'runner', { escortMs: 60000, viewFn: () => null }); + a2.state = 'escort'; a2.x = 80; a2.y = 0; + h2.combat.update(1000, 16); + check('mixed: a live hostile keeps the neutral flag down + the scan comm silent', + h2.combat._neutral === false && comms2.length === 0); + // …and the moment the last hostile goes down (escorts still escorting) + // the roster RE-RESOLVES to neutral: the seam re-fires with the flag. + b.damage(999); // the real path is a tracer sweep → damage() → killEnemy + h2.combat.killEnemy(b, 1000); + check('mixed→neutral: the last kill re-fires the seam with neutral up', + h2.combat.active === true && h2.combat._neutral === true); +} + // ---------------------------------------------------------------- engage { const { ship, combat } = harness(); diff --git a/js/combat/CombatSystem.js b/js/combat/CombatSystem.js index ca9b0b8..4476eaa 100644 --- a/js/combat/CombatSystem.js +++ b/js/combat/CombatSystem.js @@ -98,6 +98,9 @@ export class CombatSystem { this.onKill = typeof o.onKill === 'function' ? o.onKill : null; this.onLevelUp = typeof o.onLevelUp === 'function' ? o.onLevelUp : null; this.onComm = typeof o.onComm === 'function' ? o.onComm : null; + this._neutral = false; // the last _setActive's neutral-only flag (the seam's edge) + this._neutralScanCommed = false; // the encounter's scan comm already fired + this._neutralBreakCommed = false; // the encounter's scan-complete comm already fired this.enemies = []; this.allies = []; // neutral escorts (a separate roster — js/combat/NeutralShip.js) @@ -200,12 +203,32 @@ export class CombatSystem { ); } - /** Fire the state change at the scene (bar swap) when it flips. */ + /** + * Is the LIVE roster a NEUTRAL-ONLY encounter? — at least one escort in, + * zero hostiles anywhere (not even pending respawn). This is the gate + * for the neutral soundtrack + the pilot's scan comms: the moment the + * last pirate dies with escorts still escorting, it stands. + */ + _neutralOnly() { + const enemiesLive = this.enemies.some((e) => e && !e.dead); + const alliesLive = this.allies.some((a) => a && !a.dead && !a.gone && !a._removed); + return alliesLive && !enemiesLive; + } + + /** + * Fire the state change at the scene (bar swap) when it flips — now + * WITH the neutral-only flag, so a neutral swap (active → active, or + * active → over) re-runs the scene's music seam (combat ↔ neutral ↔ + * deep-space) even when the plain active flag didn't move (last pirate + * down, escorts still escorting). + */ _setActive() { const a = this.active; - if (a === this._active) return; + const n = a && this._neutralOnly(); + if (a === this._active && n === this._neutral) return; this._active = a; - if (this.onActiveChange) this.onActiveChange(a); + this._neutral = n; + if (this.onActiveChange) this.onActiveChange(a, n); } /** @@ -638,6 +661,28 @@ export class CombatSystem { if (r.wantsFire && player && r.target && r.target.dead !== true && this.craftVisible(a)) { this.fireAlly(a, r.target, time); } + // THE NEUTRAL SCAN COMM (trigger 'neutralScan' — data/comms.json): the + // moment a pilot flies ON SCREEN while the encounter is neutral-only + // (no hostiles anywhere), the pilot's visible scan comm arms (the box + // plays their clip muted with the rolled scan-XX voice). Once per + // encounter (the hub's in-flight guard keeps a barrage away regardless). + const vis = this.craftVisible(a); + if (vis && !a._wasOnScreen) { + a._wasOnScreen = true; + if (!this._neutralScanCommed && this._neutralOnly()) { + this._neutralScanCommed = true; + this._fireNeutralComm('neutralScan'); + } + } + // THE SCAN-COMPLETE COMM (trigger 'neutralScanComplete'): the moment a + // pilot BREAKS OFF (escort window lapsed → state 'break', peeling off + // screen) in a neutral-only encounter — the rolled scan-complete-XX + // voice ("scan complete, nothing found"). Once per encounter, on the + // first pilot to break. + if (a.state === 'break' && !this._neutralBreakCommed && this._neutralOnly()) { + this._neutralBreakCommed = true; + this._fireNeutralComm('neutralScanComplete'); + } } // A broken escort that has flown fully off-screen is gone (the // encounter ends when the last one is), and a shot-down escort is @@ -650,6 +695,10 @@ export class CombatSystem { } } this.allies = this.allies.filter((a) => !a.gone && !a._removed); + if (this.allies.length === 0) { + this._neutralScanCommed = false; // the roster is spent — a fresh encounter re-arms the comms + this._neutralBreakCommed = false; + } // The last escort off-screen (with the pack spent) is the encounter // OVER — the flip back to the normal deck/soundtrack rides the same // seam as a kill (killEnemy/killAlly already fire it; the break-off @@ -1295,6 +1344,22 @@ export class CombatSystem { // pirate comms at most once per life — the arrival (20%) OR the // on-screen (50%) roll, never both. + /** + * A NEUTRAL PILOT's comm (the scan / scan-complete beats): the same seam + * as the pirate rolls — hand the trigger to o.onComm (the scene arms its + * CommsHub's trigger; a context without one stays silent). No chance + * roll: the beats are guaranteed once per encounter (the marks above). + * @param {string} trigger 'neutralScan' / 'neutralScanComplete' + */ + _fireNeutralComm(trigger) { + if (!this.onComm || !trigger) return; + try { + this.onComm(trigger); + } catch (err) { + if (typeof console !== 'undefined') console.warn('[combat] onComm hook failed', err); + } + } + /** * The ATTACK roll (`chance` in [0, 1]): a hit arms the type's trigger. * @param {object} e the hostile (its comms block + the roll's marks) diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index acf028a..37f9aab 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -392,6 +392,15 @@ export class GameScene extends Phaser.Scene { if (config.get('music.enabled', true) && Array.isArray(combatTracks)) { for (const f of combatTracks) this.load.audio(gameTrackKey(f), f); } + + // The NEUTRAL-PILOT soundtrack (data/music.json → neutral) — the + // neutral-only encounter's hum (setCombatMusic, active + neutral — the + // onActiveChange seam's second argument). Same derived-key rule + // (music_neutral_01…). + const neutralTracks = config.get('music.neutral', []); + if (config.get('music.enabled', true) && Array.isArray(neutralTracks)) { + for (const f of neutralTracks) this.load.audio(gameTrackKey(f), f); + } } create() { @@ -428,6 +437,7 @@ export class GameScene extends Phaser.Scene { // wakes the scene). 'shutdown' still covers return-to-menu and game // destroy (the shutdown() method stays as the destroy safety net). this._asleep = false; // surface state (the sleep/wake events flip it) — setCombatMusic's seam + this._combatWasNeutral = false; // the last live combat's neutral-only flag — the seam's memory this.startGameMusic(); this.events.once('shutdown', () => { this.stopGameMusic(); @@ -446,7 +456,7 @@ export class GameScene extends Phaser.Scene { // Back in space — resume the RIGHT context: the combat shuffle if the // fight is still live (landing is denied mid-fight, so this is the // defensive branch), the deep-space hum otherwise. - if (this.combat?.active) this.startCombatMusic(); + if (this.combat?.active) (this._combatWasNeutral ? this.startNeutralMusic() : this.startCombatMusic()); else this.startGameMusic(); // the shuffle starts fresh if (this.mining?.state === 'mining') this.setMiningLoop(true); // the beam is still live }); @@ -1051,7 +1061,7 @@ export class GameScene extends Phaser.Scene { this.combat = new CombatSystem(this, { ship: this.ship, xp: savedXp ? XpManager.fromJSON(savedXp) : new XpManager(), - onActiveChange: (active) => this._setCombatBar(active), + onActiveChange: (active, neutral) => this._setCombatBar(active, neutral), // The readability layer's bookkeeping (js/combat/CombatHud.js — // the same shared layer the range runs): a kill lands in the feed, // and a hit flashes the bar's gauge that took it. The enemy rides @@ -1130,6 +1140,31 @@ export class GameScene extends Phaser.Scene { '[orbit] ?combat — the seven-type demo is live: 7 rolling waves, bomber → warden (data/combat.json → waves.scripts.combat_demo)', ); } + // The ?neutral boot (the dev stand-in for the NEUTRAL-ONLY + // encounter): two pilots stream in from off-screen on a SHORT escort + // window (8 s — the data's band is 10–15 s), so the whole beat plays + // in one sitting: the neutral hum in (setCombatMusic, neutral), the + // pilot's SCAN comm as one flies on-screen, the break-off, the + // SCAN-COMPLETE comm, and the deep-space hum back. Player-side ships + // only — safe to run even at home (where hostiles never go). + if (this._wantsNeutralDemo()) { + const ship = this.ship; + const nids = neutralTypeIds(); + const r = this.combat._offscreenRadius() * (0.9 + Math.random() * 0.2); + for (let i = 0; i < 2; i++) { + const a = Math.PI * (i + 0.5) + (Math.random() - 0.5) * 0.6; + this.combat.spawnAlly( + ship.x + Math.cos(a) * r, + ship.y + Math.sin(a) * r, + 90000 + this._encounterSerial++, + nids.length ? nids[(Math.random() * nids.length) | 0] : null, + { escortMs: 8000 }, // short window — the break-off lands fast + ); + } + console.info( + '[orbit] ?neutral — neutral-only encounter live: two pilots, 8 s window (the neutral hum + the scan / scan-complete comms)', + ); + } // The READABILITY LAYER (shared with the range): the lock on the // auto-fire target, the threat markers, the off-screen arrows // (the camera follows the ship — hostiles can sit outside it), @@ -2095,6 +2130,19 @@ export class GameScene extends Phaser.Scene { return new URLSearchParams(location.search).has('combat'); } + /** + * The ?neutral boot (a dev URL parameter, like ?combat): a NORMAL game + * with a NEUTRAL-ONLY encounter live from the start (two pilots, a short + * escort window) — the test stand for the neutral hum + the pilot's scan + * / scan-complete comms (see the block in create()). + * @returns {boolean} + */ + _wantsNeutralDemo() { + if (config.get('combat.enabled', true) === false) return false; + if (typeof location === 'undefined') return false; + return new URLSearchParams(location.search).has('neutral'); + } + /** * The action-deck swap (combat mode, Diablo 4 style — one bar in the * same place, depending on the mode): combat LIVE → the combat bar up @@ -2105,15 +2153,25 @@ export class GameScene extends Phaser.Scene { * the first hostile lands — or the ?combat pack's spawn); over (the * last hostile down) → OAC's sign-off (its delayMs, 1 s, lands it * after the final explosion's read). The hub's in-flight guard + the - * played ledger keep each flip to one voice. + * played ledger keep each flip to one voice. A NEUTRAL-ONLY encounter + * (neutral — the seam's second argument: escorts in, zero hostiles) + * swaps the soundtrack for the neutral hum INSTEAD, and OAC's + * hostile-signature beats stay silent (there's no hostile signature — + * the pilot's own scan comms carry the encounter). * @param {boolean} active + * @param {boolean} [neutral] the live roster is a neutral-only encounter */ - _setCombatBar(active) { + _setCombatBar(active, neutral = false) { if (this.actionBar) this.actionBar.setVisible(!active); if (this.combatBar) this.combatBar.setVisible(active); - this.setCombatMusic(active); // the soundtrack swap (combat ↔ deep-space) - if (active) this.comms?.trigger('combatStart'); - else this.comms?.trigger('combatEnd'); + this.setCombatMusic(active, neutral); // the soundtrack swap (combat/neutral ↔ deep-space) + if (active) { + this._combatWasNeutral = neutral; + if (!neutral) this.comms?.trigger('combatStart'); + } else { + if (!this._combatWasNeutral) this.comms?.trigger('combatEnd'); + this._combatWasNeutral = false; + } } /** @@ -4306,20 +4364,32 @@ export class GameScene extends Phaser.Scene { stopMusicShuffleOn(this); } + /** The NEUTRAL-PILOT soundtrack (data/music.json → neutral): the hum + * under a neutral-only encounter (escorts in, zero hostiles) — the same + * shuffle voice as combat at the neutral context's volume (music.volumes + * .neutral can override the global mix — same one-knob rule as combat). Clean to re-fire — the + * scene-level stop goes first, so the swap never stacks two shuffles. */ + startNeutralMusic() { + startMusicShuffleOn(this, config.get('music.neutral', []), 'neutral'); + } + /** * The soundtrack's combat switch (the onActiveChange seam — the same * moment the combat bar swaps in): the fight goes live → the deep-space - * hum stops and the combat shuffle starts; the fight ends → the combat - * shuffle stops and the deep-space hum resumes — unless the scene is - * asleep (on a world's surface: the surface owns the silence, and the - * wake handler resumes the right context). Clean to re-fire on either - * side (one shuffle per scene). + * hum stops and the combat shuffle starts (or the NEUTRAL shuffle when + * the encounter is neutral-only — escorts in, zero hostiles); the fight + * ends → whichever shuffle is live stops and the deep-space hum resumes + * — unless the scene is asleep (on a world's surface: the surface owns + * the silence, and the wake handler resumes the right context). Clean + * to re-fire on either side (one shuffle per scene). * @param {boolean} active combat live / over (CombatSystem's flip) + * @param {boolean} [neutral] the live roster is a neutral-only encounter */ - setCombatMusic(active) { + setCombatMusic(active, neutral = false) { if (active) { this.stopGameMusic(); - this.startCombatMusic(); + if (neutral) this.startNeutralMusic(); + else this.startCombatMusic(); } else { this.stopCombatMusic(); if (!this._asleep) this.startGameMusic();