Compare commits

...

2 Commits

Author SHA1 Message Date
Brian Fertig 7f1cfcc982 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
2026-08-02 00:18:00 -06:00
Brian Fertig d789da4582 feat(masterofvega): add species portraits, speech, landing screen, and music
- Add video, still, and procedural three-tier portrait system for all 10 species
- Add species-specific speech clips and UI voiceover (SpeechQueue integration)
- Add landing screen with New Game / Resume / Return to Arcade buttons
- Add species detail pop-over with animated expand/collapse and auto-introduction
- Add game-specific soundtrack (masterofvega-music.json, 4 tracks)
- Add video asset type support in assetLoader and PreloadScene
- Add portraitVideos and portraitStills sections to artwork manifest
- Add verification for portraits, speech clips, and music tracks
- Update sprites.md with documentation for new assets and filenames
2026-08-02 00:04:06 -06:00
39 changed files with 722 additions and 100 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,25 @@
{
"tracks": [
{
"file": "vega/track-01.mp3",
"artist": "DV-8-2303",
"title": "Power Down"
},
{
"file": "vega/track-02.mp3",
"artist": "Klaxon",
"title": "High Orbit"
},
{
"file": "vega/track-03.mp3",
"artist": "Aiko",
"title": "Master of Vega Main Theme"
},
{
"file": "vega/track-04.mp3",
"artist": "Cy-Bro",
"title": "Defend the Colony"
}
],
"volume": 0.6
}

View File

@ -1,7 +1,35 @@
{
"_readme": "Master of Vega — drop-in artwork manifest. Every sheet starts with path:null, which means VegaArt.js paints a procedural stand-in at exactly the same frame geometry. To use real art, drop the PNG in assets/images/mastervega/ and set its path here — no code changes. Frame layouts are documented in src/games/mastervega/sprites.md and are APPEND-ONLY.",
"_readme": "Master of Vega — drop-in artwork manifest. Every sheet starts with path:null, which means VegaArt.js paints a procedural stand-in at exactly the same frame geometry. To use real art, drop the PNG in assets/images/vega/ and set its path here — no code changes. Frame layouts are documented in src/games/mastervega/sprites.md and are APPEND-ONLY.",
"_kindReadme": "kind selects the procedural painter in VegaArt.PROC_PAINTERS. Painters are keyed by kind, never by sheet name, so adding a sheet later needs no code change.",
"_portraitVideosReadme": "Species portraits are looping muted videos rather than spritesheet frames, 256x256. All ten are present. NOTE: cerebrai's file is spelled 'char-cerebai.mp4' on disk (missing the r) — the path below points at the real filename, so rename the file and this one line together if you want the spelling fixed. To add or replace one: drop the file and set its path here; nothing else changes.",
"portraitVideos": {
"human": { "key": "vega-char-human", "path": "assets/videos/vega/char-human.mp4" },
"kestrelli": { "key": "vega-char-kestrelli", "path": "assets/videos/vega/char-kestrelli.mp4" },
"ursaal": { "key": "vega-char-ursaal", "path": "assets/videos/vega/char-ursaal.mp4" },
"umbrix": { "key": "vega-char-umbrix", "path": "assets/videos/vega/char-umbrix.mp4" },
"kkrix": { "key": "vega-char-kkrix", "path": "assets/videos/vega/char-kkrix.mp4" },
"mekhan": { "key": "vega-char-mekhan", "path": "assets/videos/vega/char-mekhan.mp4" },
"rrashaa": { "key": "vega-char-rrashaa", "path": "assets/videos/vega/char-rrashaa.mp4" },
"cerebrai": { "key": "vega-char-cerebrai", "path": "assets/videos/vega/char-cerebai.mp4" },
"ssakar": { "key": "vega-char-ssakar", "path": "assets/videos/vega/char-ssakar.mp4" },
"lithox": { "key": "vega-char-lithox", "path": "assets/videos/vega/char-lithox.mp4" }
},
"_portraitStillsReadme": "High-resolution (1024x1024) still portraits, one per species. Every species now has a video too, so these serve as the recovery path if a video fails to decode. NOTE: kestrelli's file is spelled 'char-kestralli.png' on disk — the path below points at the real filename; rename the file and this one line together if you want the spelling fixed.",
"portraitStills": {
"human": { "key": "vega-still-human", "path": "assets/images/vega/char-human.png" },
"kestrelli": { "key": "vega-still-kestrelli", "path": "assets/images/vega/char-kestralli.png" },
"ursaal": { "key": "vega-still-ursaal", "path": "assets/images/vega/char-ursaal.png" },
"umbrix": { "key": "vega-still-umbrix", "path": "assets/images/vega/char-umbrix.png" },
"kkrix": { "key": "vega-still-kkrix", "path": "assets/images/vega/char-kkrix.png" },
"mekhan": { "key": "vega-still-mekhan", "path": "assets/images/vega/char-mekhan.png" },
"rrashaa": { "key": "vega-still-rrashaa", "path": "assets/images/vega/char-rrashaa.png" },
"cerebrai": { "key": "vega-still-cerebrai", "path": "assets/images/vega/char-cerebrai.png" },
"ssakar": { "key": "vega-still-ssakar", "path": "assets/images/vega/char-ssakar.png" },
"lithox": { "key": "vega-still-lithox", "path": "assets/images/vega/char-lithox.png" }
},
"sheets": {
"ships": {
"key": "vega-ships",

View File

@ -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.

View File

@ -40,6 +40,26 @@ function sheetsFrom(scene, jsonKey, fields) {
}
// Drop-in standalone images declared in a cached artwork JSON's `artwork` array.
// Pull video descriptors out of an artwork manifest block keyed by id, e.g.
// mastervega's portraitVideos. An entry with `path: null` is one that has not
// been recorded yet and is skipped, exactly like a sheet with no PNG.
function videosFrom(scene, jsonKey, field) {
const art = scene.cache.json.get(jsonKey);
if (!art || !art[field]) return [];
return Object.entries(art[field])
.filter(([id, v]) => !id.startsWith('_') && v && v.key && v.path)
.map(([, v]) => ({ type: 'video', key: v.key, path: v.path }));
}
// Same shape as videosFrom, for image entries keyed by id.
function imagesFromMap(scene, jsonKey, field) {
const art = scene.cache.json.get(jsonKey);
if (!art || !art[field]) return [];
return Object.entries(art[field])
.filter(([id, v]) => !id.startsWith('_') && v && v.key && v.path)
.map(([, v]) => ({ type: 'image', key: v.key, path: v.path }));
}
function imagesFrom(scene, jsonKey) {
const art = scene.cache.json.get(jsonKey);
return (art?.artwork ?? [])
@ -77,13 +97,15 @@ export const MANIFEST = {
mastervega: [
{ type: 'json', key: 'mastervega-rules', path: 'data/mastervega-rules.json' },
(scene) => sheetsFrom(scene, 'mastervega-artwork', ['sheets']),
(scene) => videosFrom(scene, 'mastervega-artwork', 'portraitVideos'),
(scene) => imagesFromMap(scene, 'mastervega-artwork', 'portraitStills'),
image('vega-menu-bg', 'assets/images/vega/background-menu.png'),
image('vega-menu-title', 'assets/images/vega/menu-title.png'),
{ type: 'audio', key: 'laser-zap', path: 'assets/fx/laser-zap.mp3' },
{ type: 'audio', key: 'scifi-explode', path: 'assets/fx/scifi-explode.mp3' },
{ type: 'audio', key: 'ta-rocket-1', path: 'assets/fx/ta-rocket-1.mp3' },
{ type: 'audio', key: 'ta-rocket-2', path: 'assets/fx/ta-rocket-2.mp3' },
(scene) => musicFrom(scene, 'hacker-music'),
(scene) => musicFrom(scene, 'masterofvega-music'),
],
forbiddenisland: [
// Tiles: 2 cols (dry, flooded) × 24 rows. Row i → dry frame 2i, flooded

View File

@ -10,10 +10,13 @@ import { Button } from '../../ui/Button.js';
import { Tooltip } from '../../ui/Tooltip.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { getGameSoundtrack } from '../../services/soundtrack.js';
import { playSound } from '../../ui/Sounds.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { enqueue as enqueueSpeech, resetQueue as resetSpeechQueue } from '../../ui/SpeechQueue.js';
import { compileRules, turnToYear } from './VegaRules.js';
import { ensureSheets, speciesPortraitFrame } from './VegaArt.js';
import {
ensureSheets, makeSpeciesPortrait, sizeSpeciesPortrait, speciesSpeechClip, UI_SPEECH,
} from './VegaArt.js';
import * as Logic from './VegaLogic.js';
import { runAITurn } from './VegaAI.js';
@ -59,50 +62,271 @@ export default class MasterOfVegaGame extends Phaser.Scene {
this.events.once('shutdown', () => this.teardown());
this.showSetup();
this.showLanding();
}
// Wrap a menu handler so it clicks. Every button and card on the front-end
// screens goes through this, so the cue can never drift out of sync with what
// is actually clickable.
uiClick(fn) {
return (...args) => {
playSound(this, SFX.EIGHTBIT_ACTIVATE);
fn?.(...args);
};
}
teardown() {
resetSpeechQueue();
this.map?.destroy();
this.fx?.destroy();
this.music?.destroy?.();
}
// -------------------------------------------------------------- backdrop
// Shared by the landing screen and the setup screen. `dim` is how hard to
// darken the artwork: the landing screen wants to show it off, the setup
// screen has to keep ten species cards legible on top of it.
paintBackdrop(layer, dim) {
if (this.textures.exists('vega-menu-bg')) {
const bg = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'vega-menu-bg');
bg.setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
layer.add(bg);
layer.add(this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x03060d, dim).setOrigin(0, 0));
} else {
// The whole game runs with zero art files; the menus are no exception.
layer.add(this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x03060d, 1).setOrigin(0, 0));
}
}
// Draw the title logo scaled to CONTAIN a box, never stretched — the source
// is a large title card and its aspect has to survive.
paintTitle(layer, cx, cy, boxW, boxH, fallbackSize) {
if (this.textures.exists('vega-menu-title')) {
const t = this.add.image(cx, cy, 'vega-menu-title').setOrigin(0.5);
const src = this.textures.get('vega-menu-title').getSourceImage();
t.setScale(Math.min(boxW / src.width, boxH / src.height));
layer.add(t);
return t;
}
const t = this.add.text(cx, cy, 'MASTER OF VEGA', {
fontFamily: FONT, fontSize: `${fallbackSize}px`, color: '#cfe8ff', align: 'center',
}).setOrigin(0.5);
layer.add(t);
return t;
}
// --------------------------------------------------------------- landing
showLanding() {
const layer = this.add.container(0, 0).setDepth(D.modal);
this.landingLayer = layer;
this.paintBackdrop(layer, 0.34);
// Logo large on the left.
this.paintTitle(layer, 580, 430, 900, 560, 76);
// Buttons well below it, over on the right.
const bx = 1420;
let by = 640;
const gap = 104;
const newGame = new Button(this, bx, by, 'New Game', this.uiClick(() => {
layer.destroy();
this.showSetup();
}), { width: 420, height: 78, fontSize: 30 });
layer.add(newGame);
by += gap;
const saved = this.readSave();
const resume = new Button(this, bx, by, 'Resume Game', this.uiClick(() => {
if (!saved) return;
layer.destroy();
this.beginGame(null, saved);
}), { width: 420, height: 78, fontSize: 30 });
// setEnabled(false) both dims it and drops its input, so a missing save
// reads as unavailable rather than as a button that silently does nothing.
if (!saved) resume.setEnabled(false);
layer.add(resume);
by += gap;
const quit = new Button(this, bx, by, 'Return to Arcade', this.uiClick(() => {
this.scene.start('GameMenu');
}), { width: 420, height: 78, fontSize: 30 });
layer.add(quit);
}
// ------------------------------------------------------- species detail
/**
* Grow a species card into a centred pop-over with full-size art and
* readable type, then commit or discard the choice.
*
* The portrait is REPARENTED out of the card rather than a second one being
* created. Two Phaser Video objects on one cached video is not a
* configuration worth betting on if they share an element, destroying the
* pop-over's copy would kill the card's and moving the one that already
* exists sidesteps the question entirely while halving the decoding.
*/
openSpeciesDetail(parentLayer, spec, card, portrait, onSelect) {
if (this.speciesDetailOpen) return;
this.speciesDetailOpen = true;
const CARD_PORTRAIT = { x: 52, y: 62, size: 84 };
const PANEL_W = 860;
const PANEL_H = 640;
const halfW = PANEL_W / 2;
const halfH = PANEL_H / 2;
const accent = Phaser.Display.Color.HexStringToColor(spec.color).color;
const veil = this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x00060e, 0)
.setOrigin(0, 0).setInteractive();
parentLayer.add(veil);
this.tweens.add({ targets: veil, fillAlpha: 0.78, duration: 200 });
// Built at full size, then scaled down to the card and grown — so the type
// is rendered crisp at its final size instead of being a magnified
// thumbnail.
const pop = this.add.container(card.x + CARD_PORTRAIT.x, card.y + CARD_PORTRAIT.y);
pop.setScale(0.18).setAlpha(0.35);
parentLayer.add(pop);
const panel = this.add.rectangle(-halfW, -halfH, PANEL_W, PANEL_H, 0x0b1220, 0.97).setOrigin(0, 0);
panel.setStrokeStyle(2, accent, 0.85);
pop.add(panel);
const ticks = this.add.graphics();
ticks.lineStyle(3, accent, 0.9);
const t = 30;
for (const [tx, ty, dx, dy] of [
[-halfW, -halfH, 1, 1], [halfW, -halfH, -1, 1], [-halfW, halfH, 1, -1], [halfW, halfH, -1, -1],
]) {
ticks.lineBetween(tx, ty, tx + dx * t, ty);
ticks.lineBetween(tx, ty, tx, ty + dy * t);
}
pop.add(ticks);
// The portrait moves house, at its full 256px.
card.remove(portrait);
portrait.setPosition(-halfW + 168, -halfH + 188);
sizeSpeciesPortrait(portrait, 256);
pop.add(portrait);
const colX = -100;
const colW = PANEL_W - 40 - (colX + halfW);
let y = -halfH + 46;
pop.add(this.add.text(colX, y, spec.name, {
fontFamily: FONT, fontSize: '44px', color: spec.color,
}));
y += 62;
const heading = (label) => {
pop.add(this.add.text(colX, y, label, {
fontFamily: FONT, fontSize: '15px', color: '#6f8aa3',
}));
y += 24;
};
const bullets = (lines, colour) => {
for (const line of lines) {
const item = this.add.text(colX, y, `· ${line}`, {
fontFamily: FONT, fontSize: '20px', color: colour, wordWrap: { width: colW },
});
pop.add(item);
y += item.height + 6;
}
y += 10;
};
heading('STRENGTHS');
bullets(spec.strengths, '#7fd8a0');
heading('WEAKNESSES');
bullets(spec.weaknesses, '#e08a8a');
pop.add(this.add.text(-halfW + 40, halfH - 220, spec.desc, {
fontFamily: FONT, fontSize: '19px', color: '#a8c4e0',
wordWrap: { width: PANEL_W - 80 }, lineSpacing: 4,
}));
pop.add(this.add.text(-halfW + 40, halfH - 128, `Homeworld: ${this.rules.planetTypes[spec.homeworld].name}`, {
fontFamily: FONT, fontSize: '17px', color: '#7f97b3',
}));
// --- close, either way
let closing = false;
const close = (commit) => {
if (closing) return;
closing = true;
if (commit) onSelect?.();
// The window is going away, so the voice goes with it.
resetSpeechQueue();
this.tweens.add({ targets: veil, fillAlpha: 0, duration: 180 });
this.tweens.add({
targets: pop,
x: card.x + CARD_PORTRAIT.x,
y: card.y + CARD_PORTRAIT.y,
scale: 0.18,
alpha: 0.25,
duration: 200,
ease: 'Cubic.easeIn',
onComplete: () => {
// Hand the portrait back BEFORE the pop-over is destroyed, or it
// would be destroyed along with it and the card left empty.
pop.remove(portrait);
portrait.setPosition(CARD_PORTRAIT.x, CARD_PORTRAIT.y);
sizeSpeciesPortrait(portrait, CARD_PORTRAIT.size);
card.add(portrait);
pop.destroy();
veil.destroy();
this.speciesDetailOpen = false;
},
});
};
pop.add(new Button(this, -160, halfH - 62, 'Select Species', this.uiClick(() => close(true)),
{ width: 300, height: 62, fontSize: 24 }));
pop.add(new Button(this, 170, halfH - 62, 'Cancel', this.uiClick(() => close(false)),
{ width: 220, height: 62, fontSize: 24, variant: 'ghost' }));
veil.on('pointerup', this.uiClick(() => close(false)));
this.tweens.add({
targets: pop,
x: GAME_WIDTH / 2,
y: GAME_HEIGHT / 2,
scale: 1,
alpha: 1,
duration: 300,
ease: 'Back.easeOut',
});
// The species introduces itself. resetQueue first so opening a second card
// cuts the first one off instead of letting two voices talk over each
// other, and `force` so the line plays even if the queue was full.
resetSpeechQueue();
enqueueSpeech(speciesSpeechClip(spec.id), null, { force: true });
}
// ---------------------------------------------------------------- setup
showSetup() {
const layer = this.add.container(0, 0).setDepth(D.modal);
this.setupLayer = layer;
// Supplied menu art if present, flat panel colour if not — the setup screen
// has to work with zero art files like everything else here.
if (this.textures.exists('vega-menu-bg')) {
const bg = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'vega-menu-bg');
bg.setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
layer.add(bg);
// Darken it so the cards and text stay legible over the artwork.
layer.add(this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x03060d, 0.62).setOrigin(0, 0));
} else {
layer.add(this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x03060d, 1).setOrigin(0, 0));
}
this.paintBackdrop(layer, 0.62);
this.paintTitle(layer, GAME_WIDTH / 2, 74, 760, 132, 58);
if (this.textures.exists('vega-menu-title')) {
const t = this.add.image(GAME_WIDTH / 2, 74, 'vega-menu-title').setOrigin(0.5);
// Contain within a header box rather than stretching — the source is a
// large title card and its aspect must be preserved.
const src = this.textures.get('vega-menu-title').getSourceImage();
const fit = Math.min(760 / src.width, 132 / src.height);
t.setScale(fit);
layer.add(t);
} else {
layer.add(this.add.text(GAME_WIDTH / 2, 60, 'MASTER OF VEGA', {
fontFamily: FONT, fontSize: '58px', color: '#cfe8ff',
}).setOrigin(0.5));
}
layer.add(this.add.text(GAME_WIDTH / 2, 138, 'Choose your species and the shape of the galaxy.', {
fontFamily: FONT, fontSize: '20px', color: '#7f97b3',
}).setOrigin(0.5));
// Spoken greeting on arrival. resetQueue first so stepping back to the
// landing screen and returning does not stack a second copy on top of a
// line that is still running.
resetSpeechQueue();
enqueueSpeech(UI_SPEECH.chooseSpecies, null, { force: true });
const choice = {
speciesId: 'human',
sizeId: 'medium',
@ -125,8 +349,8 @@ export default class MasterOfVegaGame extends Phaser.Scene {
const bg = this.add.rectangle(0, 0, cardW - 12, cardH - 12, 0x0b1220, 0.95).setOrigin(0, 0);
bg.setStrokeStyle(1.5, 0x24405f, 1);
card.add(bg);
card.add(this.add.image(52, 62, this.art.portraits, speciesPortraitFrame(this.rules, spec.id))
.setDisplaySize(84, 84));
const portrait = makeSpeciesPortrait(this, this.rules, this.art, spec.id, 52, 62, 84);
card.add(portrait);
card.add(this.add.text(104, 16, spec.name, {
fontFamily: FONT, fontSize: '22px', color: spec.color,
}));
@ -144,11 +368,13 @@ export default class MasterOfVegaGame extends Phaser.Scene {
// a Container's hit area is always centred on its origin.
const zone = this.add.rectangle(0, 0, cardW - 12, cardH - 12, 0xffffff, 0.001)
.setOrigin(0, 0).setInteractive({ useHandCursor: true });
zone.on('pointerup', () => {
choice.speciesId = spec.id;
for (const c of cards) c.bg.setStrokeStyle(1.5, 0x24405f, 1);
bg.setStrokeStyle(2.5, 0x6fc4ff, 1);
});
zone.on('pointerup', this.uiClick(() => {
this.openSpeciesDetail(layer, spec, card, portrait, () => {
choice.speciesId = spec.id;
for (const c of cards) c.bg.setStrokeStyle(1.5, 0x24405f, 1);
bg.setStrokeStyle(2.5, 0x6fc4ff, 1);
});
}));
card.add(zone);
layer.add(card);
cards.push({ bg, spec });
@ -163,11 +389,11 @@ export default class MasterOfVegaGame extends Phaser.Scene {
}).setOrigin(0, 0.5));
const buttons = [];
options.forEach((opt, i) => {
const b = new Button(this, GAME_WIDTH / 2 - 380 + i * 210, y, format(opt), () => {
const b = new Button(this, GAME_WIDTH / 2 - 380 + i * 210, y, format(opt), this.uiClick(() => {
choice[key] = opt.id ?? opt;
for (const other of buttons) other.setActive(false);
b.setActive(true);
}, { width: 195, height: 46, fontSize: 19 });
}), { width: 195, height: 46, fontSize: 19 });
if ((opt.id ?? opt) === choice[key]) b.setActive(true);
buttons.push(b);
layer.add(b);
@ -183,34 +409,27 @@ export default class MasterOfVegaGame extends Phaser.Scene {
}).setOrigin(0, 0.5));
const empButtons = [];
[2, 3, 4, 5, 6].forEach((n, i) => {
const b = new Button(this, GAME_WIDTH / 2 - 380 + i * 210, optY + 186, `${n}`, () => {
const b = new Button(this, GAME_WIDTH / 2 - 380 + i * 210, optY + 186, `${n}`, this.uiClick(() => {
choice.empires = n;
for (const other of empButtons) other.setActive(false);
b.setActive(true);
}, { width: 195, height: 46, fontSize: 19 });
}), { width: 195, height: 46, fontSize: 19 });
if (n === choice.empires) b.setActive(true);
empButtons.push(b);
layer.add(b);
});
const start = new Button(this, GAME_WIDTH / 2 + 430, optY + 124, 'Begin', () => {
const start = new Button(this, GAME_WIDTH / 2 + 430, optY + 124, 'Begin', this.uiClick(() => {
layer.destroy();
this.beginGame(choice);
}, { width: 260, height: 64, fontSize: 30 });
}), { width: 260, height: 64, fontSize: 30 });
layer.add(start);
// Resume, if a save is waiting.
const saved = this.readSave();
if (saved) {
const resume = new Button(this, GAME_WIDTH / 2 + 430, optY + 196, 'Resume', () => {
layer.destroy();
this.beginGame(null, saved);
}, { width: 260, height: 50, fontSize: 22 });
layer.add(resume);
}
const back = new Button(this, 120, 60, '← Menu', () => this.scene.start('GameMenu'),
{ width: 170, height: 48, fontSize: 20, variant: 'ghost' });
// Resuming lives on the landing screen now, so this only steps back to it.
const back = new Button(this, 120, 60, '← Back', this.uiClick(() => {
layer.destroy();
this.showLanding();
}), { width: 170, height: 48, fontSize: 20, variant: 'ghost' });
layer.add(back);
}

View File

@ -542,6 +542,90 @@ export function ensureSheets(scene, rules, art) {
return { keys, procedural };
}
// --------------------------------------------------------------------------
// Species portraits
//
// Three tiers, best first:
// 1. a looping, muted video (assets/videos/vega/char-<id>.mp4)
// 2. a high-resolution still (assets/images/vega/char-<id>.png)
// 3. a frame on the procedural sheet (always present)
//
// All three are square and return a plain display object, so no caller has to
// know which it got. That is what lets the roster be finished a species at a
// time without any code changing.
/** Source resolution of the portrait videos (see sprites.md). */
const PORTRAIT_VIDEO_PX = 256;
export const speciesVideoKey = (speciesId) => `vega-char-${speciesId}`;
export const speciesStillKey = (speciesId) => `vega-still-${speciesId}`;
// Speech is streamed by ui/SpeechQueue.js straight from assets/speech/<clip>.mp3,
// never through Phaser's loader, so unlike the portraits it is addressed purely
// by convention rather than declared in the artwork manifest. The verifier
// enforces the convention so a missing or misspelled file is caught here rather
// than as a silent 404.
export const speciesSpeechClip = (speciesId) => `vega/char-${speciesId}`;
/** Non-species speech clips, addressed the same way. */
export const UI_SPEECH = {
chooseSpecies: 'vega/ui-choose-start',
};
export function hasSpeciesVideo(scene, speciesId) {
return !!scene.cache.video?.exists(speciesVideoKey(speciesId));
}
export function hasSpeciesStill(scene, speciesId) {
return scene.textures.exists(speciesStillKey(speciesId));
}
/**
* Create a square species portrait `size` px across, centred on (x, y).
* Returns a Phaser Video or Image caller adds it to its own container.
*/
export function makeSpeciesPortrait(scene, rules, art, speciesId, x, y, size) {
if (hasSpeciesVideo(scene, speciesId)) {
const v = scene.add.video(x, y, speciesVideoKey(speciesId));
v.setMute(true);
v.setLoop(true);
// Scale from the known source size rather than setDisplaySize: a Video's
// texture can still report zero width before its first frame is decoded,
// and setDisplaySize would then divide by it and blank the portrait.
v.setScale(size / (v.width || PORTRAIT_VIDEO_PX));
v.play(true);
// If the file is present but the browser refuses to decode it, drop to the
// still rather than leaving a hole where the portrait should be.
v.once('error', () => {
if (!v.scene) return;
const fallback = makeSpeciesStillOrFrame(scene, rules, art, speciesId, x, y, size);
v.parentContainer?.add(fallback);
v.destroy();
});
return v;
}
return makeSpeciesStillOrFrame(scene, rules, art, speciesId, x, y, size);
}
function makeSpeciesStillOrFrame(scene, rules, art, speciesId, x, y, size) {
const img = hasSpeciesStill(scene, speciesId)
? scene.add.image(x, y, speciesStillKey(speciesId))
: scene.add.image(x, y, art.portraits, speciesPortraitFrame(rules, speciesId));
sizeSpeciesPortrait(img, size);
return img;
}
/**
* Resize a portrait made by makeSpeciesPortrait, whichever tier it came from.
*
* Scales from the object's TEXTURE width rather than calling setDisplaySize,
* so it stays correct after repeated resizes (displayWidth changes, width does
* not) and survives a Video whose first frame has not decoded yet.
*/
export function sizeSpeciesPortrait(obj, size) {
obj.setScale(size / (obj.width || PORTRAIT_VIDEO_PX));
return obj;
}
// Frame helpers — the one place that knows how the sheets are indexed.
export const shipFrame = (rules, speciesId, hullId) => {
const s = rules.species[speciesId];

View File

@ -14,7 +14,7 @@ import {
} from './VegaDiplomacy.js';
import { leaderOffers } from './VegaLeaders.js';
import { speciesPortraitFrame } from './VegaArt.js';
import { makeSpeciesPortrait } from './VegaArt.js';
export const FONT = '"Julius Sans One"';
export const D = { map: 1, hud: 30, modal: 60, toast: 80 };
@ -219,9 +219,8 @@ export function openDiplomacyScreen(scene, rules, state, e, art, onClose, onChan
const y = shell.body.y + i * rowH;
const spec = rules.species[other.speciesId];
const portrait = scene.add.image(shell.body.x + 52, y + rowH / 2, art.portraits,
speciesPortraitFrame(rules, other.speciesId)).setDisplaySize(96, 96);
shell.add(portrait);
shell.add(makeSpeciesPortrait(scene, rules, art, other.speciesId,
shell.body.x + 52, y + rowH / 2, 96));
const att = attitudeOf(state, other.idx, e);
const treaty = emp.treaties[other.idx] ?? 'none';

View File

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

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

@ -97,17 +97,56 @@ the pulsar's beam axis vertical and the binary's companion at the frame centre.
---
## 4. `portraits` — ⬜ `assets/images/vega/portraits.png`
## 4. Species portraits — **video**, with a sheet fallback
| | |
|---|---|
| Sheet size | **1280 × 512** |
| Frame | **256 × 256** |
| Grid | 5 cols × 2 rows = 10 frames |
Portraits are **looping, muted 256 × 256 videos**, one per species, at
`assets/videos/vega/char-<speciesId>.mp4`. They play silently on a loop wherever
a species portrait appears — the setup screen's species cards (84 px), the
species detail pop-over (**full 256 px**), and the diplomacy screen (96 px).
One frame per species (`species.portraitFrame`), same order as the ship rows.
Head-and-shoulders, facing the viewer. Shown at 8496 px on the species picker
and the diplomacy screen, so read at small size.
Clicking a card grows it into a centred pop-over showing the portrait at native
resolution. The card's portrait is *reparented* into the pop-over rather than a
second one being created, so there is only ever one video object per species.
**To add one:** drop the file in, then change that species' `path` from `null`
to it in `portraitVideos` in `data/mastervega-artwork.json`. Nothing else. The
verifier checks every species has an entry, that the key matches, and that every
non-null path points at a file that exists.
**All ten species have both a video and a still.** ✅
| Species | Video (256 × 256) | Still (1024 × 1024) |
|---|---|---|
| Human | `char-human.mp4` | `char-human.png` |
| Kestrelli | `char-kestrelli.mp4` | ⚠️ `char-kestr**a**lli.png` |
| Ursaal | `char-ursaal.mp4` | `char-ursaal.png` |
| Umbrix | `char-umbrix.mp4` | `char-umbrix.png` |
| Kkrix | `char-kkrix.mp4` | `char-kkrix.png` |
| Mekhan | `char-mekhan.mp4` | `char-mekhan.png` |
| Rrashaa | `char-rrashaa.mp4` | `char-rrashaa.png` |
| Cerebrai | ⚠️ `char-cereb**a**i.mp4` | `char-cerebrai.png` |
| Ssakar | `char-ssakar.mp4` | `char-ssakar.png` |
| Lithox | `char-lithox.mp4` | `char-lithox.png` |
### Fallback order
Three tiers, best first — no caller knows which it got, so the roster can be
finished a species at a time with no code changes:
1. **Video**`assets/videos/vega/char-<id>.mp4`, listed in `portraitVideos`.
2. **Still**`assets/images/vega/char-<id>.png` (1024 × 1024), listed in
`portraitStills`. Used when there is no video, and as the recovery path if a
video is present but the browser refuses to decode it.
3. **Painted sheet** — the optional ⬜ `assets/images/vega/portraits.png`
(1280 × 512, 256 × 256 frames, 5 × 2, one per `species.portraitFrame` in the
ship-row order), which itself falls back to a procedural painting.
> ⚠️ **Two filenames do not match their species id**, each with a correctly
> spelled counterpart: the kestrelli *still* is `char-kestralli.png`, and the
> cerebrai *video* is `char-cerebai.mp4` (no `r`). The manifest points at the
> real filenames so both work as-is — rename a file and its one path together
> if you want the spelling fixed. The verifier fails immediately if a declared
> path stops resolving, so a rename cannot go unnoticed.
These are **species portraits, not characters** — this game deliberately does
not use the `data/opponents.json` roster or its videos.
@ -183,11 +222,44 @@ column 2 → pixel origin **(88, 264)**. **Painted.**
| File | Size | Used for |
|---|---|---|
| `assets/images/vega/background-menu.png` | 1920 × 1080 | Setup-screen background (darkened 62% so the cards stay legible) |
| `assets/images/vega/menu-title.png` | 1368 × 768 | Setup-screen title, scaled to fit a 760 × 132 header box (aspect preserved) |
| `assets/images/vega/background-menu.png` | 1920 × 1080 | Backdrop for both menus — dimmed 34% on the landing screen, 62% on the setup screen so the species cards stay legible |
| `assets/images/vega/menu-title.png` | 1368 × 768 | Title logo. Large on the **left** of the landing screen (contained in 900 × 560 → 900 × 505), and small as the setup screen's header (contained in 760 × 132). Always contained, never stretched. |
## Speech — ✅ `assets/speech/vega/`
| Clip | When it plays |
|---|---|
| `ui-choose-start.mp3` | On arriving at the choose-a-species screen |
| `char-<speciesId>.mp3` | Once, when that species' detail pop-over opens |
All ten species lines are present and correctly named.
Unlike the portraits, speech is **not** declared in the artwork manifest:
`ui/SpeechQueue.js` streams it straight from `assets/speech/<clip>.mp3` without
going through Phaser's loader, so the filename convention *is* the wiring. Drop
in a replacement and it plays — the verifier checks every species resolves.
Each new line cuts off whatever was playing rather than letting two voices
overlap — so opening a species interrupts the greeting, opening a second species
interrupts the first, and closing the window stops playback.
---
## Sound
Already wired, no new audio needed: `laser-zap.mp3`, `scifi-explode.mp3`,
`ta-rocket-1.mp3`, `ta-rocket-2.mp3`. Soundtrack is the `hacker` set
(`src/services/soundtrack.js`).
Menu clicks use the shared `8bit-activate` cue (already preloaded globally as
`sfx-8bit-activate`) — every button on the landing and species screens, the
species cards, and the detail pop-over.
Combat effects already wired, no new audio needed: `laser-zap.mp3`, `scifi-explode.mp3`,
`ta-rocket-1.mp3`, `ta-rocket-2.mp3`.
**Soundtrack** is the game's own set, `data/masterofvega-music.json`
`assets/music/vega/`, at volume 0.6. Four tracks are declared: *Power Down*
(DV-8-2303), *High Orbit* (Klaxon), *Master of Vega Main Theme* (Aiko), *Defend
the Colony* (Cy-Bro).
> ⚠️ `assets/music/vega/track-05.mp3` is on disk but **not listed** in
> `masterofvega-music.json`, so it never plays. Add an entry for it if that was
> not deliberate. The verifier checks every declared track exists, but cannot
> know about files you meant to declare.

View File

@ -49,6 +49,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('adventure-music', 'data/adventure-music.json');
this.load.json('chinese-music', 'data/chinese-music.json');
this.load.json('zuma-music', 'data/zuma-music.json');
this.load.json('masterofvega-music', 'data/masterofvega-music.json');
this.load.json('advancewars-artwork', 'data/advancewars-artwork.json');
this.load.json('tetrisattack-artwork', 'data/tetrisattack-artwork.json');
this.load.json('totalannihilation-artwork', 'data/totalannihilation-artwork.json');

View File

@ -7,6 +7,8 @@ import { resolveGameAssets } from '../data/assetManifest.js';
function isLoaded(scene, d) {
if (d.type === 'audio') return scene.cache.audio.exists(d.key);
if (d.type === 'json') return scene.cache.json.exists(d.key);
// Video lives in its own cache, not the texture manager.
if (d.type === 'video') return !!scene.cache.video?.exists(d.key);
return scene.textures.exists(d.key);
}
@ -21,6 +23,10 @@ function queueDescriptor(scene, d) {
scene.load.audio(d.key, d.path);
} else if (d.type === 'json') {
scene.load.json(d.key, d.path);
} else if (d.type === 'video') {
// noAudio: true — these are ambient portrait loops. It also lets them
// autoplay, which browsers refuse for anything with an audio track.
scene.load.video(d.key, d.path, true);
} else {
scene.load.image(d.key, d.path);
}

View File

@ -34,7 +34,7 @@ export const GAME_SOUNDTRACK_OVERRIDES = {
mahjong: 'chinese',
mahjongmatch: 'chinese',
zuma: 'zuma',
mastervega: 'hacker',
mastervega: 'masterofvega',
};
// Resolve the track list (and optional volume override) a game scene's

View File

@ -19,7 +19,7 @@
// 10. AI self-play soak: full games terminate, both victory kinds occur,
// invariants hold, AI turn-time budget.
import { readFileSync } from 'node:fs';
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
@ -31,9 +31,14 @@ 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 { ensureSheets, shipFrame, planetFrame, techFrame, buildingFrame } from '../src/games/mastervega/VegaArt.js';
import {
ensureSheets, shipFrame, planetFrame, techFrame, buildingFrame,
speciesVideoKey, speciesStillKey, speciesSpeechClip, UI_SPEECH,
} from '../src/games/mastervega/VegaArt.js';
const QUICK = process.argv.includes('--quick');
const gamesArg = process.argv.find((a) => a.startsWith('--games='));
@ -207,6 +212,80 @@ section('2. Procedural art');
for (const b of RULES.buildingList) {
check(`building icon ${b.id} painted`, buildTex?.frames.includes(buildingFrame(RULES, b.id)));
}
// Species portraits are looping videos where one has been recorded and a
// sheet frame where one has not. The manifest has to name every species so a
// new one cannot be silently forgotten, and every non-null path has to point
// at a file that is actually there — otherwise the first sign of a typo is a
// 404 and a blank portrait in the browser.
const vids = artJson.portraitVideos ?? {};
const vidIds = Object.keys(vids).filter((k) => !k.startsWith('_'));
for (const id of vidIds) {
check(`portraitVideos entry ${id} is a known species`, !!RULES.species[id]);
check(`portraitVideos ${id} key matches the loader's key`,
vids[id].key === speciesVideoKey(id), `${vids[id].key} vs ${speciesVideoKey(id)}`);
if (vids[id].path) {
check(`portraitVideos ${id} file exists`, existsSync(join(root, vids[id].path)), vids[id].path);
check(`portraitVideos ${id} is an mp4`, vids[id].path.endsWith('.mp4'));
}
}
for (const s of RULES.speciesList) {
check(`species ${s.id} has a portraitVideos entry`, vidIds.includes(s.id));
// Whether or not it has a video, the sheet fallback must exist for it.
const portraitsTex = made.get(keys.portraits);
check(`species ${s.id} has a portrait fallback frame`,
portraitsTex?.frames.includes(s.portraitFrame));
}
// The still-portrait tier, checked the same way. Its filenames are NOT
// derived from the species id — one of them is spelled differently on disk —
// so the path in the manifest is the only source of truth and has to be
// confirmed against the filesystem.
const stills = artJson.portraitStills ?? {};
const stillIds = Object.keys(stills).filter((k) => !k.startsWith('_'));
for (const id of stillIds) {
check(`portraitStills entry ${id} is a known species`, !!RULES.species[id]);
check(`portraitStills ${id} key matches the loader's key`,
stills[id].key === speciesStillKey(id), `${stills[id].key} vs ${speciesStillKey(id)}`);
if (stills[id].path) {
check(`portraitStills ${id} file exists`, existsSync(join(root, stills[id].path)), stills[id].path);
}
}
for (const s of RULES.speciesList) {
check(`species ${s.id} has a portraitStills entry`, stillIds.includes(s.id));
}
const recorded = vidIds.filter((id) => vids[id].path).length;
const stillCount = stillIds.filter((id) => stills[id].path).length;
console.log(` (${recorded}/${RULES.speciesList.length} portraits are video, `
+ `${stillCount} have a still; the rest fall back to the painted sheet)`);
// Species speech. Unlike the portraits this is NOT declared in the artwork
// manifest — ui/SpeechQueue.js streams it straight from
// assets/speech/<clip>.mp3 without going through Phaser's loader — so the
// filename convention itself is the contract, and this is the only place it
// gets enforced.
for (const sp of RULES.speciesList) {
const clip = speciesSpeechClip(sp.id);
check(`species ${sp.id} speech clip exists`,
existsSync(join(root, 'assets/speech', `${clip}.mp3`)), `${clip}.mp3`);
}
for (const [name, clip] of Object.entries(UI_SPEECH)) {
check(`UI speech clip ${name} exists`,
existsSync(join(root, 'assets/speech', `${clip}.mp3`)), `${clip}.mp3`);
}
// Soundtrack: every track the JSON names must be on disk, or the game boots
// into silence with a console error and nothing says why.
const music = JSON.parse(readFileSync(join(root, 'data/masterofvega-music.json'), 'utf8'));
check('soundtrack declares tracks', Array.isArray(music.tracks) && music.tracks.length > 0);
for (const t of music.tracks ?? []) {
check(`soundtrack file ${t.file} exists`, existsSync(join(root, 'assets/music', t.file)), t.file);
check(`soundtrack ${t.file} has artist and title`, !!t.artist && !!t.title);
}
if (typeof music.volume === 'number') {
check('soundtrack volume in range', music.volume > 0 && music.volume <= 1, `${music.volume}`);
}
}
// ---------------------------------------------------------------------------
@ -282,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');
// ---------------------------------------------------------------------------