From 28fdfdb741ae8dfcf20dde29763b668aff2cf502 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Wed, 16 Sep 2026 20:35:03 -0600 Subject: [PATCH] Simplify pirate ambush spawn to off-screen around player - Remove the gate-to-capital midpoint origin logic (_pirateAmbushOrigin) and the startWaves opts.origin seam; wave ships now materialize off-screen around the player from a random direction, matching the ?combat pack. - Update combat.json, comms.json, quests.json comments to reflect the new spawn behavior (off-screen around player, streaming in on chase AI). --- data/combat.json | 2 +- data/comms.json | 2 +- data/quests.json | 2 +- js/combat/CombatSystem.js | 44 ++++++++--------------- js/scenes/GameScene.js | 73 ++++++++++----------------------------- 5 files changed, 36 insertions(+), 87 deletions(-) diff --git a/data/combat.json b/data/combat.json index 315a3f1..7382085 100644 --- a/data/combat.json +++ b/data/combat.json @@ -116,7 +116,7 @@ ], "pirate_ambush": [ { - "$comment": "'Pirate Problems' (data/quests.json → pirate_problems) — the quest's combat step ('Defend your ship from pirates'): ONE rolling wave of SIX frame-0 raiders (EnemyTypes: raider = the sheet's frame 0). Fired when the player ENTERS the Neutral Capital's system with the quest live (GameScene._maybeStartPirateAmbush — create() and the grant's start beats), materializing at the MIDPOINT between the entry gate (the system's gate nearest the ship's arrival position) and the capital planet — the run.origin seam (startWaves' opts.origin), three at a time (maxConcurrent), each kill sending in a replacement (replacePerKill) until all six are down; the completion marks the 'combatDone' ledger (GameScene._recordCombatDone — the step's monotonic witness, SaveData-captured).", + "$comment": "'Pirate Problems' (data/quests.json → pirate_problems) — the quest's combat step ('Defend your ship from pirates'): ONE rolling wave of SIX frame-0 raiders (EnemyTypes: raider = the sheet's frame 0). Fired when the player ENTERS the Neutral Capital's system with the quest live (GameScene._maybeStartPirateAmbush — create() and the grant's start beats); the wave ships materialize OFF-SCREEN around the player, each from a random direction (CombatSystem._spawnWaveEnemy — the same off-screen materialization as the ?combat pack), streaming in on their own chase AI; three at a time (maxConcurrent), each kill sending in a replacement (replacePerKill) until all six are down; the completion marks the 'combatDone' ledger (GameScene._recordCombatDone — the step's monotonic witness, SaveData-captured).", "kind": "rolling", "type": "raider", "count": 6, diff --git a/data/comms.json b/data/comms.json index 1769c3e..1c21dd7 100644 --- a/data/comms.json +++ b/data/comms.json @@ -322,7 +322,7 @@ "delayMs": 0 }, { - "$comment": "Pirate Problems START beat 3 of 3 (trigger 'trestleQ4') — the send-back: the bar is the destination (already set by the grant's _questStartBeats) and the warning names the ambush — the pirates live in the capital's system, and the ambush itself (GameScene._maybeStartPirateAmbush → the pirate_ambush wave at the gate-to-capital midpoint, data/combat.json) is what 'all bets are off' means. Queued behind beat 2 (delayMs 0).", + "$comment": "Pirate Problems START beat 3 of 3 (trigger 'trestleQ4') — the send-back: the bar is the destination (already set by the grant's _questStartBeats) and the warning names the ambush — the pirates live in the capital's system, and the ambush itself (GameScene._maybeStartPirateAmbush → the pirate_ambush wave, data/combat.json) is what 'all bets are off' means. Queued behind beat 2 (delayMs 0).", "id": "trestle-q4-3", "from": "trestle", "type": "ack", diff --git a/data/quests.json b/data/quests.json index a48d2f0..77ea983 100644 --- a/data/quests.json +++ b/data/quests.json @@ -172,7 +172,7 @@ } }, { - "_comment": "THE FOURTH QUEST — 'Pirate Problems' (the run's third link after 'Special Delivery'). It GRANTS the moment 'Special Delivery' is COMPLETED AND CLAIMED AND its payoff chain is HEARD ALL THE WAY THROUGH (unlocks.claimed + unlocks.acked — the deliveredLaunch four-beat: carol-delivered-1/2/3 + oac-delivered-1, every one of them ACKed, not merely started — the run's comms acked-ledger, registry 'commsAcked', owned by js/comms/CommsHub.js's onAck hook; GameScene._grantUnlockedQuests supplies it to QuestState.isUnlockMet, so the grant lands on whichever of the two conditions completes last: the claim (claimQuest) or the final ACK (_commEnded's grant pass)). On its grant the run turns: the player's DESTINATION is set to the Neutral Capital world (GameScene._questStartBeats) and Trestle's three-beat warning plays (data/comms.json → trigger 'trestleQ4' — trestle-q4-1/2/3 on trestle-q4-01/02/03.mp3; the 'Speak with Trestle' step's witness is that sequence in the played-ledger, the same _trestleCheck seam as 'Leaving the Nest'). Then the run's story turns on the pirates: ENTERING the capital's system with the quest live, the ambush fires — ONE rolling wave of SIX frame-0 raiders (data/combat.json → waves.scripts.pirate_ambush; EnemyTypes: raider = frame 0) materializing at the MIDPOINT between the entry gate (the system's gate nearest the ship's arrival — jumpThroughGate parks the ship just past it) and the capital planet (GameScene._maybeStartPirateAmbush → CombatSystem.startWaves' run.origin seam); the wave's completion marks the 'combatDone' ledger (registry, SaveData-captured) — the 'Defend your ship from pirates' step's monotonic witness. 'Return to the Neutral Capital World' completes on the ARRIVAL itself — the ship within the capital world's keep-out rim with the quest held + unclaimed sets story.capitalReturned (GameScene._maybeWitnessCapitalReturn; the story ledger is SaveData-captured, monotonic). The bar's follow-up conversation is a later layer (the arrival is the step; the scene lands on it).", + "_comment": "THE FOURTH QUEST — 'Pirate Problems' (the run's third link after 'Special Delivery'). It GRANTS the moment 'Special Delivery' is COMPLETED AND CLAIMED AND its payoff chain is HEARD ALL THE WAY THROUGH (unlocks.claimed + unlocks.acked — the deliveredLaunch four-beat: carol-delivered-1/2/3 + oac-delivered-1, every one of them ACKed, not merely started — the run's comms acked-ledger, registry 'commsAcked', owned by js/comms/CommsHub.js's onAck hook; GameScene._grantUnlockedQuests supplies it to QuestState.isUnlockMet, so the grant lands on whichever of the two conditions completes last: the claim (claimQuest) or the final ACK (_commEnded's grant pass)). On its grant the run turns: the player's DESTINATION is set to the Neutral Capital world (GameScene._questStartBeats) and Trestle's three-beat warning plays (data/comms.json → trigger 'trestleQ4' — trestle-q4-1/2/3 on trestle-q4-01/02/03.mp3; the 'Speak with Trestle' step's witness is that sequence in the played-ledger, the same _trestleCheck seam as 'Leaving the Nest'). Then the run's story turns on the pirates: ENTERING the capital's system with the quest live, the ambush fires — ONE rolling wave of SIX frame-0 raiders (data/combat.json → waves.scripts.pirate_ambush; EnemyTypes: raider = frame 0), the wave ships materializing OFF-SCREEN around the player, each from a random direction (CombatSystem._spawnWaveEnemy — the same off-screen materialization as the ?combat pack), streaming in on their own chase AI (GameScene._maybeStartPirateAmbush fires the run); the wave's completion marks the 'combatDone' ledger (registry, SaveData-captured) — the 'Defend your ship from pirates' step's monotonic witness. 'Return to the Neutral Capital World' completes on the ARRIVAL itself — the ship within the capital world's keep-out rim with the quest held + unclaimed sets story.capitalReturned (GameScene._maybeWitnessCapitalReturn; the story ledger is SaveData-captured, monotonic). The bar's follow-up conversation is a later layer (the arrival is the step; the scene lands on it).", "id": "pirate_problems", "category": "main", "icon": "diamond", diff --git a/js/combat/CombatSystem.js b/js/combat/CombatSystem.js index 978fed3..b538b57 100644 --- a/js/combat/CombatSystem.js +++ b/js/combat/CombatSystem.js @@ -210,15 +210,13 @@ export class CombatSystem { * (index, wave), onComplete(status) — the quest hook's 'combat over' * seam — and onCancel(). * + * Every wave ship — the first burst (fixed AND rolling) and the rolling + * replacements alike — materializes OFF-SCREEN around the player, from + * a random direction (_spawnWaveEnemy), and streams in on its own chase + * AI: the wave never pops on top of the player. + * * @param {Array|object|string} script the wave script (data/combat.json → waves) - * @param {object} [opts] the callbacks (above) + `origin {x,y}` — when - * set, the run's ships materialize in a tight ring AROUND THAT POINT - * instead of off-screen around the ship (the quest ambush's seam: - * 'Pirate Problems'' wave sits at the gate-to-capital midpoint — - * GameScene._maybeStartPirateAmbush — and converges on the player on - * its own chase AI); the run's `origin` is read by _spawnWaveEnemy - * (the first burst AND the rolling replacements — the ambush keeps - * coming from where it was waiting). + * @param {object} [opts] the callbacks (above) * @returns {object|null} the run (query via waveStatus()) — null on a bad script */ startWaves(script, opts = {}) { @@ -229,16 +227,11 @@ export class CombatSystem { } this.cancelWaves(true); // a new fight replaces the old one (its ships go down) const cb = (f) => (typeof f === 'function' ? f : null); - const origin = - opts.origin && Number.isFinite(opts.origin.x) && Number.isFinite(opts.origin.y) - ? { x: opts.origin.x, y: opts.origin.y } - : null; this.waveRun = { runner: new WaveRunner(norm), state: 'lead', nextAt: null, // set on the first tick (the clock is the scene's update time) lead: norm.lead, - origin, // null = off-screen around the ship (the pack's behavior) onWaveStart: cb(opts.onWaveStart), onWaveClear: cb(opts.onWaveClear), onComplete: cb(opts.onComplete), @@ -289,28 +282,21 @@ export class CombatSystem { } /** - * One wave ship, bearing in on its own chase AI. Default: off-screen - * around the ship (_offscreenRadius's band — the same off-screen - * materialization as the ?combat pack). When the run's `origin` stands - * (startWaves' opts.origin — the quest ambush), a tight ring around - * THAT point instead: the ambush's point is far off the ship's camera - * by definition (the midpoint between the entry gate and the capital - * planet), so the ring still materializes off-screen, and the chase - * AI streams the wave in. + * One wave ship, bearing in on its own chase AI. It materializes + * OFF-SCREEN around the PLAYER — a random direction from the ship at + * _offscreenRadius's band (the same off-screen materialization as the + * ?combat pack): neither the first burst (fixed or rolling) nor the + * rolling replacements ever pop on top of the player; the chase AI + * streams each one in from whichever way it came out. * @param {string} typeId the hostile type to send (a combat.enemyTypes id) * @returns {EnemyShip} the fresh hostile (tagged `wave`) */ _spawnWaveEnemy(typeId) { - const run = this.waveRun; const px = this.ship ? this.ship.x : 0; const py = this.ship ? this.ship.y : 0; - const a = Math.random() * TAU; - const r = run?.origin - ? 70 * (0.6 + Math.random() * 0.8) // the ambush ring (a cluster, not a band) - : this._offscreenRadius() * (0.9 + Math.random() * 0.2); - const ox = run?.origin ? run.origin.x : px; - const oy = run?.origin ? run.origin.y : py; - const e = this.spawnEnemy(ox + Math.cos(a) * r, oy + Math.sin(a) * r, this._spawnSerial++, typeId); + const a = Math.random() * TAU; // a random direction from the player + const r = this._offscreenRadius() * (0.9 + Math.random() * 0.2); + 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 return e; } diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index f678a68..5c41ab5 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -912,7 +912,9 @@ export class GameScene extends Phaser.Scene { // ENTER of the Neutral Capital's system — GameScene. // _maybeStartPirateAmbush fires the pirate_ambush wave run (data/ // combat.json → waves.scripts.pirate_ambush — one rolling wave of six - // frame-0 raiders) at the gate-to-capital midpoint, and the deck swap, + // frame-0 raiders, materializing off-screen around the player from a + // random direction each and streaming in on their chase AI), and the + // deck swap, // the readability layer, the auto-fire, and the armor are all the // same shared parts (the ?combat sandbox is where the numbers get // tuned). data/combat.json (game.*): the ?combat boot adds the hostile @@ -1782,13 +1784,13 @@ export class GameScene extends Phaser.Scene { this._trestleQ4Beats(); // The 'Pirate Problems' AMBUSH — this is the ENTER of the Neutral // Capital's system (the scene was just built for it): with the quest - // live and its combat step open, the pirate wave starts at the - // midpoint between the entry gate and the capital planet (the ship's - // arrival position stands just past that gate, so the nearest gate - // is the entry gate — _maybeStartPirateAmbush's gate). The other - // arm is the grant itself (a save whose grant landed while the - // player was already IN the system — the same method is a no-op - // there only when the quest isn't live or the wave already ran). + // live and its combat step open, the pirate wave starts — its ships + // materialize OFF-SCREEN around the player, each from a random + // direction (CombatSystem._spawnWaveEnemy), and converge on their + // own chase AI. The other arm is the grant itself (a save whose + // grant landed while the player was already IN the system — the + // same method is a no-op there only when the quest isn't live or + // the wave already ran). this._maybeStartPirateAmbush(); } @@ -4664,14 +4666,14 @@ export class GameScene extends Phaser.Scene { /** * The 'Pirate Problems' AMBUSH — the quest's 'Defend your ship from * pirates' step: when the player ENTERS the Neutral Capital's system - * with the quest live (held + unclaimed) and its combat step still - * open, the pirate wave starts — ONE rolling wave of six frame-0 - * raiders (data/combat.json → waves.scripts.pirate_ambush) — at the - * MIDPOINT between the ENTRY GATE and the capital planet - * (_pirateAmbushOrigin: the gate nearest the ship's arrival position - * is the entry gate — jumpThroughGate parks the ship just past it), - * via startWaves' origin seam (three at a time, each kill feeding a - * replacement until all six are down). Two arms call this: + * (the gate-to-capital stretch — 'all bets are off' out here) with the + * quest live (held + unclaimed) and its combat step still open, the + * pirate wave starts — ONE rolling wave of six frame-0 raiders + * (data/combat.json → waves.scripts.pirate_ambush), three at a time, + * each kill feeding a replacement until all six are down. The wave + * ships materialize OFF-SCREEN around the player, each from a random + * direction (CombatSystem._spawnWaveEnemy), and stream in on their + * own chase AI. Two arms call this: * · create()'s tail — the ENTER of the system (the normal case); * · _questStartBeats — a grant that lands while the player is * already in the system (a late claim out there). @@ -4697,50 +4699,11 @@ export class GameScene extends Phaser.Scene { const run = this.combat.waveRun; if (run && run.state !== 'done' && run.state !== 'cancelled') return; // in flight if (this.galaxy?.neutralCapitalTarget?.()?.systemId !== this.systemRecord?.id) return; - const origin = this._pirateAmbushOrigin(); - if (!origin) return; // no entry gate to anchor the midpoint on this.combat.startWaves(script, { - origin, // the ambush point (the gate-to-capital midpoint) onComplete: () => this._recordCombatDone(script), }); } - /** - * The ambush's point: the MIDPOINT between the entry jump gate and - * the Neutral Capital planet (the quest's combat step — the wave - * materializes in a ring around it and converges on the player on - * its chase AI). The ENTRY GATE is inferred as the system's gate - * NEAREST THE SHIP'S ARRIVAL POSITION — jumpThroughGate parks the - * ship just past the return gate (gates.jump.arrivalGap), so on an - * enter the nearest gate is the one the ship came in through (the - * capital system's other gates, if the roster gave it any, sit far - * off). Null when the capital target doesn't resolve in this system - * or the system holds no gate (a degenerate anchor — the ambush - * waits for a real enter). - * @returns {{x: number, y: number}|null} the midpoint (or null) - */ - _pirateAmbushOrigin() { - const cap = this.galaxy?.neutralCapitalTarget?.(); - if (!cap?.objectId || cap.systemId !== this.systemRecord?.id) return null; - const planet = (this.systemPlanets ?? []).find((p) => p.discoveryId === cap.objectId); - if (!planet || !this.ship) return null; - const gates = (this.systemGates ?? []).filter( - (g) => g && Number.isFinite(g.x) && Number.isFinite(g.y), - ); - if (!gates.length) return null; - let best = null; - let bestD = Infinity; - for (const g of gates) { - const d = (g.x - this.ship.x) ** 2 + (g.y - this.ship.y) ** 2; - if (d < bestD) { - bestD = d; - best = g; - } - } - if (!best) return null; - return { x: (best.x + planet.x) / 2, y: (best.y + planet.y) / 2 }; - } - /** * The 'Pirate Problems' RETURN witness — the step's 'Return to the * Neutral Capital World': the moment the ship is at the capital