231 lines
9.2 KiB
JavaScript
231 lines
9.2 KiB
JavaScript
// Master of Vega — the tactical battle screen.
|
||
//
|
||
// This view does not decide anything. It drives VegaCombat's stepper one round
|
||
// at a time and animates the events that come back, which is why "play it out"
|
||
// and "auto-resolve" can never disagree: auto-resolve is the same stepper with
|
||
// the animation skipped.
|
||
|
||
import * as Phaser from 'phaser';
|
||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||
import { Button } from '../../ui/Button.js';
|
||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||
import { FONT, D, uiClick } from './VegaScreens.js';
|
||
import VegaFx from './VegaFx.js';
|
||
import { stepRound, runBattle, battleResult } from './VegaCombat.js';
|
||
import { shipFrame } from './VegaArt.js';
|
||
|
||
const LANE_LEFT = 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;
|
||
}
|
||
// Only the four combatant hulls have a clip; other hulls (starbase, or an
|
||
// unarmed civilian ship caught in a raid) explode silently — visual FX only.
|
||
function destroySfxKey(hullId) {
|
||
if (hullId === 'frigate') return SFX.VEGA_DESTROY_FRIGATE;
|
||
if (hullId === 'destroyer') return SFX.VEGA_DESTROY_DESTROYER;
|
||
if (hullId === 'cruiser') return SFX.VEGA_DESTROY_CRUISER;
|
||
if (hullId === 'battleship') return SFX.VEGA_DESTROY_BATTLESHIP;
|
||
return null;
|
||
}
|
||
|
||
export function openCombatView(scene, rules, battle, art, opts = {}) {
|
||
const { attackerSpecies = 'human', defenderSpecies = 'human', onDone = null, playerSide = null } = opts;
|
||
|
||
const layer = scene.add.container(0, 0).setDepth(D.modal);
|
||
layer.add(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x01040a, 0.94)
|
||
.setOrigin(0, 0).setInteractive());
|
||
|
||
const fxLayer = scene.add.container(0, 0);
|
||
layer.add(fxLayer);
|
||
const fx = new VegaFx(scene, fxLayer);
|
||
|
||
const title = scene.add.text(GAME_WIDTH / 2, 40,
|
||
`${battle.attackerName} vs ${battle.defenderName}`, {
|
||
fontFamily: FONT, fontSize: '30px', color: '#cfe8ff',
|
||
}).setOrigin(0.5);
|
||
layer.add(title);
|
||
|
||
const roundText = scene.add.text(GAME_WIDTH / 2, 82, 'Round 0', {
|
||
fontFamily: FONT, fontSize: '20px', color: '#8fa8c0',
|
||
}).setOrigin(0.5);
|
||
layer.add(roundText);
|
||
|
||
const stackLayer = scene.add.container(0, 0);
|
||
layer.add(stackLayer);
|
||
|
||
const colX = (x) => LANE_LEFT + (x / (rules.combat.gridCols - 1)) * (LANE_RIGHT - LANE_LEFT);
|
||
|
||
const markers = new Map();
|
||
|
||
function buildMarkers() {
|
||
stackLayer.removeAll(true);
|
||
markers.clear();
|
||
const rows = { attacker: 0, defender: 0 };
|
||
for (const s of battle.stacks) {
|
||
const side = s.side;
|
||
const row = rows[side]++;
|
||
const y = 200 + row * 120 + (side === 'attacker' ? 0 : 60);
|
||
const c = scene.add.container(colX(s.x), y);
|
||
|
||
if (s.isPlanet) {
|
||
const g = scene.add.graphics();
|
||
g.fillStyle(0x8a6a3a, 1);
|
||
g.fillCircle(0, 0, 26);
|
||
g.lineStyle(2, 0xffd88a, 0.8);
|
||
g.strokeCircle(0, 0, 32);
|
||
c.add(g);
|
||
} else {
|
||
const species = side === 'attacker' ? attackerSpecies : defenderSpecies;
|
||
const img = scene.add.image(0, 0, art.ships, shipFrame(rules, species, s.hullId))
|
||
.setDisplaySize(58, 58)
|
||
.setRotation(side === 'attacker' ? Math.PI / 2 : -Math.PI / 2);
|
||
c.add(img);
|
||
}
|
||
|
||
const label = scene.add.text(0, 38, `${s.name}`, {
|
||
fontFamily: FONT, fontSize: '13px', color: '#9fb6cc',
|
||
}).setOrigin(0.5);
|
||
c.add(label);
|
||
const count = scene.add.text(0, 54, `×${s.count}`, {
|
||
fontFamily: FONT, fontSize: '16px', color: side === 'attacker' ? '#9fd8ff' : '#ffb0a0',
|
||
}).setOrigin(0.5);
|
||
c.add(count);
|
||
|
||
stackLayer.add(c);
|
||
markers.set(s.uid, { stack: s, container: c, count, label, y });
|
||
}
|
||
}
|
||
|
||
function syncMarkers() {
|
||
for (const [, m] of markers) {
|
||
m.count.setText(`×${m.stack.count}`);
|
||
m.container.setAlpha(m.stack.count > 0 && !m.stack.retreated ? 1 : 0.25);
|
||
scene.tweens.add({
|
||
targets: m.container, x: colX(m.stack.x), duration: 260, ease: 'Sine.easeInOut',
|
||
});
|
||
}
|
||
}
|
||
|
||
function animate(step) {
|
||
if (!step) return;
|
||
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) {
|
||
const from = markers.get(ev.from);
|
||
const to = markers.get(ev.to);
|
||
if (ev.kind === 'fire' && from && to) {
|
||
// 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,
|
||
missile ? 0xffb060 : 0x9fd8ff, missile);
|
||
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 (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') {
|
||
const m = markers.get(ev.uid);
|
||
if (m) scene.time.delayedCall(180, () => {
|
||
if (m.container.active) fx.explode(m.container.x, m.container.y, 0xffa050, 1);
|
||
});
|
||
// Reuses the same stagger counter/timeline as the weapon cues above,
|
||
// so a destroy cue never lands on top of one of them — a round
|
||
// rarely loses more than one or two stacks at once, but this keeps
|
||
// it safe if it ever does.
|
||
const destroyKey = m && destroySfxKey(m.stack.hullId);
|
||
if (destroyKey) {
|
||
const thisDelay = soundDelay;
|
||
soundDelay += SOUND_STAGGER_MS;
|
||
scene.time.delayedCall(thisDelay, () => playSound(scene, destroyKey));
|
||
}
|
||
} else if (ev.kind === 'retreat') {
|
||
const m = markers.get(ev.uid);
|
||
if (m) m.container.setAlpha(0.25);
|
||
}
|
||
}
|
||
scene.time.delayedCall(320, syncMarkers);
|
||
}
|
||
|
||
function finish() {
|
||
const result = battleResult(battle);
|
||
const won = playerSide && result.winner === playerSide;
|
||
const banner = scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2,
|
||
result.winner === 'draw' ? 'STALEMATE'
|
||
: `${result.winner === 'attacker' ? battle.attackerName : battle.defenderName} HOLDS THE FIELD`, {
|
||
fontFamily: FONT, fontSize: '52px',
|
||
color: playerSide ? (won ? '#ffd88a' : '#e08a8a') : '#cfe8ff',
|
||
}).setOrigin(0.5);
|
||
layer.add(banner);
|
||
scene.time.delayedCall(1400, () => {
|
||
fx.destroy();
|
||
layer.destroy();
|
||
onDone?.(result);
|
||
});
|
||
}
|
||
|
||
// --- controls
|
||
// 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;
|
||
playSound(scene, SFX.VEGA_ENDTURN);
|
||
animate(stepRound(battle, {}));
|
||
if (battle.done) scene.time.delayedCall(700, finish);
|
||
}, { width: 220, height: 52 });
|
||
layer.add(next);
|
||
|
||
const auto = new Button(scene, GAME_WIDTH / 2, GAME_HEIGHT - 70, 'Auto-resolve', uiClick(scene, () => {
|
||
runBattle(battle);
|
||
syncMarkers();
|
||
finish();
|
||
}), { width: 220, height: 52 });
|
||
layer.add(auto);
|
||
|
||
const retreat = new Button(scene, GAME_WIDTH / 2 + 230, GAME_HEIGHT - 70, 'Withdraw', uiClick(scene, () => {
|
||
if (battle.done || !playerSide) return;
|
||
const orders = {};
|
||
for (const s of battle.stacks) if (s.side === playerSide) orders[s.uid] = 'retreat';
|
||
animate(stepRound(battle, orders));
|
||
if (battle.done) scene.time.delayedCall(700, finish);
|
||
}), { width: 220, height: 52, variant: playerSide ? 'solid' : 'ghost' });
|
||
layer.add(retreat);
|
||
|
||
buildMarkers();
|
||
syncMarkers();
|
||
return { layer, destroy: () => { fx.destroy(); layer.destroy(); } };
|
||
}
|