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

195 lines
7.8 KiB
JavaScript

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