Add combat comms, block jump/landing during fights, and retime pirate am
- Add OAC "enemies detected" / "enemies defeated" visual comms with random voice pools, fired on CombatSystem active-flag flips - Deny jumping or landing while a fight is live so the wave run can't be escaped or hidden on a surface - Move the 'Pirate Problems' ambush trigger to the midpoint of the entry-gate → capital-world stretch, persisting lastJumpFrom through saves for a stable midpoint
This commit is contained in:
parent
63bb6508bb
commit
d61f2d6934
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
|
|
@ -56,7 +56,8 @@
|
|||
"jumpDelayMs": 420,
|
||||
"toast": "JUMP — {dest}",
|
||||
"dormantToast": "JUMP GATE DORMANT — CHART {system} TO ACTIVATE ITS JUMP GATES",
|
||||
"miningToast": "CANNOT JUMP WHILE MINING"
|
||||
"miningToast": "CANNOT JUMP WHILE MINING",
|
||||
"combatToast": "CANNOT JUMP WHILE UNDER ATTACK"
|
||||
},
|
||||
"route": {
|
||||
"_comment": "ROUTE (js/galaxy/Route.js + GameScene): the player's plotted course to a SET DESTINATION (SYSTEM tab of the MAP console). The run persists only the destination (systemId + objectId); the path from the current system is derived (planRoute — the jump network is a spanning tree, so it is the unique route). While a route is active, the compass marks the NEXT STOP in ORANGE (compassColor), replacing that object's ordinary arrow, and shows it even while still UNDISCOVERED (it is the thing to find): in an intermediate system the next stop is that system's jump gate toward the destination (typeLabel 'Route Gate'); in the DESTINATION system the next stop is the destination OBJECT itself (destTypeLabel) — the last hop is across the system to the world, and it stays orange until the ship actually reaches it (GameScene._checkDestinationReached fires reachedToast, then the route clears). The NEXT STOP in this system ALSO wears an in-world orange pulse the moment it is on screen (js/entities/RoutePulse.js — GameScene marks it each frame): the FULL pulse on the route GATE (pulse), the SAME pulse SUBDUED on the destination OBJECT in the destination system (objectPulse). A jump to any other system re-plots from the detour (replotToast). A destination with NO specific object clears the moment its system is entered. setToast fires when the destination is set ({dest}/{hops}).",
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@
|
|||
"greyed out in comms, same as a reputation ban)."
|
||||
],
|
||||
"enabled": true,
|
||||
"combatDenyToast": {
|
||||
"_comment": "The COMBAT gate (js/scenes/GameScene.js → startLanding): a live fight (CombatSystem.active — a wave run in flight or hostiles up) holds the ship in the air — landing would sleep the flight scene under the wave and let the player hide on the surface, so a landing request mid-combat is a flat denial on the console (the comms panel already closed — the toast is the ack; the ✗ mark reads the deny). Same shape as the capital's claim gate (data/quests.json → capitalDenyToast).",
|
||||
"glyph": "✗",
|
||||
"color": "#ff5c77",
|
||||
"text": "CANNOT LAND WHILE UNDER ATTACK"
|
||||
},
|
||||
"videoBase": "assets/videos/",
|
||||
"volume": 1,
|
||||
"_hud_comment": "The upper-left HUD while the surface clip loops: the planet's NAME (Ethnocentric, the SAME shade the menu's game title uses — data/menu.json → colors.title — with a black stroke behind it so it holds over any clip) and, beneath it in the body face (Centauri), the planet TYPE + TETHER LEVEL (\"Terran World, Tether Level 1\"). Both decode in with the shared scramble (the name first). marginX/marginY = corner offsets (px); name/line = font sizes, letter spacing (px), stroke color/thickness, and line color (the theme's dim by default).",
|
||||
|
|
|
|||
|
|
@ -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), 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).",
|
||||
"_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: with the quest live, the ambush fires at the MIDPOINT of the entry-jumpgate → capital-world stretch — the player ENTERS the capital's system at the gate (a full stretch from the world), and the raiders materialize once the ship is within HALF that gate-to-world distance of the capital (GameScene._maybeStartPirateAmbush: the entry gate is the destination's return gate for the last jump's source system — the restore's lastJumpFrom, staged in jumpThroughGate and saved in captureState — falling back to the gate nearest the spawn, then the spawn itself; the trigger is dist(gate, capital)/2 via JumpTravel.ambushTriggerDistance, and update() arms it the frame the ship crosses the halfway point) — 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",
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@
|
|||
* carol-delivered-1 → carol-delivered-2 → carol-delivered-3 →
|
||||
* oac-delivered-1 (the 'Special Delivery' payoff — the launch back
|
||||
* into space after the package's home-world sale, ack) on
|
||||
* deliveredLaunch, with Carol registered as a sender);
|
||||
* deliveredLaunch, oac-enemies-detected (visual, once:false,
|
||||
* audioPool) on combatStart, and oac-enemies-defeated (visual,
|
||||
* once:false, audioPool, the 1 s delay) on combatEnd, with Carol
|
||||
* registered as a sender);
|
||||
* - the reserved triggers are RESERVED (no comm uses them yet);
|
||||
* - normalizeComm drops what can't be shown (bad type / no trigger /
|
||||
* no text for a box / no speech for an audio comm / unknown sender),
|
||||
|
|
@ -90,8 +93,9 @@ 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) = 28).
|
||||
check('the roster holds exactly the shipped comms', roster.length === 28);
|
||||
// 2/3, data/comms.json) + the combat pair (oac-enemies-detected /
|
||||
// oac-enemies-defeated) = 30).
|
||||
check('the roster holds exactly the shipped comms', roster.length === 30);
|
||||
const byId = Object.fromEntries(roster.map((c) => [c.id, c]));
|
||||
const intro = byId['oac-intro'];
|
||||
check(
|
||||
|
|
@ -300,6 +304,75 @@ check('oac-quest-new: speaks oac/quest-new.mp3', byId['oac-quest-new']?.audio ==
|
|||
check('oac-quest-new: the clip ships on disk', existsSync(join(root, byId['oac-quest-new']?.audio ?? '')));
|
||||
check('oac-quest-new: beats the grant SFX (delayMs > 0)', (byId['oac-quest-new']?.delayMs ?? 0) > 0);
|
||||
|
||||
// The combat pair — OAC's contact call the moment the fight goes live
|
||||
// and the sign-off the moment the last hostile is down (GameScene
|
||||
// ._setCombatBar — the CombatSystem active-flag flips; both once:false,
|
||||
// both voiced by an audioPool — the same contract as oac-system-complete).
|
||||
check(
|
||||
'oac-enemies-detected: the combat-start call (visual, combatStart, oac, once:false)',
|
||||
!!byId['oac-enemies-detected'] &&
|
||||
byId['oac-enemies-detected'].from === 'oac' &&
|
||||
byId['oac-enemies-detected'].type === 'visual' &&
|
||||
byId['oac-enemies-detected'].trigger === 'combatStart' &&
|
||||
byId['oac-enemies-detected'].once === false,
|
||||
);
|
||||
const detPool = byId['oac-enemies-detected']?.audioPool ?? [];
|
||||
check(
|
||||
'oac-enemies-detected: the three detected clips (audioPool, no fixed audio)',
|
||||
byId['oac-enemies-detected']?.audio === null &&
|
||||
JSON.stringify(detPool) ===
|
||||
JSON.stringify([
|
||||
'assets/speech/oac/enemies-detected-01.mp3',
|
||||
'assets/speech/oac/enemies-detected-02.mp3',
|
||||
'assets/speech/oac/enemies-detected-03.mp3',
|
||||
]),
|
||||
);
|
||||
check('oac-enemies-detected: every pool clip ships on disk', detPool.every((f) => existsSync(join(root, f))));
|
||||
check('oac-enemies-detected: fires AT the fight (delayMs === 0)', (byId['oac-enemies-detected']?.delayMs ?? -1) === 0);
|
||||
check(
|
||||
'oac-enemies-detected: a played call RE-arms (once:false — every combat start)',
|
||||
commsForTrigger(cfg, 'combatStart', new Set(['oac-enemies-detected'])).length === 1,
|
||||
);
|
||||
check(
|
||||
'oac-enemies-defeated: the combat-end sign-off (visual, combatEnd, oac, once:false)',
|
||||
!!byId['oac-enemies-defeated'] &&
|
||||
byId['oac-enemies-defeated'].from === 'oac' &&
|
||||
byId['oac-enemies-defeated'].type === 'visual' &&
|
||||
byId['oac-enemies-defeated'].trigger === 'combatEnd' &&
|
||||
byId['oac-enemies-defeated'].once === false,
|
||||
);
|
||||
const defPool = byId['oac-enemies-defeated']?.audioPool ?? [];
|
||||
check(
|
||||
'oac-enemies-defeated: the three defeated clips (audioPool, no fixed audio)',
|
||||
byId['oac-enemies-defeated']?.audio === null &&
|
||||
JSON.stringify(defPool) ===
|
||||
JSON.stringify([
|
||||
'assets/speech/oac/enemies-defeated-01.mp3',
|
||||
'assets/speech/oac/enemies-defeated-02.mp3',
|
||||
'assets/speech/oac/enemies-defeated-03.mp3',
|
||||
]),
|
||||
);
|
||||
check('oac-enemies-defeated: every pool clip ships on disk', defPool.every((f) => existsSync(join(root, f))));
|
||||
check('oac-enemies-defeated: waits 1 s for the last kill to land (delayMs === 1000)', (byId['oac-enemies-defeated']?.delayMs ?? -1) === 1000);
|
||||
check(
|
||||
'oac-enemies-defeated: a played sign-off RE-arms (once:false — every combat end)',
|
||||
commsForTrigger(cfg, 'combatEnd', new Set(['oac-enemies-defeated'])).length === 1,
|
||||
);
|
||||
const detAssets = commAssets(cfg, byId['oac-enemies-detected']);
|
||||
check(
|
||||
'oac-enemies-detected assets: the pool rolls to a shipped clip, keyed',
|
||||
detAssets.video?.key === 'comms_vid_oac' &&
|
||||
detPool.includes(detAssets.audio?.url) &&
|
||||
detAssets.audio?.key === audioKeyFor(detAssets.audio.url),
|
||||
);
|
||||
const defAssets = commAssets(cfg, byId['oac-enemies-defeated']);
|
||||
check(
|
||||
'oac-enemies-defeated assets: the pool rolls to a shipped clip, keyed',
|
||||
defAssets.video?.key === 'comms_vid_oac' &&
|
||||
defPool.includes(defAssets.audio?.url) &&
|
||||
defAssets.audio?.key === audioKeyFor(defAssets.audio.url),
|
||||
);
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ const check = (label, cond) => {
|
|||
// ----------------------------------------------------------------------
|
||||
const gates = read('gates.json');
|
||||
const tether = read('tether.json');
|
||||
const landing = read('landing.json');
|
||||
const jump = gates.jump;
|
||||
|
||||
check('jump: section present', !!jump && typeof jump === 'object');
|
||||
|
|
@ -55,13 +56,24 @@ check('jump: the toast templates keep their placeholders',
|
|||
typeof jump?.toast === 'string' && jump.toast.includes('{dest}') &&
|
||||
typeof jump?.dormantToast === 'string' && jump.dormantToast.includes('{system}') &&
|
||||
typeof jump?.miningToast === 'string' && jump.miningToast.length > 0);
|
||||
check('jump: combatToast is the live-fight denial (non-empty string)',
|
||||
typeof jump?.combatToast === 'string' && jump.combatToast.length > 0);
|
||||
check('jump: the gap keeps the ship outside the keepout with room to spare',
|
||||
Number(jump?.arrivalGap ?? -1) + Number(gates.size ?? 0) + Number(gates.shipClearance ?? 0) > Number(gates.size ?? 0) + Number(gates.shipClearance ?? 0));
|
||||
|
||||
// The COMBAT gates (GameScene.jumpThroughGate / startLanding — a live
|
||||
// fight, CombatSystem.active, denies the jump AND the landing: the run
|
||||
// can't leave the system it's fighting in, and can't hide on a surface
|
||||
// while the wave runs): the toasts' shapes.
|
||||
check('landing: combatDenyToast configured (glyph + text + color)',
|
||||
!!landing.combatDenyToast && typeof landing.combatDenyToast.glyph === 'string' &&
|
||||
typeof landing.combatDenyToast.text === 'string' && landing.combatDenyToast.text.length > 0 &&
|
||||
/^#[0-9a-fA-F]{6}$/.test(landing.combatDenyToast.color ?? ''));
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// The pure geometry (js/galaxy/JumpTravel.js)
|
||||
// ----------------------------------------------------------------------
|
||||
const { returnGateFor, arrivalPoint, jumpArrival } = await import('../js/galaxy/JumpTravel.js');
|
||||
const { returnGateFor, arrivalPoint, jumpArrival, ambushTriggerDistance } = await import('../js/galaxy/JumpTravel.js');
|
||||
|
||||
const contentA = {
|
||||
jumps: [
|
||||
|
|
@ -100,6 +112,23 @@ check('jumpArrival: { x, y, heading, gateId } for a tree-edge jump',
|
|||
check('jumpArrival: null for a one-way shortcut (the scene falls back to the origin)',
|
||||
jumpArrival(contentA, 'D', cfg) === null);
|
||||
|
||||
// The 'Pirate Problems' ambush's trigger — HALF the gate→capital stretch
|
||||
// (GameScene._maybeStartPirateAmbush fires the wave once the ship is
|
||||
// within that distance of the capital world: the midpoint of the
|
||||
// jumpgate→world line).
|
||||
const gate0 = { x: 1000, y: 2000 };
|
||||
const cap0 = { x: 4000, y: 6000 }; // 3000, 4000 apart → 5000
|
||||
check('ambushTriggerDistance: half the gate→world stretch (the ambush midpoint)',
|
||||
ambushTriggerDistance(gate0, cap0) === 2500);
|
||||
check('ambushTriggerDistance: symmetric (gate/world order is immaterial)',
|
||||
ambushTriggerDistance(cap0, gate0) === 2500);
|
||||
check('ambushTriggerDistance: null on a missing point (no gate / no capital)',
|
||||
ambushTriggerDistance(null, cap0) === null &&
|
||||
ambushTriggerDistance(gate0, null) === null &&
|
||||
ambushTriggerDistance({ x: 1 }, { y: 1 }) === null);
|
||||
check('ambushTriggerDistance: null on a zero stretch (gate on the world)',
|
||||
ambushTriggerDistance({ x: 5, y: 5 }, { x: 5, y: 5 }) === null);
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// A REAL galaxy (bootstrap config the way main.js does, from the data dir)
|
||||
// ----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -82,3 +82,26 @@ export function jumpArrival(content, fromId, cfg = {}) {
|
|||
if (!gate) return null;
|
||||
return { ...arrivalPoint(gate, cfg), gateId: gate.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* The 'Pirate Problems' ambush's TRIGGER DISTANCE — half the
|
||||
* gate→capital-world stretch (GameScene._maybeStartPirateAmbush): the
|
||||
* raiders materialise once the player is that far from the capital
|
||||
* world, i.e. at the MIDPOINT of the entry-jumpgate → world line, so
|
||||
* the conflict starts halfway to the capital from the jumpgate.
|
||||
*
|
||||
* @param {{x:number,y:number}|null} gate the entry gate (a content
|
||||
* record, or any {x, y} anchor — the scene's spawn fallback)
|
||||
* @param {{x:number,y:number}|null} world the capital world
|
||||
* @returns {number|null} dist(gate, world) / 2, or null when either
|
||||
* point is missing or the stretch is degenerate (zero)
|
||||
*/
|
||||
export function ambushTriggerDistance(gate, world) {
|
||||
const gx = Number(gate?.x);
|
||||
const gy = Number(gate?.y);
|
||||
const wx = Number(world?.x);
|
||||
const wy = Number(world?.y);
|
||||
if (![gx, gy, wx, wy].every(Number.isFinite)) return null;
|
||||
const d = Math.hypot(gx - wx, gy - wy);
|
||||
return d > 0 ? d / 2 : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,6 +118,17 @@ export function captureState(scene, now) {
|
|||
galaxyName: galaxy.name,
|
||||
currentSystemId: galaxy.currentSystemId,
|
||||
systemName: scene.systemRecord?.name ?? null,
|
||||
// The last JUMP's source system (GameScene.lastJumpFromSystem —
|
||||
// jumpThroughGate stages it into the restore record; the scene
|
||||
// re-reads it from its own restore). The 'Pirate Problems'
|
||||
// ambush's midpoint is measured from the ENTRY GATE
|
||||
// (returnGateFor(content, lastJumpFrom) —
|
||||
// GameScene._pirateAmbushEntryGate), so the field rides saves and a
|
||||
// reload keeps the same midpoint. A save predating it has no field;
|
||||
// the restore treats the absence as a fresh run (the ambush's
|
||||
// nearest-gate fallback).
|
||||
lastJumpFrom:
|
||||
typeof scene.lastJumpFromSystem === 'string' ? scene.lastJumpFromSystem : null,
|
||||
ship: {
|
||||
x: Number(scene.ship.x),
|
||||
y: Number(scene.ship.y),
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { Planet } from '../entities/Planet.js';
|
|||
import { AsteroidCluster } from '../entities/AsteroidCluster.js';
|
||||
import { Station } from '../entities/Station.js';
|
||||
import { JumpGate } from '../entities/JumpGate.js';
|
||||
import { jumpArrival } from '../galaxy/JumpTravel.js';
|
||||
import { jumpArrival, returnGateFor, ambushTriggerDistance } from '../galaxy/JumpTravel.js';
|
||||
import { Starfield } from '../visuals/Starfield.js';
|
||||
import { NebulaAtmosphere } from '../visuals/NebulaAtmosphere.js';
|
||||
import { NebulaLightning } from '../visuals/NebulaLightning.js';
|
||||
|
|
@ -448,6 +448,17 @@ export class GameScene extends Phaser.Scene {
|
|||
// playtime) in the registry (js/save/SaveData.js) — the ship and the
|
||||
// tether field don't exist yet, so apply it once they do (below).
|
||||
this._pendingRestore = consumeRestore(this.registry);
|
||||
// The last JUMP's source system (jumpThroughGate stages it into the
|
||||
// restore record; captureState carries it through a panel save too):
|
||||
// the 'Pirate Problems' ambush measures its midpoint from the gate
|
||||
// the player came in through — returnGateFor(content, lastJumpFrom)
|
||||
// (GameScene._pirateAmbushEntryGate). Null on a fresh run or a save
|
||||
// predating the field — the ambush's nearest-gate fallback stands
|
||||
// in for it.
|
||||
this.lastJumpFromSystem =
|
||||
this._pendingRestore && typeof this._pendingRestore.lastJumpFrom === 'string'
|
||||
? this._pendingRestore.lastJumpFrom
|
||||
: null;
|
||||
// A FRESH run (no staged restore) is what the 'newGame' comms fire
|
||||
// for (the welcome plays on a new game, never on a load) — and a
|
||||
// jump-cut restart (scene.restart, no resetRunState) is NOT one: its
|
||||
|
|
@ -908,8 +919,11 @@ export class GameScene extends Phaser.Scene {
|
|||
|
||||
// ---- COMBAT — the hostile pack (the ?combat boot) + the combat bar --
|
||||
// COMBAT IS LIVE IN THE MAIN GAME: the 'Pirate Problems' quest
|
||||
// (data/quests.json → pirate_problems) ambushes the player on the
|
||||
// ENTER of the Neutral Capital's system — GameScene.
|
||||
// (data/quests.json → pirate_problems) ambushes the player at the
|
||||
// MIDPOINT of the entry-jumpgate → Neutral-Capital-world stretch —
|
||||
// the enter lands the ship at the gate (a full stretch out); the
|
||||
// wave starts when it reaches half that distance from the world —
|
||||
// GameScene.
|
||||
// _maybeStartPirateAmbush fires the pirate_ambush wave run (data/
|
||||
// combat.json → waves.scripts.pirate_ambush — one rolling wave of six
|
||||
// frame-0 raiders, materializing off-screen around the player from a
|
||||
|
|
@ -1782,15 +1796,19 @@ export class GameScene extends Phaser.Scene {
|
|||
// and the quest guard (_trestleQ4Beats) keeps the beats off the
|
||||
// player before the grant.
|
||||
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 — 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).
|
||||
// The 'Pirate Problems' AMBUSH — the ENTER of the Neutral Capital's
|
||||
// system (the scene was just built for it): the ship is at the entry
|
||||
// gate (a full gate→capital stretch from the world), so the wave
|
||||
// starts here only when the spawn already sits within the MIDPOINT
|
||||
// (a degenerate short stretch) — otherwise update() arms it the
|
||||
// frame the flight crosses the halfway point (the same method's
|
||||
// guards are no-op-gated either way). The third arm is the grant
|
||||
// itself (_questStartBeats — a save whose grant landed while the
|
||||
// player was already IN the system, past the midpoint or not).
|
||||
// When it fires, its ships materialize OFF-SCREEN around the player,
|
||||
// each from a random direction (CombatSystem._spawnWaveEnemy), and
|
||||
// converge on their own chase AI.
|
||||
this._ambushSpawn = this.ship ? { x: this.ship.x, y: this.ship.y } : null;
|
||||
this._maybeStartPirateAmbush();
|
||||
}
|
||||
|
||||
|
|
@ -1845,11 +1863,20 @@ export class GameScene extends Phaser.Scene {
|
|||
* 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
|
||||
* and the deck down; combat over → back to the deck.
|
||||
* The COMBAT COMMS ride this same flip (data/comms.json →
|
||||
* oac-enemies-detected / oac-enemies-defeated, both visual, both
|
||||
* once:false): live → OAC's contact call (the wave run's lead — before
|
||||
* 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.
|
||||
* @param {boolean} active
|
||||
*/
|
||||
_setCombatBar(active) {
|
||||
if (this.actionBar) this.actionBar.setVisible(!active);
|
||||
if (this.combatBar) this.combatBar.setVisible(active);
|
||||
if (active) this.comms?.trigger('combatStart');
|
||||
else this.comms?.trigger('combatEnd');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -2368,6 +2395,13 @@ export class GameScene extends Phaser.Scene {
|
|||
this.tetherField.draw(_time); // the barrier (on-screen dots only)
|
||||
this.actionBar?.update(_time, delta); // the deck's living details
|
||||
this._checkDestinationReached(); // last leg done? clear the destination (REACHED)
|
||||
// 'Pirate Problems' step 2 — the AMBUSH's arm: with the quest live
|
||||
// and its combat step open, the pirate wave starts the moment the
|
||||
// ship is within HALF the entry-gate → capital-world stretch — the
|
||||
// conflict begins halfway to the world from the jumpgate (the
|
||||
// method is no-op-gated, so the per-frame cost is a few ledger
|
||||
// reads + one distance check).
|
||||
this._maybeStartPirateAmbush();
|
||||
// 'Pirate Problems' step 3 — the RETURN's witness: the ship at the
|
||||
// capital world's rim with the quest held + unclaimed sets
|
||||
// story.capitalReturned (monotonic; _maybeWitnessCapitalReturn is a
|
||||
|
|
@ -2856,6 +2890,20 @@ export class GameScene extends Phaser.Scene {
|
|||
);
|
||||
return;
|
||||
}
|
||||
// COMBAT — a live fight (CombatSystem.active — a wave run in flight,
|
||||
// the 'Pirate Problems' ambush included, or hostiles up) holds the
|
||||
// ship in the system: a jump mid-wave would carry the run out of its
|
||||
// own fight (the wave run is scene-local — the enemies and the
|
||||
// fight's momentum would die with the scene, the run would simply
|
||||
// vanish with the threat). Same flat denial shape as the mining
|
||||
// guard above.
|
||||
if (this.combat?.active) {
|
||||
this.consoleToast(
|
||||
String(config.get('gates.jump.combatToast', 'CANNOT JUMP WHILE UNDER ATTACK')),
|
||||
{ glyph: '⌁', glyphColor: toCss(themeColor('neon2', 0xff9b9b)) },
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Where we arrive: just past the return gate's keepout (JumpTravel).
|
||||
// Fallback — no return gate (a one-way shortcut, shortcuts are OFF in
|
||||
// the current config): the destination's origin (its center is empty —
|
||||
|
|
@ -2881,6 +2929,14 @@ export class GameScene extends Phaser.Scene {
|
|||
|
||||
const rec = captureState(this, this.time.now);
|
||||
rec.currentSystemId = destId;
|
||||
// The AMBUSH's bookkeeping — which gate the player came in through:
|
||||
// the new scene resolves its entry gate as
|
||||
// returnGateFor(content, lastJumpFrom) and measures the 'Pirate
|
||||
// Problems' midpoint from it (GameScene._pirateAmbushEntryGate).
|
||||
// A panel save carries the field through (captureState), so a
|
||||
// reload keeps the same midpoint; a save predating it simply lacks
|
||||
// the field (the nearest-gate fallback).
|
||||
rec.lastJumpFrom = from.id;
|
||||
rec.systemName = dest.name;
|
||||
rec.ship = {
|
||||
x: arrival.x,
|
||||
|
|
@ -3164,6 +3220,10 @@ export class GameScene extends Phaser.Scene {
|
|||
* 'land' — an unsettled world → land (same gate for the
|
||||
* capital world, were it ever unsettled)
|
||||
* 'cancel' — just close (the console log is the ack)
|
||||
* Both transports keep their OWN combat guard on top of the panel's
|
||||
* (GameScene.jumpThroughGate / startLanding — a live fight,
|
||||
* CombatSystem.active, denies the jump and the landing: the run can't
|
||||
* leave the system it's fighting in).
|
||||
*/
|
||||
commsAction(id, target) {
|
||||
this.commsPanel.close(); // the buttons close the panel (like miningAction)
|
||||
|
|
@ -3171,7 +3231,7 @@ export class GameScene extends Phaser.Scene {
|
|||
if (id === 'request-jump') {
|
||||
// The panel only offers this when the gate is active and
|
||||
// gates.jump.enabled — jumpThroughGate keeps its own guards
|
||||
// (mining in progress, a missing destination).
|
||||
// (mining in progress, a live combat, a missing destination).
|
||||
const gt = target.gate?.entity;
|
||||
if (gt) this.jumpThroughGate(gt);
|
||||
return;
|
||||
|
|
@ -3219,6 +3279,26 @@ export class GameScene extends Phaser.Scene {
|
|||
console.info(`[orbit] comms: landing disabled for ${target.name}`);
|
||||
return;
|
||||
}
|
||||
// COMBAT — a live fight (CombatSystem.active — a wave run in flight,
|
||||
// the 'Pirate Problems' ambush included, or hostiles up) holds the
|
||||
// ship in the air: landing would SLEEP the flight scene under the
|
||||
// wave — the player would hide on the surface while the raiders
|
||||
// wait, and the fight's momentum (the wave run is scene-local) would
|
||||
// die with the sleep. The fight ends where it started, in the air.
|
||||
// Same flat denial shape as the capital's claim gate below.
|
||||
if (this.combat?.active) {
|
||||
const t = config.get('landing.combatDenyToast', {});
|
||||
this.playSfx('ui_close');
|
||||
this.consoleToast(
|
||||
String(t.text ?? 'CANNOT LAND WHILE UNDER ATTACK'),
|
||||
{
|
||||
glyph: t.glyph ?? '✗',
|
||||
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('red', 0xff5c77)),
|
||||
durationMs: 3200,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The CAPITAL WORLD holds its door until the 'Leaving the Nest'
|
||||
// reward is CLAIMED (the quest's 200 credits — the quest itself
|
||||
// completes when the ship arrives at this planet; the claim is the
|
||||
|
|
@ -4490,10 +4570,12 @@ export class GameScene extends Phaser.Scene {
|
|||
}
|
||||
this._trestleQ4Beats();
|
||||
// The AMBUSH's other arm: the grant may have landed while the player
|
||||
// was already IN the capital's system (a late claim out there) — the
|
||||
// create() arm only covers the enter. _maybeStartPirateAmbush is
|
||||
// no-op-gated (quest live + step open + system match + not already
|
||||
// running/done), so re-calling it here is safe.
|
||||
// was already IN the capital's system (a late claim out there) — if
|
||||
// the ship is already within the midpoint the wave starts here; if
|
||||
// it's still gate-side, update()'s arm arms it once the flight
|
||||
// crosses the halfway point. _maybeStartPirateAmbush is no-op-gated
|
||||
// (quest live + step open + system match + not already running/done
|
||||
// + within the midpoint trigger), so re-calling it here is safe.
|
||||
this._maybeStartPirateAmbush();
|
||||
}
|
||||
|
||||
|
|
@ -4665,23 +4747,30 @@ 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
|
||||
* (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);
|
||||
* pirates' step: with the quest live (held + unclaimed) and its
|
||||
* combat step still open, the pirate wave starts the moment the ship
|
||||
* reaches the MIDPOINT of the entry-gate → capital-world stretch —
|
||||
* HALF the jumpgate-to-world distance from the capital (the quest's
|
||||
* destination is the capital, so the run's flight crosses it), NOT
|
||||
* on the system's enter (the gate-side spawn is a full stretch out).
|
||||
* 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. Three arms call it (all no-op-gated by the guards below):
|
||||
* · update() — the per-frame arm: the flight crosses the midpoint;
|
||||
* · create()'s tail — the ENTER, when the spawn already sits
|
||||
* within the midpoint (a degenerate short stretch);
|
||||
* · _questStartBeats — a grant that lands while the player is
|
||||
* already in the system (a late claim out there).
|
||||
* Guards (all must hold, in order): combat enabled + built; the
|
||||
* quest held + unclaimed; the combat step not yet cleared (the
|
||||
* monotonic done-ledger — a cleared ambush never re-fires); no wave
|
||||
* run in flight (a live fight is the fight); the capital target
|
||||
* resolves in THIS system (the ambush lives where Trestle is).
|
||||
* resolves in THIS system (the ambush lives where Trestle is); the
|
||||
* midpoint resolves (entry gate + capital world both found) and the
|
||||
* ship is within it of the capital world.
|
||||
* A mid-fight quit re-arms the ambush on load (the in-flight run is
|
||||
* scene-local) — the step's witness is the completion, not the
|
||||
* attempt, so the re-fire is the fair read.
|
||||
|
|
@ -4698,12 +4787,55 @@ export class GameScene extends Phaser.Scene {
|
|||
if (this._combatDone().has(script)) return; // cleared once — the step stands
|
||||
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 t = this.galaxy?.neutralCapitalTarget?.();
|
||||
if (!t?.objectId || t.systemId !== this.systemRecord?.id) return; // not this system
|
||||
// The MIDPOINT TRIGGER — half the entry-gate → capital stretch:
|
||||
// the raiders materialise at the halfway point, not at the gate.
|
||||
const cap = (this.systemPlanets ?? []).find((p) => p.discoveryId === t.objectId);
|
||||
const trigger = ambushTriggerDistance(this._pirateAmbushEntryGate(), cap);
|
||||
if (trigger == null || !this.ship) return; // no stretch to halve
|
||||
if (Math.hypot(this.ship.x - cap.x, this.ship.y - cap.y) > trigger) return; // not halfway yet
|
||||
this.combat.startWaves(script, {
|
||||
onComplete: () => this._recordCombatDone(script),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The AMBUSH's entry gate — the gate the player CAME IN through (the
|
||||
* midpoint is measured from it): the destination's return gate for
|
||||
* the last jump's source system first (jumpThroughGate stages
|
||||
* lastJumpFrom into the restore record — exact, and stable across a
|
||||
* save parked mid-stretch), then the system's gate NEAREST the spawn
|
||||
* point (a jump materialises the ship just past its return gate's
|
||||
* keepout, so the nearest gate is the one they used — the fallback
|
||||
* for a fresh run or a save that predates lastJumpFrom), and
|
||||
* finally the spawn point itself (a gate-less system — the spawn IS
|
||||
* where the ship came from).
|
||||
* @returns {{x:number,y:number}|null} the gate record, or null
|
||||
*/
|
||||
_pirateAmbushEntryGate() {
|
||||
if (typeof this.lastJumpFromSystem === 'string') {
|
||||
const g = returnGateFor(this.systemContent, this.lastJumpFromSystem);
|
||||
if (g && typeof g.x === 'number' && typeof g.y === 'number') return g;
|
||||
}
|
||||
const anchor = this._ambushSpawn ?? this.ship;
|
||||
if (anchor && Array.isArray(this.systemGates)) {
|
||||
let best = null;
|
||||
let bestD = Infinity;
|
||||
for (const gt of this.systemGates) {
|
||||
const d = Math.hypot(gt.x - anchor.x, gt.y - anchor.y);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = gt;
|
||||
}
|
||||
}
|
||||
if (best) return best;
|
||||
}
|
||||
return anchor && typeof anchor.x === 'number' && typeof anchor.y === 'number'
|
||||
? { x: anchor.x, y: anchor.y }
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 'Pirate Problems' RETURN witness — the step's 'Return to the
|
||||
* Neutral Capital World': the moment the ship is at the capital
|
||||
|
|
|
|||
Loading…
Reference in New Issue