360 lines
15 KiB
JavaScript
360 lines
15 KiB
JavaScript
// Master of Vega — the tactical battle screen, PER-SHIP PROTOTYPE.
|
||
//
|
||
// Mirrors VegaCombatView.js's shape (drive VegaCombatV2's simulation,
|
||
// animate the events that come back — "watch it play out" and "auto-resolve"
|
||
// can never disagree) but renders every individual ship on its own, sized/
|
||
// rotated by hull, inside a zoomable/pannable world with a parallax
|
||
// starfield behind it. Wired only into VegaCombatSim.js's (?movsim) Live/V2
|
||
// toggle — the real game's battle screen (VegaCombatView.js) is untouched.
|
||
//
|
||
// Unlike the old discrete-round engine, VegaCombatV2 no longer has "rounds"
|
||
// to step through one at a time — it's a continuous simulation advanced at a
|
||
// fixed SIM_DT tick. This view drives that tick with a real-time
|
||
// accumulator off the scene's own 'update' event (same lifecycle pattern as
|
||
// VegaCombatCamera's parallax reposition hook — registered once per battle,
|
||
// explicitly unregistered in destroy()/finish() so a second battle opened in
|
||
// the same scene session doesn't stack a second ticker on a torn-down view).
|
||
// Ship containers are set directly to the engine's real x/y/facing every
|
||
// frame rather than tweened between round snapshots — motion is already
|
||
// smooth at 30 ticks/sec, so a tween on top of it would only add lag.
|
||
|
||
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 {
|
||
advance, runBattle, battleResult, shipBounds, SIM_DT,
|
||
} from './VegaCombatV2.js';
|
||
import { shipFrame } from './VegaArt.js';
|
||
import { buildParallax, bindZoomPan } from './VegaCombatCamera.js';
|
||
import { formationName } from './VegaFormations.js';
|
||
|
||
// Same baseline the live view used for every hull uniformly; here it's
|
||
// multiplied by the firing ship's hull.sizeScale instead.
|
||
const SHIP_BASE_SIZE = 58;
|
||
|
||
// Fire events now arrive as a steady trickle (each ship's own ~2s cooldown,
|
||
// staggered by whenever it entered range) rather than one big per-round
|
||
// batch, so every event gets both its visual FX AND its sound cue — no
|
||
// per-round sampling needed any more. What DOES still need a cap is a burst
|
||
// of many ships happening to fire in the same real-time window (e.g. a big
|
||
// fleet all closing to range together): a minimum real-time gap between sfx
|
||
// starts keeps that from turning into a wall of overlapping audio, the same
|
||
// problem the old per-round cap solved, just measured continuously instead
|
||
// of batched.
|
||
const SOUND_MIN_GAP_MS = 90;
|
||
const DEATH_SOUND_MIN_GAP_MS = 140;
|
||
|
||
// Real-time accumulator clamp: if the tab was backgrounded or a frame spikes
|
||
// badly, don't try to catch up by running hundreds of sim ticks in one
|
||
// frame — clamp the catch-up window and let the battle simply run a little
|
||
// slower in wall-clock time instead of freezing the frame.
|
||
const MAX_CATCHUP_SEC = 0.25;
|
||
|
||
// Weapon cues are banded by the firing ship's Mark (I-VII) — identical
|
||
// mapping to VegaCombatView.js, duplicated rather than imported since that
|
||
// file's helpers aren't exported (kept private to its own module).
|
||
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 openCombatViewV2(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);
|
||
// Solid, not the live view's semi-transparent veil — the starfield sits
|
||
// right on top of this, and a translucent backdrop let whatever screen was
|
||
// open underneath (star map, etc.) bleed through around the sparse stars.
|
||
layer.add(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x01040a, 1)
|
||
.setOrigin(0, 0).setInteractive());
|
||
|
||
// Parallax starfield: screen-space, behind the world, deterministic per
|
||
// battle (not per render) so reopening the same battle looks the same.
|
||
const starLayer = scene.add.container(0, 0);
|
||
layer.add(starLayer);
|
||
const seed = Math.abs((battle.starIdx + 1) * 7919 + battle.attackerIdx * 31 + battle.defenderIdx * 17);
|
||
const parallaxLayers = buildParallax(scene, starLayer, seed);
|
||
|
||
// World-space root: everything that pans/zooms lives inside this one
|
||
// container. fxLayer is INSIDE battleRoot (not a sibling) specifically so
|
||
// beam/hit/explosion FX are drawn in the same transformed space as the
|
||
// ships and never need manual world-to-screen matrix math, including
|
||
// mid-animation if the camera pans or zooms.
|
||
const battleRoot = scene.add.container(0, 0);
|
||
layer.add(battleRoot);
|
||
const fxLayer = scene.add.container(0, 0);
|
||
battleRoot.add(fxLayer);
|
||
const fx = new VegaFx(scene, fxLayer);
|
||
const shipLayer = scene.add.container(0, 0);
|
||
battleRoot.add(shipLayer);
|
||
|
||
const camera = bindZoomPan(scene, battleRoot, {
|
||
worldW: rules.combatV2.worldWidth,
|
||
worldH: rules.combatV2.worldHeight,
|
||
parallaxLayers,
|
||
// Opens framing however many ships are actually in THIS battle, not a
|
||
// fixed rung — a 2-ship skirmish starts zoomed in, a 20-ship fleet
|
||
// action starts zoomed out, both filling the screen.
|
||
fitBounds: shipBounds(battle.ships),
|
||
});
|
||
|
||
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, 't = 0.0s', {
|
||
fontFamily: FONT, fontSize: '20px', color: '#8fa8c0',
|
||
}).setOrigin(0.5);
|
||
layer.add(roundText);
|
||
|
||
const hint = scene.add.text(GAME_WIDTH / 2, 108, 'Scroll to zoom, drag to pan', {
|
||
fontFamily: FONT, fontSize: '14px', color: '#4d6478',
|
||
}).setOrigin(0.5);
|
||
layer.add(hint);
|
||
|
||
// Formations are fixed for the whole battle (chosen before it started —
|
||
// VegaCombatSim.js's picker for the attacker, silently for the defender),
|
||
// so this is set once and never updated.
|
||
const formationText = scene.add.text(GAME_WIDTH / 2, 134,
|
||
`${formationName(battle.attackerFormation)} vs ${formationName(battle.defenderFormation)}`, {
|
||
fontFamily: FONT, fontSize: '15px', color: '#7f97b3',
|
||
}).setOrigin(0.5);
|
||
layer.add(formationText);
|
||
|
||
// Fleet-composition readout stands in for per-ship labels (which would
|
||
// clutter fast at 5-20 ships a side) — a placeholder overlay, same as
|
||
// ship placement itself; a real formation UI is deferred to a later pass.
|
||
const attackerSummary = scene.add.text(36, 128, '', {
|
||
fontFamily: FONT, fontSize: '15px', color: '#9fd8ff', lineSpacing: 4,
|
||
});
|
||
layer.add(attackerSummary);
|
||
const defenderSummary = scene.add.text(GAME_WIDTH - 36, 128, '', {
|
||
fontFamily: FONT, fontSize: '15px', color: '#ffb0a0', align: 'right', lineSpacing: 4,
|
||
}).setOrigin(1, 0);
|
||
layer.add(defenderSummary);
|
||
|
||
function refreshSummaries() {
|
||
const summarize = (side) => {
|
||
const byHull = new Map();
|
||
for (const s of battle.ships) {
|
||
if (s.side !== side || s.isPlanet || s.hp <= 0) continue;
|
||
byHull.set(s.hullId, (byHull.get(s.hullId) ?? 0) + 1);
|
||
}
|
||
const lines = [...byHull.entries()].map(([id, n]) => `${rules.hulls[id]?.name ?? id} ×${n}`);
|
||
return lines.length ? lines.join('\n') : '(no ships remain)';
|
||
};
|
||
attackerSummary.setText(summarize('attacker'));
|
||
defenderSummary.setText(summarize('defender'));
|
||
}
|
||
|
||
const markers = new Map();
|
||
let lastSummaryAlive = -1;
|
||
|
||
function buildMarkers() {
|
||
shipLayer.removeAll(true);
|
||
markers.clear();
|
||
for (const s of battle.ships) {
|
||
const c = scene.add.container(s.x, s.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 = s.side === 'attacker' ? attackerSpecies : defenderSpecies;
|
||
const size = SHIP_BASE_SIZE * (s.design.hull.sizeScale ?? 1);
|
||
// Rotation lives on the CONTAINER only (see syncMarkers below), not
|
||
// here on the image — the image itself stays unrotated. Ship art
|
||
// faces "up" natively; the sim's `facing` uses the usual atan2
|
||
// convention (0 = +X/right). +PI/2 converts one to the other —
|
||
// matches the live view's fixed ±PI/2 exactly at facing 0/PI
|
||
// (attacker/defender starting headings). Rotating BOTH the image
|
||
// here and the container in syncMarkers double-applies this offset
|
||
// (the two stack), which is what made ships appear to face roughly
|
||
// 180° off their real heading — caught by inspection, not by eye.
|
||
const img = scene.add.image(0, 0, art.ships, shipFrame(rules, species, s.hullId))
|
||
.setDisplaySize(size, size);
|
||
c.add(img);
|
||
}
|
||
shipLayer.add(c);
|
||
markers.set(s.uid, { ship: s, container: c });
|
||
}
|
||
refreshSummaries();
|
||
}
|
||
|
||
// Called every frame (not tweened) — the sim already produces smooth
|
||
// per-tick motion at SIM_DT, so this is a direct position/heading copy,
|
||
// not an animation of its own.
|
||
function syncMarkers() {
|
||
let aliveCount = 0;
|
||
for (const [, m] of markers) {
|
||
const alive = m.ship.hp > 0 && !m.ship.retreated;
|
||
if (alive) aliveCount += 1;
|
||
m.container.setAlpha(alive ? 1 : 0.25);
|
||
m.container.setPosition(m.ship.x, m.ship.y);
|
||
if (!m.ship.isPlanet) m.container.setRotation(m.ship.facing + Math.PI / 2);
|
||
}
|
||
if (aliveCount !== lastSummaryAlive) {
|
||
lastSummaryAlive = aliveCount;
|
||
refreshSummaries();
|
||
}
|
||
}
|
||
|
||
let nextSoundAt = 0;
|
||
let nextDeathSoundAt = 0;
|
||
|
||
function handleEvents(events) {
|
||
for (const ev of events) {
|
||
const from = markers.get(ev.from);
|
||
const to = markers.get(ev.to);
|
||
if (ev.kind === 'fire' && from && to) {
|
||
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 now = scene.time.now;
|
||
if (now >= nextSoundAt) {
|
||
nextSoundAt = now + SOUND_MIN_GAP_MS;
|
||
const key = missile ? missileLaunchSfxKey(mark) : weaponSfxKey(mark);
|
||
playSound(scene, key);
|
||
if (missile) {
|
||
scene.time.delayedCall(hitDelay, () => playSound(scene, missileHitSfxKey(mark)));
|
||
}
|
||
}
|
||
}
|
||
} else if (ev.kind === 'losses') {
|
||
// Exact death position, straight off the event — a fidelity win the
|
||
// per-ship model buys for free (the stack-based view could only
|
||
// explode at a stack's shared marker position).
|
||
const m = markers.get(ev.uid);
|
||
const x = ev.x ?? m?.container.x;
|
||
const y = ev.y ?? m?.container.y;
|
||
if (x != null && y != null) {
|
||
scene.time.delayedCall(180, () => fx.explode(x, y, 0xffa050, 1));
|
||
}
|
||
const destroyKey = m && destroySfxKey(m.ship.hullId);
|
||
if (destroyKey) {
|
||
const now = scene.time.now;
|
||
if (now >= nextDeathSoundAt) {
|
||
nextDeathSoundAt = now + DEATH_SOUND_MIN_GAP_MS;
|
||
playSound(scene, destroyKey);
|
||
}
|
||
}
|
||
} else if (ev.kind === 'retreat') {
|
||
const m = markers.get(ev.uid);
|
||
if (m) m.container.setAlpha(0.25);
|
||
}
|
||
}
|
||
}
|
||
|
||
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, () => {
|
||
teardown();
|
||
onDone?.(result);
|
||
});
|
||
}
|
||
|
||
// --- real-time playback ---
|
||
let playing = true;
|
||
let accumulator = 0;
|
||
|
||
const onUpdate = (_time, deltaMs) => {
|
||
if (battle.done) return;
|
||
if (playing) {
|
||
accumulator = Math.min(accumulator + deltaMs / 1000, MAX_CATCHUP_SEC);
|
||
while (accumulator >= SIM_DT) {
|
||
accumulator -= SIM_DT;
|
||
const step = advance(battle, SIM_DT, { allowRetreat: true });
|
||
if (!step) break;
|
||
if (step.events.length) handleEvents(step.events);
|
||
if (battle.done) break;
|
||
}
|
||
}
|
||
roundText.setText(`t = ${battle.elapsed.toFixed(1)}s`);
|
||
syncMarkers();
|
||
if (battle.done) {
|
||
scene.events.off('update', onUpdate);
|
||
scene.time.delayedCall(500, finish);
|
||
}
|
||
};
|
||
scene.events.on('update', onUpdate);
|
||
|
||
function teardown() {
|
||
scene.events.off('update', onUpdate);
|
||
camera.destroy();
|
||
fx.destroy();
|
||
layer.destroy();
|
||
}
|
||
|
||
// --- controls
|
||
const playPause = new Button(scene, GAME_WIDTH / 2 - 230, GAME_HEIGHT - 70, 'Pause', uiClick(scene, () => {
|
||
if (battle.done) return;
|
||
playing = !playing;
|
||
playPause.text.setText(playing ? 'Pause' : 'Play');
|
||
}), { width: 220, height: 52 });
|
||
layer.add(playPause);
|
||
|
||
const auto = new Button(scene, GAME_WIDTH / 2, GAME_HEIGHT - 70, 'Auto-resolve', uiClick(scene, () => {
|
||
if (battle.done) return;
|
||
scene.events.off('update', onUpdate);
|
||
runBattle(battle);
|
||
syncMarkers();
|
||
roundText.setText(`t = ${battle.elapsed.toFixed(1)}s`);
|
||
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;
|
||
// Every one of the player's living ships, individually — matches the
|
||
// live view's Withdraw, which was always all-or-nothing per side too.
|
||
// Takes effect on the next simulated tick, same as any other order.
|
||
for (const s of battle.ships) if (s.side === playerSide) battle.orders[s.uid] = 'retreat';
|
||
}), { width: 220, height: 52, variant: playerSide ? 'solid' : 'ghost' });
|
||
layer.add(retreat);
|
||
|
||
buildMarkers();
|
||
syncMarkers();
|
||
return {
|
||
layer,
|
||
destroy: teardown,
|
||
};
|
||
}
|