// Master of Vega — zoom/pan/parallax for the VegaCombatViewV2 per-ship // battle screen. Adapted from VegaStarMap.js's manual-container-scale // pattern (cursor-anchored zoom, drag-to-pan, a scattered-circle parallax // starfield) rather than a real Phaser camera — combat view is a modal // overlay function drawn inside the caller's existing scene, not a Scene // that owns `cameras.main` outright, so the star map's approach is the // better fit here too. // // One real difference from VegaStarMap: that class is instantiated once per // game session and its `scene.input`/`scene.events` listeners live for the // whole session. openCombatViewV2() is a plain function called ONCE PER // BATTLE, and the game can open several battles in a row within one scene // session (MasterOfVegaGame.playPlayerBattles() loops over pending fights). // bindZoomPan()'s destroy() unregisters every listener it added for exactly // this reason — skipping that would stack another listener (with a stale // closure over an already-destroyed container) on top of every earlier // battle's, first as a leak, then as a crash the moment a stale listener // fires against a destroyed root. import * as Phaser from 'phaser'; import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; import { playSound, SFX } from '../../ui/Sounds.js'; import { mulberry32 } from './VegaGalaxyGen.js'; import { buildZoomLadder, minZoomFor, pickFitZoomIndex } from './VegaZoom.js'; const PAN_SLACK = 200; // Four layers of scattered circles, near/sparse/bright/fast to far/dense/ // dim/slow — same recipe as VegaStarMap.buildParallax, just a standalone // function since this view is a plain function, not a class with its own // `this.starfield`. Caller supplies the (screen-space) container to draw // into; repositioning each layer as the camera pans is bindZoomPan's job // (see `parallaxLayers` below), not this function's. export function buildParallax(scene, container, seed) { const rnd = mulberry32(seed * 7919 + 13); const specs = [ { count: 200, rate: 0.08, size: 1.0, alpha: 0.35 }, { count: 140, rate: 0.16, size: 1.4, alpha: 0.5 }, { count: 90, rate: 0.28, size: 1.9, alpha: 0.7 }, { count: 36, rate: 0.44, size: 2.6, alpha: 0.9 }, ]; const layers = []; for (const spec of specs) { const g = scene.add.graphics(); for (let i = 0; i < spec.count; i += 1) { const x = rnd() * GAME_WIDTH * 1.6 - GAME_WIDTH * 0.3; const y = rnd() * GAME_HEIGHT * 1.6 - GAME_HEIGHT * 0.3; const tone = 0.65 + rnd() * 0.35; const c = Math.round(255 * tone); g.fillStyle((c << 16) | (c << 8) | 255, spec.alpha); g.fillCircle(x, y, spec.size); } container.add(g); layers.push({ g, rate: spec.rate }); } return layers; } /** * Wires cursor-anchored zoom (wheel) and drag-to-pan onto `root`, a * world-space container the caller has already populated. Returns * `{ applyZoom, clampPan, getZoomIndex, destroy }` — call `destroy()` when * the battle view closes, unconditionally, even if the view is also about * to `layer.destroy()` its containers; the listeners live on `scene.input`/ * `scene.events`, not on `root`, so destroying `root` alone does not remove * them. * * `worldW`/`worldH` size the zoom ladder (see VegaZoom.buildZoomLadder) and * the pan-clamp bounds; `steps`/`maxZoom` default to a closer, finer ladder * than the galaxy map's, since a battle world is fixed-size and benefits * from a genuine close-up on individual ships. `parallaxLayers` (from * buildParallax, if any) are repositioned every frame at their own rate as * `root` pans — purely position-based, not rescaled with zoom, same as the * star map's starfield. * * `fitBounds` (world units, `{minX, minY, maxX, maxY}` — see * VegaCombatV2.shipBounds()) picks the starting zoom to frame that box * (plus `fitPadding` on every side) instead of `defaultIndex`'s fixed rung, * via VegaZoom.pickFitZoomIndex — so a small skirmish opens zoomed in close * and a big fleet action opens zoomed further out, both filling the screen * with the actual fight rather than a fixed slice of a now much bigger * world. Falls back to `defaultIndex` if omitted. Because `placeShips()` * centers each side's grid on the world's own center in both axes, the * world's center and the fleet's bounding-box center coincide exactly, so * panning still just centers on the world below — only the zoom rung * changes, not the centering math. */ export function bindZoomPan(scene, root, { worldW, worldH, steps = 6, maxZoom = 4.0, defaultIndex = 0, fitBounds = null, fitPadding = 180, blockPointer, blockWheel, parallaxLayers = [], onZoom, } = {}) { const zooms = buildZoomLadder(worldW, worldH, { steps, maxZoom }); let zoomIndex = fitBounds ? pickFitZoomIndex(zooms, fitBounds.maxX - fitBounds.minX, fitBounds.maxY - fitBounds.minY, fitPadding) : Math.min(defaultIndex, zooms.length - 1); let zoom = zooms[zoomIndex]; root.setScale(zoom); function clampPan() { const w = worldW * zoom; const h = worldH * zoom; root.x = w + PAN_SLACK * 2 >= GAME_WIDTH ? Phaser.Math.Clamp(root.x, GAME_WIDTH - w - PAN_SLACK, PAN_SLACK) : (GAME_WIDTH - w) / 2; root.y = h + PAN_SLACK * 2 >= GAME_HEIGHT ? Phaser.Math.Clamp(root.y, GAME_HEIGHT - h - PAN_SLACK, PAN_SLACK) : (GAME_HEIGHT - h) / 2; } // Center the world in the viewport at the starting zoom (see the // `fitBounds` doc comment above for why this stays correct even when the // zoom was picked to frame the fleet rather than the whole world). root.x = (GAME_WIDTH - worldW * zoom) / 2; root.y = (GAME_HEIGHT - worldH * zoom) / 2; clampPan(); function applyZoom(newIndex, focusX = GAME_WIDTH / 2, focusY = GAME_HEIGHT / 2) { const idx = Phaser.Math.Clamp(newIndex, 0, zooms.length - 1); if (idx === zoomIndex) return; playSound(scene, idx > zoomIndex ? SFX.VEGA_ZOOMIN : SFX.VEGA_ZOOMOUT); const old = zoom; const next = zooms[idx]; // Keep whatever is under the cursor under the cursor. const worldX = (focusX - root.x) / old; const worldY = (focusY - root.y) / old; zoomIndex = idx; zoom = next; root.setScale(next); root.x = focusX - worldX * next; root.y = focusY - worldY * next; clampPan(); onZoom?.(next); } let dragging = false; let dragged = false; let startX = 0; let startY = 0; let originX = 0; let originY = 0; const onDown = (p) => { if (blockPointer?.(p)) return; dragging = true; dragged = false; startX = p.x; startY = p.y; originX = root.x; originY = root.y; }; const onMove = (p) => { if (!dragging) return; const dx = p.x - startX; const dy = p.y - startY; if (Math.abs(dx) > 8 || Math.abs(dy) > 8) dragged = true; if (!dragged) return; root.x = originX + dx; root.y = originY + dy; clampPan(); }; const onUp = () => { dragging = false; }; const onWheel = (p, _objs, _dx, dy) => { if (blockWheel?.(p) || blockPointer?.(p)) return; applyZoom(zoomIndex + (dy > 0 ? -1 : 1), p.x, p.y); }; scene.input.on('pointerdown', onDown); scene.input.on('pointermove', onMove); scene.input.on('pointerup', onUp); scene.input.on('wheel', onWheel); const onTick = () => { for (const layer of parallaxLayers) { layer.g.setPosition(root.x * layer.rate, root.y * layer.rate); } }; scene.events.on('update', onTick); return { applyZoom, clampPan, getZoomIndex: () => zoomIndex, destroy: () => { scene.input.off('pointerdown', onDown); scene.input.off('pointermove', onMove); scene.input.off('pointerup', onUp); scene.input.off('wheel', onWheel); scene.events.off('update', onTick); }, }; } // Re-exported for callers that want to size a starting camera without // building a full ladder (e.g. picking a sensible default before the view // exists yet). export { minZoomFor };