195 lines
7.8 KiB
JavaScript
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 };
|