From 7f1cfcc9827ce6928079bc9fecc838bef030d6d2 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 2 Aug 2026 00:18:00 -0600 Subject: [PATCH] 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 --- docs/mastervega-build-plan.md | 16 +++++++-- src/games/mastervega/VegaStarMap.js | 51 ++++++++++++++++------------- src/games/mastervega/VegaZoom.js | 32 ++++++++++++++++++ tools/verifyMasterOfVega.js | 40 ++++++++++++++++++++++ 4 files changed, 114 insertions(+), 25 deletions(-) create mode 100644 src/games/mastervega/VegaZoom.js diff --git a/docs/mastervega-build-plan.md b/docs/mastervega-build-plan.md index a2c705f..b83b6f4 100644 --- a/docs/mastervega-build-plan.md +++ b/docs/mastervega-build-plan.md @@ -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. diff --git a/src/games/mastervega/VegaStarMap.js b/src/games/mastervega/VegaStarMap.js index 1b06831..0beb4e1 100644 --- a/src/games/mastervega/VegaStarMap.js +++ b/src/games/mastervega/VegaStarMap.js @@ -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; diff --git a/src/games/mastervega/VegaZoom.js b/src/games/mastervega/VegaZoom.js new file mode 100644 index 0000000..0dd9530 --- /dev/null +++ b/src/games/mastervega/VegaZoom.js @@ -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); +} diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index d82cd7f..9df80b5 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -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'); // ---------------------------------------------------------------------------