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

231 lines
9.2 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 — 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(); } };
}