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
This commit is contained in:
Brian Fertig 2026-08-02 00:04:06 -06:00
parent 96179fe6f0
commit d789da4582
36 changed files with 608 additions and 75 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.", "_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": { "sheets": {
"ships": { "ships": {
"key": "vega-ships", "key": "vega-ships",

View File

@ -40,6 +40,26 @@ function sheetsFrom(scene, jsonKey, fields) {
} }
// Drop-in standalone images declared in a cached artwork JSON's `artwork` array. // 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) { function imagesFrom(scene, jsonKey) {
const art = scene.cache.json.get(jsonKey); const art = scene.cache.json.get(jsonKey);
return (art?.artwork ?? []) return (art?.artwork ?? [])
@ -77,13 +97,15 @@ export const MANIFEST = {
mastervega: [ mastervega: [
{ type: 'json', key: 'mastervega-rules', path: 'data/mastervega-rules.json' }, { type: 'json', key: 'mastervega-rules', path: 'data/mastervega-rules.json' },
(scene) => sheetsFrom(scene, 'mastervega-artwork', ['sheets']), (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-bg', 'assets/images/vega/background-menu.png'),
image('vega-menu-title', 'assets/images/vega/menu-title.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: 'laser-zap', path: 'assets/fx/laser-zap.mp3' },
{ type: 'audio', key: 'scifi-explode', path: 'assets/fx/scifi-explode.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-1', path: 'assets/fx/ta-rocket-1.mp3' },
{ type: 'audio', key: 'ta-rocket-2', path: 'assets/fx/ta-rocket-2.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: [ forbiddenisland: [
// Tiles: 2 cols (dry, flooded) × 24 rows. Row i → dry frame 2i, flooded // 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 { Tooltip } from '../../ui/Tooltip.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { getGameSoundtrack } from '../../services/soundtrack.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 { 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 * as Logic from './VegaLogic.js';
import { runAITurn } from './VegaAI.js'; import { runAITurn } from './VegaAI.js';
@ -59,50 +62,271 @@ export default class MasterOfVegaGame extends Phaser.Scene {
this.events.once('shutdown', () => this.teardown()); 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() { teardown() {
resetSpeechQueue();
this.map?.destroy(); this.map?.destroy();
this.fx?.destroy(); this.fx?.destroy();
this.music?.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 // ---------------------------------------------------------------- setup
showSetup() { showSetup() {
const layer = this.add.container(0, 0).setDepth(D.modal); const layer = this.add.container(0, 0).setDepth(D.modal);
this.setupLayer = layer; this.setupLayer = layer;
// Supplied menu art if present, flat panel colour if not — the setup screen this.paintBackdrop(layer, 0.62);
// has to work with zero art files like everything else here. this.paintTitle(layer, GAME_WIDTH / 2, 74, 760, 132, 58);
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));
}
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.', { layer.add(this.add.text(GAME_WIDTH / 2, 138, 'Choose your species and the shape of the galaxy.', {
fontFamily: FONT, fontSize: '20px', color: '#7f97b3', fontFamily: FONT, fontSize: '20px', color: '#7f97b3',
}).setOrigin(0.5)); }).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 = { const choice = {
speciesId: 'human', speciesId: 'human',
sizeId: 'medium', 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); const bg = this.add.rectangle(0, 0, cardW - 12, cardH - 12, 0x0b1220, 0.95).setOrigin(0, 0);
bg.setStrokeStyle(1.5, 0x24405f, 1); bg.setStrokeStyle(1.5, 0x24405f, 1);
card.add(bg); card.add(bg);
card.add(this.add.image(52, 62, this.art.portraits, speciesPortraitFrame(this.rules, spec.id)) const portrait = makeSpeciesPortrait(this, this.rules, this.art, spec.id, 52, 62, 84);
.setDisplaySize(84, 84)); card.add(portrait);
card.add(this.add.text(104, 16, spec.name, { card.add(this.add.text(104, 16, spec.name, {
fontFamily: FONT, fontSize: '22px', color: spec.color, 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. // 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) const zone = this.add.rectangle(0, 0, cardW - 12, cardH - 12, 0xffffff, 0.001)
.setOrigin(0, 0).setInteractive({ useHandCursor: true }); .setOrigin(0, 0).setInteractive({ useHandCursor: true });
zone.on('pointerup', () => { zone.on('pointerup', this.uiClick(() => {
choice.speciesId = spec.id; this.openSpeciesDetail(layer, spec, card, portrait, () => {
for (const c of cards) c.bg.setStrokeStyle(1.5, 0x24405f, 1); choice.speciesId = spec.id;
bg.setStrokeStyle(2.5, 0x6fc4ff, 1); for (const c of cards) c.bg.setStrokeStyle(1.5, 0x24405f, 1);
}); bg.setStrokeStyle(2.5, 0x6fc4ff, 1);
});
}));
card.add(zone); card.add(zone);
layer.add(card); layer.add(card);
cards.push({ bg, spec }); cards.push({ bg, spec });
@ -163,11 +389,11 @@ export default class MasterOfVegaGame extends Phaser.Scene {
}).setOrigin(0, 0.5)); }).setOrigin(0, 0.5));
const buttons = []; const buttons = [];
options.forEach((opt, i) => { 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; choice[key] = opt.id ?? opt;
for (const other of buttons) other.setActive(false); for (const other of buttons) other.setActive(false);
b.setActive(true); b.setActive(true);
}, { width: 195, height: 46, fontSize: 19 }); }), { width: 195, height: 46, fontSize: 19 });
if ((opt.id ?? opt) === choice[key]) b.setActive(true); if ((opt.id ?? opt) === choice[key]) b.setActive(true);
buttons.push(b); buttons.push(b);
layer.add(b); layer.add(b);
@ -183,34 +409,27 @@ export default class MasterOfVegaGame extends Phaser.Scene {
}).setOrigin(0, 0.5)); }).setOrigin(0, 0.5));
const empButtons = []; const empButtons = [];
[2, 3, 4, 5, 6].forEach((n, i) => { [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; choice.empires = n;
for (const other of empButtons) other.setActive(false); for (const other of empButtons) other.setActive(false);
b.setActive(true); b.setActive(true);
}, { width: 195, height: 46, fontSize: 19 }); }), { width: 195, height: 46, fontSize: 19 });
if (n === choice.empires) b.setActive(true); if (n === choice.empires) b.setActive(true);
empButtons.push(b); empButtons.push(b);
layer.add(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(); layer.destroy();
this.beginGame(choice); this.beginGame(choice);
}, { width: 260, height: 64, fontSize: 30 }); }), { width: 260, height: 64, fontSize: 30 });
layer.add(start); layer.add(start);
// Resume, if a save is waiting. // Resuming lives on the landing screen now, so this only steps back to it.
const saved = this.readSave(); const back = new Button(this, 120, 60, '← Back', this.uiClick(() => {
if (saved) { layer.destroy();
const resume = new Button(this, GAME_WIDTH / 2 + 430, optY + 196, 'Resume', () => { this.showLanding();
layer.destroy(); }), { width: 170, height: 48, fontSize: 20, variant: 'ghost' });
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' });
layer.add(back); layer.add(back);
} }

View File

@ -542,6 +542,90 @@ export function ensureSheets(scene, rules, art) {
return { keys, procedural }; 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. // Frame helpers — the one place that knows how the sheets are indexed.
export const shipFrame = (rules, speciesId, hullId) => { export const shipFrame = (rules, speciesId, hullId) => {
const s = rules.species[speciesId]; const s = rules.species[speciesId];

View File

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

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
| | | Portraits are **looping, muted 256 × 256 videos**, one per species, at
|---|---| `assets/videos/vega/char-<speciesId>.mp4`. They play silently on a loop wherever
| Sheet size | **1280 × 512** | a species portrait appears — the setup screen's species cards (84 px), the
| Frame | **256 × 256** | species detail pop-over (**full 256 px**), and the diplomacy screen (96 px).
| Grid | 5 cols × 2 rows = 10 frames |
One frame per species (`species.portraitFrame`), same order as the ship rows. Clicking a card grows it into a centred pop-over showing the portrait at native
Head-and-shoulders, facing the viewer. Shown at 8496 px on the species picker resolution. The card's portrait is *reparented* into the pop-over rather than a
and the diplomacy screen, so read at small size. 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 These are **species portraits, not characters** — this game deliberately does
not use the `data/opponents.json` roster or its videos. 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 | | 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/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 | Setup-screen title, scaled to fit a 760 × 132 header box (aspect preserved) | | `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 ## Sound
Already wired, no new audio needed: `laser-zap.mp3`, `scifi-explode.mp3`, Menu clicks use the shared `8bit-activate` cue (already preloaded globally as
`ta-rocket-1.mp3`, `ta-rocket-2.mp3`. Soundtrack is the `hacker` set `sfx-8bit-activate`) — every button on the landing and species screens, the
(`src/services/soundtrack.js`). 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('adventure-music', 'data/adventure-music.json');
this.load.json('chinese-music', 'data/chinese-music.json'); this.load.json('chinese-music', 'data/chinese-music.json');
this.load.json('zuma-music', 'data/zuma-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('advancewars-artwork', 'data/advancewars-artwork.json');
this.load.json('tetrisattack-artwork', 'data/tetrisattack-artwork.json'); this.load.json('tetrisattack-artwork', 'data/tetrisattack-artwork.json');
this.load.json('totalannihilation-artwork', 'data/totalannihilation-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) { function isLoaded(scene, d) {
if (d.type === 'audio') return scene.cache.audio.exists(d.key); if (d.type === 'audio') return scene.cache.audio.exists(d.key);
if (d.type === 'json') return scene.cache.json.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); return scene.textures.exists(d.key);
} }
@ -21,6 +23,10 @@ function queueDescriptor(scene, d) {
scene.load.audio(d.key, d.path); scene.load.audio(d.key, d.path);
} else if (d.type === 'json') { } else if (d.type === 'json') {
scene.load.json(d.key, d.path); 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 { } else {
scene.load.image(d.key, d.path); scene.load.image(d.key, d.path);
} }

View File

@ -34,7 +34,7 @@ export const GAME_SOUNDTRACK_OVERRIDES = {
mahjong: 'chinese', mahjong: 'chinese',
mahjongmatch: 'chinese', mahjongmatch: 'chinese',
zuma: 'zuma', zuma: 'zuma',
mastervega: 'hacker', mastervega: 'masterofvega',
}; };
// Resolve the track list (and optional volume override) a game scene's // 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, // 10. AI self-play soak: full games terminate, both victory kinds occur,
// invariants hold, AI turn-time budget. // invariants hold, AI turn-time budget.
import { readFileSync } from 'node:fs'; import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
@ -33,7 +33,10 @@ 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';
// 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 { 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 QUICK = process.argv.includes('--quick');
const gamesArg = process.argv.find((a) => a.startsWith('--games=')); const gamesArg = process.argv.find((a) => a.startsWith('--games='));
@ -207,6 +210,80 @@ section('2. Procedural art');
for (const b of RULES.buildingList) { for (const b of RULES.buildingList) {
check(`building icon ${b.id} painted`, buildTex?.frames.includes(buildingFrame(RULES, b.id))); 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}`);
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------