// 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, }; }