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
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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 84–96 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.
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||