fertig-classic-games/src/games/mastervega/VegaCombatSim.js

492 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Master of Vega — combat simulator. Secret entrance: index.html?movsim
// (see PreloadScene). A dev tool for tuning VegaCombat: configure a fleet (or
// a fleet vs. a bare planet) on each side, then either watch one battle play
// out on the real tactical screen (openCombatView, unmodified) or fire a
// batch of headless trials through runBattle for win-rate stats. Both paths
// call the exact same VegaCombat stepper the live game uses, so a balance
// change this tool disagrees with is a change the real game disagrees with
// too.
//
// A "Live / V2 Prototype" toggle switches BOTH Watch Battle and Simulate ×N
// together onto VegaCombatV2.js's per-ship engine and VegaCombatViewV2.js's
// zoomable per-ship view — never one on each, so the fight you watch and the
// trial stats you run always agree. Live mode is untouched, byte-for-byte
// the same tool this always was; V2 is an experimental parallel engine (see
// docs/mastervega-build-plan.md) that does not affect real player battles.
//
// Boots straight past GameMenuScene/OpponentSelect, so it has to load its own
// slice of the mastervega asset manifest (rules JSON + ship art) instead of
// relying on GameRoomScene's lazy fetch — same trick as the other *Editor
// scenes.
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from './VegaButton.js';
import { queueGameAssets } from '../../services/assetLoader.js';
import { compileRules, markNumeral } from './VegaRules.js';
import { ensureSheets } from './VegaArt.js';
import { designFor, stackPower } from './VegaShips.js';
import { createBattle, runBattle } from './VegaCombat.js';
import { openCombatView } from './VegaCombatView.js';
import * as CombatV2 from './VegaCombatV2.js';
import { openCombatViewV2 } from './VegaCombatViewV2.js';
import { formationName } from './VegaFormations.js';
import { VegaMusic } from './VegaMusic.js';
import { FONT, uiClick, openFormationPicker } from './VegaScreens.js';
const PANEL_BG = 0x0b1220;
const PANEL_LINE = 0x2c435f;
const ATTACKER_COLOR = '#9fd8ff';
const DEFENDER_COLOR = '#ffb0a0';
const TOP = 170;
const COL_W = 800;
const COL_A_X = 90;
const COL_D_X = GAME_WIDTH - 90 - COL_W;
// Starter compositions for the "Small / Medium / Large" one-click presets.
// Hand-tuned, not derived — they exist to get a plausible fleet on the grid
// fast; every count is still a manual stepper afterwards.
const FLEET_PRESETS = {
small: {
frigate: 4, destroyer: 2, cruiser: 0, battleship: 0, starbase: 0,
},
medium: {
frigate: 6, destroyer: 6, cruiser: 3, battleship: 1, starbase: 0,
},
large: {
frigate: 10, destroyer: 10, cruiser: 8, battleship: 5, starbase: 1,
},
};
const PLANET_PRESETS = {
small: { defenseHp: 100, shieldBonus: 0 },
medium: { defenseHp: 400, shieldBonus: 2 },
large: { defenseHp: 1200, shieldBonus: 4 },
};
const TRIAL_COUNTS = [10, 50, 200, 1000];
// The tech tier (0..9, same scale as rules.techs[].tier) needed in every
// component field to land VegaShips.markFor() on a given Mark. Derived by
// inverting markFor's `1 + floor(avg*0.7)` — see VegaShips.js — so a chosen
// Mark here produces a design identical to an empire that actually researched
// its way there, not an approximation.
const MARK_TIER = {
1: 0, 2: 2, 3: 3, 4: 5, 5: 6, 6: 8, 7: 9,
};
function knownForMark(rules, mark) {
const tier = MARK_TIER[mark] ?? 9;
const known = {};
for (const t of rules.techList) if (t.tier <= tier) known[t.id] = true;
return known;
}
const totalLosses = (losses) => losses.reduce((t, l) => t + l.lost, 0);
export default class VegaCombatSim extends Phaser.Scene {
constructor() { super('MasterVegaSim'); }
preload() {
// Boots straight out of PreloadScene, so the lazy game art (ships sheet,
// sfx, soundtrack) GameRoomScene would normally fetch isn't loaded yet.
queueGameAssets(this, 'mastervega');
}
create() {
this.cameras.main.setBackgroundColor('#03060d');
this.rules = compileRules(this.cache.json.get('mastervega-rules'));
const artwork = this.cache.json.get('mastervega-artwork') ?? { sheets: {} };
this.art = ensureSheets(this, this.rules, artwork).keys;
try {
this.music = new VegaMusic(this, this.cache.json.get('masterofvega-music'));
this.music.setCategory('combat');
} catch (err) { /* music is optional */ }
// Only the hulls a tactical battle actually cares about — scouts,
// transports and colony ships never fire a shot.
this.combatHulls = this.rules.hullList.filter((h) => h.role === 'warship' || h.role === 'base');
this.speciesIds = this.rules.speciesList.map((s) => s.id);
this.battleType = 'ship'; // 'ship' | 'planet'
this.engineMode = 'live'; // 'live' | 'v2' — see the header comment
this.trialCount = 200;
this.sideA = this.freshSide();
this.sideD = this.freshSide();
this.sideD.defenseHp = PLANET_PRESETS.medium.defenseHp;
this.sideD.shieldBonus = PLANET_PRESETS.medium.shieldBonus;
this.applyFleetPreset(this.sideA, 'medium', false);
this.applyFleetPreset(this.sideD, 'medium', false);
this.add.text(GAME_WIDTH / 2, 44, 'MASTER OF VEGA — COMBAT SIMULATOR', {
fontFamily: FONT, fontSize: '34px', color: '#cfe8ff',
}).setOrigin(0.5);
this.add.text(GAME_WIDTH / 2, 82,
'Configure both sides, then watch an animated battle or run a batch of instant trials.', {
fontFamily: FONT, fontSize: '17px', color: '#6f8aa8',
}).setOrigin(0.5);
new Button(this, GAME_WIDTH - 150, 44, 'Exit', uiClick(this, () => { window.location.search = ''; }),
{ width: 200, height: 44, fontSize: 16, variant: 'ghost' });
this.panelLayer = this.add.container(0, 0);
const resultsBg = this.add.rectangle(GAME_WIDTH / 2, 962, 1620, 170, PANEL_BG, 0.6)
.setStrokeStyle(2, PANEL_LINE, 0.9);
this.resultsText = this.add.text(GAME_WIDTH / 2, 962, 'Run a battle to see results here.', {
fontFamily: FONT, fontSize: '20px', color: '#cfe8ff', align: 'center', lineSpacing: 10,
}).setOrigin(0.5);
this.resultsBg = resultsBg;
this.rebuild();
}
freshSide() {
const counts = {};
for (const h of this.combatHulls) counts[h.id] = 0;
return { species: 'human', mark: 4, counts };
}
// ------------------------------------------------------------- mutators
applyFleetPreset(side, size, rebuild = true) {
const p = FLEET_PRESETS[size];
for (const h of this.combatHulls) side.counts[h.id] = p[h.id] ?? 0;
if (rebuild) this.rebuild();
}
applyPlanetPreset(size) {
const p = PLANET_PRESETS[size];
this.sideD.defenseHp = p.defenseHp;
this.sideD.shieldBonus = p.shieldBonus;
this.rebuild();
}
clearSide(side) {
for (const h of this.combatHulls) side.counts[h.id] = 0;
this.rebuild();
}
cycleSpecies(side) {
const i = this.speciesIds.indexOf(side.species);
side.species = this.speciesIds[(i + 1) % this.speciesIds.length];
this.rebuild();
}
cycleMark(side) {
side.mark = side.mark >= 7 ? 1 : side.mark + 1;
this.rebuild();
}
adjustCount(side, hullId, delta) {
side.counts[hullId] = Phaser.Math.Clamp((side.counts[hullId] ?? 0) + delta, 0, 999);
this.rebuild();
}
// --------------------------------------------------------- battle setup
sidePower(side) {
const known = knownForMark(this.rules, side.mark);
const traits = this.rules.species[side.species]?.traits ?? {};
let ships = 0;
let power = 0;
for (const h of this.combatHulls) {
const c = side.counts[h.id] ?? 0;
if (c <= 0) continue;
ships += c;
power += stackPower(designFor(this.rules, known, h.id, traits), c);
}
return { ships, power };
}
buildSideShips(side) {
return this.combatHulls
.filter((h) => (side.counts[h.id] ?? 0) > 0)
.map((h) => ({ hullId: h.id, mark: side.mark, count: side.counts[h.id] }));
}
buildBattleOpts() {
const a = this.sideA;
const d = this.sideD;
const attacker = {
empireIdx: 0,
name: 'Attacker',
empire: { known: knownForMark(this.rules, a.mark), traits: this.rules.species[a.species]?.traits ?? {} },
ships: this.buildSideShips(a),
};
const defender = {
empireIdx: 1,
name: this.battleType === 'planet' ? 'Planetary Defenses' : 'Defender',
empire: { known: knownForMark(this.rules, d.mark), traits: this.rules.species[d.species]?.traits ?? {} },
ships: this.battleType === 'planet' ? [] : this.buildSideShips(d),
};
const colony = this.battleType === 'planet' ? { defenseHp: d.defenseHp, shieldBonus: d.shieldBonus } : null;
return { attacker, defender, colony };
}
battleReady() {
const { attacker, defender, colony } = this.buildBattleOpts();
return attacker.ships.length > 0 && (defender.ships.length > 0 || (colony && colony.defenseHp > 0));
}
// ---------------------------------------------------------------- run
watchBattle() {
if (!this.battleReady()) return;
if (this.engineMode === 'v2') {
// V2 only: a pre-battle prompt for the attacker's ("your") formation
// strategy. The defender ("AI") gets no prompt at all — it picks
// silently, which CombatV2.createBattle already does on its own for
// any side that doesn't supply a valid choice.
openFormationPicker(this, (formationStrategy) => this.launchV2Battle(formationStrategy));
return;
}
const { attacker, defender, colony } = this.buildBattleOpts();
const battle = createBattle(this.rules, {
attacker, defender, colony, starIdx: -1, rnd: Math.random,
});
this.music?.setCategory('combat');
openCombatView(this, this.rules, battle, this.art, {
attackerSpecies: this.sideA.species,
defenderSpecies: this.sideD.species,
playerSide: null,
onDone: (result) => this.showWatchResult(result),
});
}
launchV2Battle(attackerFormation) {
const { attacker, defender, colony } = this.buildBattleOpts();
attacker.formationStrategy = attackerFormation;
const battle = CombatV2.createBattle(this.rules, {
attacker, defender, colony, starIdx: -1, rnd: Math.random,
});
this.music?.setCategory('combat');
openCombatViewV2(this, this.rules, battle, this.art, {
attackerSpecies: this.sideA.species,
defenderSpecies: this.sideD.species,
playerSide: null,
onDone: (result) => this.showWatchResult(result, battle),
});
}
runTrials(n) {
if (!this.battleReady()) return;
const { attacker, defender, colony } = this.buildBattleOpts();
const engine = this.engineMode === 'v2' ? CombatV2 : { createBattle, runBattle };
let aWins = 0;
let dWins = 0;
let draws = 0;
let roundsSum = 0;
let aLossSum = 0;
let dLossSum = 0;
let planetKills = 0;
for (let i = 0; i < n; i += 1) {
const battle = engine.createBattle(this.rules, {
attacker, defender, colony, starIdx: -1, rnd: Math.random,
});
const result = engine.runBattle(battle);
if (result.winner === 'attacker') aWins += 1;
else if (result.winner === 'defender') dWins += 1;
else draws += 1;
roundsSum += result.rounds;
aLossSum += totalLosses(result.attackerLosses);
dLossSum += totalLosses(result.defenderLosses);
if (result.planetDestroyed) planetKills += 1;
}
this.showTrialResults({
n, aWins, dWins, draws, roundsSum, aLossSum, dLossSum, planetKills,
});
}
showWatchResult(result, battle = null) {
const aLoss = totalLosses(result.attackerLosses);
const dLoss = totalLosses(result.defenderLosses);
const winner = result.winner === 'draw' ? 'Draw — mutual stalemate'
: result.winner === 'attacker' ? 'Attacker wins' : 'Defender wins';
let line2 = `Attacker lost ${aLoss} ship${aLoss === 1 ? '' : 's'} · Defender lost ${dLoss} ship${dLoss === 1 ? '' : 's'}`;
if (this.battleType === 'planet') {
line2 += result.planetDestroyed
? ' · Planetary defenses destroyed'
: ` · Planetary defenses at ${Math.round(result.planetDefenseLeft)} HP`;
}
let text = `LAST BATTLE — ${winner} in ${result.rounds} round${result.rounds === 1 ? '' : 's'}\n${line2}`;
if (battle?.attackerFormation) {
text += `\nAttacker formation: ${formationName(battle.attackerFormation)}`
+ ` · Defender formation: ${formationName(battle.defenderFormation)}`;
}
this.resultsText.setText(text);
}
showTrialResults({
n, aWins, dWins, draws, roundsSum, aLossSum, dLossSum, planetKills,
}) {
const pct = (x) => ((x / n) * 100).toFixed(1);
let text = `${n} TRIALS — Attacker wins ${pct(aWins)}% · Defender wins ${pct(dWins)}% · Draws ${pct(draws)}%\n`
+ `Avg rounds ${(roundsSum / n).toFixed(1)} · Avg attacker losses ${(aLossSum / n).toFixed(1)} ships`
+ ` · Avg defender losses ${(dLossSum / n).toFixed(1)} ships`;
if (this.battleType === 'planet') text += `\nPlanetary defenses destroyed in ${pct(planetKills)}% of trials`;
this.resultsText.setText(text);
}
// --------------------------------------------------------------- draw
rebuild() {
this.panelLayer.removeAll(true);
this.drawModeToggle();
this.drawEngineToggle();
this.drawSide(COL_A_X, this.sideA, false, 'ATTACKER FLEET', ATTACKER_COLOR);
this.drawSide(COL_D_X, this.sideD, true, this.battleType === 'planet' ? 'PLANETARY DEFENSES' : 'DEFENDER FLEET', DEFENDER_COLOR);
this.drawRunControls();
}
// Side by side in one row (not a second row) so neither the side panels
// below (which start at TOP=170) nor the run controls further down need
// to move for it.
drawModeToggle() {
const label = this.battleType === 'ship' ? 'Battle Type: Fleet vs Fleet ▸' : 'Battle Type: Fleet vs Planet ▸';
const btn = new Button(this, GAME_WIDTH / 2 - 235, 130, label, uiClick(this, () => {
this.battleType = this.battleType === 'ship' ? 'planet' : 'ship';
this.rebuild();
}), { width: 440, height: 48, fontSize: 18, variant: 'ghost' });
this.panelLayer.add(btn);
}
// Live and V2 are two completely separate engines/views (see the header
// comment) — Watch Battle and Simulate both follow this one switch, so
// what you watch and what the trial stats measure never disagree.
drawEngineToggle() {
const isV2 = this.engineMode === 'v2';
const label = isV2 ? 'Engine: V2 Prototype ▸' : 'Engine: Live ▸';
const btn = new Button(this, GAME_WIDTH / 2 + 235, 130, label, uiClick(this, () => {
this.engineMode = isV2 ? 'live' : 'v2';
this.rebuild();
}), {
width: 440, height: 48, fontSize: 18, variant: isV2 ? 'solid' : 'ghost',
});
this.panelLayer.add(btn);
}
rowLabel(x0, y, label) {
const t = this.add.text(x0 + 24, y, label, { fontFamily: FONT, fontSize: '19px', color: '#cfe0f0' }).setOrigin(0, 0.5);
this.panelLayer.add(t);
}
stepperControl(cx, y, value, onDelta, step) {
const minus = new Button(this, cx - 95, y, '', uiClick(this, () => onDelta(-step)), { width: 40, height: 38, fontSize: 20, variant: 'ghost' });
const valText = this.add.text(cx, y, String(value), { fontFamily: FONT, fontSize: '20px', color: '#f2ead8' }).setOrigin(0.5);
const plus = new Button(this, cx + 95, y, '+', uiClick(this, () => onDelta(step)), { width: 40, height: 38, fontSize: 20, variant: 'ghost' });
this.panelLayer.add([minus, valText, plus]);
}
presetRow(x0, width, y, onPick) {
const gap = 12;
const bw = (width - 40 - gap * 2) / 3;
['small', 'medium', 'large'].forEach((size, i) => {
const bx = x0 + 20 + bw / 2 + i * (bw + gap);
const btn = new Button(this, bx, y, size[0].toUpperCase() + size.slice(1),
uiClick(this, () => onPick(size)), { width: bw, height: 44, fontSize: 16, variant: 'ghost' });
this.panelLayer.add(btn);
});
}
drawSide(x0, side, isDefender, title, accentHex) {
const cx = x0 + COL_W / 2;
const isPlanet = isDefender && this.battleType === 'planet';
const panelH = isPlanet ? 420 : 600;
const bg = this.add.rectangle(cx, TOP + panelH / 2, COL_W, panelH, PANEL_BG, 0.55)
.setStrokeStyle(2, PANEL_LINE, 0.85);
this.panelLayer.add(bg);
this.panelLayer.add(this.add.text(cx, TOP + 26, title, { fontFamily: FONT, fontSize: '24px', color: accentHex }).setOrigin(0.5));
let y = TOP + 76;
if (!isPlanet) {
const spName = this.rules.species[side.species]?.name ?? side.species;
this.rowLabel(x0, y, 'Species');
const spBtn = new Button(this, x0 + COL_W - 140, y, spName, uiClick(this, () => this.cycleSpecies(side)),
{ width: 220, height: 40, fontSize: 17, variant: 'ghost' });
this.panelLayer.add(spBtn);
y += 56;
this.rowLabel(x0, y, 'Tech Level');
const markBtn = new Button(this, x0 + COL_W - 140, y, `Mark ${markNumeral(side.mark)}`, uiClick(this, () => this.cycleMark(side)),
{ width: 220, height: 40, fontSize: 17, variant: 'ghost' });
this.panelLayer.add(markBtn);
y += 70;
for (const h of this.combatHulls) {
this.rowLabel(x0, y, h.name);
const count = side.counts[h.id] ?? 0;
this.stepperControl(x0 + COL_W - 170, y, count, (d) => this.adjustCount(side, h.id, d), 1);
y += 56;
}
y += 14;
this.presetRow(x0, COL_W, y, (size) => this.applyFleetPreset(side, size));
y += 60;
const clearBtn = new Button(this, cx, y, 'Clear', uiClick(this, () => this.clearSide(side)),
{ width: 160, height: 38, fontSize: 15, variant: 'ghost' });
this.panelLayer.add(clearBtn);
y += 48;
const { ships, power } = this.sidePower(side);
this.panelLayer.add(this.add.text(cx, y, `${ships} ship${ships === 1 ? '' : 's'} · power score ${power.toLocaleString()}`, {
fontFamily: FONT, fontSize: '16px', color: '#6f8aa8',
}).setOrigin(0.5));
} else {
this.rowLabel(x0, y, 'Defense HP');
this.stepperControl(x0 + COL_W - 170, y, side.defenseHp, (d) => {
side.defenseHp = Phaser.Math.Clamp(side.defenseHp + d, 0, 5000);
this.rebuild();
}, 50);
y += 56;
this.rowLabel(x0, y, 'Shield Bonus');
this.stepperControl(x0 + COL_W - 170, y, side.shieldBonus, (d) => {
side.shieldBonus = Phaser.Math.Clamp(side.shieldBonus + d, 0, 10);
this.rebuild();
}, 1);
y += 70;
this.presetRow(x0, COL_W, y, (size) => this.applyPlanetPreset(size));
y += 80;
this.panelLayer.add(this.add.text(cx, y,
'No orbiting ships — the attacker must break the planetary\ndefenses alone before the colony can be invaded.', {
fontFamily: FONT, fontSize: '15px', color: '#5a7086', align: 'center', lineSpacing: 6,
}).setOrigin(0.5));
}
}
drawRunControls() {
const ready = this.battleReady();
const y = 830;
const watchBtn = new Button(this, GAME_WIDTH / 2 - 260, y, 'Watch Battle ▶', uiClick(this, () => this.watchBattle()),
{ width: 300, height: 58, fontSize: 20 });
watchBtn.setEnabled(ready);
this.panelLayer.add(watchBtn);
const trialBtn = new Button(this, GAME_WIDTH / 2 + 10, y, `Trials: ${this.trialCount}`, uiClick(this, () => {
const i = TRIAL_COUNTS.indexOf(this.trialCount);
this.trialCount = TRIAL_COUNTS[(i + 1) % TRIAL_COUNTS.length];
this.rebuild();
}), { width: 180, height: 58, fontSize: 18, variant: 'ghost' });
this.panelLayer.add(trialBtn);
const simBtn = new Button(this, GAME_WIDTH / 2 + 260, y, `Simulate ×${this.trialCount}`, uiClick(this, () => this.runTrials(this.trialCount)),
{ width: 300, height: 58, fontSize: 20 });
simBtn.setEnabled(ready);
this.panelLayer.add(simBtn);
if (!ready) {
const { attacker } = this.buildBattleOpts();
const msg = attacker.ships.length === 0
? 'Attacker needs at least one ship.'
: 'Defender needs ships, or a Battle Type of Fleet vs Planet with Defense HP above 0.';
this.panelLayer.add(this.add.text(GAME_WIDTH / 2, y + 48, msg, {
fontFamily: FONT, fontSize: '16px', color: '#e08a8a',
}).setOrigin(0.5));
}
}
}