diff --git a/assets/images/vega/char-cerebrai.png b/assets/images/vega/char-cerebrai.png new file mode 100644 index 0000000..d1e0b9f Binary files /dev/null and b/assets/images/vega/char-cerebrai.png differ diff --git a/assets/images/vega/char-kkrix.png b/assets/images/vega/char-kkrix.png new file mode 100644 index 0000000..07fc1f9 Binary files /dev/null and b/assets/images/vega/char-kkrix.png differ diff --git a/assets/images/vega/char-lithox.png b/assets/images/vega/char-lithox.png new file mode 100644 index 0000000..1ad5e16 Binary files /dev/null and b/assets/images/vega/char-lithox.png differ diff --git a/assets/images/vega/char-mekhan.png b/assets/images/vega/char-mekhan.png new file mode 100644 index 0000000..a9acf90 Binary files /dev/null and b/assets/images/vega/char-mekhan.png differ diff --git a/assets/images/vega/char-rrashaa.png b/assets/images/vega/char-rrashaa.png new file mode 100644 index 0000000..9334861 Binary files /dev/null and b/assets/images/vega/char-rrashaa.png differ diff --git a/assets/images/vega/char-ssakar.png b/assets/images/vega/char-ssakar.png new file mode 100644 index 0000000..cbc853f Binary files /dev/null and b/assets/images/vega/char-ssakar.png differ diff --git a/assets/images/vega/char-umbrix.png b/assets/images/vega/char-umbrix.png new file mode 100644 index 0000000..87799d2 Binary files /dev/null and b/assets/images/vega/char-umbrix.png differ diff --git a/assets/speech/vega/char-cerebrai.mp3 b/assets/speech/vega/char-cerebrai.mp3 new file mode 100644 index 0000000..d9ffaa6 Binary files /dev/null and b/assets/speech/vega/char-cerebrai.mp3 differ diff --git a/assets/speech/vega/char-human.mp3 b/assets/speech/vega/char-human.mp3 new file mode 100644 index 0000000..dacac43 Binary files /dev/null and b/assets/speech/vega/char-human.mp3 differ diff --git a/assets/speech/vega/char-kestrelli.mp3 b/assets/speech/vega/char-kestrelli.mp3 new file mode 100644 index 0000000..bb1d9e5 Binary files /dev/null and b/assets/speech/vega/char-kestrelli.mp3 differ diff --git a/assets/speech/vega/char-kkrix.mp3 b/assets/speech/vega/char-kkrix.mp3 new file mode 100644 index 0000000..8227bd1 Binary files /dev/null and b/assets/speech/vega/char-kkrix.mp3 differ diff --git a/assets/speech/vega/char-lithox.mp3 b/assets/speech/vega/char-lithox.mp3 new file mode 100644 index 0000000..7a663c4 Binary files /dev/null and b/assets/speech/vega/char-lithox.mp3 differ diff --git a/assets/speech/vega/char-mekhan.mp3 b/assets/speech/vega/char-mekhan.mp3 new file mode 100644 index 0000000..7a4c0fd Binary files /dev/null and b/assets/speech/vega/char-mekhan.mp3 differ diff --git a/assets/speech/vega/char-rrashaa.mp3 b/assets/speech/vega/char-rrashaa.mp3 new file mode 100644 index 0000000..ea1013e Binary files /dev/null and b/assets/speech/vega/char-rrashaa.mp3 differ diff --git a/assets/speech/vega/char-ssakar.mp3 b/assets/speech/vega/char-ssakar.mp3 new file mode 100644 index 0000000..aedf19d Binary files /dev/null and b/assets/speech/vega/char-ssakar.mp3 differ diff --git a/assets/speech/vega/char-umbrix.mp3 b/assets/speech/vega/char-umbrix.mp3 new file mode 100644 index 0000000..a932b68 Binary files /dev/null and b/assets/speech/vega/char-umbrix.mp3 differ diff --git a/assets/speech/vega/char-ursaal.mp3 b/assets/speech/vega/char-ursaal.mp3 new file mode 100644 index 0000000..018cad2 Binary files /dev/null and b/assets/speech/vega/char-ursaal.mp3 differ diff --git a/assets/speech/vega/ui-choose-start.mp3 b/assets/speech/vega/ui-choose-start.mp3 new file mode 100644 index 0000000..cb18f46 Binary files /dev/null and b/assets/speech/vega/ui-choose-start.mp3 differ diff --git a/assets/videos/vega/char-cerebai.mp4 b/assets/videos/vega/char-cerebai.mp4 new file mode 100644 index 0000000..772bd14 Binary files /dev/null and b/assets/videos/vega/char-cerebai.mp4 differ diff --git a/assets/videos/vega/char-kkrix.mp4 b/assets/videos/vega/char-kkrix.mp4 new file mode 100644 index 0000000..036f4d4 Binary files /dev/null and b/assets/videos/vega/char-kkrix.mp4 differ diff --git a/assets/videos/vega/char-lithox.mp4 b/assets/videos/vega/char-lithox.mp4 new file mode 100644 index 0000000..51384ef Binary files /dev/null and b/assets/videos/vega/char-lithox.mp4 differ diff --git a/assets/videos/vega/char-mekhan.mp4 b/assets/videos/vega/char-mekhan.mp4 new file mode 100644 index 0000000..2b47dd6 Binary files /dev/null and b/assets/videos/vega/char-mekhan.mp4 differ diff --git a/assets/videos/vega/char-rrashaa.mp4 b/assets/videos/vega/char-rrashaa.mp4 new file mode 100644 index 0000000..701bfab Binary files /dev/null and b/assets/videos/vega/char-rrashaa.mp4 differ diff --git a/assets/videos/vega/char-ssakar.mp4 b/assets/videos/vega/char-ssakar.mp4 new file mode 100644 index 0000000..6054b66 Binary files /dev/null and b/assets/videos/vega/char-ssakar.mp4 differ diff --git a/assets/videos/vega/char-umbrix.mp4 b/assets/videos/vega/char-umbrix.mp4 new file mode 100644 index 0000000..1ecea99 Binary files /dev/null and b/assets/videos/vega/char-umbrix.mp4 differ diff --git a/data/masterofvega-music.json b/data/masterofvega-music.json new file mode 100644 index 0000000..e2f9ce0 --- /dev/null +++ b/data/masterofvega-music.json @@ -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 +} diff --git a/data/mastervega-artwork.json b/data/mastervega-artwork.json index ae49ec8..18e0a61 100644 --- a/data/mastervega-artwork.json +++ b/data/mastervega-artwork.json @@ -1,7 +1,35 @@ { - "_readme": "Master of Vega — drop-in artwork manifest. Every sheet starts with path:null, which means VegaArt.js paints a procedural stand-in at exactly the same frame geometry. To use real art, drop the PNG in assets/images/mastervega/ and set its path here — no code changes. Frame layouts are documented in src/games/mastervega/sprites.md and are APPEND-ONLY.", + "_readme": "Master of Vega — drop-in artwork manifest. Every sheet starts with path:null, which means VegaArt.js paints a procedural stand-in at exactly the same frame geometry. To use real art, drop the PNG in assets/images/vega/ and set its path here — no code changes. Frame layouts are documented in src/games/mastervega/sprites.md and are APPEND-ONLY.", "_kindReadme": "kind selects the procedural painter in VegaArt.PROC_PAINTERS. Painters are keyed by kind, never by sheet name, so adding a sheet later needs no code change.", + "_portraitVideosReadme": "Species portraits are looping muted videos rather than spritesheet frames, 256x256. All ten are present. NOTE: cerebrai's file is spelled 'char-cerebai.mp4' on disk (missing the r) — the path below points at the real filename, so rename the file and this one line together if you want the spelling fixed. To add or replace one: drop the file and set its path here; nothing else changes.", + "portraitVideos": { + "human": { "key": "vega-char-human", "path": "assets/videos/vega/char-human.mp4" }, + "kestrelli": { "key": "vega-char-kestrelli", "path": "assets/videos/vega/char-kestrelli.mp4" }, + "ursaal": { "key": "vega-char-ursaal", "path": "assets/videos/vega/char-ursaal.mp4" }, + "umbrix": { "key": "vega-char-umbrix", "path": "assets/videos/vega/char-umbrix.mp4" }, + "kkrix": { "key": "vega-char-kkrix", "path": "assets/videos/vega/char-kkrix.mp4" }, + "mekhan": { "key": "vega-char-mekhan", "path": "assets/videos/vega/char-mekhan.mp4" }, + "rrashaa": { "key": "vega-char-rrashaa", "path": "assets/videos/vega/char-rrashaa.mp4" }, + "cerebrai": { "key": "vega-char-cerebrai", "path": "assets/videos/vega/char-cerebai.mp4" }, + "ssakar": { "key": "vega-char-ssakar", "path": "assets/videos/vega/char-ssakar.mp4" }, + "lithox": { "key": "vega-char-lithox", "path": "assets/videos/vega/char-lithox.mp4" } + }, + + "_portraitStillsReadme": "High-resolution (1024x1024) still portraits, one per species. Every species now has a video too, so these serve as the recovery path if a video fails to decode. NOTE: kestrelli's file is spelled 'char-kestralli.png' on disk — the path below points at the real filename; rename the file and this one line together if you want the spelling fixed.", + "portraitStills": { + "human": { "key": "vega-still-human", "path": "assets/images/vega/char-human.png" }, + "kestrelli": { "key": "vega-still-kestrelli", "path": "assets/images/vega/char-kestralli.png" }, + "ursaal": { "key": "vega-still-ursaal", "path": "assets/images/vega/char-ursaal.png" }, + "umbrix": { "key": "vega-still-umbrix", "path": "assets/images/vega/char-umbrix.png" }, + "kkrix": { "key": "vega-still-kkrix", "path": "assets/images/vega/char-kkrix.png" }, + "mekhan": { "key": "vega-still-mekhan", "path": "assets/images/vega/char-mekhan.png" }, + "rrashaa": { "key": "vega-still-rrashaa", "path": "assets/images/vega/char-rrashaa.png" }, + "cerebrai": { "key": "vega-still-cerebrai", "path": "assets/images/vega/char-cerebrai.png" }, + "ssakar": { "key": "vega-still-ssakar", "path": "assets/images/vega/char-ssakar.png" }, + "lithox": { "key": "vega-still-lithox", "path": "assets/images/vega/char-lithox.png" } + }, + "sheets": { "ships": { "key": "vega-ships", diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index 9a198f1..212cdf0 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -40,6 +40,26 @@ function sheetsFrom(scene, jsonKey, fields) { } // Drop-in standalone images declared in a cached artwork JSON's `artwork` array. +// Pull video descriptors out of an artwork manifest block keyed by id, e.g. +// mastervega's portraitVideos. An entry with `path: null` is one that has not +// been recorded yet and is skipped, exactly like a sheet with no PNG. +function videosFrom(scene, jsonKey, field) { + const art = scene.cache.json.get(jsonKey); + if (!art || !art[field]) return []; + return Object.entries(art[field]) + .filter(([id, v]) => !id.startsWith('_') && v && v.key && v.path) + .map(([, v]) => ({ type: 'video', key: v.key, path: v.path })); +} + +// Same shape as videosFrom, for image entries keyed by id. +function imagesFromMap(scene, jsonKey, field) { + const art = scene.cache.json.get(jsonKey); + if (!art || !art[field]) return []; + return Object.entries(art[field]) + .filter(([id, v]) => !id.startsWith('_') && v && v.key && v.path) + .map(([, v]) => ({ type: 'image', key: v.key, path: v.path })); +} + function imagesFrom(scene, jsonKey) { const art = scene.cache.json.get(jsonKey); return (art?.artwork ?? []) @@ -77,13 +97,15 @@ export const MANIFEST = { mastervega: [ { type: 'json', key: 'mastervega-rules', path: 'data/mastervega-rules.json' }, (scene) => sheetsFrom(scene, 'mastervega-artwork', ['sheets']), + (scene) => videosFrom(scene, 'mastervega-artwork', 'portraitVideos'), + (scene) => imagesFromMap(scene, 'mastervega-artwork', 'portraitStills'), image('vega-menu-bg', 'assets/images/vega/background-menu.png'), image('vega-menu-title', 'assets/images/vega/menu-title.png'), { type: 'audio', key: 'laser-zap', path: 'assets/fx/laser-zap.mp3' }, { type: 'audio', key: 'scifi-explode', path: 'assets/fx/scifi-explode.mp3' }, { type: 'audio', key: 'ta-rocket-1', path: 'assets/fx/ta-rocket-1.mp3' }, { type: 'audio', key: 'ta-rocket-2', path: 'assets/fx/ta-rocket-2.mp3' }, - (scene) => musicFrom(scene, 'hacker-music'), + (scene) => musicFrom(scene, 'masterofvega-music'), ], forbiddenisland: [ // Tiles: 2 cols (dry, flooded) × 24 rows. Row i → dry frame 2i, flooded diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js index e04c1bd..b070f40 100644 --- a/src/games/mastervega/MasterOfVegaGame.js +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -10,10 +10,13 @@ import { Button } from '../../ui/Button.js'; import { Tooltip } from '../../ui/Tooltip.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { getGameSoundtrack } from '../../services/soundtrack.js'; -import { playSound } from '../../ui/Sounds.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { enqueue as enqueueSpeech, resetQueue as resetSpeechQueue } from '../../ui/SpeechQueue.js'; import { compileRules, turnToYear } from './VegaRules.js'; -import { ensureSheets, speciesPortraitFrame } from './VegaArt.js'; +import { + ensureSheets, makeSpeciesPortrait, sizeSpeciesPortrait, speciesSpeechClip, UI_SPEECH, +} from './VegaArt.js'; import * as Logic from './VegaLogic.js'; import { runAITurn } from './VegaAI.js'; @@ -59,50 +62,271 @@ export default class MasterOfVegaGame extends Phaser.Scene { this.events.once('shutdown', () => this.teardown()); - this.showSetup(); + this.showLanding(); + } + + // Wrap a menu handler so it clicks. Every button and card on the front-end + // screens goes through this, so the cue can never drift out of sync with what + // is actually clickable. + uiClick(fn) { + return (...args) => { + playSound(this, SFX.EIGHTBIT_ACTIVATE); + fn?.(...args); + }; } teardown() { + resetSpeechQueue(); this.map?.destroy(); this.fx?.destroy(); this.music?.destroy?.(); } + // -------------------------------------------------------------- backdrop + + // Shared by the landing screen and the setup screen. `dim` is how hard to + // darken the artwork: the landing screen wants to show it off, the setup + // screen has to keep ten species cards legible on top of it. + paintBackdrop(layer, dim) { + if (this.textures.exists('vega-menu-bg')) { + const bg = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'vega-menu-bg'); + bg.setDisplaySize(GAME_WIDTH, GAME_HEIGHT); + layer.add(bg); + layer.add(this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x03060d, dim).setOrigin(0, 0)); + } else { + // The whole game runs with zero art files; the menus are no exception. + layer.add(this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x03060d, 1).setOrigin(0, 0)); + } + } + + // Draw the title logo scaled to CONTAIN a box, never stretched — the source + // is a large title card and its aspect has to survive. + paintTitle(layer, cx, cy, boxW, boxH, fallbackSize) { + if (this.textures.exists('vega-menu-title')) { + const t = this.add.image(cx, cy, 'vega-menu-title').setOrigin(0.5); + const src = this.textures.get('vega-menu-title').getSourceImage(); + t.setScale(Math.min(boxW / src.width, boxH / src.height)); + layer.add(t); + return t; + } + const t = this.add.text(cx, cy, 'MASTER OF VEGA', { + fontFamily: FONT, fontSize: `${fallbackSize}px`, color: '#cfe8ff', align: 'center', + }).setOrigin(0.5); + layer.add(t); + return t; + } + + // --------------------------------------------------------------- landing + + showLanding() { + const layer = this.add.container(0, 0).setDepth(D.modal); + this.landingLayer = layer; + + this.paintBackdrop(layer, 0.34); + + // Logo large on the left. + this.paintTitle(layer, 580, 430, 900, 560, 76); + + // Buttons well below it, over on the right. + const bx = 1420; + let by = 640; + const gap = 104; + + const newGame = new Button(this, bx, by, 'New Game', this.uiClick(() => { + layer.destroy(); + this.showSetup(); + }), { width: 420, height: 78, fontSize: 30 }); + layer.add(newGame); + by += gap; + + const saved = this.readSave(); + const resume = new Button(this, bx, by, 'Resume Game', this.uiClick(() => { + if (!saved) return; + layer.destroy(); + this.beginGame(null, saved); + }), { width: 420, height: 78, fontSize: 30 }); + // setEnabled(false) both dims it and drops its input, so a missing save + // reads as unavailable rather than as a button that silently does nothing. + if (!saved) resume.setEnabled(false); + layer.add(resume); + by += gap; + + const quit = new Button(this, bx, by, 'Return to Arcade', this.uiClick(() => { + this.scene.start('GameMenu'); + }), { width: 420, height: 78, fontSize: 30 }); + layer.add(quit); + } + + // ------------------------------------------------------- species detail + + /** + * Grow a species card into a centred pop-over with full-size art and + * readable type, then commit or discard the choice. + * + * The portrait is REPARENTED out of the card rather than a second one being + * created. Two Phaser Video objects on one cached video is not a + * configuration worth betting on — if they share an element, destroying the + * pop-over's copy would kill the card's — and moving the one that already + * exists sidesteps the question entirely while halving the decoding. + */ + openSpeciesDetail(parentLayer, spec, card, portrait, onSelect) { + if (this.speciesDetailOpen) return; + this.speciesDetailOpen = true; + + const CARD_PORTRAIT = { x: 52, y: 62, size: 84 }; + const PANEL_W = 860; + const PANEL_H = 640; + const halfW = PANEL_W / 2; + const halfH = PANEL_H / 2; + const accent = Phaser.Display.Color.HexStringToColor(spec.color).color; + + const veil = this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x00060e, 0) + .setOrigin(0, 0).setInteractive(); + parentLayer.add(veil); + this.tweens.add({ targets: veil, fillAlpha: 0.78, duration: 200 }); + + // Built at full size, then scaled down to the card and grown — so the type + // is rendered crisp at its final size instead of being a magnified + // thumbnail. + const pop = this.add.container(card.x + CARD_PORTRAIT.x, card.y + CARD_PORTRAIT.y); + pop.setScale(0.18).setAlpha(0.35); + parentLayer.add(pop); + + const panel = this.add.rectangle(-halfW, -halfH, PANEL_W, PANEL_H, 0x0b1220, 0.97).setOrigin(0, 0); + panel.setStrokeStyle(2, accent, 0.85); + pop.add(panel); + + const ticks = this.add.graphics(); + ticks.lineStyle(3, accent, 0.9); + const t = 30; + for (const [tx, ty, dx, dy] of [ + [-halfW, -halfH, 1, 1], [halfW, -halfH, -1, 1], [-halfW, halfH, 1, -1], [halfW, halfH, -1, -1], + ]) { + ticks.lineBetween(tx, ty, tx + dx * t, ty); + ticks.lineBetween(tx, ty, tx, ty + dy * t); + } + pop.add(ticks); + + // The portrait moves house, at its full 256px. + card.remove(portrait); + portrait.setPosition(-halfW + 168, -halfH + 188); + sizeSpeciesPortrait(portrait, 256); + pop.add(portrait); + + const colX = -100; + const colW = PANEL_W - 40 - (colX + halfW); + let y = -halfH + 46; + + pop.add(this.add.text(colX, y, spec.name, { + fontFamily: FONT, fontSize: '44px', color: spec.color, + })); + y += 62; + + const heading = (label) => { + pop.add(this.add.text(colX, y, label, { + fontFamily: FONT, fontSize: '15px', color: '#6f8aa3', + })); + y += 24; + }; + const bullets = (lines, colour) => { + for (const line of lines) { + const item = this.add.text(colX, y, `· ${line}`, { + fontFamily: FONT, fontSize: '20px', color: colour, wordWrap: { width: colW }, + }); + pop.add(item); + y += item.height + 6; + } + y += 10; + }; + + heading('STRENGTHS'); + bullets(spec.strengths, '#7fd8a0'); + heading('WEAKNESSES'); + bullets(spec.weaknesses, '#e08a8a'); + + pop.add(this.add.text(-halfW + 40, halfH - 220, spec.desc, { + fontFamily: FONT, fontSize: '19px', color: '#a8c4e0', + wordWrap: { width: PANEL_W - 80 }, lineSpacing: 4, + })); + + pop.add(this.add.text(-halfW + 40, halfH - 128, `Homeworld: ${this.rules.planetTypes[spec.homeworld].name}`, { + fontFamily: FONT, fontSize: '17px', color: '#7f97b3', + })); + + // --- close, either way + let closing = false; + const close = (commit) => { + if (closing) return; + closing = true; + if (commit) onSelect?.(); + // The window is going away, so the voice goes with it. + resetSpeechQueue(); + this.tweens.add({ targets: veil, fillAlpha: 0, duration: 180 }); + this.tweens.add({ + targets: pop, + x: card.x + CARD_PORTRAIT.x, + y: card.y + CARD_PORTRAIT.y, + scale: 0.18, + alpha: 0.25, + duration: 200, + ease: 'Cubic.easeIn', + onComplete: () => { + // Hand the portrait back BEFORE the pop-over is destroyed, or it + // would be destroyed along with it and the card left empty. + pop.remove(portrait); + portrait.setPosition(CARD_PORTRAIT.x, CARD_PORTRAIT.y); + sizeSpeciesPortrait(portrait, CARD_PORTRAIT.size); + card.add(portrait); + pop.destroy(); + veil.destroy(); + this.speciesDetailOpen = false; + }, + }); + }; + + pop.add(new Button(this, -160, halfH - 62, 'Select Species', this.uiClick(() => close(true)), + { width: 300, height: 62, fontSize: 24 })); + pop.add(new Button(this, 170, halfH - 62, 'Cancel', this.uiClick(() => close(false)), + { width: 220, height: 62, fontSize: 24, variant: 'ghost' })); + + veil.on('pointerup', this.uiClick(() => close(false))); + + this.tweens.add({ + targets: pop, + x: GAME_WIDTH / 2, + y: GAME_HEIGHT / 2, + scale: 1, + alpha: 1, + duration: 300, + ease: 'Back.easeOut', + }); + + // The species introduces itself. resetQueue first so opening a second card + // cuts the first one off instead of letting two voices talk over each + // other, and `force` so the line plays even if the queue was full. + resetSpeechQueue(); + enqueueSpeech(speciesSpeechClip(spec.id), null, { force: true }); + } + // ---------------------------------------------------------------- setup showSetup() { const layer = this.add.container(0, 0).setDepth(D.modal); this.setupLayer = layer; - // Supplied menu art if present, flat panel colour if not — the setup screen - // has to work with zero art files like everything else here. - if (this.textures.exists('vega-menu-bg')) { - const bg = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'vega-menu-bg'); - bg.setDisplaySize(GAME_WIDTH, GAME_HEIGHT); - layer.add(bg); - // Darken it so the cards and text stay legible over the artwork. - layer.add(this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x03060d, 0.62).setOrigin(0, 0)); - } else { - layer.add(this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x03060d, 1).setOrigin(0, 0)); - } + this.paintBackdrop(layer, 0.62); + this.paintTitle(layer, GAME_WIDTH / 2, 74, 760, 132, 58); - if (this.textures.exists('vega-menu-title')) { - const t = this.add.image(GAME_WIDTH / 2, 74, 'vega-menu-title').setOrigin(0.5); - // Contain within a header box rather than stretching — the source is a - // large title card and its aspect must be preserved. - const src = this.textures.get('vega-menu-title').getSourceImage(); - const fit = Math.min(760 / src.width, 132 / src.height); - t.setScale(fit); - layer.add(t); - } else { - layer.add(this.add.text(GAME_WIDTH / 2, 60, 'MASTER OF VEGA', { - fontFamily: FONT, fontSize: '58px', color: '#cfe8ff', - }).setOrigin(0.5)); - } layer.add(this.add.text(GAME_WIDTH / 2, 138, 'Choose your species and the shape of the galaxy.', { fontFamily: FONT, fontSize: '20px', color: '#7f97b3', }).setOrigin(0.5)); + // Spoken greeting on arrival. resetQueue first so stepping back to the + // landing screen and returning does not stack a second copy on top of a + // line that is still running. + resetSpeechQueue(); + enqueueSpeech(UI_SPEECH.chooseSpecies, null, { force: true }); + const choice = { speciesId: 'human', sizeId: 'medium', @@ -125,8 +349,8 @@ export default class MasterOfVegaGame extends Phaser.Scene { const bg = this.add.rectangle(0, 0, cardW - 12, cardH - 12, 0x0b1220, 0.95).setOrigin(0, 0); bg.setStrokeStyle(1.5, 0x24405f, 1); card.add(bg); - card.add(this.add.image(52, 62, this.art.portraits, speciesPortraitFrame(this.rules, spec.id)) - .setDisplaySize(84, 84)); + const portrait = makeSpeciesPortrait(this, this.rules, this.art, spec.id, 52, 62, 84); + card.add(portrait); card.add(this.add.text(104, 16, spec.name, { fontFamily: FONT, fontSize: '22px', color: spec.color, })); @@ -144,11 +368,13 @@ export default class MasterOfVegaGame extends Phaser.Scene { // a Container's hit area is always centred on its origin. const zone = this.add.rectangle(0, 0, cardW - 12, cardH - 12, 0xffffff, 0.001) .setOrigin(0, 0).setInteractive({ useHandCursor: true }); - zone.on('pointerup', () => { - choice.speciesId = spec.id; - for (const c of cards) c.bg.setStrokeStyle(1.5, 0x24405f, 1); - bg.setStrokeStyle(2.5, 0x6fc4ff, 1); - }); + zone.on('pointerup', this.uiClick(() => { + this.openSpeciesDetail(layer, spec, card, portrait, () => { + choice.speciesId = spec.id; + for (const c of cards) c.bg.setStrokeStyle(1.5, 0x24405f, 1); + bg.setStrokeStyle(2.5, 0x6fc4ff, 1); + }); + })); card.add(zone); layer.add(card); cards.push({ bg, spec }); @@ -163,11 +389,11 @@ export default class MasterOfVegaGame extends Phaser.Scene { }).setOrigin(0, 0.5)); const buttons = []; options.forEach((opt, i) => { - const b = new Button(this, GAME_WIDTH / 2 - 380 + i * 210, y, format(opt), () => { + const b = new Button(this, GAME_WIDTH / 2 - 380 + i * 210, y, format(opt), this.uiClick(() => { choice[key] = opt.id ?? opt; for (const other of buttons) other.setActive(false); b.setActive(true); - }, { width: 195, height: 46, fontSize: 19 }); + }), { width: 195, height: 46, fontSize: 19 }); if ((opt.id ?? opt) === choice[key]) b.setActive(true); buttons.push(b); layer.add(b); @@ -183,34 +409,27 @@ export default class MasterOfVegaGame extends Phaser.Scene { }).setOrigin(0, 0.5)); const empButtons = []; [2, 3, 4, 5, 6].forEach((n, i) => { - const b = new Button(this, GAME_WIDTH / 2 - 380 + i * 210, optY + 186, `${n}`, () => { + const b = new Button(this, GAME_WIDTH / 2 - 380 + i * 210, optY + 186, `${n}`, this.uiClick(() => { choice.empires = n; for (const other of empButtons) other.setActive(false); b.setActive(true); - }, { width: 195, height: 46, fontSize: 19 }); + }), { width: 195, height: 46, fontSize: 19 }); if (n === choice.empires) b.setActive(true); empButtons.push(b); layer.add(b); }); - const start = new Button(this, GAME_WIDTH / 2 + 430, optY + 124, 'Begin', () => { + const start = new Button(this, GAME_WIDTH / 2 + 430, optY + 124, 'Begin', this.uiClick(() => { layer.destroy(); this.beginGame(choice); - }, { width: 260, height: 64, fontSize: 30 }); + }), { width: 260, height: 64, fontSize: 30 }); layer.add(start); - // Resume, if a save is waiting. - const saved = this.readSave(); - if (saved) { - const resume = new Button(this, GAME_WIDTH / 2 + 430, optY + 196, 'Resume', () => { - layer.destroy(); - this.beginGame(null, saved); - }, { width: 260, height: 50, fontSize: 22 }); - layer.add(resume); - } - - const back = new Button(this, 120, 60, '← Menu', () => this.scene.start('GameMenu'), - { width: 170, height: 48, fontSize: 20, variant: 'ghost' }); + // Resuming lives on the landing screen now, so this only steps back to it. + const back = new Button(this, 120, 60, '← Back', this.uiClick(() => { + layer.destroy(); + this.showLanding(); + }), { width: 170, height: 48, fontSize: 20, variant: 'ghost' }); layer.add(back); } diff --git a/src/games/mastervega/VegaArt.js b/src/games/mastervega/VegaArt.js index 660afde..b2dd0ae 100644 --- a/src/games/mastervega/VegaArt.js +++ b/src/games/mastervega/VegaArt.js @@ -542,6 +542,90 @@ export function ensureSheets(scene, rules, art) { return { keys, procedural }; } +// -------------------------------------------------------------------------- +// Species portraits +// +// Three tiers, best first: +// 1. a looping, muted video (assets/videos/vega/char-.mp4) +// 2. a high-resolution still (assets/images/vega/char-.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/.mp3, +// never through Phaser's loader, so unlike the portraits it is addressed purely +// by convention rather than declared in the artwork manifest. The verifier +// enforces the convention so a missing or misspelled file is caught here rather +// than as a silent 404. +export const speciesSpeechClip = (speciesId) => `vega/char-${speciesId}`; + +/** Non-species speech clips, addressed the same way. */ +export const UI_SPEECH = { + chooseSpecies: 'vega/ui-choose-start', +}; + +export function hasSpeciesVideo(scene, speciesId) { + return !!scene.cache.video?.exists(speciesVideoKey(speciesId)); +} +export function hasSpeciesStill(scene, speciesId) { + return scene.textures.exists(speciesStillKey(speciesId)); +} + +/** + * Create a square species portrait `size` px across, centred on (x, y). + * Returns a Phaser Video or Image — caller adds it to its own container. + */ +export function makeSpeciesPortrait(scene, rules, art, speciesId, x, y, size) { + if (hasSpeciesVideo(scene, speciesId)) { + const v = scene.add.video(x, y, speciesVideoKey(speciesId)); + v.setMute(true); + v.setLoop(true); + // Scale from the known source size rather than setDisplaySize: a Video's + // texture can still report zero width before its first frame is decoded, + // and setDisplaySize would then divide by it and blank the portrait. + v.setScale(size / (v.width || PORTRAIT_VIDEO_PX)); + v.play(true); + // If the file is present but the browser refuses to decode it, drop to the + // still rather than leaving a hole where the portrait should be. + v.once('error', () => { + if (!v.scene) return; + const fallback = makeSpeciesStillOrFrame(scene, rules, art, speciesId, x, y, size); + v.parentContainer?.add(fallback); + v.destroy(); + }); + return v; + } + return makeSpeciesStillOrFrame(scene, rules, art, speciesId, x, y, size); +} + +function makeSpeciesStillOrFrame(scene, rules, art, speciesId, x, y, size) { + const img = hasSpeciesStill(scene, speciesId) + ? scene.add.image(x, y, speciesStillKey(speciesId)) + : scene.add.image(x, y, art.portraits, speciesPortraitFrame(rules, speciesId)); + sizeSpeciesPortrait(img, size); + return img; +} + +/** + * Resize a portrait made by makeSpeciesPortrait, whichever tier it came from. + * + * Scales from the object's TEXTURE width rather than calling setDisplaySize, + * so it stays correct after repeated resizes (displayWidth changes, width does + * not) and survives a Video whose first frame has not decoded yet. + */ +export function sizeSpeciesPortrait(obj, size) { + obj.setScale(size / (obj.width || PORTRAIT_VIDEO_PX)); + return obj; +} + // Frame helpers — the one place that knows how the sheets are indexed. export const shipFrame = (rules, speciesId, hullId) => { const s = rules.species[speciesId]; diff --git a/src/games/mastervega/VegaScreens.js b/src/games/mastervega/VegaScreens.js index d34cc59..6211d6e 100644 --- a/src/games/mastervega/VegaScreens.js +++ b/src/games/mastervega/VegaScreens.js @@ -14,7 +14,7 @@ import { } from './VegaDiplomacy.js'; import { leaderOffers } from './VegaLeaders.js'; -import { speciesPortraitFrame } from './VegaArt.js'; +import { makeSpeciesPortrait } from './VegaArt.js'; export const FONT = '"Julius Sans One"'; export const D = { map: 1, hud: 30, modal: 60, toast: 80 }; @@ -219,9 +219,8 @@ export function openDiplomacyScreen(scene, rules, state, e, art, onClose, onChan const y = shell.body.y + i * rowH; const spec = rules.species[other.speciesId]; - const portrait = scene.add.image(shell.body.x + 52, y + rowH / 2, art.portraits, - speciesPortraitFrame(rules, other.speciesId)).setDisplaySize(96, 96); - shell.add(portrait); + shell.add(makeSpeciesPortrait(scene, rules, art, other.speciesId, + shell.body.x + 52, y + rowH / 2, 96)); const att = attitudeOf(state, other.idx, e); const treaty = emp.treaties[other.idx] ?? 'none'; diff --git a/src/games/mastervega/sprites.md b/src/games/mastervega/sprites.md index 826f5f4..8e21cbc 100644 --- a/src/games/mastervega/sprites.md +++ b/src/games/mastervega/sprites.md @@ -97,17 +97,56 @@ the pulsar's beam axis vertical and the binary's companion at the frame centre. --- -## 4. `portraits` — ⬜ `assets/images/vega/portraits.png` +## 4. Species portraits — **video**, with a sheet fallback -| | | -|---|---| -| Sheet size | **1280 × 512** | -| Frame | **256 × 256** | -| Grid | 5 cols × 2 rows = 10 frames | +Portraits are **looping, muted 256 × 256 videos**, one per species, at +`assets/videos/vega/char-.mp4`. They play silently on a loop wherever +a species portrait appears — the setup screen's species cards (84 px), the +species detail pop-over (**full 256 px**), and the diplomacy screen (96 px). -One frame per species (`species.portraitFrame`), same order as the ship rows. -Head-and-shoulders, facing the viewer. Shown at 84–96 px on the species picker -and the diplomacy screen, so read at small size. +Clicking a card grows it into a centred pop-over showing the portrait at native +resolution. The card's portrait is *reparented* into the pop-over rather than a +second one being created, so there is only ever one video object per species. + +**To add one:** drop the file in, then change that species' `path` from `null` +to it in `portraitVideos` in `data/mastervega-artwork.json`. Nothing else. The +verifier checks every species has an entry, that the key matches, and that every +non-null path points at a file that exists. + +**All ten species have both a video and a still.** ✅ + +| Species | Video (256 × 256) | Still (1024 × 1024) | +|---|---|---| +| Human | `char-human.mp4` | `char-human.png` | +| Kestrelli | `char-kestrelli.mp4` | ⚠️ `char-kestr**a**lli.png` | +| Ursaal | `char-ursaal.mp4` | `char-ursaal.png` | +| Umbrix | `char-umbrix.mp4` | `char-umbrix.png` | +| Kkrix | `char-kkrix.mp4` | `char-kkrix.png` | +| Mekhan | `char-mekhan.mp4` | `char-mekhan.png` | +| Rrashaa | `char-rrashaa.mp4` | `char-rrashaa.png` | +| Cerebrai | ⚠️ `char-cereb**a**i.mp4` | `char-cerebrai.png` | +| Ssakar | `char-ssakar.mp4` | `char-ssakar.png` | +| Lithox | `char-lithox.mp4` | `char-lithox.png` | + +### Fallback order + +Three tiers, best first — no caller knows which it got, so the roster can be +finished a species at a time with no code changes: + +1. **Video** — `assets/videos/vega/char-.mp4`, listed in `portraitVideos`. +2. **Still** — `assets/images/vega/char-.png` (1024 × 1024), listed in + `portraitStills`. Used when there is no video, and as the recovery path if a + video is present but the browser refuses to decode it. +3. **Painted sheet** — the optional ⬜ `assets/images/vega/portraits.png` + (1280 × 512, 256 × 256 frames, 5 × 2, one per `species.portraitFrame` in the + ship-row order), which itself falls back to a procedural painting. + +> ⚠️ **Two filenames do not match their species id**, each with a correctly +> spelled counterpart: the kestrelli *still* is `char-kestralli.png`, and the +> cerebrai *video* is `char-cerebai.mp4` (no `r`). The manifest points at the +> real filenames so both work as-is — rename a file and its one path together +> if you want the spelling fixed. The verifier fails immediately if a declared +> path stops resolving, so a rename cannot go unnoticed. These are **species portraits, not characters** — this game deliberately does not use the `data/opponents.json` roster or its videos. @@ -183,11 +222,44 @@ column 2 → pixel origin **(88, 264)**. **Painted.** | File | Size | Used for | |---|---|---| -| `assets/images/vega/background-menu.png` | 1920 × 1080 | Setup-screen background (darkened 62% so the cards stay legible) | -| `assets/images/vega/menu-title.png` | 1368 × 768 | Setup-screen title, scaled to fit a 760 × 132 header box (aspect preserved) | +| `assets/images/vega/background-menu.png` | 1920 × 1080 | Backdrop for both menus — dimmed 34% on the landing screen, 62% on the setup screen so the species cards stay legible | +| `assets/images/vega/menu-title.png` | 1368 × 768 | Title logo. Large on the **left** of the landing screen (contained in 900 × 560 → 900 × 505), and small as the setup screen's header (contained in 760 × 132). Always contained, never stretched. | + +## Speech — ✅ `assets/speech/vega/` + +| Clip | When it plays | +|---|---| +| `ui-choose-start.mp3` | On arriving at the choose-a-species screen | +| `char-.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/.mp3` without +going through Phaser's loader, so the filename convention *is* the wiring. Drop +in a replacement and it plays — the verifier checks every species resolves. + +Each new line cuts off whatever was playing rather than letting two voices +overlap — so opening a species interrupts the greeting, opening a second species +interrupts the first, and closing the window stops playback. + +--- ## Sound -Already wired, no new audio needed: `laser-zap.mp3`, `scifi-explode.mp3`, -`ta-rocket-1.mp3`, `ta-rocket-2.mp3`. Soundtrack is the `hacker` set -(`src/services/soundtrack.js`). +Menu clicks use the shared `8bit-activate` cue (already preloaded globally as +`sfx-8bit-activate`) — every button on the landing and species screens, the +species cards, and the detail pop-over. + +Combat effects already wired, no new audio needed: `laser-zap.mp3`, `scifi-explode.mp3`, +`ta-rocket-1.mp3`, `ta-rocket-2.mp3`. + +**Soundtrack** is the game's own set, `data/masterofvega-music.json` → +`assets/music/vega/`, at volume 0.6. Four tracks are declared: *Power Down* +(DV-8-2303), *High Orbit* (Klaxon), *Master of Vega Main Theme* (Aiko), *Defend +the Colony* (Cy-Bro). + +> ⚠️ `assets/music/vega/track-05.mp3` is on disk but **not listed** in +> `masterofvega-music.json`, so it never plays. Add an entry for it if that was +> not deliberate. The verifier checks every declared track exists, but cannot +> know about files you meant to declare. diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 77db825..2ed1457 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -49,6 +49,7 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('adventure-music', 'data/adventure-music.json'); this.load.json('chinese-music', 'data/chinese-music.json'); this.load.json('zuma-music', 'data/zuma-music.json'); + this.load.json('masterofvega-music', 'data/masterofvega-music.json'); this.load.json('advancewars-artwork', 'data/advancewars-artwork.json'); this.load.json('tetrisattack-artwork', 'data/tetrisattack-artwork.json'); this.load.json('totalannihilation-artwork', 'data/totalannihilation-artwork.json'); diff --git a/src/services/assetLoader.js b/src/services/assetLoader.js index f3f8bf9..44a0b74 100644 --- a/src/services/assetLoader.js +++ b/src/services/assetLoader.js @@ -7,6 +7,8 @@ import { resolveGameAssets } from '../data/assetManifest.js'; function isLoaded(scene, d) { if (d.type === 'audio') return scene.cache.audio.exists(d.key); if (d.type === 'json') return scene.cache.json.exists(d.key); + // Video lives in its own cache, not the texture manager. + if (d.type === 'video') return !!scene.cache.video?.exists(d.key); return scene.textures.exists(d.key); } @@ -21,6 +23,10 @@ function queueDescriptor(scene, d) { scene.load.audio(d.key, d.path); } else if (d.type === 'json') { scene.load.json(d.key, d.path); + } else if (d.type === 'video') { + // noAudio: true — these are ambient portrait loops. It also lets them + // autoplay, which browsers refuse for anything with an audio track. + scene.load.video(d.key, d.path, true); } else { scene.load.image(d.key, d.path); } diff --git a/src/services/soundtrack.js b/src/services/soundtrack.js index 40dd62c..0101209 100644 --- a/src/services/soundtrack.js +++ b/src/services/soundtrack.js @@ -34,7 +34,7 @@ export const GAME_SOUNDTRACK_OVERRIDES = { mahjong: 'chinese', mahjongmatch: 'chinese', zuma: 'zuma', - mastervega: 'hacker', + mastervega: 'masterofvega', }; // Resolve the track list (and optional volume override) a game scene's diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index 3201d0a..d82cd7f 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -19,7 +19,7 @@ // 10. AI self-play soak: full games terminate, both victory kinds occur, // invariants hold, AI turn-time budget. -import { readFileSync } from 'node:fs'; +import { readFileSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; @@ -33,7 +33,10 @@ import * as Diplo from '../src/games/mastervega/VegaDiplomacy.js'; import * as Leaders from '../src/games/mastervega/VegaLeaders.js'; // Pure art module: the painters need a canvas, but the sheet bookkeeping around // them is checkable headlessly and worth checking. -import { ensureSheets, shipFrame, planetFrame, techFrame, buildingFrame } from '../src/games/mastervega/VegaArt.js'; +import { + ensureSheets, shipFrame, planetFrame, techFrame, buildingFrame, + speciesVideoKey, speciesStillKey, speciesSpeechClip, UI_SPEECH, +} from '../src/games/mastervega/VegaArt.js'; const QUICK = process.argv.includes('--quick'); const gamesArg = process.argv.find((a) => a.startsWith('--games=')); @@ -207,6 +210,80 @@ section('2. Procedural art'); for (const b of RULES.buildingList) { check(`building icon ${b.id} painted`, buildTex?.frames.includes(buildingFrame(RULES, b.id))); } + + // Species portraits are looping videos where one has been recorded and a + // sheet frame where one has not. The manifest has to name every species so a + // new one cannot be silently forgotten, and every non-null path has to point + // at a file that is actually there — otherwise the first sign of a typo is a + // 404 and a blank portrait in the browser. + const vids = artJson.portraitVideos ?? {}; + const vidIds = Object.keys(vids).filter((k) => !k.startsWith('_')); + for (const id of vidIds) { + check(`portraitVideos entry ${id} is a known species`, !!RULES.species[id]); + check(`portraitVideos ${id} key matches the loader's key`, + vids[id].key === speciesVideoKey(id), `${vids[id].key} vs ${speciesVideoKey(id)}`); + if (vids[id].path) { + check(`portraitVideos ${id} file exists`, existsSync(join(root, vids[id].path)), vids[id].path); + check(`portraitVideos ${id} is an mp4`, vids[id].path.endsWith('.mp4')); + } + } + for (const s of RULES.speciesList) { + check(`species ${s.id} has a portraitVideos entry`, vidIds.includes(s.id)); + // Whether or not it has a video, the sheet fallback must exist for it. + const portraitsTex = made.get(keys.portraits); + check(`species ${s.id} has a portrait fallback frame`, + portraitsTex?.frames.includes(s.portraitFrame)); + } + // The still-portrait tier, checked the same way. Its filenames are NOT + // derived from the species id — one of them is spelled differently on disk — + // so the path in the manifest is the only source of truth and has to be + // confirmed against the filesystem. + const stills = artJson.portraitStills ?? {}; + const stillIds = Object.keys(stills).filter((k) => !k.startsWith('_')); + for (const id of stillIds) { + check(`portraitStills entry ${id} is a known species`, !!RULES.species[id]); + check(`portraitStills ${id} key matches the loader's key`, + stills[id].key === speciesStillKey(id), `${stills[id].key} vs ${speciesStillKey(id)}`); + if (stills[id].path) { + check(`portraitStills ${id} file exists`, existsSync(join(root, stills[id].path)), stills[id].path); + } + } + for (const s of RULES.speciesList) { + check(`species ${s.id} has a portraitStills entry`, stillIds.includes(s.id)); + } + + const recorded = vidIds.filter((id) => vids[id].path).length; + const stillCount = stillIds.filter((id) => stills[id].path).length; + console.log(` (${recorded}/${RULES.speciesList.length} portraits are video, ` + + `${stillCount} have a still; the rest fall back to the painted sheet)`); + + // Species speech. Unlike the portraits this is NOT declared in the artwork + // manifest — ui/SpeechQueue.js streams it straight from + // assets/speech/.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}`); + } } // ---------------------------------------------------------------------------