Add neutral-only encounter comms and soundtrack
- Introduce a NEUTRAL-ONLY encounter path: when escorts are live with zero hostiles, the combat seam now carries a `neutral` flag so the scene can swap to a dedicated neutral hum instead of the combat shuffle. - Fire two new pilot comms once per neutral encounter — `neutralScan` (pilot on screen) and `neutralScanComplete` (first pilot break-off) — via a new `onComm` seam in CombatSystem; hostile-only beats stay silent during neutral encounters. - Add `neutral` music context to data/music.json with its own shuffle playlist, load it in GameScene, and resume the correct context on wake. - Ship a `?neutral` dev boot (two pilots, 8 s escort window) for testing the full beat at home, plus tests covering the comm seam, mixed→neutral re-resolution, and data validation.
This commit is contained in:
parent
1e9c528108
commit
81a1589307
|
|
@ -56,6 +56,13 @@ respawns, the bar — lives in **`data/combat.json`**:
|
|||
a line in the kill feed. Poke the live fight in DevTools via
|
||||
`window.__ORBIT_COMBAT__ = { system, bar, player, kills, spawn,
|
||||
damagePlayer, cast, xp, grantXp }`.
|
||||
- **Neutral encounter** — **`index.html?neutral`** boots that same normal
|
||||
game with a NEUTRAL-ONLY encounter live instead of hostiles: two
|
||||
pilots stream in on a short escort window — the neutral hum replaces
|
||||
the combat music the moment one is on screen, the pilot's SCAN comm
|
||||
lands as it flies in, and the SCAN-COMPLETE comm rides the break-off
|
||||
(the dev stand-in for the neutral encounter beat — safe to run even at
|
||||
home, where hostiles never go).
|
||||
|
||||
- **Feel** — the handling is what the range tunes. Every feel number
|
||||
(thrust, top speed, drag, turn rate + turn response, throttle ramp/
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"_comment": "Music (assets/music). enabled = master switch (also skips the load). volume = the ONE knob for the mix — the DEFAULT volume for ALL music (menu, game, surface), 0..1. volumes = per-CONTEXT overrides on top of it (see _volumes_comment). menu = the loop that plays while the main menu is up. game = the SHUFFLED soundtrack for the main game scene (open space) — a plain file list, so adding a track is adding one line; one track plays at its natural length and when it ends the next is picked at random (never the same track twice in a row). combat = the SHUFFLED COMBAT soundtrack — the SAME playlist shape as game (a plain file list), swapped in the moment combat goes live and back to game the moment it ends (GameScene.setCombatMusic, on the onActiveChange seam where the combat bar flips; one shuffle per scene, so the swap is a clean restart of the voice). frames = the loop that plays on a world's surface — keyed by the planets.png SHEET FRAME (the same key the landing videos use, data/landing.json: frames 0..2 are the terran worlds, 3..5 the gas giants), so a world hums the track matching the art it is drawn with. The surface loop starts the moment the landing clip starts and stops the moment the takeoff clip ends (or the scene leaves any other way). A frame with no entry (or a null entry) is silent. intro-01.mp3 is parked for the intro sequence that hasn't been built yet.",
|
||||
"_comment": "Music (assets/music). enabled = master switch (also skips the load). volume = the ONE knob for the mix — the DEFAULT volume for ALL music (menu, game, surface), 0..1. volumes = per-CONTEXT overrides on top of it (see _volumes_comment). menu = the loop that plays while the main menu is up. game = the SHUFFLED soundtrack for the main game scene (open space) — a plain file list, so adding a track is adding one line; one track plays at its natural length and when it ends the next is picked at random (never the same track twice in a row). combat = the SHUFFLED COMBAT soundtrack — the SAME playlist shape as game (a plain file list), swapped in the moment combat goes live and back to game the moment it ends (GameScene.setCombatMusic, on the onActiveChange seam where the combat bar flips; one shuffle per scene, so the swap is a clean restart of the voice). neutral = the SHUFFLED NEUTRAL-PILOT soundtrack — the same shape as combat, swapped in instead of combat for a NEUTRAL-ONLY encounter (at least one escort in, zero hostiles anywhere — the onActiveChange seam's second argument, js/combat/CombatSystem.js → _setActive): the neutral hum plays the whole escort window, and it swaps back to game when the last pilot breaks off (GameScene.setCombatMusic, active + neutral). frames = the loop that plays on a world's surface — keyed by the planets.png SHEET FRAME (the same key the landing videos use, data/landing.json: frames 0..2 are the terran worlds, 3..5 the gas giants), so a world hums the track matching the art it is drawn with. The surface loop starts the moment the landing clip starts and stops the moment the takeoff clip ends (or the scene leaves any other way). A frame with no entry (or a null entry) is silent. intro-01.mp3 is parked for the intro sequence that hasn't been built yet.",
|
||||
"enabled": true,
|
||||
"volume": 0.6,
|
||||
"_volumes_comment": "volumes = per-CONTEXT overrides on top of volume (volume stays the default for everything else): 'menu' = the main-menu loop, 'game' = the deep-space shuffle, 'combat' = the combat shuffle, 'frames' = the world surface loops, 'stationFrames' = the station surface loops. A NUMBER applies to that whole context; an OBJECT goes one level deeper, so one frame/variant can have its own volume (e.g. \"frames\": { \"0\": 1.0 }). A context with no entry plays at volume. The menu ships at 1.0 — full volume — on purpose.",
|
||||
"_volumes_comment": "volumes = per-CONTEXT overrides on top of volume (volume stays the default for everything else): 'menu' = the main-menu loop, 'game' = the deep-space shuffle, 'combat' = the combat shuffle, 'neutral' = the neutral-pilot shuffle, 'frames' = the world surface loops, 'stationFrames' = the station surface loops. A NUMBER applies to that whole context; an OBJECT goes one level deeper, so one frame/variant can have its own volume (e.g. \"frames\": { \"0\": 1.0 }). A context with no entry plays at volume. The menu ships at 1.0 — full volume — on purpose.",
|
||||
"volumes": {
|
||||
"menu": 1.0
|
||||
},
|
||||
|
|
@ -15,6 +15,9 @@
|
|||
"assets/music/combat-01.mp3",
|
||||
"assets/music/combat-02.mp3"
|
||||
],
|
||||
"neutral": [
|
||||
"assets/music/neutral-01.mp3"
|
||||
],
|
||||
"frames": {
|
||||
"0": "assets/music/terran-01.mp3",
|
||||
"1": "assets/music/terran-02.mp3",
|
||||
|
|
|
|||
|
|
@ -98,6 +98,11 @@ check('the defeat clip ships on disk', existsSync(join(root, 'assets/videos/enem
|
|||
const pirateBounty = charEntry(cfg, 'pirate_bounty');
|
||||
check('the bounty mark is registered (label + role + the bounty clip)', !!pirateBounty && pirateBounty.label === 'PIRATE' && !!pirateBounty.role && pirateBounty.video === 'assets/videos/enemies/pirate-bounty.mp4');
|
||||
check('the bounty clip ships on disk', existsSync(join(root, 'assets/videos/enemies/pirate-bounty.mp4')));
|
||||
const neutralPilot = charEntry(cfg, 'neutral_pilot');
|
||||
check('the neutral pilot is registered (label + role + the pilot clip)', !!neutralPilot && neutralPilot.label === 'NEUTRAL PILOT' && !!neutralPilot.role && neutralPilot.video === 'assets/videos/allies/neutral-pilot.mp4');
|
||||
check('the pilot clip ships on disk', existsSync(join(root, 'assets/videos/allies/neutral-pilot.mp4')));
|
||||
check('the scan voice ships on disk', existsSync(join(root, 'assets/speech/neutral/scan-01.mp3')));
|
||||
check('the scan-complete voice ships on disk', existsSync(join(root, 'assets/speech/neutral/scan-complete-01.mp3')));
|
||||
|
||||
const roster = listComms(cfg);
|
||||
// The shipped roster count (the data pins it: a stray unvalidated comm
|
||||
|
|
@ -107,7 +112,7 @@ const roster = listComms(cfg);
|
|||
// quest-complete sign-off) + the combat pair (oac-enemies-detected /
|
||||
// oac-enemies-defeated) + the pirate trio (pirate-attack / pirate-defeat /
|
||||
// pirate-bounty-attack — the comming type's own voice, the mark's too) = 35).
|
||||
check('the roster holds exactly the shipped comms', roster.length === 35);
|
||||
check('the roster holds exactly the shipped comms', roster.length === 37);
|
||||
const byId = Object.fromEntries(roster.map((c) => [c.id, c]));
|
||||
const intro = byId['oac-intro'];
|
||||
check(
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ const check = (label, cond) => {
|
|||
};
|
||||
|
||||
// A fresh harness per block: stub scene + player + real CombatSystem.
|
||||
function harness() {
|
||||
function harness(o = {}) {
|
||||
const scene = makeScene();
|
||||
const ship = new PhaserStub.Physics.Arcade.Sprite(scene, 0, 0, '__ship', 0);
|
||||
scene.physics.add.existing(ship);
|
||||
|
|
@ -189,6 +189,7 @@ function harness() {
|
|||
const combat = new CombatSystem(scene, {
|
||||
ship,
|
||||
onActiveChange: (a) => activeFlips.push(a),
|
||||
onComm: typeof o.onComm === 'function' ? o.onComm : null,
|
||||
});
|
||||
return { scene, ship, combat, activeFlips };
|
||||
}
|
||||
|
|
@ -251,6 +252,50 @@ const ids = neutralTypeIds();
|
|||
JSON.stringify(activeFlips) === '[true,false]');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the scan comms
|
||||
{
|
||||
// The NEUTRAL PILOT's comms (data/comms.json → neutralScan /
|
||||
// neutralScanComplete): armed by the system, delivered through the
|
||||
// onComm seam — once per encounter, and only while the roster is
|
||||
// neutral-only (zero hostiles).
|
||||
const comms = [];
|
||||
const { ship, combat } = harness({ onComm: (t) => comms.push(t) });
|
||||
const a = combat.spawnAlly(300, 0, 0, 'courier', { escortMs: 60000, viewFn: () => null });
|
||||
check('seam: a neutral-only encounter stands (active + the neutral flag)',
|
||||
combat.active === true && combat._neutral === true && combat._neutralOnly() === true);
|
||||
a.state = 'escort'; // skip the stream-in (approach)
|
||||
a.x = 80; a.y = 0;
|
||||
combat.update(1000, 16);
|
||||
check('scan: the first on-screen moment arms the scan comm (once)',
|
||||
JSON.stringify(comms) === '["neutralScan"]');
|
||||
combat.update(1016, 16); // no barrage on the next frames
|
||||
check('scan: the mark holds — no repeat arm', JSON.stringify(comms) === '["neutralScan"]');
|
||||
// The break-off (escort window lapsed) arms the scan-complete comm.
|
||||
a.escortMs = 1000; a.escortStart = 0;
|
||||
combat.update(2000, 16);
|
||||
check('break: window elapse → break-off', a.state === 'break');
|
||||
check('scan-complete: the break-off arms the completion comm (once)',
|
||||
JSON.stringify(comms) === '["neutralScan","neutralScanComplete"]');
|
||||
|
||||
// A live hostile anywhere keeps the roster MIXED: no neutral flag, no
|
||||
// scan comm (the pilot's line is a neutral-only beat).
|
||||
const comms2 = [];
|
||||
const h2 = harness({ onComm: (t) => comms2.push(t) });
|
||||
const b = h2.combat.spawnEnemy(400, 0, 0, 'raider');
|
||||
b.respawn = false; b.nextFireAt = Infinity; b.comms = null; // hold it + no pirate line
|
||||
const a2 = h2.combat.spawnAlly(300, 0, 1, 'runner', { escortMs: 60000, viewFn: () => null });
|
||||
a2.state = 'escort'; a2.x = 80; a2.y = 0;
|
||||
h2.combat.update(1000, 16);
|
||||
check('mixed: a live hostile keeps the neutral flag down + the scan comm silent',
|
||||
h2.combat._neutral === false && comms2.length === 0);
|
||||
// …and the moment the last hostile goes down (escorts still escorting)
|
||||
// the roster RE-RESOLVES to neutral: the seam re-fires with the flag.
|
||||
b.damage(999); // the real path is a tracer sweep → damage() → killEnemy
|
||||
h2.combat.killEnemy(b, 1000);
|
||||
check('mixed→neutral: the last kill re-fires the seam with neutral up',
|
||||
h2.combat.active === true && h2.combat._neutral === true);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- engage
|
||||
{
|
||||
const { ship, combat } = harness();
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@ export class CombatSystem {
|
|||
this.onKill = typeof o.onKill === 'function' ? o.onKill : null;
|
||||
this.onLevelUp = typeof o.onLevelUp === 'function' ? o.onLevelUp : null;
|
||||
this.onComm = typeof o.onComm === 'function' ? o.onComm : null;
|
||||
this._neutral = false; // the last _setActive's neutral-only flag (the seam's edge)
|
||||
this._neutralScanCommed = false; // the encounter's scan comm already fired
|
||||
this._neutralBreakCommed = false; // the encounter's scan-complete comm already fired
|
||||
|
||||
this.enemies = [];
|
||||
this.allies = []; // neutral escorts (a separate roster — js/combat/NeutralShip.js)
|
||||
|
|
@ -200,12 +203,32 @@ export class CombatSystem {
|
|||
);
|
||||
}
|
||||
|
||||
/** Fire the state change at the scene (bar swap) when it flips. */
|
||||
/**
|
||||
* Is the LIVE roster a NEUTRAL-ONLY encounter? — at least one escort in,
|
||||
* zero hostiles anywhere (not even pending respawn). This is the gate
|
||||
* for the neutral soundtrack + the pilot's scan comms: the moment the
|
||||
* last pirate dies with escorts still escorting, it stands.
|
||||
*/
|
||||
_neutralOnly() {
|
||||
const enemiesLive = this.enemies.some((e) => e && !e.dead);
|
||||
const alliesLive = this.allies.some((a) => a && !a.dead && !a.gone && !a._removed);
|
||||
return alliesLive && !enemiesLive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire the state change at the scene (bar swap) when it flips — now
|
||||
* WITH the neutral-only flag, so a neutral swap (active → active, or
|
||||
* active → over) re-runs the scene's music seam (combat ↔ neutral ↔
|
||||
* deep-space) even when the plain active flag didn't move (last pirate
|
||||
* down, escorts still escorting).
|
||||
*/
|
||||
_setActive() {
|
||||
const a = this.active;
|
||||
if (a === this._active) return;
|
||||
const n = a && this._neutralOnly();
|
||||
if (a === this._active && n === this._neutral) return;
|
||||
this._active = a;
|
||||
if (this.onActiveChange) this.onActiveChange(a);
|
||||
this._neutral = n;
|
||||
if (this.onActiveChange) this.onActiveChange(a, n);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -638,6 +661,28 @@ export class CombatSystem {
|
|||
if (r.wantsFire && player && r.target && r.target.dead !== true && this.craftVisible(a)) {
|
||||
this.fireAlly(a, r.target, time);
|
||||
}
|
||||
// THE NEUTRAL SCAN COMM (trigger 'neutralScan' — data/comms.json): the
|
||||
// moment a pilot flies ON SCREEN while the encounter is neutral-only
|
||||
// (no hostiles anywhere), the pilot's visible scan comm arms (the box
|
||||
// plays their clip muted with the rolled scan-XX voice). Once per
|
||||
// encounter (the hub's in-flight guard keeps a barrage away regardless).
|
||||
const vis = this.craftVisible(a);
|
||||
if (vis && !a._wasOnScreen) {
|
||||
a._wasOnScreen = true;
|
||||
if (!this._neutralScanCommed && this._neutralOnly()) {
|
||||
this._neutralScanCommed = true;
|
||||
this._fireNeutralComm('neutralScan');
|
||||
}
|
||||
}
|
||||
// THE SCAN-COMPLETE COMM (trigger 'neutralScanComplete'): the moment a
|
||||
// pilot BREAKS OFF (escort window lapsed → state 'break', peeling off
|
||||
// screen) in a neutral-only encounter — the rolled scan-complete-XX
|
||||
// voice ("scan complete, nothing found"). Once per encounter, on the
|
||||
// first pilot to break.
|
||||
if (a.state === 'break' && !this._neutralBreakCommed && this._neutralOnly()) {
|
||||
this._neutralBreakCommed = true;
|
||||
this._fireNeutralComm('neutralScanComplete');
|
||||
}
|
||||
}
|
||||
// A broken escort that has flown fully off-screen is gone (the
|
||||
// encounter ends when the last one is), and a shot-down escort is
|
||||
|
|
@ -650,6 +695,10 @@ export class CombatSystem {
|
|||
}
|
||||
}
|
||||
this.allies = this.allies.filter((a) => !a.gone && !a._removed);
|
||||
if (this.allies.length === 0) {
|
||||
this._neutralScanCommed = false; // the roster is spent — a fresh encounter re-arms the comms
|
||||
this._neutralBreakCommed = false;
|
||||
}
|
||||
// The last escort off-screen (with the pack spent) is the encounter
|
||||
// OVER — the flip back to the normal deck/soundtrack rides the same
|
||||
// seam as a kill (killEnemy/killAlly already fire it; the break-off
|
||||
|
|
@ -1295,6 +1344,22 @@ export class CombatSystem {
|
|||
// pirate comms at most once per life — the arrival (20%) OR the
|
||||
// on-screen (50%) roll, never both.
|
||||
|
||||
/**
|
||||
* A NEUTRAL PILOT's comm (the scan / scan-complete beats): the same seam
|
||||
* as the pirate rolls — hand the trigger to o.onComm (the scene arms its
|
||||
* CommsHub's trigger; a context without one stays silent). No chance
|
||||
* roll: the beats are guaranteed once per encounter (the marks above).
|
||||
* @param {string} trigger 'neutralScan' / 'neutralScanComplete'
|
||||
*/
|
||||
_fireNeutralComm(trigger) {
|
||||
if (!this.onComm || !trigger) return;
|
||||
try {
|
||||
this.onComm(trigger);
|
||||
} catch (err) {
|
||||
if (typeof console !== 'undefined') console.warn('[combat] onComm hook failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ATTACK roll (`chance` in [0, 1]): a hit arms the type's trigger.
|
||||
* @param {object} e the hostile (its comms block + the roll's marks)
|
||||
|
|
|
|||
|
|
@ -392,6 +392,15 @@ export class GameScene extends Phaser.Scene {
|
|||
if (config.get('music.enabled', true) && Array.isArray(combatTracks)) {
|
||||
for (const f of combatTracks) this.load.audio(gameTrackKey(f), f);
|
||||
}
|
||||
|
||||
// The NEUTRAL-PILOT soundtrack (data/music.json → neutral) — the
|
||||
// neutral-only encounter's hum (setCombatMusic, active + neutral — the
|
||||
// onActiveChange seam's second argument). Same derived-key rule
|
||||
// (music_neutral_01…).
|
||||
const neutralTracks = config.get('music.neutral', []);
|
||||
if (config.get('music.enabled', true) && Array.isArray(neutralTracks)) {
|
||||
for (const f of neutralTracks) this.load.audio(gameTrackKey(f), f);
|
||||
}
|
||||
}
|
||||
|
||||
create() {
|
||||
|
|
@ -428,6 +437,7 @@ export class GameScene extends Phaser.Scene {
|
|||
// wakes the scene). 'shutdown' still covers return-to-menu and game
|
||||
// destroy (the shutdown() method stays as the destroy safety net).
|
||||
this._asleep = false; // surface state (the sleep/wake events flip it) — setCombatMusic's seam
|
||||
this._combatWasNeutral = false; // the last live combat's neutral-only flag — the seam's memory
|
||||
this.startGameMusic();
|
||||
this.events.once('shutdown', () => {
|
||||
this.stopGameMusic();
|
||||
|
|
@ -446,7 +456,7 @@ export class GameScene extends Phaser.Scene {
|
|||
// Back in space — resume the RIGHT context: the combat shuffle if the
|
||||
// fight is still live (landing is denied mid-fight, so this is the
|
||||
// defensive branch), the deep-space hum otherwise.
|
||||
if (this.combat?.active) this.startCombatMusic();
|
||||
if (this.combat?.active) (this._combatWasNeutral ? this.startNeutralMusic() : this.startCombatMusic());
|
||||
else this.startGameMusic(); // the shuffle starts fresh
|
||||
if (this.mining?.state === 'mining') this.setMiningLoop(true); // the beam is still live
|
||||
});
|
||||
|
|
@ -1051,7 +1061,7 @@ export class GameScene extends Phaser.Scene {
|
|||
this.combat = new CombatSystem(this, {
|
||||
ship: this.ship,
|
||||
xp: savedXp ? XpManager.fromJSON(savedXp) : new XpManager(),
|
||||
onActiveChange: (active) => this._setCombatBar(active),
|
||||
onActiveChange: (active, neutral) => this._setCombatBar(active, neutral),
|
||||
// The readability layer's bookkeeping (js/combat/CombatHud.js —
|
||||
// the same shared layer the range runs): a kill lands in the feed,
|
||||
// and a hit flashes the bar's gauge that took it. The enemy rides
|
||||
|
|
@ -1130,6 +1140,31 @@ export class GameScene extends Phaser.Scene {
|
|||
'[orbit] ?combat — the seven-type demo is live: 7 rolling waves, bomber → warden (data/combat.json → waves.scripts.combat_demo)',
|
||||
);
|
||||
}
|
||||
// The ?neutral boot (the dev stand-in for the NEUTRAL-ONLY
|
||||
// encounter): two pilots stream in from off-screen on a SHORT escort
|
||||
// window (8 s — the data's band is 10–15 s), so the whole beat plays
|
||||
// in one sitting: the neutral hum in (setCombatMusic, neutral), the
|
||||
// pilot's SCAN comm as one flies on-screen, the break-off, the
|
||||
// SCAN-COMPLETE comm, and the deep-space hum back. Player-side ships
|
||||
// only — safe to run even at home (where hostiles never go).
|
||||
if (this._wantsNeutralDemo()) {
|
||||
const ship = this.ship;
|
||||
const nids = neutralTypeIds();
|
||||
const r = this.combat._offscreenRadius() * (0.9 + Math.random() * 0.2);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const a = Math.PI * (i + 0.5) + (Math.random() - 0.5) * 0.6;
|
||||
this.combat.spawnAlly(
|
||||
ship.x + Math.cos(a) * r,
|
||||
ship.y + Math.sin(a) * r,
|
||||
90000 + this._encounterSerial++,
|
||||
nids.length ? nids[(Math.random() * nids.length) | 0] : null,
|
||||
{ escortMs: 8000 }, // short window — the break-off lands fast
|
||||
);
|
||||
}
|
||||
console.info(
|
||||
'[orbit] ?neutral — neutral-only encounter live: two pilots, 8 s window (the neutral hum + the scan / scan-complete comms)',
|
||||
);
|
||||
}
|
||||
// The READABILITY LAYER (shared with the range): the lock on the
|
||||
// auto-fire target, the threat markers, the off-screen arrows
|
||||
// (the camera follows the ship — hostiles can sit outside it),
|
||||
|
|
@ -2095,6 +2130,19 @@ export class GameScene extends Phaser.Scene {
|
|||
return new URLSearchParams(location.search).has('combat');
|
||||
}
|
||||
|
||||
/**
|
||||
* The ?neutral boot (a dev URL parameter, like ?combat): a NORMAL game
|
||||
* with a NEUTRAL-ONLY encounter live from the start (two pilots, a short
|
||||
* escort window) — the test stand for the neutral hum + the pilot's scan
|
||||
* / scan-complete comms (see the block in create()).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_wantsNeutralDemo() {
|
||||
if (config.get('combat.enabled', true) === false) return false;
|
||||
if (typeof location === 'undefined') return false;
|
||||
return new URLSearchParams(location.search).has('neutral');
|
||||
}
|
||||
|
||||
/**
|
||||
* The action-deck swap (combat mode, Diablo 4 style — one bar in the
|
||||
* same place, depending on the mode): combat LIVE → the combat bar up
|
||||
|
|
@ -2105,15 +2153,25 @@ export class GameScene extends Phaser.Scene {
|
|||
* the first hostile lands — or the ?combat pack's spawn); over (the
|
||||
* last hostile down) → OAC's sign-off (its delayMs, 1 s, lands it
|
||||
* after the final explosion's read). The hub's in-flight guard + the
|
||||
* played ledger keep each flip to one voice.
|
||||
* played ledger keep each flip to one voice. A NEUTRAL-ONLY encounter
|
||||
* (neutral — the seam's second argument: escorts in, zero hostiles)
|
||||
* swaps the soundtrack for the neutral hum INSTEAD, and OAC's
|
||||
* hostile-signature beats stay silent (there's no hostile signature —
|
||||
* the pilot's own scan comms carry the encounter).
|
||||
* @param {boolean} active
|
||||
* @param {boolean} [neutral] the live roster is a neutral-only encounter
|
||||
*/
|
||||
_setCombatBar(active) {
|
||||
_setCombatBar(active, neutral = false) {
|
||||
if (this.actionBar) this.actionBar.setVisible(!active);
|
||||
if (this.combatBar) this.combatBar.setVisible(active);
|
||||
this.setCombatMusic(active); // the soundtrack swap (combat ↔ deep-space)
|
||||
if (active) this.comms?.trigger('combatStart');
|
||||
else this.comms?.trigger('combatEnd');
|
||||
this.setCombatMusic(active, neutral); // the soundtrack swap (combat/neutral ↔ deep-space)
|
||||
if (active) {
|
||||
this._combatWasNeutral = neutral;
|
||||
if (!neutral) this.comms?.trigger('combatStart');
|
||||
} else {
|
||||
if (!this._combatWasNeutral) this.comms?.trigger('combatEnd');
|
||||
this._combatWasNeutral = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -4306,20 +4364,32 @@ export class GameScene extends Phaser.Scene {
|
|||
stopMusicShuffleOn(this);
|
||||
}
|
||||
|
||||
/** The NEUTRAL-PILOT soundtrack (data/music.json → neutral): the hum
|
||||
* under a neutral-only encounter (escorts in, zero hostiles) — the same
|
||||
* shuffle voice as combat at the neutral context's volume (music.volumes
|
||||
* .neutral can override the global mix — same one-knob rule as combat). Clean to re-fire — the
|
||||
* scene-level stop goes first, so the swap never stacks two shuffles. */
|
||||
startNeutralMusic() {
|
||||
startMusicShuffleOn(this, config.get('music.neutral', []), 'neutral');
|
||||
}
|
||||
|
||||
/**
|
||||
* The soundtrack's combat switch (the onActiveChange seam — the same
|
||||
* moment the combat bar swaps in): the fight goes live → the deep-space
|
||||
* hum stops and the combat shuffle starts; the fight ends → the combat
|
||||
* shuffle stops and the deep-space hum resumes — unless the scene is
|
||||
* asleep (on a world's surface: the surface owns the silence, and the
|
||||
* wake handler resumes the right context). Clean to re-fire on either
|
||||
* side (one shuffle per scene).
|
||||
* hum stops and the combat shuffle starts (or the NEUTRAL shuffle when
|
||||
* the encounter is neutral-only — escorts in, zero hostiles); the fight
|
||||
* ends → whichever shuffle is live stops and the deep-space hum resumes
|
||||
* — unless the scene is asleep (on a world's surface: the surface owns
|
||||
* the silence, and the wake handler resumes the right context). Clean
|
||||
* to re-fire on either side (one shuffle per scene).
|
||||
* @param {boolean} active combat live / over (CombatSystem's flip)
|
||||
* @param {boolean} [neutral] the live roster is a neutral-only encounter
|
||||
*/
|
||||
setCombatMusic(active) {
|
||||
setCombatMusic(active, neutral = false) {
|
||||
if (active) {
|
||||
this.stopGameMusic();
|
||||
this.startCombatMusic();
|
||||
if (neutral) this.startNeutralMusic();
|
||||
else this.startCombatMusic();
|
||||
} else {
|
||||
this.stopCombatMusic();
|
||||
if (!this._asleep) this.startGameMusic();
|
||||
|
|
|
|||
Loading…
Reference in New Issue