Compare commits

...

4 Commits

Author SHA1 Message Date
Brian Fertig 5e799bbd5a Add pirate comms: attack/defeat/bounty voice lines with speech and video
- Raiders now roll an attack comm on-screen (50%) or on rolling-wave arrival (20%), and a defeat comm on kill (20%), driven by per-type config in combat.json
- New bounty-attack comm fires when the wanted leader's fight stands up in GameScene._fireBountyEngagements
- Ship 12 pirate speech clips, 3 enemy video clips, and register three new senders (pirate, pirate_defeat, pirate_bounty) in comms.json
- Wire onComm seam through CombatSystem so the scene can arm CommsHub triggers; rolls are no-ops when the seam is absent
- Add combat, bounty-hunt, and comms test coverage for the new roll logic, trigger arming, asset presence, and roster count
2026-09-20 21:34:05 -06:00
Brian Fertig 18d65ea267 Added tracks 2026-09-20 20:43:33 -06:00
Brian Fertig 27a9a59c03 Add combat soundtrack that swaps in when combat goes live
- Introduce `music.combat` playlist (same shuffled contract as `game`) with its own per-context volume override (`volumes.combat`).
- Add `GameScene.setCombatMusic(active)` wired to the `_setCombatBar` seam: combat live stops the deep-space shuffle and starts the combat one; combat over swaps back unless the scene is asleep on a world's surface.
- Track sleep/wake with `_asleep` so the wake handler resumes the correct context (combat if still active, otherwise deep-space).
- Generalize `startMusicShuffleOn` to take a `context` parameter for independent volume resolution.
- Update preload to queue combat tracks under their derived keys and extend tests for combat shuffle, context swap, re-fire, asleep behavior, and data contract.
2026-09-20 20:38:07 -06:00
Brian Fertig 93a844f07b Fix bounty-hunt tracker so READY repaints when complete flips after all
- Include the quest's complete bit in the tracker row fingerprint so a later frame that only flips `complete` (with no check change) still triggers a repaint to READY.
- Update bounty-hunt and quest-tracker tests to pin the ordering: all legs checked while contract is still out, then COMPLETE landing on a subsequent frame with checks unchanged.
- Add station surface music tracks for spacestation variants 0 and 1 in data/music.json.
2026-09-20 20:25:06 -06:00
31 changed files with 682 additions and 30 deletions

BIN
assets/music/combat-01.mp3 Normal file

Binary file not shown.

BIN
assets/music/combat-02.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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.",

File diff suppressed because one or more lines are too long

View File

@ -1,8 +1,8 @@
{
"_comment": "Music (assets/music). enabled = master switch (also skips the load). volume = the ONE knob for the mix — the DEFAULT volume for ALL music (menu, game, surface), 0..1. volumes = per-CONTEXT overrides on top of it (see _volumes_comment). menu = the loop that plays while the main menu is up. game = the SHUFFLED soundtrack for the main game scene (open space) — a plain file list, so adding a track is adding one line; one track plays at its natural length and when it ends the next is picked at random (never the same track twice in a row). frames = the loop that plays on a world's surface — keyed by the planets.png SHEET FRAME (the same key the landing videos use, data/landing.json: frames 0..2 are the terran worlds, 3..5 the gas giants), so a world hums the track matching the art it is drawn with. The surface loop starts the moment the landing clip starts and stops the moment the takeoff clip ends (or the scene leaves any other way). A frame with no entry (or a null entry) is silent. intro-01.mp3 is parked for the intro sequence that hasn't been built yet.",
"_comment": "Music (assets/music). enabled = master switch (also skips the load). volume = the ONE knob for the mix — the DEFAULT volume for ALL music (menu, game, surface), 0..1. volumes = per-CONTEXT overrides on top of it (see _volumes_comment). menu = the loop that plays while the main menu is up. game = the SHUFFLED soundtrack for the main game scene (open space) — a plain file list, so adding a track is adding one line; one track plays at its natural length and when it ends the next is picked at random (never the same track twice in a row). combat = the SHUFFLED COMBAT soundtrack — the SAME playlist shape as game (a plain file list), swapped in the moment combat goes live and back to game the moment it ends (GameScene.setCombatMusic, on the onActiveChange seam where the combat bar flips; one shuffle per scene, so the swap is a clean restart of the voice). frames = the loop that plays on a world's surface — keyed by the planets.png SHEET FRAME (the same key the landing videos use, data/landing.json: frames 0..2 are the terran worlds, 3..5 the gas giants), so a world hums the track matching the art it is drawn with. The surface loop starts the moment the landing clip starts and stops the moment the takeoff clip ends (or the scene leaves any other way). A frame with no entry (or a null entry) is silent. intro-01.mp3 is parked for the intro sequence that hasn't been built yet.",
"enabled": true,
"volume": 0.6,
"_volumes_comment": "volumes = per-CONTEXT overrides on top of volume (volume stays the default for everything else): 'menu' = the main-menu loop, 'game' = the deep-space shuffle, 'frames' = the world surface loops, 'stationFrames' = the station surface loops. A NUMBER applies to that whole context; an OBJECT goes one level deeper, so one frame/variant can have its own volume (e.g. \"frames\": { \"0\": 1.0 }). A context with no entry plays at volume. The menu ships at 1.0 — full volume — on purpose.",
"_volumes_comment": "volumes = per-CONTEXT overrides on top of volume (volume stays the default for everything else): 'menu' = the main-menu loop, 'game' = the deep-space shuffle, 'combat' = the combat shuffle, 'frames' = the world surface loops, 'stationFrames' = the station surface loops. A NUMBER applies to that whole context; an OBJECT goes one level deeper, so one frame/variant can have its own volume (e.g. \"frames\": { \"0\": 1.0 }). A context with no entry plays at volume. The menu ships at 1.0 — full volume — on purpose.",
"volumes": {
"menu": 1.0
},
@ -11,6 +11,10 @@
"assets/music/deepspace-01.mp3",
"assets/music/deepspace-02.mp3"
],
"combat": [
"assets/music/combat-01.mp3",
"assets/music/combat-02.mp3"
],
"frames": {
"0": "assets/music/terran-01.mp3",
"1": "assets/music/terran-02.mp3",
@ -33,5 +37,8 @@
"18": "assets/music/capital-neutral-01.mp3"
},
"_stationFrames_comment": "stationFrames = the loop that plays on a deep-space station's surface — keyed by the spacestations.png VARIANT frame (the same key its landing/takeoff clips use, data/landing.json → stationVideos), so a station hums the track matching the art it is drawn with. Empty for now (a station surface is silent) until the station tracks are authored — add one line per variant when they land.",
"stationFrames": {}
"stationFrames": {
"0": "assets/music/spacestation-01.mp3",
"1": "assets/music/spacestation-02.mp3"
}
}

View File

@ -69,6 +69,7 @@ const SQ = configData.sidequests; // data/sidequests.json (categories / bounty /
const S = await import(pathToFileURL(join(root, 'js/quests/SideQuests.js')).href);
const { SideQuestState } = S;
const { GameScene } = await import(pathToFileURL(join(root, 'js/scenes/GameScene.js')).href);
const { QuestState } = await import(pathToFileURL(join(root, 'js/quests/QuestState.js')).href);
let failures = 0;
const check = (label, cond, extra = '') => {
@ -356,6 +357,7 @@ section('THE FLYING — accept → legs → mark → kill → claim (the real sc
s.planet = null;
s.tetherField = { tethers: [{ x: 0, y: 0, radius: 12000 }, { x: -400, y: 0, radius: 9000 }, { x: 400, y: 0, radius: 9000 }] };
s.sideQuestState = new SideQuestState();
s.questState = new QuestState(); // no story grants — the tracker's side row stands alone
s.sfx = [];
s.playSfx = (n) => { s.sfx.push(n); };
s.toasts = [];
@ -499,9 +501,25 @@ section('THE FLYING — accept → legs → mark → kill → claim (the real sc
at(plan.bountyAt); // stand in the lane so the field is read here
s.updateSideQuestRoute();
check('survivors keep it OUT of complete (the escort must stop shooting)', s.sideQuestState.isCompleted(id) === false);
// The tracker's view of the same frames (the upper-left quest summary):
// the kill has ALREADY checked every leg off — 100% painted — while the
// contract is still out, and the COMPLETE mark lands a frame LATER with
// no check change (the tracker's fingerprint must carry the bit —
// dev/quest-tracker.test.mjs pins the repaint; this pins the ORDER).
const snapRow = () => s.questSnapshot().quests.find((q) => q.id === id);
const rowOut = snapRow();
check('the summary shows every leg checked while the contract is still out',
rowOut.checks.length > 0 && rowOut.checks.every((c) => c.done) && rowOut.complete === false && rowOut.claimed === false,
JSON.stringify(rowOut));
for (const e of escort) e.dead = true;
s.combat.tickWaves(); // the last survivor drops — the run reads 'done'
s.updateSideQuestRoute();
const rowReady = snapRow();
check('the COMPLETE mark lands with the legs STILL checked (no check change)',
rowReady.complete === true && rowReady.claimed === false &&
JSON.stringify(rowReady.checks.map((c) => c.done)) === JSON.stringify(rowOut.checks.map((c) => c.done)) &&
rowReady.checks.every((c) => c.done),
JSON.stringify(rowReady));
check('with the last escort down the contract is COMPLETE', s.sideQuestState.isCompleted(id) === true, JSON.stringify(s.toasts.at(-1) ?? {}));
const completeToast = s.toasts.filter((t) => /COMPLETE/.test(t.text)).length;
check('the completion toasts once', completeToast === 1);
@ -583,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`);

View File

@ -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`);

View File

@ -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

View File

@ -230,11 +230,13 @@ const makeScene = (o = {}) => {
check("gameTrackKey('terran-02.mp3') → music_terran_02", gameTrackKey('terran-02.mp3') === 'music_terran_02');
}
// 13) data/music.json contract: `game` is a list, pointing at real files.
// 13) data/music.json contract: `game` and `combat` are lists, pointing at real files.
{
const ok = Array.isArray(MUSIC.game) && MUSIC.game.length >= 1 &&
MUSIC.game.every((p) => typeof p === 'string' && fs.existsSync(join(__dirname, '..', p)));
check('music.json: game is a non-empty list of files that exist', ok);
MUSIC.game.every((p) => typeof p === 'string' && fs.existsSync(join(__dirname, '..', p))) &&
Array.isArray(MUSIC.combat) && MUSIC.combat.length >= 1 &&
MUSIC.combat.every((p) => typeof p === 'string' && fs.existsSync(join(__dirname, '..', p)));
check('music.json: game and combat are non-empty lists of files that exist', ok);
}
/** A shuffle-capable stub scene: sound manager + cache + a time.addEvent
@ -340,14 +342,84 @@ const makeShuffleScene = (o = {}) => {
check('no sound manager → silent, no throw', !threw && noMgr.plays.length === 0);
}
// 17) GameScene.preload queues every game-track file under its derived key.
// 17) GameScene.preload queues every game-track AND combat-track file under its derived key.
{
const s = Object.create(GameScene.prototype);
s.queued = [];
s.load = { audio: (k, u) => s.queued.push([k, u]), spritesheet: () => {} };
GameScene.prototype.preload.call(s);
const ok = MUSIC.game.every((f) => s.queued.some(([k, u]) => k === gameTrackKey(f) && u === f));
check('preload queues each music.game file under its derived key', ok);
const ok = MUSIC.game.every((f) => s.queued.some(([k, u]) => k === gameTrackKey(f) && u === f)) &&
MUSIC.combat.every((f) => s.queued.some(([k, u]) => k === gameTrackKey(f) && u === f));
check('preload queues each music.game + music.combat file under its derived key', ok);
}
// 17b) The COMBAT shuffle: the same voice at the combat context's volume
// (startMusicShuffleOn's context parameter — music.volumes.combat
// can override the global mix without touching 'game').
{
const files = MUSIC.combat;
const keys = files.map(gameTrackKey);
const base = { enabled: true, volume: 0.6, menu: 'assets/music/mainmenu.mp3', game: ['assets/music/a.mp3'], combat: ['assets/music/c.mp3', 'assets/music/d.mp3'] };
config.init({ music: { ...base } });
const noOverride = makeShuffleScene();
startMusicShuffleOn(noOverride, base.combat, 'combat');
check('the combat shuffle plays at the global mix without an override',
noOverride.plays.length === 1 && noOverride.plays[0].opts.volume === 0.6);
stopMusicShuffleOn(noOverride);
config.init({ music: { ...base, volumes: { game: 0.5, combat: 0.9 } } });
const sh = makeShuffleScene();
startMusicShuffleOn(sh, base.combat, 'combat');
check('volumes.combat overrides ONLY the combat context',
sh.plays.length === 1 && sh.plays[0].opts.volume === 0.9 &&
musicVolumeFor('game') === 0.5);
stopMusicShuffleOn(sh);
// The swap is a clean restart: starting one context stops the other's track.
const swap = makeShuffleScene();
startMusicShuffleOn(swap, base.game);
startMusicShuffleOn(swap, base.combat, 'combat');
check('a context swap stops the previous track and plays ONE combat track',
swap.stops.length >= 1 && swap.plays.length === 2 &&
keys.includes(swap.plays[1].key) === false &&
base.combat.map(gameTrackKey).includes(swap.plays[1].key) &&
[gameTrackKey(base.game[0]), ...base.combat.map(gameTrackKey)]
.filter((k) => swap.sound.isPlaying(k)).length === 1);
stopMusicShuffleOn(swap);
config.init({ music: { ...MUSIC }, landing: { ...LANDING } }); // back to the shipped data
}
// 17c) GameScene.setCombatMusic: the onActiveChange seam — combat live
// swaps the deep-space shuffle for the combat one; combat over
// swaps back (unless the scene is asleep, which owns the silence).
{
const scene = Object.assign(Object.create(GameScene.prototype), makeShuffleScene());
scene._asleep = false;
scene.setCombatMusic(true);
const combatKeys = MUSIC.combat.map(gameTrackKey);
check('setCombatMusic(true) plays ONE combat track at the combat volume',
scene.plays.length === 1 && combatKeys.includes(scene.plays[0].key) &&
scene.plays[0].opts.volume === musicVolumeFor('combat'));
const first = scene.plays[0].key;
scene.setCombatMusic(true); // a re-fire (combat re-armed): clean restart, no stack
check('... a re-fire stops the old combat track and keeps exactly ONE playing',
scene.stops.includes(first) && scene.plays.length === 2 &&
combatKeys.filter((k) => scene.sound.isPlaying(k)).length === 1);
scene.setCombatMusic(false);
const gameKeys = MUSIC.game.map(gameTrackKey);
check('setCombatMusic(false) stops the combat track and resumes the deep-space shuffle',
scene.stops.includes(scene.plays[1].key) &&
scene.plays.length === 3 &&
gameKeys.includes(scene.plays[2].key) &&
scene.plays[2].opts.volume === musicVolumeFor('game'));
// Asleep (on a world's surface): ending combat must NOT restart the hum.
const asleep = Object.assign(Object.create(GameScene.prototype), makeShuffleScene());
asleep._asleep = true;
asleep.setCombatMusic(true);
asleep.setCombatMusic(false);
check('asleep, a combat end keeps the silence (the wake handler resumes)',
asleep.plays.length === 1); // only the combat start played; no deep-space resume
stopMusicShuffleOn(asleep);
}
// 18) Per-context volume overrides (data/music.json → volumes): a context
@ -409,7 +481,7 @@ const makeShuffleScene = (o = {}) => {
check('music.json: volumes is absent or a context→number (or per-frame) map',
vols == null || (typeof vols === 'object' && Object.values(vols).every((v) => wellFormed(v))));
check('music.json: every context resolves to a finite volume',
['menu', 'game', 'frames.0', 'frames.11', 'stationFrames.0']
['menu', 'game', 'combat', 'frames.0', 'frames.11', 'stationFrames.0']
.every((c) => Number.isFinite(musicVolumeFor(c))));
check('music.json: the shipped menu override is full volume', musicVolumeFor('menu') === 1.0);
}

View File

@ -905,11 +905,24 @@ const T0 = 100000;
!scene.add.texts.some((t) => t.text === 'READY') &&
rowTints() === 0 && trk._shimmers.length === 0);
// The quest COMPLETES (a check flips) — the same-frame repaint goes READY.
snap.quests[0].checks[0].done = true;
snap.quests[0].complete = true;
snap.quests[0].progress = '1/1';
// The BOUNTY HUNT's ORDER (the tracker's one repaint trap): a quest's
// checks can all stand DONE before its COMPLETE mark lands — the hunt's
// kill logs every leg at once (100% painted, still open), and the
// field-clear completes it on a LATER frame with NO check flipping.
// The first stage must paint plain (all checks done ≠ complete) …
tweens.length = 0; // (the constructor's first poll is not the moment)
snap.quests[0].checks[0].done = true;
snap.quests[0].progress = '1/1';
trk.update(scene.time.now);
check('all checks done but NOT complete: the row stays plain (100%, no READY, no rim)',
scene.add.texts.some((t) => t.text === '100%') &&
!scene.add.texts.some((t) => t.text === 'READY') &&
rowTints() === 0 && trk._shimmers.length === 0);
// … and NOW ONLY the complete bit flips (the checks already painted) —
// the fingerprint carries the bit, so the same frame still repaints READY.
tweens.length = 0;
snap.quests[0].complete = true;
trk.update(scene.time.now);
check('READY row: the % readout flips to READY (amber, bold)',
scene.add.texts.some((t) => t.text === 'READY' && t.color === '#ffc94d' && t.style?.fontStyle === 'bold'));

View File

@ -1603,6 +1603,16 @@ contract is COMPLETE, every leg is checked, the reward claims once.
1 s scene-clock tick advances it (js/utils/Music.js →
startMusicShuffleOn/stopMusicShuffleOn, files queue under their
filename-derived key: deepspace-01.mp3 → music_deepspace_01);
when COMBAT goes live the COMBAT soundtrack (music.combat — the
SAME shuffled-voice contract: combat-01/02 for now, adding a
track = adding a line) swaps in and the deep-space one stops —
the flip rides the onActiveChange seam (GameScene.setCombatMusic,
called from _setCombatBar, the same moment the combat bar comes
up), and they swap back the moment the fight ends (asleep = on a
world's surface: the surface owns the silence, the wake handler
resumes the right context); startMusicShuffleOn takes the context
so music.volumes.combat can override the combat shuffle's volume
independently (shipped at the global mix, like game);
on a world's surface the track for that world's
planets.png frame (terran / gas-giant families — the SAME key as
the landing videos) runs from the start of the landing clip to the
@ -1610,7 +1620,7 @@ contract is COMPLETE, every leg is checked, the reward claims once.
(looped play + `stopByKey` + one-loop-per-track guard;
`music.enabled` / `music.volume` (the ONE knob for the mix) +
per-CONTEXT volume overrides in `music.volumes` — menu / game /
frames / stationFrames, a number per context or a per-frame object,
combat / frames / stationFrames, a number per context or a per-frame object,
the menu ships at 1.0 for full volume), stops hooked on the scene's
`shutdown` EVENT (v4 quirk — see the list above), so the hum dies
on Take Off AND on Return to Main Menu, and the game soundtrack

View File

@ -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.<type>.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.<type>.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.<type>.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
/**

View File

@ -382,6 +382,14 @@ export class GameScene extends Phaser.Scene {
if (config.get('music.enabled', true) && Array.isArray(gameTracks)) {
for (const f of gameTracks) this.load.audio(gameTrackKey(f), f);
}
// The COMBAT soundtrack (data/music.json → combat) — the shuffled
// playlist that swaps in the moment the fight goes live (setCombatMusic,
// on the onActiveChange seam). Same derived-key rule (music_combat_01…).
const combatTracks = config.get('music.combat', []);
if (config.get('music.enabled', true) && Array.isArray(combatTracks)) {
for (const f of combatTracks) this.load.audio(gameTrackKey(f), f);
}
}
create() {
@ -407,13 +415,17 @@ export class GameScene extends Phaser.Scene {
this.playTimeMs = 0;
// The deep-space soundtrack — the data/music.json (game) playlist,
// shuffled: one track at a time, next picked when one ends.
// shuffled: one track at a time, next picked when one ends. The COMBAT
// soundtrack (→ combat) swaps in on the onActiveChange seam (see
// setCombatMusic): the deep-space one stops, the combat one starts, and
// they swap back the moment the fight ends.
// LANDING does not transition us — it LAUNCHES SurfaceScene on top
// and SLEEPS this scene (v4: `launch` doesn't freeze the caller, and
// a sleeping scene never emits 'shutdown') — so the soundtrack and
// the mining hum stop on 'sleep' and come back on 'wake' (Take Off
// wakes the scene). 'shutdown' still covers return-to-menu and game
// destroy (the shutdown() method stays as the destroy safety net).
this._asleep = false; // surface state (the sleep/wake events flip it) — setCombatMusic's seam
this.startGameMusic();
this.events.once('shutdown', () => {
this.stopGameMusic();
@ -422,12 +434,18 @@ export class GameScene extends Phaser.Scene {
this.levelMarquee?.destroy(); // the marquee can't outlive its scene
});
this.events.on('sleep', () => {
this.stopGameMusic(); // no space hum on a world's surface
this._asleep = true; // on a world's surface — the surface owns the silence
this.stopGameMusic(); // no space hum on a world's surface (combat or deep-space)
this.setMiningLoop(false); // …nor the mining hum
this.engineFx?.sleep(); // no ghost exhaust waiting on the pad
});
this.events.on('wake', () => {
this.startGameMusic(); // back in space — the shuffle starts fresh
this._asleep = false;
// Back in space — resume the RIGHT context: the combat shuffle if the
// fight is still live (landing is denied mid-fight, so this is the
// defensive branch), the deep-space hum otherwise.
if (this.combat?.active) this.startCombatMusic();
else this.startGameMusic(); // the shuffle starts fresh
if (this.mining?.state === 'mining') this.setMiningLoop(true); // the beam is still live
});
@ -1034,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.
@ -2056,6 +2081,7 @@ export class GameScene extends Phaser.Scene {
_setCombatBar(active) {
if (this.actionBar) this.actionBar.setVisible(!active);
if (this.combatBar) this.combatBar.setVisible(active);
this.setCombatMusic(active); // the soundtrack swap (combat ↔ deep-space)
if (active) this.comms?.trigger('combatStart');
else this.comms?.trigger('combatEnd');
}
@ -4129,6 +4155,40 @@ export class GameScene extends Phaser.Scene {
stopMusicShuffleOn(this);
}
/** The COMBAT soundtrack (data/music.json → combat): the shuffled
* combat playlist — one track at a time, the next picked at random
* when one ends (never the same twice in a row). Plays at the combat
* context's volume (music.volumes.combat can override the global mix).
* Clean to re-fire — the scene-level stop goes first, so the swap in
* setCombatMusic never stacks two shuffles. */
startCombatMusic() {
startMusicShuffleOn(this, config.get('music.combat', []), 'combat');
}
stopCombatMusic() {
stopMusicShuffleOn(this);
}
/**
* The soundtrack's combat switch (the onActiveChange seam — the same
* moment the combat bar swaps in): the fight goes live → the deep-space
* hum stops and the combat shuffle starts; the fight ends → the combat
* shuffle stops and the deep-space hum resumes — unless the scene is
* asleep (on a world's surface: the surface owns the silence, and the
* wake handler resumes the right context). Clean to re-fire on either
* side (one shuffle per scene).
* @param {boolean} active combat live / over (CombatSystem's flip)
*/
setCombatMusic(active) {
if (active) {
this.stopGameMusic();
this.startCombatMusic();
} else {
this.stopCombatMusic();
if (!this._asleep) this.startGameMusic();
}
}
/** The "new object" moment: a rim ping at the world + a HUD toast. */
celebrateDiscovery(o) {
const neon = themeColor('neon', 0x00e5ff);
@ -5445,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;
}

View File

@ -32,7 +32,10 @@
* scene's toast + 'discovery' chime carry it when the tracker is slid
* off), and the row's popup gains a CLAIM REWARD button (the console's
* claim-plate palette) that claims in place (GameScene.claimQuest — the
* console's own path).
* console's own path). The fingerprint carries each quest's COMPLETE bit
* as well as its checks: a BOUNTY HUNT's mark lands on a frame AFTER its
* last check has painted (the kill logs every leg at once, the field-
* clear completes it) — that flip must still repaint the row READY.
*
* Lifecycle (driven by GameScene — see GameScene.updateHud /
* startExpand): the tracker is VISIBLE ⇔ the dossier is FOLDED, armed by
@ -318,7 +321,15 @@ export class QuestTrackerHud {
a: ids,
c: active.map((q) => {
const checks = q.checks ?? [];
// The quest's OWN complete bit LEADS its entry — a BOUNTY HUNT's
// ledger mark lands AFTER its last check flips (the kill logs every
// leg at once — 100% painted, still open — and the field-clear
// completes it with NO check change behind it). Without the bit
// here that frame repaints nothing and the READY state — the
// CLAIM's door — never lands (QuestWindow._fpOf carries it; the
// tracker must match).
return (
(q.complete === true ? 1 : 0) +
checks.map((c) => (c?.done ? 1 : 0)).join('') +
'|' +
checks.map((c) => String(c?.detail ?? '')).join(',').slice(0, 40)

View File

@ -117,12 +117,16 @@ export function stopMusicShuffleOn(scene) {
}
/**
* Start the shuffle. `files` = the data/music.json `game` list. No-ops
* per the guard rails above; a scene with no usable (cached) tracks stays
* silent without throwing. Re-calling restarts cleanly — the old track is
* stopped and its timer removed first, so nothing stacks.
* Start the shuffle. `files` = the data/music.json playlist ('game' or
* 'combat' — a plain file LIST); `context` = the music CONTEXT the
* tracks play at (the spec minus the 'music.' prefix — 'game' by
* default; pass 'combat' for the combat playlist, so
* music.volumes.combat can override it independently). No-ops per the
* guard rails above; a scene with no usable (cached) tracks stays
* silent without throwing. Re-calling restarts cleanly — the old track
* is stopped and its timer removed first, so nothing stacks.
*/
export function startMusicShuffleOn(scene, files) {
export function startMusicShuffleOn(scene, files, context = 'game') {
stopMusicShuffleOn(scene); // clean slate before a (re)start
if (!scene) return;
if (!config.get('music.enabled', true)) return;
@ -148,7 +152,7 @@ export function startMusicShuffleOn(scene, files) {
}
const file = pool[Math.floor(Math.random() * pool.length)];
st.current = file;
snd.play(gameTrackKey(file), { loop: false, volume: musicVolumeFor('game') });
snd.play(gameTrackKey(file), { loop: false, volume: musicVolumeFor(context) });
return true;
};