feat(masterofvega): add combat SFX, cloak/singularity mechanics, and sound staggering
Move vega audio files into assets/fx/vega/ subdirectory and register them via assetManifest.js. Add weapon and missile sound cues banded by ship Mark, with staggered playback in VegaCombatView to prevent audio clipping during barrages. Wire up two previously inert tech effects in combat: - Stealth Field (cloaked): applies a flat accuracy malus via cloakEvasion rule. - Black Hole Generator (singularity): adds passive shield pierce via singularityShieldPierce rule on every weapon fired by the design. Update tech descriptions, ship detail display, and designFor() to reflect these effects. Fix a bug where singularity was never copied into the design object. Add vega-endturn cue to End Turn / Next Round buttons (no-op when busy), remove hardcoded audio loads from PreloadScene, and add verification tests that isolate each flag with A/B battle comparisons.
This commit is contained in:
parent
016f91349a
commit
3dee8a251a
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.
|
|
@ -97,11 +97,7 @@
|
||||||
"artist": "Ssakar",
|
"artist": "Ssakar",
|
||||||
"title": "Meet N Greet"
|
"title": "Meet N Greet"
|
||||||
}] },
|
}] },
|
||||||
"lithox": { "tracks": [{
|
"lithox": { "tracks": [] }
|
||||||
"file": "vega/audience-lithox.mp3",
|
|
||||||
"artist": "Lithox",
|
|
||||||
"title": "Meet N Greet"
|
|
||||||
}] }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -498,7 +498,9 @@
|
||||||
"disengageRound": 25,
|
"disengageRound": 25,
|
||||||
"planetDefenseBase": 20,
|
"planetDefenseBase": 20,
|
||||||
"bombardPopKill": 0.22,
|
"bombardPopKill": 0.22,
|
||||||
"groundOddsScale": 0.01
|
"groundOddsScale": 0.01,
|
||||||
|
"cloakEvasion": 0.02,
|
||||||
|
"singularityShieldPierce": 0.5
|
||||||
},
|
},
|
||||||
|
|
||||||
"victory": {
|
"victory": {
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,33 @@ Each of these was a real bug that produced a plausible-looking but broken game.
|
||||||
two states apart, so the placeholder's actual value never matters. Section 2
|
two states apart, so the placeholder's actual value never matters. Section 2
|
||||||
asserts both branches.
|
asserts both branches.
|
||||||
|
|
||||||
|
24. **`bestComponents()` computing a flag is not the same as `designFor()`
|
||||||
|
returning it.** Wiring the `cloaked`/`singularity` tech effects into combat
|
||||||
|
(2026-08-09) found `singularity` tallied correctly into `comps.singularity`
|
||||||
|
in `VegaShips.js` — same as `cloaked` — but the object literal `designFor()`
|
||||||
|
returns only ever copied `cloaked: comps.cloaked` onto the design; there was
|
||||||
|
no `singularity:` line at all, so every design's `design.singularity` was
|
||||||
|
`undefined` forever, silently. A combat check that read `shooter.design?.
|
||||||
|
singularity` would never have fired and nothing would have said why. Caught
|
||||||
|
only because the new verifier check builds a design with **both** flags
|
||||||
|
stripped as a sanity assertion (`baseDesign.cloaked && baseDesign.
|
||||||
|
singularity`) *before* running the A/B battle that depends on them —
|
||||||
|
without that assertion the A/B test would have "passed" by measuring
|
||||||
|
nothing. Lesson: any per-tech boolean/stat added to `bestComponents()`
|
||||||
|
needs a matching line in `designFor()`'s return object, and there is no
|
||||||
|
compiler to catch the omission — assert the field is actually present on a
|
||||||
|
fully-teched design, not just that the battle outcome looks plausible.
|
||||||
|
Also: attrition combat **compounds** small per-shot edges hard. An isolated
|
||||||
|
A/B fleet fight (same design, one flag flipped) at `cloakEvasion: 0.15`
|
||||||
|
produced a 99% win rate — more decisive than the suite's own ">0.8" bar for
|
||||||
|
a full two-tech-tier lead across every field at once. Tuned down empirically
|
||||||
|
(not from the single-hit-chance formula) against an isolated same-fleet A/B
|
||||||
|
harness until it landed near what a mild species combat-trait is worth in
|
||||||
|
that same harness (~62%): `cloakEvasion: 0.02`, `singularityShieldPierce:
|
||||||
|
0.5`. Both are checked in section 5 with a `> 0.52 && < 0.8` band, loose
|
||||||
|
enough to absorb RNG noise but tight enough to catch another 0.15-style
|
||||||
|
overshoot.
|
||||||
|
|
||||||
### Ship rows carry video, so they have to be pooled
|
### Ship rows carry video, so they have to be pooled
|
||||||
|
|
||||||
Every place a ship is listed — the side panel's task force, its in-transit,
|
Every place a ship is listed — the side panel's task force, its in-transit,
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,29 @@ export const MANIFEST = {
|
||||||
{ type: 'audio', key: 'scifi-explode', path: 'assets/fx/scifi-explode.mp3' },
|
{ type: 'audio', key: 'scifi-explode', path: 'assets/fx/scifi-explode.mp3' },
|
||||||
{ type: 'audio', key: 'ta-rocket-1', path: 'assets/fx/ta-rocket-1.mp3' },
|
{ type: 'audio', key: 'ta-rocket-1', path: 'assets/fx/ta-rocket-1.mp3' },
|
||||||
{ type: 'audio', key: 'ta-rocket-2', path: 'assets/fx/ta-rocket-2.mp3' },
|
{ type: 'audio', key: 'ta-rocket-2', path: 'assets/fx/ta-rocket-2.mp3' },
|
||||||
|
// UI sound effects (src/ui/Sounds.js's SFX.VEGA_*). Used to be loaded
|
||||||
|
// eagerly by PreloadScene for every game; moved here 2026-08-09 alongside
|
||||||
|
// Brian's own move of the files into assets/fx/vega/.
|
||||||
|
{ type: 'audio', key: 'sfx-vega-select', path: 'assets/fx/vega/vega-select.mp3' },
|
||||||
|
{ type: 'audio', key: 'sfx-vega-build', path: 'assets/fx/vega/vega-build.mp3' },
|
||||||
|
{ type: 'audio', key: 'sfx-vega-close', path: 'assets/fx/vega/vega-close.mp3' },
|
||||||
|
{ type: 'audio', key: 'sfx-vega-newturn', path: 'assets/fx/vega/vega-newturn.mp3' },
|
||||||
|
{ type: 'audio', key: 'sfx-vega-unit', path: 'assets/fx/vega/vega-unit.mp3' },
|
||||||
|
{ type: 'audio', key: 'sfx-vega-view', path: 'assets/fx/vega/vega-view.mp3' },
|
||||||
|
{ type: 'audio', key: 'sfx-vega-warp', path: 'assets/fx/vega/vega-warp.mp3' },
|
||||||
|
{ type: 'audio', key: 'sfx-vega-star', path: 'assets/fx/vega/vega-star.mp3' },
|
||||||
|
{ type: 'audio', key: 'sfx-vega-zoomin', path: 'assets/fx/vega/vega-zoomin.mp3' },
|
||||||
|
{ type: 'audio', key: 'sfx-vega-zoomout', path: 'assets/fx/vega/vega-zoomout.mp3' },
|
||||||
|
{ type: 'audio', key: 'sfx-vega-viewcolony', path: 'assets/fx/vega/vega-viewcolony.mp3' },
|
||||||
|
{ type: 'audio', key: 'sfx-vega-endturn', path: 'assets/fx/vega/vega-endturn.mp3' },
|
||||||
|
// Combat weapon cues, Mark-banded (see VegaCombat.js's weaponSfxKey()).
|
||||||
|
{ type: 'audio', key: 'sfx-vega-weapon-123', path: 'assets/fx/vega/vega-weapon-123.mp3' },
|
||||||
|
{ type: 'audio', key: 'sfx-vega-weapon-45', path: 'assets/fx/vega/vega-weapon-45.mp3' },
|
||||||
|
{ type: 'audio', key: 'sfx-vega-weapon-67', path: 'assets/fx/vega/vega-weapon-67.mp3' },
|
||||||
|
{ type: 'audio', key: 'sfx-vega-missle-launch-12345', path: 'assets/fx/vega/vega-missle-launch-12345.mp3' },
|
||||||
|
{ type: 'audio', key: 'sfx-vega-missle-launch-67', path: 'assets/fx/vega/vega-missle-launch-67.mp3' },
|
||||||
|
{ type: 'audio', key: 'sfx-vega-missle-hit-12345', path: 'assets/fx/vega/vega-missle-hit-12345.mp3' },
|
||||||
|
{ type: 'audio', key: 'sfx-vega-missle-hit-67', path: 'assets/fx/vega/vega-missle-hit-67.mp3' },
|
||||||
(scene) => vegaMusicFrom(scene, 'masterofvega-music'),
|
(scene) => vegaMusicFrom(scene, 'masterofvega-music'),
|
||||||
],
|
],
|
||||||
forbiddenisland: [
|
forbiddenisland: [
|
||||||
|
|
|
||||||
|
|
@ -662,7 +662,10 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
openLeaderScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done,
|
openLeaderScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done,
|
||||||
() => this.refreshHud())));
|
() => this.refreshHud())));
|
||||||
|
|
||||||
this.endTurnBtn = new Button(this, GAME_WIDTH - 130, 31, 'End turn', this.uiClick(() => this.onEndTurn()),
|
// Not this.uiClick — onEndTurn plays its own vega-endturn cue (shared with
|
||||||
|
// combat's Next round button) rather than the generic click, and a no-op
|
||||||
|
// press while busy/modal should stay silent.
|
||||||
|
this.endTurnBtn = new Button(this, GAME_WIDTH - 130, 31, 'End turn', () => this.onEndTurn(),
|
||||||
{ width: 190, height: 46, fontSize: 20 });
|
{ width: 190, height: 46, fontSize: 20 });
|
||||||
hud.add(this.endTurnBtn);
|
hud.add(this.endTurnBtn);
|
||||||
|
|
||||||
|
|
@ -1001,6 +1004,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
|
|
||||||
onEndTurn() {
|
onEndTurn() {
|
||||||
if (this.busy || this.modalOpen) return;
|
if (this.busy || this.modalOpen) return;
|
||||||
|
playSound(this, SFX.VEGA_ENDTURN);
|
||||||
this.busy = true;
|
this.busy = true;
|
||||||
this.endTurnBtn.setEnabled(false);
|
this.endTurnBtn.setEnabled(false);
|
||||||
this.writeSave();
|
this.writeSave();
|
||||||
|
|
|
||||||
|
|
@ -124,10 +124,20 @@ function hitChance(C, shooter, target, shooterTraits, targetTraits) {
|
||||||
const targeting = shooter.design?.targeting ?? 0;
|
const targeting = shooter.design?.targeting ?? 0;
|
||||||
const attack = (shooter.design?.attack ?? 0);
|
const attack = (shooter.design?.attack ?? 0);
|
||||||
const defense = (target.design?.defense ?? 0);
|
const defense = (target.design?.defense ?? 0);
|
||||||
|
// Stealth Field: a simplified always-on version of MOO1's "evasive unless
|
||||||
|
// it attacks" cloak — flat accuracy malus against the target. A single hit-
|
||||||
|
// chance percentage point sounds trivial but a fleet fight is thousands of
|
||||||
|
// aggregate shots over many rounds (see sampleHits), so even a small,
|
||||||
|
// *sustained* per-shot edge compounds round over round. Calibrated against
|
||||||
|
// an isolated same-fleet A/B (tools/verifyMasterOfVega.js section 5): 0.02
|
||||||
|
// lands around a mild species combat-trait's worth of advantage, well
|
||||||
|
// short of the >0.8 win rate the suite treats as "decisive".
|
||||||
|
const cloak = target.design?.cloaked ? C.cloakEvasion : 0;
|
||||||
const chance = C.baseHitChance
|
const chance = C.baseHitChance
|
||||||
+ C.hitPerTargeting * targeting
|
+ C.hitPerTargeting * targeting
|
||||||
+ C.hitPerAttack * attack
|
+ C.hitPerAttack * attack
|
||||||
- C.hitPerDefense * defense;
|
- C.hitPerDefense * defense
|
||||||
|
- cloak;
|
||||||
return Math.max(0.05, Math.min(0.95, chance));
|
return Math.max(0.05, Math.min(0.95, chance));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,7 +178,15 @@ function fire(b, shooter, targets, events) {
|
||||||
const hits = sampleHits(b.rnd, totalShots, chance);
|
const hits = sampleHits(b.rnd, totalShots, chance);
|
||||||
if (hits <= 0) continue;
|
if (hits <= 0) continue;
|
||||||
|
|
||||||
const effShield = Math.max(0, (target.shield ?? 0) - (w.shieldPierce ?? 0));
|
// Black Hole Generator: the whole ship's fire punches through deflectors a
|
||||||
|
// little further, on top of whatever the mounted weapon already pierces.
|
||||||
|
// Unlike a weapon's own shieldPierce, this rides for free on every mount
|
||||||
|
// with no hull-space trade-off, so it is kept well under an integer
|
||||||
|
// shield point (see the cloak comment above for how the calibration was
|
||||||
|
// done) — otherwise a passive fleet-wide buff would outclass equivalent
|
||||||
|
// per-weapon pierce tech like Disruptor Cannon or Disintegrator Beam.
|
||||||
|
const singularityPierce = shooter.design?.singularity ? (C.singularityShieldPierce ?? 0) : 0;
|
||||||
|
const effShield = Math.max(0, (target.shield ?? 0) - (w.shieldPierce ?? 0) - singularityPierce);
|
||||||
const perHit = Math.max(0, avgDmg(w) - effShield);
|
const perHit = Math.max(0, avgDmg(w) - effShield);
|
||||||
if (perHit <= 0) {
|
if (perHit <= 0) {
|
||||||
events.push({ kind: 'bounce', from: shooter.uid, to: target.uid, weapon: w.name });
|
events.push({ kind: 'bounce', from: shooter.uid, to: target.uid, weapon: w.name });
|
||||||
|
|
@ -191,6 +209,11 @@ function applyDamage(b, target, damage, events, shooter, weapon = null) {
|
||||||
from: shooter.uid,
|
from: shooter.uid,
|
||||||
to: target.uid,
|
to: target.uid,
|
||||||
weapon: weapon?.name ?? 'Planetary Defences',
|
weapon: weapon?.name ?? 'Planetary Defences',
|
||||||
|
// Plain data for the render tier to key sound/FX off of (beam vs missile,
|
||||||
|
// and the firing ship's Mark) — deliberately not a resolved asset key,
|
||||||
|
// since this file stays headless (no ui/Sounds.js import).
|
||||||
|
weaponKind: weapon?.kind ?? null,
|
||||||
|
mark: shooter.mark ?? null,
|
||||||
damage: Math.round(damage),
|
damage: Math.round(damage),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
import * as Phaser from 'phaser';
|
import * as Phaser from 'phaser';
|
||||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||||
import { Button } from '../../ui/Button.js';
|
import { Button } from '../../ui/Button.js';
|
||||||
|
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||||
import { FONT, D, uiClick } from './VegaScreens.js';
|
import { FONT, D, uiClick } from './VegaScreens.js';
|
||||||
import VegaFx from './VegaFx.js';
|
import VegaFx from './VegaFx.js';
|
||||||
import { stepRound, runBattle, battleResult } from './VegaCombat.js';
|
import { stepRound, runBattle, battleResult } from './VegaCombat.js';
|
||||||
|
|
@ -16,6 +17,20 @@ import { shipFrame } from './VegaArt.js';
|
||||||
const LANE_LEFT = 260;
|
const LANE_LEFT = 260;
|
||||||
const LANE_RIGHT = GAME_WIDTH - 260;
|
const LANE_RIGHT = GAME_WIDTH - 260;
|
||||||
|
|
||||||
|
// Weapon cues are banded by the firing ship's Mark (I-VII) — one file per
|
||||||
|
// band rather than one per Mark, matching the clips Brian recorded.
|
||||||
|
function weaponSfxKey(mark) {
|
||||||
|
if (mark <= 3) return SFX.VEGA_WEAPON_123;
|
||||||
|
if (mark <= 5) return SFX.VEGA_WEAPON_45;
|
||||||
|
return SFX.VEGA_WEAPON_67;
|
||||||
|
}
|
||||||
|
function missileLaunchSfxKey(mark) {
|
||||||
|
return mark <= 5 ? SFX.VEGA_MISSILE_LAUNCH_12345 : SFX.VEGA_MISSILE_LAUNCH_67;
|
||||||
|
}
|
||||||
|
function missileHitSfxKey(mark) {
|
||||||
|
return mark <= 5 ? SFX.VEGA_MISSILE_HIT_12345 : SFX.VEGA_MISSILE_HIT_67;
|
||||||
|
}
|
||||||
|
|
||||||
export function openCombatView(scene, rules, battle, art, opts = {}) {
|
export function openCombatView(scene, rules, battle, art, opts = {}) {
|
||||||
const { attackerSpecies = 'human', defenderSpecies = 'human', onDone = null, playerSide = null } = opts;
|
const { attackerSpecies = 'human', defenderSpecies = 'human', onDone = null, playerSide = null } = opts;
|
||||||
|
|
||||||
|
|
@ -97,16 +112,41 @@ export function openCombatView(scene, rules, battle, art, opts = {}) {
|
||||||
function animate(step) {
|
function animate(step) {
|
||||||
if (!step) return;
|
if (!step) return;
|
||||||
roundText.setText(`Round ${step.round}`);
|
roundText.setText(`Round ${step.round}`);
|
||||||
|
// A round can bank many 'fire' events (one per stack per mount), and they
|
||||||
|
// are all processed in this one synchronous loop — playing every cue the
|
||||||
|
// instant its event is seen would fire them all on the exact same frame
|
||||||
|
// and clip. Stagger each weapon/missile cue by a growing offset instead:
|
||||||
|
// still close enough to overlap (a barrage should sound like one), just
|
||||||
|
// never literally simultaneous. Visual FX are untouched — only sound
|
||||||
|
// timing is offset — and a missile's hit cue inherits its launch's slot
|
||||||
|
// plus travel time, so it stays offset from other shots' hits too.
|
||||||
|
let soundDelay = 0;
|
||||||
|
const SOUND_STAGGER_MS = 65;
|
||||||
for (const ev of step.events) {
|
for (const ev of step.events) {
|
||||||
const from = markers.get(ev.from);
|
const from = markers.get(ev.from);
|
||||||
const to = markers.get(ev.to);
|
const to = markers.get(ev.to);
|
||||||
if (ev.kind === 'fire' && from && to) {
|
if (ev.kind === 'fire' && from && to) {
|
||||||
const missile = /rocket|missile/i.test(ev.weapon);
|
// weaponKind comes straight off the fired weapon's own `kind`, unlike
|
||||||
|
// the old name-sniffing regex here, which missed both Torpedo weapons
|
||||||
|
// (matches neither /rocket/ nor /missile/) and so drew and sounded
|
||||||
|
// them as beams.
|
||||||
|
const missile = ev.weaponKind === 'missile';
|
||||||
|
const mark = ev.mark ?? 1;
|
||||||
fx.beam(from.container.x, from.container.y, to.container.x, to.container.y,
|
fx.beam(from.container.x, from.container.y, to.container.x, to.container.y,
|
||||||
missile ? 0xffb060 : 0x9fd8ff, missile);
|
missile ? 0xffb060 : 0x9fd8ff, missile);
|
||||||
scene.time.delayedCall(missile ? 240 : 60, () => {
|
const hitDelay = missile ? 240 : 60;
|
||||||
|
scene.time.delayedCall(hitDelay, () => {
|
||||||
if (to.container.active) fx.hit(to.container.x, to.container.y, missile ? 0xffb060 : 0xffd28a);
|
if (to.container.active) fx.hit(to.container.x, to.container.y, missile ? 0xffb060 : 0xffd28a);
|
||||||
});
|
});
|
||||||
|
if (ev.weaponKind === 'beam' || missile) {
|
||||||
|
const thisDelay = soundDelay;
|
||||||
|
soundDelay += SOUND_STAGGER_MS;
|
||||||
|
const key = missile ? missileLaunchSfxKey(mark) : weaponSfxKey(mark);
|
||||||
|
scene.time.delayedCall(thisDelay, () => playSound(scene, key));
|
||||||
|
if (missile) {
|
||||||
|
scene.time.delayedCall(thisDelay + hitDelay, () => playSound(scene, missileHitSfxKey(mark)));
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (ev.kind === 'losses') {
|
} else if (ev.kind === 'losses') {
|
||||||
const m = markers.get(ev.uid);
|
const m = markers.get(ev.uid);
|
||||||
if (m) scene.time.delayedCall(180, () => {
|
if (m) scene.time.delayedCall(180, () => {
|
||||||
|
|
@ -138,11 +178,15 @@ export function openCombatView(scene, rules, battle, art, opts = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- controls
|
// --- controls
|
||||||
const next = new Button(scene, GAME_WIDTH / 2 - 230, GAME_HEIGHT - 70, 'Next round', uiClick(scene, () => {
|
// Not uiClick — this plays its own vega-endturn cue (same one the main
|
||||||
|
// game's End Turn button uses) rather than the generic click, and a no-op
|
||||||
|
// press on a finished battle should stay silent.
|
||||||
|
const next = new Button(scene, GAME_WIDTH / 2 - 230, GAME_HEIGHT - 70, 'Next round', () => {
|
||||||
if (battle.done) return;
|
if (battle.done) return;
|
||||||
|
playSound(scene, SFX.VEGA_ENDTURN);
|
||||||
animate(stepRound(battle, {}));
|
animate(stepRound(battle, {}));
|
||||||
if (battle.done) scene.time.delayedCall(700, finish);
|
if (battle.done) scene.time.delayedCall(700, finish);
|
||||||
}), { width: 220, height: 52 });
|
}, { width: 220, height: 52 });
|
||||||
layer.add(next);
|
layer.add(next);
|
||||||
|
|
||||||
const auto = new Button(scene, GAME_WIDTH / 2, GAME_HEIGHT - 70, 'Auto-resolve', uiClick(scene, () => {
|
const auto = new Button(scene, GAME_WIDTH / 2, GAME_HEIGHT - 70, 'Auto-resolve', uiClick(scene, () => {
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,7 @@ export function openShipDetail(scene, rules, state, art, opts, onClose) {
|
||||||
|
|
||||||
const flags = [];
|
const flags = [];
|
||||||
if (design.cloaked) flags.push('Cloaked');
|
if (design.cloaked) flags.push('Cloaked');
|
||||||
|
if (design.singularity) flags.push('Singularity core');
|
||||||
if (design.planetCracker) flags.push('Planet cracker');
|
if (design.planetCracker) flags.push('Planet cracker');
|
||||||
if (design.immobile) flags.push('Never leaves its system');
|
if (design.immobile) flags.push('Never leaves its system');
|
||||||
if (flags.length) text(flags.join(' · '), 15, '#ffd88a', 4);
|
if (flags.length) text(flags.join(' · '), 15, '#ffd88a', 4);
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,7 @@ export function designFor(rules, known, hullId, traits = {}, skills = {}) {
|
||||||
troops: hull.troops ?? 0,
|
troops: hull.troops ?? 0,
|
||||||
planetCracker: comps.planetCracker && mounts.some((m) => m.weapon.planetCracker),
|
planetCracker: comps.planetCracker && mounts.some((m) => m.weapon.planetCracker),
|
||||||
cloaked: comps.cloaked,
|
cloaked: comps.cloaked,
|
||||||
|
singularity: comps.singularity,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,9 @@
|
||||||
//
|
//
|
||||||
// Every formatter is hand-matched to how the effect is actually consumed
|
// Every formatter is hand-matched to how the effect is actually consumed
|
||||||
// elsewhere in the engine (VegaCombat.js/VegaLogic.js/VegaShips.js) — not a
|
// elsewhere in the engine (VegaCombat.js/VegaLogic.js/VegaShips.js) — not a
|
||||||
// generic key/value dump. A few effects (cloaked, scanRange, singularity)
|
// generic key/value dump. `scanRange` is currently an inert stat with no
|
||||||
// are currently inert stats with no downstream mechanic wired up yet; their
|
// downstream mechanic wired up yet; its wording stays modest/flavor rather
|
||||||
// wording stays modest/flavor rather than promising a numeric benefit that
|
// than promising a numeric benefit that doesn't exist in the sim.
|
||||||
// doesn't exist in the sim.
|
|
||||||
|
|
||||||
const pct = (mult) => `${Math.round(Math.abs(mult - 1) * 100)}%`;
|
const pct = (mult) => `${Math.round(Math.abs(mult - 1) * 100)}%`;
|
||||||
|
|
||||||
|
|
@ -29,7 +28,7 @@ function hostilityWorlds(rules, tier) {
|
||||||
|
|
||||||
const FORMATTERS = {
|
const FORMATTERS = {
|
||||||
armor: (rules, v) => `Hull armor upgraded to ${v.name} plating — hull hit points ×${v.hpMult} (+${pct(v.hpMult)}).`,
|
armor: (rules, v) => `Hull armor upgraded to ${v.name} plating — hull hit points ×${v.hpMult} (+${pct(v.hpMult)}).`,
|
||||||
cloaked: () => 'Ships are fitted with a stealth field, cloaking them from enemy sensors.',
|
cloaked: (rules) => `Ships are fitted with a stealth field — enemy weapons are ${Math.round((rules.combat.cloakEvasion ?? 0) * 100)} percentage points less likely to hit.`,
|
||||||
colonizeHostility: (rules, v) => `Colonists can now settle ${hostilityWorlds(rules, v)} worlds.`,
|
colonizeHostility: (rules, v) => `Colonists can now settle ${hostilityWorlds(rules, v)} worlds.`,
|
||||||
counterEspionage: (rules, v) => `+${v} counter-espionage — makes it harder for enemy agents to steal your technology.`,
|
counterEspionage: (rules, v) => `+${v} counter-espionage — makes it harder for enemy agents to steal your technology.`,
|
||||||
engine: (rules, v) => `Engines upgraded to ${v.name} Drive (speed ${v.speed}).`,
|
engine: (rules, v) => `Engines upgraded to ${v.name} Drive (speed ${v.speed}).`,
|
||||||
|
|
@ -49,7 +48,7 @@ const FORMATTERS = {
|
||||||
researchMult: (rules, v) => `Research output increased by ${pct(v)} empire-wide.`,
|
researchMult: (rules, v) => `Research output increased by ${pct(v)} empire-wide.`,
|
||||||
scanRange: (rules, v) => `+${v} scanner range for detecting distant fleets and systems.`,
|
scanRange: (rules, v) => `+${v} scanner range for detecting distant fleets and systems.`,
|
||||||
shield: (rules, v) => `Deflector shields absorb ${v} point${v === 1 ? '' : 's'} of damage from every incoming hit, on ships and planetary defenses alike.`,
|
shield: (rules, v) => `Deflector shields absorb ${v} point${v === 1 ? '' : 's'} of damage from every incoming hit, on ships and planetary defenses alike.`,
|
||||||
singularity: () => "A breakthrough in exotic physics — the foundation for the galaxy's ultimate weapon.",
|
singularity: (rules) => `A breakthrough in exotic physics — every weapon on this ship warps enemy deflectors, trimming ${rules.combat.singularityShieldPierce ?? 0} point of shielding off every hit it lands.`,
|
||||||
targeting: (rules, v) => `Combat accuracy increased by ${Math.round(v * 6)}% (better targeting computers).`,
|
targeting: (rules, v) => `Combat accuracy increased by ${Math.round(v * 6)}% (better targeting computers).`,
|
||||||
wasteMult: (rules, v) => `Industrial waste reduced by ${pct(v)}.`,
|
wasteMult: (rules, v) => `Industrial waste reduced by ${pct(v)}.`,
|
||||||
weapon: (rules, v) => describeWeapon(v),
|
weapon: (rules, v) => describeWeapon(v),
|
||||||
|
|
|
||||||
|
|
@ -182,18 +182,6 @@ export default class PreloadScene extends Phaser.Scene {
|
||||||
this.load.audio('sfx-monopoly-expense', 'assets/fx/monopoly-expense.mp3');
|
this.load.audio('sfx-monopoly-expense', 'assets/fx/monopoly-expense.mp3');
|
||||||
this.load.audio('sfx-monopoly-pay', 'assets/fx/monopoly-pay.mp3');
|
this.load.audio('sfx-monopoly-pay', 'assets/fx/monopoly-pay.mp3');
|
||||||
this.load.audio('sfx-monopoly-paid', 'assets/fx/monopoly-paid.mp3');
|
this.load.audio('sfx-monopoly-paid', 'assets/fx/monopoly-paid.mp3');
|
||||||
|
|
||||||
this.load.audio('sfx-vega-select', 'assets/fx/vega-select.mp3');
|
|
||||||
this.load.audio('sfx-vega-build', 'assets/fx/vega-build.mp3');
|
|
||||||
this.load.audio('sfx-vega-close', 'assets/fx/vega-close.mp3');
|
|
||||||
this.load.audio('sfx-vega-newturn', 'assets/fx/vega-newturn.mp3');
|
|
||||||
this.load.audio('sfx-vega-unit', 'assets/fx/vega-unit.mp3');
|
|
||||||
this.load.audio('sfx-vega-view', 'assets/fx/vega-view.mp3');
|
|
||||||
this.load.audio('sfx-vega-warp', 'assets/fx/vega-warp.mp3');
|
|
||||||
this.load.audio('sfx-vega-star', 'assets/fx/vega-star.mp3');
|
|
||||||
this.load.audio('sfx-vega-zoomin', 'assets/fx/vega-zoomin.mp3');
|
|
||||||
this.load.audio('sfx-vega-zoomout', 'assets/fx/vega-zoomout.mp3');
|
|
||||||
this.load.audio('sfx-vega-viewcolony', 'assets/fx/vega-viewcolony.mp3');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async create() {
|
async create() {
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,18 @@ export const SFX = {
|
||||||
VEGA_ZOOMIN: 'sfx-vega-zoomin',
|
VEGA_ZOOMIN: 'sfx-vega-zoomin',
|
||||||
VEGA_ZOOMOUT: 'sfx-vega-zoomout',
|
VEGA_ZOOMOUT: 'sfx-vega-zoomout',
|
||||||
VEGA_VIEWCOLONY: 'sfx-vega-viewcolony',
|
VEGA_VIEWCOLONY: 'sfx-vega-viewcolony',
|
||||||
|
VEGA_ENDTURN: 'sfx-vega-endturn',
|
||||||
|
// Beam cues split by the firing ship's Mark (I-VII); missile cues split the
|
||||||
|
// same way but only two ways, and further split into launch vs. impact.
|
||||||
|
// See VegaCombatView.js's weaponSfxKey()/missileLaunchSfxKey()/
|
||||||
|
// missileHitSfxKey() for the Mark bands.
|
||||||
|
VEGA_WEAPON_123: 'sfx-vega-weapon-123',
|
||||||
|
VEGA_WEAPON_45: 'sfx-vega-weapon-45',
|
||||||
|
VEGA_WEAPON_67: 'sfx-vega-weapon-67',
|
||||||
|
VEGA_MISSILE_LAUNCH_12345: 'sfx-vega-missle-launch-12345',
|
||||||
|
VEGA_MISSILE_LAUNCH_67: 'sfx-vega-missle-launch-67',
|
||||||
|
VEGA_MISSILE_HIT_12345: 'sfx-vega-missle-hit-12345',
|
||||||
|
VEGA_MISSILE_HIT_67: 'sfx-vega-missle-hit-67',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function playSound(scene, key) {
|
export function playSound(scene, key) {
|
||||||
|
|
|
||||||
|
|
@ -1380,6 +1380,32 @@ section('5. Combat');
|
||||||
check('a large invasion force takes a lightly held world', inv.captured);
|
check('a large invasion force takes a lightly held world', inv.captured);
|
||||||
const inv2 = Combat.resolveInvasion(RULES, mulberry32(7), 4, 0, { groundDefense: 200 }, 200, 300);
|
const inv2 = Combat.resolveInvasion(RULES, mulberry32(7), 4, 0, { groundDefense: 200 }, 200, 300);
|
||||||
check('a token force fails against a fortress', !inv2.captured);
|
check('a token force fails against a fortress', !inv2.captured);
|
||||||
|
|
||||||
|
// Cloaked (Stealth Field) and singularity (Black Hole Generator) used to be
|
||||||
|
// computed onto the design and never read anywhere in combat. Isolate each
|
||||||
|
// flag by hand-cloning an otherwise-identical fully-teched design — passing
|
||||||
|
// `design` directly on a ship entry skips the known-tech derivation, so the
|
||||||
|
// A/B pair differs in exactly one field.
|
||||||
|
const baseDesign = Ships.designFor(RULES, techsUpTo(9), 'cruiser', RULES.species.human.traits);
|
||||||
|
check('a fully-teched design has both flags available to strip', baseDesign.cloaked && baseDesign.singularity);
|
||||||
|
const withoutCloak = { ...baseDesign, cloaked: false };
|
||||||
|
const withoutSingularity = { ...baseDesign, singularity: false };
|
||||||
|
|
||||||
|
const abBattle = (aDesign, dDesign, seed) => Combat.runBattle(Combat.createBattle(RULES, {
|
||||||
|
attacker: { empireIdx: 0, name: 'a', empire: mkEmp('human', 9), ships: [{ hullId: 'cruiser', count: 5, design: aDesign }] },
|
||||||
|
defender: { empireIdx: 1, name: 'd', empire: mkEmp('human', 9), ships: [{ hullId: 'cruiser', count: 5, design: dDesign }] },
|
||||||
|
rnd: mulberry32(seed),
|
||||||
|
}));
|
||||||
|
// Both effects are deliberately modest — calibrated (see VegaCombat.js
|
||||||
|
// comments) to land near what a mild species combat-trait bonus is worth in
|
||||||
|
// this exact harness (~62%), nowhere near the >0.8 "decisive" bar used
|
||||||
|
// above for a two-tier tech lead or a numbers advantage.
|
||||||
|
const cloakRate = rate((s) => abBattle(baseDesign, withoutCloak, s));
|
||||||
|
check('cloak makes an otherwise-identical fleet harder to beat, but not decisively',
|
||||||
|
cloakRate > 0.52 && cloakRate < 0.8, `attacker (cloaked) won ${(cloakRate * 100).toFixed(1)}%`);
|
||||||
|
const singularityRate = rate((s) => abBattle(baseDesign, withoutSingularity, s));
|
||||||
|
check('singularity shield-pierce makes an otherwise-identical fleet harder to beat, but not decisively',
|
||||||
|
singularityRate > 0.52 && singularityRate < 0.8, `attacker (singularity) won ${(singularityRate * 100).toFixed(1)}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue