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:
Brian Fertig 2026-08-02 00:18:00 -06:00
parent d789da4582
commit 7f1cfcc982
4 changed files with 114 additions and 25 deletions

View File

@ -28,6 +28,7 @@ than `civilization/` (whose scene grew to 1792 lines).
| `VegaAI.js` | AI empire controller | | `VegaAI.js` | AI empire controller |
| `VegaDiplomacy.js` | Treaties, attitudes, council politics | | `VegaDiplomacy.js` | Treaties, attitudes, council politics |
| `VegaLeaders.js` | Leader pool, offers, postings | | `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`, **Render tier:** `MasterOfVegaGame.js` (scene), `VegaStarMap.js`,
`VegaNebula.js`, `VegaSystemView.js`, `VegaCombatView.js`, `VegaScreens.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. there was nothing to upgrade and that empire simply could not expand.
`guaranteeNearbyWorlds` now seeds new worlds when it must. `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) ## Balance reference (27-game AI soak)
``` ```
@ -166,12 +176,12 @@ wins spread across 8 of 10 species
## Verification ## Verification
```bash ```bash
node tools/verifyMasterOfVega.js # 809 checks, ~60s node tools/verifyMasterOfVega.js # 972 checks, ~60s
node tools/verifyMasterOfVega.js --quick # 808 checks, ~15s node tools/verifyMasterOfVega.js --quick # 971 checks, ~15s
node tools/verifyMasterOfVega.js --games=50 # a deeper soak 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. 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. **Never browser-tested.** Everything above is engine- and Node-verified only.

View File

@ -17,17 +17,10 @@ import { makeNebula } from './VegaNebula.js';
import { PARSEC_PX, parsecs, mulberry32 } from './VegaGalaxyGen.js'; import { PARSEC_PX, parsecs, mulberry32 } from './VegaGalaxyGen.js';
import { reachableStars, coloniesAt, empireColonies, fleetEta } from './VegaLogic.js'; import { reachableStars, coloniesAt, empireColonies, fleetEta } from './VegaLogic.js';
import { starFrame } from './VegaArt.js'; import { starFrame } from './VegaArt.js';
import { buildZoomLadder, DEFAULT_ZOOM_INDEX } from './VegaZoom.js';
const FONT = '"Julius Sans One"'; 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 // The range and territory fields are painted into low-resolution
// RenderTextures and scaled up. A huge galaxy is 5400px wide — past the safe // 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 // 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.cb = callbacks;
this.viewerIdx = state.humanIndex; this.viewerIdx = state.humanIndex;
this.zoomIndex = DEFAULT_ZOOM_INDEX; this.zooms = buildZoomLadder(state.galaxy.width, state.galaxy.height);
this.zoom = ZOOMS[this.zoomIndex]; this.zoomIndex = Math.min(DEFAULT_ZOOM_INDEX, this.zooms.length - 1);
this.zoom = this.zooms[this.zoomIndex];
this.selectedStar = -1; this.selectedStar = -1;
this.hoverStar = -1; this.hoverStar = -1;
this.rangeDirty = true; this.rangeDirty = true;
@ -114,6 +108,14 @@ export default class VegaStarMap {
this.bindInput(); 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 // ------------------------------------------------------------------ setup
buildParallax(seed) { buildParallax(seed) {
@ -272,7 +274,7 @@ export default class VegaStarMap {
const viewer = this.viewerIdx >= 0 ? state.empires[this.viewerIdx] : null; const viewer = this.viewerIdx >= 0 ? state.empires[this.viewerIdx] : null;
for (const s of this.starSprites) { for (const s of this.starSprites) {
const explored = !viewer || viewer.explored[s.star.idx]; 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); s.body.setAlpha(explored ? 1 : 0.25);
const cols = coloniesAt(state, s.star.idx); 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; const viewer = this.viewerIdx >= 0 ? state.empires[this.viewerIdx] : null;
// At the widest zoom the map shows empire names over their territory // At the widest zoom the map shows empire names over their territory
// instead of a fog of unreadable star labels. // instead of a fog of unreadable star labels.
if (this.zoom < FAR) { if (this.isFarZoom) {
for (const emp of state.empires) { for (const emp of state.empires) {
if (!emp.alive) continue; if (!emp.alive) continue;
const cols = empireColonies(state, emp.idx); const cols = empireColonies(state, emp.idx);
@ -391,7 +393,7 @@ export default class VegaStarMap {
this.labelLayer.add(label); this.labelLayer.add(label);
// Closest zoom adds the system's contents. // 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; const habitable = s.star.planets.filter((p) => this.rules.planetTypes[p.typeId].colonizable).length;
if (s.star.planets.length) { if (s.star.planets.length) {
const sub = this.scene.add.text( const sub = this.scene.add.text(
@ -435,23 +437,28 @@ export default class VegaStarMap {
// ---------------------------------------------------------------- camera // ---------------------------------------------------------------- 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() { clampPan() {
const w = this.worldW * this.zoom; const w = this.worldW * this.zoom;
const h = this.worldH * this.zoom; const h = this.worldH * this.zoom;
const slack = 160; // At the bottom of the ladder w/h equal the viewport, so both bounds
const minX = Math.min(slack, GAME_WIDTH - w - slack); // collapse to a single value and the map sits locked and centred.
const maxX = Math.max(GAME_WIDTH - w - slack, slack); this.root.x = w >= GAME_WIDTH
const minY = Math.min(slack, GAME_HEIGHT - h - slack); ? Phaser.Math.Clamp(this.root.x, GAME_WIDTH - w, 0)
const maxY = Math.max(GAME_HEIGHT - h - slack, slack); : (GAME_WIDTH - w) / 2;
this.root.x = Phaser.Math.Clamp(this.root.x, Math.min(minX, maxX), Math.max(minX, maxX)); this.root.y = h >= GAME_HEIGHT
this.root.y = Phaser.Math.Clamp(this.root.y, Math.min(minY, maxY), Math.max(minY, maxY)); ? Phaser.Math.Clamp(this.root.y, GAME_HEIGHT - h, 0)
: (GAME_HEIGHT - h) / 2;
} }
applyZoom(newIndex, focusX = GAME_WIDTH / 2, focusY = GAME_HEIGHT / 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; if (idx === this.zoomIndex) return;
const old = this.zoom; const old = this.zoom;
const next = ZOOMS[idx]; const next = this.zooms[idx];
// Keep whatever is under the cursor under the cursor. // Keep whatever is under the cursor under the cursor.
const worldX = (focusX - this.root.x) / old; const worldX = (focusX - this.root.x) / old;
const worldY = (focusY - this.root.y) / old; const worldY = (focusY - this.root.y) / old;

View File

@ -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);
}

View File

@ -31,6 +31,8 @@ import * as Logic from '../src/games/mastervega/VegaLogic.js';
import * as AI from '../src/games/mastervega/VegaAI.js'; import * as AI from '../src/games/mastervega/VegaAI.js';
import * as Diplo from '../src/games/mastervega/VegaDiplomacy.js'; import * as Diplo from '../src/games/mastervega/VegaDiplomacy.js';
import * as Leaders from '../src/games/mastervega/VegaLeaders.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 // Pure art module: the painters need a canvas, but the sheet bookkeeping around
// them is checkable headlessly and worth checking. // them is checkable headlessly and worth checking.
import { 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'); section('4. Ship Marks');
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------