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

360 lines
15 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, 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,
};
}