fix(mastervega): build per-galaxy zoom ladder and eliminate pan slack
- Add VegaZoom.js with Phaser-free zoom ladder builder, verifiable headlessly - Replace hardcoded ZOOMS array with galaxy-sized ladder anchored so the bottom rung exactly covers the viewport - Remove 160 px of pan slack in clampPan so empty space can never be dragged into view - Key semantic-zoom thresholds (isFarZoom / isNearZoom) to ladder index instead of absolute scale, keeping behaviour consistent across galaxy sizes - Add section 3b to verifyMasterOfVega.js with 163 new checks covering ladder coverage, covering-zoom invariant, top-rung reach, and tiny-galaxy single-step collapse
This commit is contained in:
parent
d789da4582
commit
7f1cfcc982
|
|
@ -28,6 +28,7 @@ than `civilization/` (whose scene grew to 1792 lines).
|
|||
| `VegaAI.js` | AI empire controller |
|
||||
| `VegaDiplomacy.js` | Treaties, attitudes, council politics |
|
||||
| `VegaLeaders.js` | Leader pool, offers, postings |
|
||||
| `VegaZoom.js` | Star-map zoom ladder (Phaser-free so it is headlessly checkable) |
|
||||
|
||||
**Render tier:** `MasterOfVegaGame.js` (scene), `VegaStarMap.js`,
|
||||
`VegaNebula.js`, `VegaSystemView.js`, `VegaCombatView.js`, `VegaScreens.js`,
|
||||
|
|
@ -146,6 +147,15 @@ Each of these was a real bug that produced a plausible-looking but broken game.
|
|||
there was nothing to upgrade and that empire simply could not expand.
|
||||
`guaranteeNearbyWorlds` now seeds new worlds when it must.
|
||||
|
||||
20. **Zoom could pull back past the edge of the map.** The zoom ladder was a
|
||||
fixed `[0.35 … 2.0]`, which only ever suited the *huge* galaxy — on a small
|
||||
one the bottom two steps showed the map floating in empty space, and
|
||||
`clampPan` allowed another 160px of slack on every side on top of that. The
|
||||
ladder is now built per galaxy in `VegaZoom.js`, anchored so its bottom rung
|
||||
is exactly the zoom at which the galaxy covers the viewport, and the pan
|
||||
clamp has no slack. Kept Phaser-free so the verifier can assert the
|
||||
invariant directly (section 3b).
|
||||
|
||||
## Balance reference (27-game AI soak)
|
||||
|
||||
```
|
||||
|
|
@ -166,12 +176,12 @@ wins spread across 8 of 10 species
|
|||
## Verification
|
||||
|
||||
```bash
|
||||
node tools/verifyMasterOfVega.js # 809 checks, ~60s
|
||||
node tools/verifyMasterOfVega.js --quick # 808 checks, ~15s
|
||||
node tools/verifyMasterOfVega.js # 972 checks, ~60s
|
||||
node tools/verifyMasterOfVega.js --quick # 971 checks, ~15s
|
||||
node tools/verifyMasterOfVega.js --games=50 # a deeper soak
|
||||
```
|
||||
|
||||
Ten sections; section 2 runs the real procedural painters against a Proxy fake
|
||||
Eleven sections; section 2 runs the real procedural painters against a Proxy fake
|
||||
canvas, section 10 is the self-play soak with invariants and a turn-time budget.
|
||||
|
||||
**Never browser-tested.** Everything above is engine- and Node-verified only.
|
||||
|
|
|
|||
|
|
@ -17,17 +17,10 @@ import { makeNebula } from './VegaNebula.js';
|
|||
import { PARSEC_PX, parsecs, mulberry32 } from './VegaGalaxyGen.js';
|
||||
import { reachableStars, coloniesAt, empireColonies, fleetEta } from './VegaLogic.js';
|
||||
import { starFrame } from './VegaArt.js';
|
||||
import { buildZoomLadder, DEFAULT_ZOOM_INDEX } from './VegaZoom.js';
|
||||
|
||||
const FONT = '"Julius Sans One"';
|
||||
|
||||
export const ZOOMS = [0.35, 0.55, 0.85, 1.3, 2.0];
|
||||
export const DEFAULT_ZOOM_INDEX = 2;
|
||||
|
||||
// Semantic-zoom thresholds. Below FAR the map shows territory and empire names
|
||||
// only; above NEAR it shows per-system detail.
|
||||
const FAR = 0.5;
|
||||
const NEAR = 1.2;
|
||||
|
||||
// The range and territory fields are painted into low-resolution
|
||||
// RenderTextures and scaled up. A huge galaxy is 5400px wide — past the safe
|
||||
// single-texture size — and the upscale blur is exactly the soft edge both
|
||||
|
|
@ -57,8 +50,9 @@ export default class VegaStarMap {
|
|||
this.cb = callbacks;
|
||||
this.viewerIdx = state.humanIndex;
|
||||
|
||||
this.zoomIndex = DEFAULT_ZOOM_INDEX;
|
||||
this.zoom = ZOOMS[this.zoomIndex];
|
||||
this.zooms = buildZoomLadder(state.galaxy.width, state.galaxy.height);
|
||||
this.zoomIndex = Math.min(DEFAULT_ZOOM_INDEX, this.zooms.length - 1);
|
||||
this.zoom = this.zooms[this.zoomIndex];
|
||||
this.selectedStar = -1;
|
||||
this.hoverStar = -1;
|
||||
this.rangeDirty = true;
|
||||
|
|
@ -114,6 +108,14 @@ export default class VegaStarMap {
|
|||
this.bindInput();
|
||||
}
|
||||
|
||||
// Semantic zoom is keyed to the LADDER INDEX, not an absolute scale. The same
|
||||
// scale means different things in different galaxies now that the ladder is
|
||||
// built per galaxy — 0.74 is fully zoomed out on a small map and mid-range on
|
||||
// a huge one — so an absolute threshold would put the two in different modes
|
||||
// while showing the same amount of sky.
|
||||
get isFarZoom() { return this.zoomIndex === 0 && this.zooms.length > 1; }
|
||||
get isNearZoom() { return this.zoomIndex >= this.zooms.length - 2; }
|
||||
|
||||
// ------------------------------------------------------------------ setup
|
||||
|
||||
buildParallax(seed) {
|
||||
|
|
@ -272,7 +274,7 @@ export default class VegaStarMap {
|
|||
const viewer = this.viewerIdx >= 0 ? state.empires[this.viewerIdx] : null;
|
||||
for (const s of this.starSprites) {
|
||||
const explored = !viewer || viewer.explored[s.star.idx];
|
||||
s.container.setVisible(explored || this.zoom < FAR);
|
||||
s.container.setVisible(explored || this.isFarZoom);
|
||||
s.body.setAlpha(explored ? 1 : 0.25);
|
||||
|
||||
const cols = coloniesAt(state, s.star.idx);
|
||||
|
|
@ -364,7 +366,7 @@ export default class VegaStarMap {
|
|||
const viewer = this.viewerIdx >= 0 ? state.empires[this.viewerIdx] : null;
|
||||
// At the widest zoom the map shows empire names over their territory
|
||||
// instead of a fog of unreadable star labels.
|
||||
if (this.zoom < FAR) {
|
||||
if (this.isFarZoom) {
|
||||
for (const emp of state.empires) {
|
||||
if (!emp.alive) continue;
|
||||
const cols = empireColonies(state, emp.idx);
|
||||
|
|
@ -391,7 +393,7 @@ export default class VegaStarMap {
|
|||
this.labelLayer.add(label);
|
||||
|
||||
// Closest zoom adds the system's contents.
|
||||
if (this.zoom >= NEAR) {
|
||||
if (this.isNearZoom) {
|
||||
const habitable = s.star.planets.filter((p) => this.rules.planetTypes[p.typeId].colonizable).length;
|
||||
if (s.star.planets.length) {
|
||||
const sub = this.scene.add.text(
|
||||
|
|
@ -435,23 +437,28 @@ export default class VegaStarMap {
|
|||
|
||||
// ---------------------------------------------------------------- camera
|
||||
|
||||
// Keep the viewport inside the galaxy. The old version allowed 160px of slack
|
||||
// on every side, which let the player drag empty space into view even at a
|
||||
// zoom that covered the screen — the same complaint as zooming out too far,
|
||||
// just reached a different way.
|
||||
clampPan() {
|
||||
const w = this.worldW * this.zoom;
|
||||
const h = this.worldH * this.zoom;
|
||||
const slack = 160;
|
||||
const minX = Math.min(slack, GAME_WIDTH - w - slack);
|
||||
const maxX = Math.max(GAME_WIDTH - w - slack, slack);
|
||||
const minY = Math.min(slack, GAME_HEIGHT - h - slack);
|
||||
const maxY = Math.max(GAME_HEIGHT - h - slack, slack);
|
||||
this.root.x = Phaser.Math.Clamp(this.root.x, Math.min(minX, maxX), Math.max(minX, maxX));
|
||||
this.root.y = Phaser.Math.Clamp(this.root.y, Math.min(minY, maxY), Math.max(minY, maxY));
|
||||
// At the bottom of the ladder w/h equal the viewport, so both bounds
|
||||
// collapse to a single value and the map sits locked and centred.
|
||||
this.root.x = w >= GAME_WIDTH
|
||||
? Phaser.Math.Clamp(this.root.x, GAME_WIDTH - w, 0)
|
||||
: (GAME_WIDTH - w) / 2;
|
||||
this.root.y = h >= GAME_HEIGHT
|
||||
? Phaser.Math.Clamp(this.root.y, GAME_HEIGHT - h, 0)
|
||||
: (GAME_HEIGHT - h) / 2;
|
||||
}
|
||||
|
||||
applyZoom(newIndex, focusX = GAME_WIDTH / 2, focusY = GAME_HEIGHT / 2) {
|
||||
const idx = Phaser.Math.Clamp(newIndex, 0, ZOOMS.length - 1);
|
||||
const idx = Phaser.Math.Clamp(newIndex, 0, this.zooms.length - 1);
|
||||
if (idx === this.zoomIndex) return;
|
||||
const old = this.zoom;
|
||||
const next = ZOOMS[idx];
|
||||
const next = this.zooms[idx];
|
||||
// Keep whatever is under the cursor under the cursor.
|
||||
const worldX = (focusX - this.root.x) / old;
|
||||
const worldY = (focusY - this.root.y) / old;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
// Master of Vega — star-map zoom maths.
|
||||
//
|
||||
// Phaser-free on purpose, following the CivilizationIcons/CivilizationTooltips
|
||||
// precedent: the deciding half of a UI concern is checkable headlessly even
|
||||
// though the drawing half is not. tools/verifyMasterOfVega.js asserts the
|
||||
// invariant that matters — you can never pull back past the edge of the map.
|
||||
|
||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||
|
||||
// The ladder is built per galaxy rather than fixed, because the galaxies differ
|
||||
// in size by more than 2x: one ladder that frames a huge galaxy leaves a small
|
||||
// one floating in dead space. The bottom rung is always exactly the zoom at
|
||||
// which the map covers the viewport, so the map edge is the furthest you can
|
||||
// pull back and empty space is never visible.
|
||||
export const ZOOM_STEPS = 5;
|
||||
export const MAX_ZOOM = 2.2;
|
||||
export const DEFAULT_ZOOM_INDEX = 2;
|
||||
|
||||
/** Smallest zoom at which the galaxy still covers the whole viewport. */
|
||||
export function minZoomFor(worldW, worldH) {
|
||||
return Math.max(GAME_WIDTH / worldW, GAME_HEIGHT / worldH);
|
||||
}
|
||||
|
||||
/** Geometric ladder from "whole map fills the screen" up to close inspection. */
|
||||
export function buildZoomLadder(worldW, worldH) {
|
||||
const min = minZoomFor(worldW, worldH);
|
||||
// A galaxy small enough that covering it already exceeds the close-inspection
|
||||
// zoom gets a single fixed step rather than an inverted ladder.
|
||||
if (min >= MAX_ZOOM) return [min];
|
||||
const ratio = (MAX_ZOOM / min) ** (1 / (ZOOM_STEPS - 1));
|
||||
return Array.from({ length: ZOOM_STEPS }, (_, i) => min * ratio ** i);
|
||||
}
|
||||
|
|
@ -31,6 +31,8 @@ import * as Logic from '../src/games/mastervega/VegaLogic.js';
|
|||
import * as AI from '../src/games/mastervega/VegaAI.js';
|
||||
import * as Diplo from '../src/games/mastervega/VegaDiplomacy.js';
|
||||
import * as Leaders from '../src/games/mastervega/VegaLeaders.js';
|
||||
// Phaser-free half of the star map's zoom behaviour.
|
||||
import { buildZoomLadder, minZoomFor, MAX_ZOOM, DEFAULT_ZOOM_INDEX } from '../src/games/mastervega/VegaZoom.js';
|
||||
// Pure art module: the painters need a canvas, but the sheet bookkeeping around
|
||||
// them is checkable headlessly and worth checking.
|
||||
import {
|
||||
|
|
@ -359,6 +361,44 @@ section('3. Galaxy generation');
|
|||
})());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('3b. Star-map zoom');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const VIEW_W = 1920;
|
||||
const VIEW_H = 1080;
|
||||
for (const size of RULES.galaxySizeList) {
|
||||
const ladder = buildZoomLadder(size.width, size.height);
|
||||
check(`${size.id} ladder is non-empty`, ladder.length > 0);
|
||||
check(`${size.id} ladder ascends`, ladder.every((z, i) => i === 0 || z > ladder[i - 1]),
|
||||
ladder.map((z) => z.toFixed(3)).join(' '));
|
||||
|
||||
// The point of the whole exercise: at NO step may the galaxy fail to fill
|
||||
// the screen, or the player can pull back past the edge of the map into
|
||||
// empty space.
|
||||
for (const z of ladder) {
|
||||
check(`${size.id} zoom ${z.toFixed(3)} covers the viewport`,
|
||||
size.width * z >= VIEW_W - 1e-6 && size.height * z >= VIEW_H - 1e-6,
|
||||
`${Math.round(size.width * z)}x${Math.round(size.height * z)} vs ${VIEW_W}x${VIEW_H}`);
|
||||
}
|
||||
// The bottom rung must be exactly the covering zoom — any higher and the
|
||||
// player cannot see the whole galaxy at once.
|
||||
check(`${size.id} bottom rung is the covering zoom`,
|
||||
Math.abs(ladder[0] - minZoomFor(size.width, size.height)) < 1e-9);
|
||||
check(`${size.id} top rung reaches close inspection`,
|
||||
Math.abs(ladder[ladder.length - 1] - MAX_ZOOM) < 1e-9 || ladder.length === 1);
|
||||
check(`${size.id} default zoom index is in range`, DEFAULT_ZOOM_INDEX < ladder.length
|
||||
|| ladder.length === 1);
|
||||
}
|
||||
|
||||
// A galaxy so small that covering it is already past the close-inspection
|
||||
// zoom collapses to one fixed step rather than an inverted ladder.
|
||||
const tiny = buildZoomLadder(600, 400);
|
||||
check('a tiny galaxy collapses to a single zoom step', tiny.length === 1);
|
||||
check('a tiny galaxy still covers the viewport',
|
||||
600 * tiny[0] >= VIEW_W - 1e-6 && 400 * tiny[0] >= VIEW_H - 1e-6);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('4. Ship Marks');
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue