diff --git a/assets/speech/pirates/attack-01.mp3 b/assets/speech/pirates/attack-01.mp3 new file mode 100644 index 0000000..bd91ce9 Binary files /dev/null and b/assets/speech/pirates/attack-01.mp3 differ diff --git a/assets/speech/pirates/attack-02.mp3 b/assets/speech/pirates/attack-02.mp3 new file mode 100644 index 0000000..21c064e Binary files /dev/null and b/assets/speech/pirates/attack-02.mp3 differ diff --git a/assets/speech/pirates/attack-03.mp3 b/assets/speech/pirates/attack-03.mp3 new file mode 100644 index 0000000..c7cbbbf Binary files /dev/null and b/assets/speech/pirates/attack-03.mp3 differ diff --git a/assets/speech/pirates/attack-04.mp3 b/assets/speech/pirates/attack-04.mp3 new file mode 100644 index 0000000..7b8fc70 Binary files /dev/null and b/assets/speech/pirates/attack-04.mp3 differ diff --git a/assets/speech/pirates/bounty-01.mp3 b/assets/speech/pirates/bounty-01.mp3 new file mode 100644 index 0000000..3d71021 Binary files /dev/null and b/assets/speech/pirates/bounty-01.mp3 differ diff --git a/assets/speech/pirates/bounty-02.mp3 b/assets/speech/pirates/bounty-02.mp3 new file mode 100644 index 0000000..74ca32c Binary files /dev/null and b/assets/speech/pirates/bounty-02.mp3 differ diff --git a/assets/speech/pirates/bounty-03.mp3 b/assets/speech/pirates/bounty-03.mp3 new file mode 100644 index 0000000..bfe9bd7 Binary files /dev/null and b/assets/speech/pirates/bounty-03.mp3 differ diff --git a/assets/speech/pirates/bounty-04.mp3 b/assets/speech/pirates/bounty-04.mp3 new file mode 100644 index 0000000..a3e0202 Binary files /dev/null and b/assets/speech/pirates/bounty-04.mp3 differ diff --git a/assets/speech/pirates/defeat-01.mp3 b/assets/speech/pirates/defeat-01.mp3 new file mode 100644 index 0000000..8eb4b90 Binary files /dev/null and b/assets/speech/pirates/defeat-01.mp3 differ diff --git a/assets/speech/pirates/defeat-02.mp3 b/assets/speech/pirates/defeat-02.mp3 new file mode 100644 index 0000000..f5255eb Binary files /dev/null and b/assets/speech/pirates/defeat-02.mp3 differ diff --git a/assets/speech/pirates/defeat-03.mp3 b/assets/speech/pirates/defeat-03.mp3 new file mode 100644 index 0000000..1a8b3a6 Binary files /dev/null and b/assets/speech/pirates/defeat-03.mp3 differ diff --git a/assets/speech/pirates/defeat-04.mp3 b/assets/speech/pirates/defeat-04.mp3 new file mode 100644 index 0000000..d55378c Binary files /dev/null and b/assets/speech/pirates/defeat-04.mp3 differ diff --git a/assets/videos/enemies/pirate-bounty.mp4 b/assets/videos/enemies/pirate-bounty.mp4 new file mode 100644 index 0000000..88a218d Binary files /dev/null and b/assets/videos/enemies/pirate-bounty.mp4 differ diff --git a/assets/videos/enemies/pirate-defeat.mp4 b/assets/videos/enemies/pirate-defeat.mp4 new file mode 100644 index 0000000..6b87b9b Binary files /dev/null and b/assets/videos/enemies/pirate-defeat.mp4 differ diff --git a/assets/videos/enemies/pirate.mp4 b/assets/videos/enemies/pirate.mp4 new file mode 100644 index 0000000..6c3e7ac Binary files /dev/null and b/assets/videos/enemies/pirate.mp4 differ diff --git a/data/combat.json b/data/combat.json index 79a9dbe..ea458ee 100644 --- a/data/combat.json +++ b/data/combat.json @@ -84,7 +84,19 @@ "enemyTypes": { "_comment": "The hostile roster a WAVE (combat.waves.scripts) can mix — js/combat/EnemyTypes.js deep-merges a type's overrides onto the base `enemy` above, so a type declares only what differs. `raider` is the base craft under a named id (frame 0). Frames 1–7 are the SEVEN new hostiles, each a distinct craft with a distinct WEAPON PATTERN (weapon.pattern) and a MODERATE difficulty step-up: hull/shields (armor to soak) climb monotonically bomber → warden, so each is a little harder to defeat than the last. PATTERNS (js/combat/Weapons.js): 'lance' (the default — one aimed bolt, the base behavior), 'spray' (a FAN of `bolts` spread over `fan` rad), 'burst' (a TIGHT `bolts`-shot stinger — staggered speeds so it fans over distance), 'rapid' (single bolts on a short cooldown — a machine gun), 'ring' (`bolts` fired RADIAL around the craft — break the orbit), 'missile' (`missiles` HEAT-SEEKING shots that home with a CAPPED turn-rate (missileTurn rad/s) so the player can out-turn them), 'combo' (a heavy lance + a missile, the elite). Add a craft here (a new `frame` or its own `texture` + stat overrides) and name it in a wave's roster; EnemyTypes.enemySheetList() is the preload seam for its textures. Each type's `xp` = what a kill of it is worth (the rule of thumb is (hull+shields)/5, rounded, so a tougher craft is worth more of the XP bank).", "raider": { - "frame": 0 + "$comment": "The PIRATE (data/quests.json → pirate_problems — the ambush wave combat.waves.scripts.pirate_ambush is all raiders). comms = the pirate's combat VOICE (the rolls live in js/combat/CombatSystem.js; the comms themselves are data/comms.json's pirate-attack / pirate-defeat — the box's clip + one random speech clip + the message). attack.onScreenChance = the chance a pirate shows an ATTACK comm the first time it flies ON SCREEN (0.5 = 50%); attack.spawnChance = the chance a NEW pirate sent into a rolling wave (the opening burst, each kill's replacement) ATTACK-comms on arrival (0.2 = 20%) — a hit marks the ship, so its later on-screen roll doesn't stack (a pirate never double-comms); defeat.chance = the chance a downed pirate shows a DEFEAT comm (0.2 = 20%).", + "frame": 0, + "comms": { + "attack": { + "onScreenChance": 0.5, + "spawnChance": 0.2, + "trigger": "pirateAttack" + }, + "defeat": { + "chance": 0.2, + "trigger": "pirateDefeat" + } + } }, "bomber": { "_comment": "frame 1 — the LONG LANCE: slow, sits back, one heavy bolt on a long fuse. The easy end of the new ramp.", diff --git a/data/comms.json b/data/comms.json index 4c02735..846c77b 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. 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). 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.", @@ -24,6 +24,24 @@ "label": "CAROL", "role": "COURIER", "video": "assets/videos/characters/carol.mp4" + }, + "pirate": { + "$comment": "The pirate raider (data/combat.json → enemyTypes.raider — the 'Pirate Problems' ambush craft, the 'Pirate Problems' quest's enemy, data/quests.json → pirate_problems). The ATTACK comm's face (trigger 'pirateAttack'): the box's clip is assets/videos/enemies/pirate.mp4 — played muted, once (the voice is the speech clip, never the video). The DEFEAT beat wears its own sender below (a separate dying clip).", + "label": "PIRATE", + "role": "UNLAWFUL HOSTILE RAIDER", + "video": "assets/videos/enemies/pirate.mp4" + }, + "pirate_defeat": { + "$comment": "The same raider's DEFEAT beat (trigger 'pirateDefeat') — a SEPARATE sender so the box's clip is the dying pirate (assets/videos/enemies/pirate-defeat.mp4) instead of the attack clip (the comm model ties the video to the sender, so two faces = two senders).", + "label": "PIRATE", + "role": "UNLAWFUL HOSTILE RAIDER — DITCHING", + "video": "assets/videos/enemies/pirate-defeat.mp4" + }, + "pirate_bounty": { + "$comment": "The BOUNTY HUNT's mark (data/sidequests.json → bounty — the wanted leader the 'BOUNTY HUNT' contract flies: the dossier's frame buffed ×2, js/quests/SideQuests.js → bountyEngagementScript). The bounty attack comm's face (trigger 'pirateBountyAttack'): the box's clip is assets/videos/enemies/pirate-bounty.mp4 — played muted, once (the voice is the speech clip, never the video). The mark's own name rides the hunt's sighting toast — the comms model is static, the name is the contract's.", + "label": "PIRATE", + "role": "WANTED — BOUNTY ACTIVE", + "video": "assets/videos/enemies/pirate-bounty.mp4" } }, "comms": [ @@ -383,6 +401,54 @@ ], "once": false, "delayMs": 1000 + }, + { + "$comment": "The pirate's ATTACK line (trigger 'pirateAttack' — js/combat/CombatSystem.js arms it for a comming type (data/combat.json → enemyTypes.raider.comms): the first time a pirate flies ON SCREEN (50% roll) or a NEW pirate is sent into a rolling wave (the opening burst, each kill's replacement — 20% roll; a hit marks the ship so its later on-screen roll doesn't stack, a pirate never double-comms). VISUAL: the box shows the pirate's face (assets/videos/enemies/pirate.mp4 — played once, muted) with the line while the voice rolls ONE of the four pirate attack clips at RANDOM per arm (audioPool — the hub's deferred load fetches only the chosen file); no pause — it rides over the live fight. once:false — re-arms on every roll that hits (the hub's in-flight guard keeps one in the queue at a time, so a burst of rolling pirates never stacks a barrage).", + "id": "pirate-attack", + "from": "pirate", + "type": "visual", + "trigger": "pirateAttack", + "text": "You're in my waters now, and your ship is already mine. Fight on if you like, pretty dart — it'll only be faster that way.", + "audioPool": [ + "assets/speech/pirates/attack-01.mp3", + "assets/speech/pirates/attack-02.mp3", + "assets/speech/pirates/attack-03.mp3", + "assets/speech/pirates/attack-04.mp3" + ], + "once": false, + "delayMs": 0 + }, + { + "$comment": "The pirate's DEFEAT line (trigger 'pirateDefeat' — js/combat/CombatSystem.js arms it 20% of the time a comming type (data/combat.json → enemyTypes.raider.comms) goes down, on the kill itself). VISUAL: the box shows the dying pirate (assets/videos/enemies/pirate-defeat.mp4 — played once, muted) with the line while the voice rolls ONE of the four pirate defeat clips at RANDOM per arm (audioPool); no pause — it rides over the live fight. once:false — re-arms on every roll that hits (the hub's in-flight guard keeps one in the queue at a time).", + "id": "pirate-defeat", + "from": "pirate_defeat", + "type": "visual", + "trigger": "pirateDefeat", + "text": "Aaargh! You've won this round, pretty dart… but this isn't over. My crew is watching, and they'll be coming for what's left of you.", + "audioPool": [ + "assets/speech/pirates/defeat-01.mp3", + "assets/speech/pirates/defeat-02.mp3", + "assets/speech/pirates/defeat-03.mp3", + "assets/speech/pirates/defeat-04.mp3" + ], + "once": false, + "delayMs": 0 + }, + { + "$comment": "The pirate BOUNTY's ATTACK line (trigger 'pirateBountyAttack' — GameScene._fireBountyEngagements arms it the moment a BOUNTY HUNT's mark stands up its fight (data/sidequests.json → bounty): the wanted leader — the dossier's frame buffed ×2 (js/quests/SideQuests.js → bountyEngagementScript) — has flown into view. VISUAL: the box shows the bounty clip (assets/videos/enemies/pirate-bounty.mp4 — played once, muted) with the line while the voice rolls ONE of the four pirate bounty clips at RANDOM per arm (audioPool — the hub's deferred load fetches only the chosen file); no pause — it rides over the live fight. once:false — re-arms on every engagement that stands (a jump out mid-fight re-arms it on return; the hub's in-flight guard keeps one in the queue at a time, so a burst of rolling pirates never stacks a barrage).", + "id": "pirate-bounty-attack", + "from": "pirate_bounty", + "type": "visual", + "trigger": "pirateBountyAttack", + "text": "So it's you, the bounty hunter, pretty dart. The Office pays a fortune for my head — but I've got better use for your hull. Come and take me!", + "audioPool": [ + "assets/speech/pirates/bounty-01.mp3", + "assets/speech/pirates/bounty-02.mp3", + "assets/speech/pirates/bounty-03.mp3", + "assets/speech/pirates/bounty-04.mp3" + ], + "once": false, + "delayMs": 0 } ], "reservedTriggers": [ diff --git a/dev/bounty-hunts.test.mjs b/dev/bounty-hunts.test.mjs index 6bded20..0e61fad 100644 --- a/dev/bounty-hunts.test.mjs +++ b/dev/bounty-hunts.test.mjs @@ -601,6 +601,70 @@ section('NO SPOILERS — nothing the player reads says where the mark stands'); check('the hunt’s beacon style comes from the data with no contact color', /_drawBountyMarkers/.test(sceneSrc) && !/bounty\.marker\.contactColor/.test(sceneSrc)); } +// ========================================================================== +section('THE MARK\'S VOICE — the bounty attack comm (data/comms.json → pirate-bounty-attack)'); +{ + // The mark's fight stands up ONLY in the bounty branch of + // GameScene._fireBountyEngagements (the picket skirmish throws no mark), + // and it is there that the 'pirateBountyAttack' trigger is armed — the + // box shows assets/videos/enemies/pirate-bounty.mp4 with one of the four + // bounty voices. Run headless over a stub `this` (the jobboard pattern): + // the REAL prototype function, its internal `this.*` seams on stubs. + const makeHuntScene = (shipPos) => { + const state = { started: [], triggered: [], toasts: [], sfx: [] }; + const scene = { + combat: { + waveRun: null, + startWaves: (script, opts) => state.started.push({ script, opts }), + }, + ship: { x: shipPos[0], y: shipPos[1] }, + comms: { trigger: (t) => state.triggered.push(t) }, + playSfx: (n) => state.sfx.push(n), + _huntToast: (line) => state.toasts.push(String(line)), + _huntMarkName: GameScene.prototype._huntMarkName, + _fireBountyEngagements: GameScene.prototype._fireBountyEngagements, + state, + }; + return scene; + }; + const markPlan = { + down: false, + bountyAt: 0, + pickets: [false], + skirmishCleared: [], + raiders: 8, + eliteType: 'gunner', + mark: { name: 'Vex Korrath', epithet: 'the Wrecked' }, + }; + + // THE MARK — its leg is logged and the ship stands on it: the fight + // stands up (the elite deploys with the opening group) and the bounty + // attack comm is armed in the same breath. + const markRoute = { kind: 'bounty', targetSystemId: 'sys', visited: [0], waypoints: [[100, 100]], bounty: markPlan }; + const scene = makeHuntScene([100, 100]); + const fired = scene._fireBountyEngagements({ bountySkirmishCleared: () => {} }, 'sq-bounty-0', markRoute, 420); + check('the mark branch stands up the fight (one rolling wave through startWaves)', fired === true && scene.state.started.length === 1); + check('the mark deploys inside the wave (the elite block is the dossier frame)', !!scene.state.started[0]?.script?.waves?.[0]?.elite); + check('the bounty attack comm is armed the moment the mark stands up', scene.state.triggered.includes('pirateBountyAttack')); + check('…exactly once per engagement that stands', scene.state.triggered.filter((t) => t === 'pirateBountyAttack').length === 1); + + // THE PICKET WAVE — a pre-bounty leg's skirmish throws no mark (the mark + // is the fight): its fight stands up WITHOUT the bounty attack comm. + const picketRoute = { kind: 'bounty', targetSystemId: 'sys', visited: [0], waypoints: [[100, 100], [900, 900]], bounty: { ...markPlan, bountyAt: 1, pickets: [true] } }; + const sceneP = makeHuntScene([100, 100]); + const firedP = sceneP._fireBountyEngagements({ bountySkirmishCleared: () => {} }, 'sq-bounty-0', picketRoute, 420); + check('the picket branch stands up its skirmish (startWaves)', firedP === true && sceneP.state.started.length === 1); + check('the picket skirmish carries no elite (the mark is the fight)', !sceneP.state.started[0]?.script?.waves?.[0]?.elite); + check('…and it does NOT arm the bounty attack comm', !sceneP.state.triggered.includes('pirateBountyAttack')); + + // THE COMMS ITSELF — the trigger arms exactly the bounty comm (the box's + // clip is the bounty clip, the voice one of the four bounty clips, the + // line the bounty line — pinned in dev/comms.test.mjs's content checks). + const armed = (configData.comms?.comms ?? []).filter((c) => c.trigger === 'pirateBountyAttack'); + check('the trigger arms exactly one comm (pirate-bounty-attack, the bounty face)', + armed.length === 1 && armed[0].id === 'pirate-bounty-attack' && armed[0].from === 'pirate_bounty'); +} + // ========================================================================== if (failures > 0) { console.error(`\n${failures} bounty hunt check(s) FAILED`); diff --git a/dev/combat.test.mjs b/dev/combat.test.mjs index 41006eb..d0336ae 100644 --- a/dev/combat.test.mjs +++ b/dev/combat.test.mjs @@ -1349,6 +1349,98 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d); check('waves/system: the demo fires 7 starts + 7 clears', events.start === 7 && events.clear === 7); } +// -------------------------------------------------------------- comm rolls +// The comming type's VOICE (data/combat.json → enemyTypes.raider.comms; the +// comms themselves are data/comms.json's pirate-attack / pirate-defeat — +// the clip + the random voice + the line): the 50% ON-SCREEN attack roll +// (once per pirate, on its first fly-in), the 20% ROLLING-ARRIVAL attack +// roll (a new pirate sent into a rolling wave), and the 20% DEFEAT roll +// on the kill. The dice are CombatSystem's, the seam is o.onComm (the +// scene's CommsHub trigger). The rolls ride Math.random, so the checks +// stub it (hit = always < chance, miss = always >= chance) and restore it. +{ + const scene = makeScene({ withSheet: true }); + const ship = scene.physics.add.existing(new PhaserStub.Physics.Arcade.Sprite(scene, 0, 0, '__ship', 0)); + ship.radius = 12; + ship.stop = () => {}; + const comms = []; + const combat = new CombatSystem(scene, { ship, onComm: (trigger) => comms.push(trigger) }); + + const realRandom = Math.random; + const rollHit = () => { Math.random = () => 0; }; // every roll succeeds (0 < chance) + const rollMiss = () => { Math.random = () => 0.999; }; // every roll fails (>= chance) + const count = (name) => comms.filter((c) => c === name).length; + try { + // The config rides the TYPE (data/combat.json → enemyTypes): the raider + // (the pirate) carries the block; a type without one rides commless. + const pirate = combat.spawnEnemy(100, 100, 0, 'raider'); + const bomber = combat.spawnEnemy(200, 200, 1, 'bomber'); + check('comms: the raider carries its comm rolls (the pirate)', + pirate.comms?.attack?.trigger === 'pirateAttack' && pirate.comms?.defeat?.trigger === 'pirateDefeat'); + check('comms: a type without a comms block rides commless (the rolls no-op)', + bomber.comms === null); + + // ON-SCREEN (50%): headless has no camera, so inView is a no-op — the + // craft is "on screen" from its first frame, and the first update is + // its fly-in. A forced hit arms the attack trigger… + rollHit(); + combat.update(1000, 16); + check('comms: the on-screen roll arms the attack comm (the 50% hit)', + count('pirateAttack') === 1); + // …and a pirate rolls it ONCE (the edge is spent — no second line). + combat.update(1016, 16); + check('comms: the on-screen roll fires once per pirate', count('pirateAttack') === 1); + // …while a forced miss arms nothing. + rollMiss(); + const raider2 = combat.spawnEnemy(300, 300, 2, 'raider'); + combat.update(1032, 16); + check('comms: the on-screen roll\'s miss arms nothing (the 50% miss)', + count('pirateAttack') === 1 && raider2._commedAttack === false); + + // DEFEAT (20%): a downed pirate\'s line — the forced hit arms the defeat + // trigger; the commless bomber\'s death stays quiet. + rollHit(); + pirate.damage(9999); + combat.killEnemy(pirate, 2000); + check('comms: the defeat roll arms the defeat comm (the 20% hit)', + count('pirateDefeat') === 1); + rollMiss(); + bomber.damage(9999); + combat.killEnemy(bomber, 2016); + check('comms: a commless type\'s death arms no defeat comm', count('pirateDefeat') === 1); + + // ROLLING ARRIVAL (20%): a NEW pirate sent into a rolling wave (the + // opening burst, then each kill\'s replacement) rolls its own attack + // comm on spawn — a hit marks the ship, so the on-screen roll (which + // the next update would otherwise run) can\'t stack on top. + const atkBefore = count('pirateAttack'); + const defBefore = count('pirateDefeat'); + rollHit(); + combat.startWaves( + { waves: [{ kind: 'rolling', roster: { raider: 1 }, total: 2, maxConcurrent: 1, replacePerKill: 1 }], lead: 0, gap: 0 }, + {}, + ); + combat.update(3000, 16); + const live = combat.enemies.filter((e) => e.wave && !e.dead); + check('comms: the rolling opening burst comms on arrival (the 20% hit)', + live.length === 1 && count('pirateAttack') === atkBefore + 1); + check('comms: a hit arrival marks the ship (the on-screen roll can\'t stack)', + live.length === 1 && live[0]._commedAttack === true); + live[0].damage(9999); + combat.killEnemy(live[0], 3016); + combat.update(3032, 16); + const replaced = combat.enemies.filter((e) => e.wave && !e.dead); + check('comms: the rolling replacement deploys + comms on arrival', + replaced.length === 1 && replaced[0]._commedAttack === true && + count('pirateAttack') === atkBefore + 2); + check('comms: the arrival pirate\'s defeat rides its 20% roll (hit)', + count('pirateDefeat') === defBefore + 1); + combat.cancelWaves(true); + } finally { + Math.random = realRandom; + } +} + // ------------------------------------------------------------------ done console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`); diff --git a/dev/comms.test.mjs b/dev/comms.test.mjs index c10931b..ca173bc 100644 --- a/dev/comms.test.mjs +++ b/dev/comms.test.mjs @@ -89,14 +89,25 @@ const carol = charEntry(cfg, 'carol'); check('Carol is registered (label + role + her own clip)', !!carol && carol.label === 'CAROL' && !!carol.role && carol.video === 'assets/videos/characters/carol.mp4'); check('Carol\'s space clip ships on disk', existsSync(join(root, 'assets/videos/characters/carol.mp4'))); +const pirate = charEntry(cfg, 'pirate'); +check('the pirate is registered (label + role + the attack clip)', !!pirate && pirate.label === 'PIRATE' && !!pirate.role && pirate.video === 'assets/videos/enemies/pirate.mp4'); +check('the pirate\'s clip ships on disk', existsSync(join(root, 'assets/videos/enemies/pirate.mp4'))); +const pirateDefeat = charEntry(cfg, 'pirate_defeat'); +check('the pirate\'s defeat face is registered (the dying clip)', !!pirateDefeat && pirateDefeat.label === 'PIRATE' && pirateDefeat.video === 'assets/videos/enemies/pirate-defeat.mp4'); +check('the defeat clip ships on disk', existsSync(join(root, 'assets/videos/enemies/pirate-defeat.mp4'))); +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 roster = listComms(cfg); // The shipped roster count (the data pins it: a stray unvalidated comm // landing in data/comms.json changes the number and fails this line — // 25 pre-'Pirate Problems' + the trestleQ4 warning trio (trestle-q4-1/ // 2/3, data/comms.json) + the payoff pair (trestle-q4-4/5, the // quest-complete sign-off) + the combat pair (oac-enemies-detected / -// oac-enemies-defeated) = 32). -check('the roster holds exactly the shipped comms', roster.length === 32); +// 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); const byId = Object.fromEntries(roster.map((c) => [c.id, c])); const intro = byId['oac-intro']; check( @@ -374,6 +385,113 @@ check( defAssets.audio?.key === audioKeyFor(defAssets.audio.url), ); +// The PIRATE'S OWN VOICE (data/combat.json → enemyTypes.raider.comms rolls +// the pirate-attack / pirate-defeat triggers; GameScene's bounty hunt arms +// pirate-bounty-attack) — the box shows the pirate's clip with the voice +// rolling one of the four clips per arm, all riding over the live fight. +const atk = byId['pirate-attack']; +check( + 'pirate-attack: the on-screen/arrival call (visual, pirateAttack, pirate, once:false)', + !!atk && atk.from === 'pirate' && atk.type === 'visual' && atk.trigger === 'pirateAttack' && atk.once === false, +); +const atkPool = atk?.audioPool ?? []; +check( + 'pirate-attack: the four attack clips (audioPool, no fixed audio)', + atk?.audio === null && + JSON.stringify(atkPool) === + JSON.stringify([ + 'assets/speech/pirates/attack-01.mp3', + 'assets/speech/pirates/attack-02.mp3', + 'assets/speech/pirates/attack-03.mp3', + 'assets/speech/pirates/attack-04.mp3', + ]), +); +check('pirate-attack: every pool clip ships on disk', atkPool.every((f) => existsSync(join(root, f)))); +check('pirate-attack: fires AT the fight (delayMs === 0)', (atk?.delayMs ?? -1) === 0); +check( + 'pirate-attack: a played call RE-arms (once:false — every roll that hits)', + commsForTrigger(cfg, 'pirateAttack', new Set(['pirate-attack'])).length === 1, +); +const atkAssets = commAssets(cfg, atk); +check( + 'pirate-attack assets: the attack clip + the pool, keyed', + atkAssets.video?.key === 'comms_vid_pirate' && + atkAssets.video.url === 'assets/videos/enemies/pirate.mp4' && + atkPool.includes(atkAssets.audio?.url) && + atkAssets.audio?.key === audioKeyFor(atkAssets.audio.url), +); +const dft = byId['pirate-defeat']; +check( + 'pirate-defeat: the kill sign-off (visual, pirateDefeat, pirate_defeat, once:false)', + !!dft && dft.from === 'pirate_defeat' && dft.type === 'visual' && dft.trigger === 'pirateDefeat' && dft.once === false, +); +const dftPool = dft?.audioPool ?? []; +check( + 'pirate-defeat: the four defeat clips (audioPool, no fixed audio)', + dft?.audio === null && + JSON.stringify(dftPool) === + JSON.stringify([ + 'assets/speech/pirates/defeat-01.mp3', + 'assets/speech/pirates/defeat-02.mp3', + 'assets/speech/pirates/defeat-03.mp3', + 'assets/speech/pirates/defeat-04.mp3', + ]), +); +check('pirate-defeat: every pool clip ships on disk', dftPool.every((f) => existsSync(join(root, f)))); +check('pirate-defeat: fires AT the kill (delayMs === 0)', (dft?.delayMs ?? -1) === 0); +check( + 'pirate-defeat: a played sign-off RE-arms (once:false — every roll that hits)', + commsForTrigger(cfg, 'pirateDefeat', new Set(['pirate-defeat'])).length === 1, +); +const dftAssets = commAssets(cfg, dft); +check( + 'pirate-defeat assets: the dying clip + the pool, keyed', + dftAssets.video?.key === 'comms_vid_pirate_defeat' && + dftAssets.video.url === 'assets/videos/enemies/pirate-defeat.mp4' && + dftPool.includes(dftAssets.audio?.url) && + dftAssets.audio?.key === audioKeyFor(dftAssets.audio.url), +); +const bty = byId['pirate-bounty-attack']; +check( + 'pirate-bounty-attack: the mark\'s call (visual, pirateBountyAttack, pirate_bounty, once:false)', + !!bty && bty.from === 'pirate_bounty' && bty.type === 'visual' && bty.trigger === 'pirateBountyAttack' && bty.once === false, +); +const btyPool = bty?.audioPool ?? []; +check( + 'pirate-bounty-attack: the four bounty clips (audioPool, no fixed audio)', + bty?.audio === null && + JSON.stringify(btyPool) === + JSON.stringify([ + 'assets/speech/pirates/bounty-01.mp3', + 'assets/speech/pirates/bounty-02.mp3', + 'assets/speech/pirates/bounty-03.mp3', + 'assets/speech/pirates/bounty-04.mp3', + ]), +); +check('pirate-bounty-attack: every pool clip ships on disk', btyPool.every((f) => existsSync(join(root, f)))); +check('pirate-bounty-attack: fires AT the engagement (delayMs === 0)', (bty?.delayMs ?? -1) === 0); +check( + 'pirate-bounty-attack: a played call RE-arms (once:false — every engagement that stands)', + commsForTrigger(cfg, 'pirateBountyAttack', new Set(['pirate-bounty-attack'])).length === 1, +); +const btyAssets = commAssets(cfg, bty); +check( + 'pirate-bounty-attack assets: the bounty clip + the pool, keyed', + btyAssets.video?.key === 'comms_vid_pirate_bounty' && + btyAssets.video.url === 'assets/videos/enemies/pirate-bounty.mp4' && + btyPool.includes(btyAssets.audio?.url) && + btyAssets.audio?.key === audioKeyFor(btyAssets.audio.url), +); +// The scene seam — the mark's fight stands up ONLY in the bounty branch of +// GameScene._fireBountyEngagements (the picket skirmish throws no mark): +// the trigger is armed there, and nowhere else. +const bountySrc = (await import('node:fs')).readFileSync(join(root, 'js/scenes/GameScene.js'), 'utf8'); +check( + 'GameScene arms pirateBountyAttack in the mark branch (and nowhere else)', + bountySrc.split("trigger('pirateBountyAttack')").length - 1 === 1 && + bountySrc.indexOf("trigger('pirateBountyAttack')") > bountySrc.indexOf('_fireBountyEngagements'), +); + // The 'Leaving the Nest' beats — Trestle's THREE-BRIEF sequence (the // home gates go online; ALL THREE in the run's comms played-ledger are // the quest's 'Talk to Trestle' witness — GameScene._trestleCheck) and diff --git a/js/combat/CombatSystem.js b/js/combat/CombatSystem.js index 65f3805..9b84ed5 100644 --- a/js/combat/CombatSystem.js +++ b/js/combat/CombatSystem.js @@ -47,6 +47,15 @@ const TAU = Math.PI * 2; * the kills feed the rolling replacements. The QUEST * HOOK THAT FIRES startWaves LANDS LATER; until then * the console drives it (GameScene → __ORBIT_COMBAT__.waves)) + * - the PIRATE COMMS (the comming types' own voice — the chances are the + * type's, data/combat.json → enemyTypes..comms): + * a 50% roll the first time a pirate flies on-screen, + * a 20% roll when a new pirate is sent into a rolling + * wave (the opening burst, each kill's replacement), a + * 20% defeat roll on the kill. The comms themselves + * (data/comms.json's pirate-attack / pirate-defeat — + * the clip + the random speech clip + the line) are + * armed through o.onComm (the scene's CommsHub) * * The scene supplies the player ship (a Ship, or anything with * .x/.y/.radius/.body/.stop()/.setVisible()) and calls @@ -73,6 +82,10 @@ export class CombatSystem { * onKill (enemy) => {} — a hostile went down * onLevelUp (level) => {} — a combat level landed (the feed's line; * the system's own FX/SFX/armor fire on the event) + * onComm (trigger) => {} — a comming hostile rolled one of its + * comm lines (the pirate's attack/defeat — the scene + * arms its CommsHub's trigger; js/comms; a context + * without one simply omits the seam) */ constructor(scene, o = {}) { this.scene = scene; @@ -83,6 +96,7 @@ export class CombatSystem { this.onPlayerUp = typeof o.onPlayerUp === 'function' ? o.onPlayerUp : null; 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.enemies = []; this.bullets = []; @@ -256,7 +270,14 @@ export class CombatSystem { * @param {string|null} [type] the hostile type (a combat.enemyTypes id) — null = the base */ spawnEnemy(x, y, index, type = null) { - const e = new EnemyShip(this.scene, x, y, index, enemyTypeConfig(type)); + const cfg = enemyTypeConfig(type); + const e = new EnemyShip(this.scene, x, y, index, cfg); + // The comm rolls' config (data/combat.json → enemyTypes..comms — + // the pirate's lines; null for a type without one, then every roll is + // a no-op). The roll's marks ride the ship (the comm section below). + e.comms = cfg.comms && typeof cfg.comms === 'object' ? cfg.comms : null; + e._commedAttack = false; // an attack line already fired (the rolls don't stack) + e._wasOnScreen = false; // the on-screen roll's edge (fires once, on entry) e.setDepth(10); const px = this.ship ? this.ship.x : x; const py = this.ship ? this.ship.y : y; @@ -391,6 +412,11 @@ export class CombatSystem { const e = this.spawnEnemy(px + Math.cos(a) * r, py + Math.sin(a) * r, this._spawnSerial++, typeId); e.wave = true; // owned by the run: no self-respawn, the kill feeds the runner if (elite) e.applyEliteBuff(elite); + // THE ROLLING ARRIVAL (data/combat.json → comms.attack.spawnChance): + // a NEW pirate sent into a rolling wave (the opening burst, each + // kill's replacement) rolls its own attack comm on arrival (20%) — a + // hit marks the ship, so its later on-screen roll doesn't stack on top. + if (e.comms?.attack) this._commAttackRoll(e, e.comms.attack.spawnChance); return e; } @@ -544,6 +570,15 @@ export class CombatSystem { } const wantsFire = e.update(time, delta, player); if (wantsFire && player && this.craftVisible(e)) this.fireEnemy(e, player, time); + // THE ATTACK COMM (the type's comms.attack — data/combat.json): the + // ON-SCREEN roll (50%) fires once the craft first flies into view — + // unless its arrival roll already commed it (a pirate never + // double-comms; the marks ride the ship). + if (e.comms?.attack && !e._commedAttack) { + const vis = this.craftVisible(e); + if (vis && !e._wasOnScreen) this._commAttackRoll(e, e.comms.attack.onScreenChance); + e._wasOnScreen = vis; + } } // 2) AUTO-FIRE: the nearest VISIBLE hostile in range (game.autoFire). @@ -952,6 +987,10 @@ export class CombatSystem { this.fx.explode(e.x, e.y, '#ff5d6f'); combatShake(this.scene, 'kill'); // the world kick (the bar stays still) combatSfx(this.scene, 'kill'); + // THE DEFEAT COMM (data/combat.json → comms.defeat): a downed pirate + // rolls its defeat line (20%) — the comm's beat rides the same kill + // (once per death — killEnemy is idempotent via _killFx). + this._commDefeatRoll(e); e.setVisible(false); if (e.body) { e.body.velocity.x = 0; @@ -985,9 +1024,69 @@ export class CombatSystem { e.revive(); e.nextFireAt = time + 900; e.setVisible(true); + // A new life: the comm rolls re-arm (the pirate comes back with its + // voice — the on-screen roll's edge resets with it). + e._commedAttack = false; + e._wasOnScreen = false; this._setActive(); } + // ------------------------------------------------------ the comm rolls + // + // The comming type's VOICE (the comm's CONTENT — the clip, the random + // speech, the line — is data/comms.json's, keyed by the roll's trigger + // name: 'pirateAttack' / 'pirateDefeat', the senders 'pirate' / + // 'pirate_defeat'). The chances are the type's (data/combat.json → + // enemyTypes..comms); this system only rolls the dice and hands + // the trigger to o.onComm (the scene arms its CommsHub's trigger — + // js/comms/CommsHub.js; a context without one simply doesn't pass the + // seam, and the rolls stay no-ops). The marks ride the ship + // (e._commedAttack: an attack line already fired, so the other attack + // roll doesn't stack; e._wasOnScreen: the on-screen roll's edge), so a + // pirate comms at most once per life — the arrival (20%) OR the + // on-screen (50%) roll, never both. + + /** + * 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) + * @param {number} chance comms.attack.onScreenChance / .spawnChance + * @returns {boolean} true when the comm was armed + */ + _commAttackRoll(e, chance) { + const atk = e?.comms?.attack; + if (!atk || e._commedAttack || !this.onComm) return false; + const trigger = String(atk.trigger ?? '').trim(); + const p = Math.min(1, Math.max(0, Number(chance) || 0)); + if (p === 0 || trigger === '' || Math.random() >= p) return false; + e._commedAttack = true; // committed — the other attack roll is spent + try { + this.onComm(trigger); + } catch (err) { + if (typeof console !== 'undefined') console.warn('[combat] onComm hook failed', err); + } + return true; + } + + /** + * The DEFEAT roll (comms.defeat — once per death: killEnemy is + * idempotent via _killFx, so a downed pirate gets at most one line). + * @param {object} e the downed hostile + * @returns {boolean} true when the comm was armed + */ + _commDefeatRoll(e) { + const def = e?.comms?.defeat; + if (!def || !this.onComm) return false; + const trigger = String(def.trigger ?? '').trim(); + const p = Math.min(1, Math.max(0, Number(def.chance) || 0)); + if (p === 0 || trigger === '' || Math.random() >= p) return false; + try { + this.onComm(trigger); + } catch (err) { + if (typeof console !== 'undefined') console.warn('[combat] onComm hook failed', err); + } + return true; + } + // ------------------------------------------------------------- the bar /** diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 30e5baf..b66b92c 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -1052,6 +1052,13 @@ export class GameScene extends Phaser.Scene { this.combatHud?.kill('HOSTILE DESTROYED'); this.handleCombatKill(e); }, + // THE PIRATE'S COMM LINES (data/combat.json → enemyTypes.raider.comms + // rolls in CombatSystem — 50% on-screen / 20% rolling-arrival for the + // attack, 20% for the defeat; the comms themselves are data/comms.json's + // pirate-attack / pirate-defeat — the clip + the random voice + the + // line): arm the hub's trigger (js/comms — the box plays the clip + // muted with the speech and the message). + onComm: (trigger) => this.comms?.trigger(trigger), // A combat level landed (the system fires its own burst + voice + // the armor bump on the manager's 'levelup' event — the skill // layer will subscribe to that same event later): the feed's line. @@ -5498,6 +5505,12 @@ export class GameScene extends Phaser.Scene { epithet: String(plan.mark?.epithet ?? '').toUpperCase(), n: String(total), }), '\u2620'); + // THE MARK'S OWN VOICE — the bounty attack comm (data/comms.json → + // pirate-bounty-attack: the bounty clip, assets/videos/enemies/ + // pirate-bounty.mp4, + one of the four bounty voice clips + the line): + // armed the moment the wanted leader's fight stands up — every time a + // bounty ship enters combat (a jump out mid-fight re-arms it on return). + this.comms?.trigger('pirateBountyAttack'); this.combat.startWaves(script, {}); // NO onComplete witness — the kill is the witness (handleCombatKill) return true; }