diff --git a/data/masterofvega-music.json b/data/masterofvega-music.json index e2f9ce0..d8d1272 100644 --- a/data/masterofvega-music.json +++ b/data/masterofvega-music.json @@ -19,6 +19,11 @@ "file": "vega/track-04.mp3", "artist": "Cy-Bro", "title": "Defend the Colony" + }, + { + "file": "vega/track-05.mp3", + "artist": "Rrashaa", + "title": "Into the Void" } ], "volume": 0.6 diff --git a/data/mastervega-artwork.json b/data/mastervega-artwork.json index e688d53..fadde41 100644 --- a/data/mastervega-artwork.json +++ b/data/mastervega-artwork.json @@ -16,6 +16,28 @@ "lithox": { "key": "vega-char-lithox", "path": "assets/videos/vega/char-lithox.mp4" } }, + "_shipVideosReadme": "Per-hull SHIP COMMANDER loops — the officer flying that class — 256x256, muted, ~10s, keyed species -> hull id. These are a different thing from portraitVideos above: that is the species' leader on the diplomacy screen, this is one face per hull. A species with none recorded yet is null, and a hull with no entry inside a recorded species falls back to that species' portraitVideos clip, so the roster fills one race — or one hull — at a time with no code change. NOTE: the human colony ship's file is 'ship-human-colony.mp4' while the hull id is 'colonyship'; the path below points at the real filename, same class of mismatch as char-cerebai.mp4. There is deliberately no 'starbase' clip yet — it falls back like any other gap.", + "shipVideos": { + "human": { + "scout": { "key": "vega-ship-human-scout", "path": "assets/videos/vega/ship-human-scout.mp4" }, + "colonyship": { "key": "vega-ship-human-colonyship", "path": "assets/videos/vega/ship-human-colony.mp4" }, + "transport": { "key": "vega-ship-human-transport", "path": "assets/videos/vega/ship-human-transport.mp4" }, + "frigate": { "key": "vega-ship-human-frigate", "path": "assets/videos/vega/ship-human-frigate.mp4" }, + "destroyer": { "key": "vega-ship-human-destroyer", "path": "assets/videos/vega/ship-human-destroyer.mp4" }, + "cruiser": { "key": "vega-ship-human-cruiser", "path": "assets/videos/vega/ship-human-cruiser.mp4" }, + "battleship": { "key": "vega-ship-human-battleship", "path": "assets/videos/vega/ship-human-battleship.mp4" } + }, + "kestrelli": null, + "ursaal": null, + "umbrix": null, + "kkrix": null, + "mekhan": null, + "rrashaa": null, + "cerebrai": null, + "ssakar": null, + "lithox": null + }, + "_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" }, diff --git a/docs/mastervega-build-plan.md b/docs/mastervega-build-plan.md index 3c6a5c2..226d40f 100644 --- a/docs/mastervega-build-plan.md +++ b/docs/mastervega-build-plan.md @@ -263,6 +263,47 @@ Each of these was a real bug that produced a plausible-looking but broken game. has its own world transform and does **not** follow a moving parent — the flyout tween drags it along by hand in `onUpdate`. +### Ship rows carry video, so they have to be pooled + +Every place a ship is listed — the side panel's task force, its in-transit, +garrison and order lists, and the colony catalogue — now shows a looping +**commander video** beside an upside-down picture of the hull, and opens a +centred detail window (`VegaShipDetail.js`, depth `D.detail` 76) with the clip at +its full 256px, the hull at 192px, `hull.desc`, the derived stat block and the +loadout the knapsack settled on. Everything in that window comes out of +`designFor`; no new data was added. + +Two things about it are load-bearing. + +**The portraits cannot be created inline.** Both hosts rebuild their entire body +on every click — `VegaSidePanel.rebuild()` on each − / +, `rebuildFlyout()` on +each enqueue — so building the portraits with the rows would tear down and +re-create a decoder per row per click, restarting every loop under the player's +cursor. `createShipMediaPool` in `VegaShipMedia.js` keeps them in a layer the +rebuild does not touch, bracketed `beginFrame()` … `endFrame()`; unclaimed +entries are hidden and paused, never destroyed. The colony catalogue wipes with +`removeAll(true)`, so its pool layer is lifted out with `detachPool()` first and +re-homed into the fresh scroll column afterwards — inside `content`, so it +scrolls and masks with the rows it belongs to. + +**Duplicate Video objects on one cached video are fine — the note at the top of +`openSpeciesDetail` is more cautious than it needs to be.** Checked against the +Phaser 3.90 source: `Video.loadHandler` builds its own element with +`document.createElement('video')`, and `preDestroy` → `removeVideoElement()` +detaches only that instance's. The cache holds a URL, not a shared element. That +is what makes the fallback ladder usable at all: a species with no ship clips +shows its portrait video on all seven rows at once. The cost is decode time, and +pausing is how it is paid — the panel pauses on `hide()`, and both hosts pause +while the detail window is up, since that window has its own portrait at 256px +and there is no point running both. (The reparenting in `openSpeciesDetail` is +still fine and still halves the decoding there; it just is not a correctness +requirement.) + +A missing clip is a **legitimate fallback, not a bug**, which is why the verifier +reports `7/80 recorded` rather than failing. What it does assert is that nothing +*declared* is wrong — every key must equal `shipVideoKey(species, hull)`, which +is the trap the `colonyship` / `ship-human-colony.mp4` filename mismatch sets. + ## Balance reference (27-game AI soak) ``` @@ -283,8 +324,8 @@ wins spread across 8 of 10 species ## Verification ```bash -node tools/verifyMasterOfVega.js # ~1082 checks, ~60s -node tools/verifyMasterOfVega.js --quick # 1081 checks, ~15s +node tools/verifyMasterOfVega.js # ~1221 checks, ~60s +node tools/verifyMasterOfVega.js --quick # 1220 checks, ~15s node tools/verifyMasterOfVega.js --games=50 # a deeper soak ``` diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index 5525afc..e36cb8c 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -51,6 +51,24 @@ function videosFrom(scene, jsonKey, field) { .map(([, v]) => ({ type: 'video', key: v.key, path: v.path })); } +// Same contract as videosFrom, one level deeper: a block keyed +// group -> member -> entry, e.g. mastervega's shipVideos (species -> hull). +// A group whose value is null has nothing recorded yet and is skipped whole, +// which is what keeps the manifest short while nine species are outstanding. +function nestedVideosFrom(scene, jsonKey, field) { + const art = scene.cache.json.get(jsonKey); + if (!art || !art[field]) return []; + const out = []; + for (const [group, members] of Object.entries(art[field])) { + if (group.startsWith('_') || !members) continue; + for (const [id, v] of Object.entries(members)) { + if (id.startsWith('_') || !v || !v.key || !v.path) continue; + out.push({ type: 'video', key: v.key, path: v.path }); + } + } + return out; +} + // Same shape as videosFrom, for image entries keyed by id. function imagesFromMap(scene, jsonKey, field) { const art = scene.cache.json.get(jsonKey); @@ -98,6 +116,7 @@ export const MANIFEST = { { type: 'json', key: 'mastervega-rules', path: 'data/mastervega-rules.json' }, (scene) => sheetsFrom(scene, 'mastervega-artwork', ['sheets']), (scene) => videosFrom(scene, 'mastervega-artwork', 'portraitVideos'), + (scene) => nestedVideosFrom(scene, 'mastervega-artwork', 'shipVideos'), (scene) => imagesFromMap(scene, 'mastervega-artwork', 'portraitStills'), (scene) => imagesFromMap(scene, 'mastervega-artwork', 'worldBackgrounds'), image('vega-menu-bg', 'assets/images/vega/background-menu.png'), diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js index 9d0e95f..7159acf 100644 --- a/src/games/mastervega/MasterOfVegaGame.js +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -28,6 +28,8 @@ import { FONT, D, openResearchScreen, openDiplomacyScreen, openCouncilScreen, openLeaderScreen, showVictoryOverlay, } from './VegaScreens.js'; +import { openTurnReportScreen } from './VegaTurnReportScreen.js'; +import { NOTABLE_TYPES, isRelevantToHuman } from './VegaTurnReport.js'; const SAVE_KEY = 'mastervega-save'; @@ -530,13 +532,16 @@ export default class MasterOfVegaGame extends Phaser.Scene { onStarClick: (idx) => this.onStarClick(idx), onFleetClick: (fleet) => this.onFleetClick(fleet), onEmptyClick: () => this.clearSelection(), - blockWheel: () => this.modalOpen, - // A modal blocks the map outright; otherwise only the side panel's own - // footprint does (so dragging a slider there doesn't pan the galaxy - // underneath it). The old `!this.modalOpen && ...` form always - // short-circuited to false while a modal was open, which let drags - // pan the star map right through the System View window. - blockPointer: (p) => this.modalOpen || !!this.panel?.containsPoint(p.x, p.y), + blockWheel: () => this.modalOpen || !!this.panel?.detailOpen, + // A modal blocks the map outright; so does the panel's ship detail + // pop-over, which veils the whole screen without being a modal. + // Otherwise only the side panel's own footprint does (so dragging a + // slider there doesn't pan the galaxy underneath it). The old + // `!this.modalOpen && ...` form always short-circuited to false while a + // modal was open, which let drags pan the star map right through the + // System View window. + blockPointer: (p) => this.modalOpen || !!this.panel?.detailOpen + || !!this.panel?.containsPoint(p.x, p.y), }); // The command panel is the only place a selection is acted on; the scene @@ -772,10 +777,23 @@ export default class MasterOfVegaGame extends Phaser.Scene { if (this.state.over) { this.finishGame(); return; } if (this.state.current === this.state.humanIndex) { Logic.beginEmpireTurn(this.rules, this.state, this.state.humanIndex); - this.announceEvents(); - this.refreshAll(); - this.busy = false; - this.endTurnBtn.setEnabled(true); + const notable = this.processTurnEvents(); + const finish = () => { + this.busy = false; + this.endTurnBtn.setEnabled(true); + }; + if (notable.length) { + // openModal's own done() (MasterOfVegaGame.js:openModal) clears + // modalOpen and refreshes; finish() on top of that re-arms the End + // Turn button, which openModal has no reason to touch on its own. + this.openModal((done) => openTurnReportScreen(this, this.rules, this.state, notable, () => { + done(); + finish(); + })); + } else { + this.refreshAll(); + finish(); + } return; } const e = this.state.current; @@ -787,41 +805,33 @@ export default class MasterOfVegaGame extends Phaser.Scene { step(); } - // Turn engine events into log lines and map pings. `announced` marks a record - // consumed, since beginEmpireTurn trims rather than clears the event list. - announceEvents() { + // Turn engine events into log lines, map pings, and — for the events worth + // interrupting the player for — rows in the "New Turn" popup. `announced` + // marks a record consumed, since beginEmpireTurn trims rather than clears + // the event list. Returns the events the popup should show this turn. + processTurnEvents() { const me = this.state.humanIndex; + const notable = []; for (const ev of this.state.events) { if (ev.announced) continue; ev.announced = true; + if (!isRelevantToHuman(ev, me)) continue; + + if (NOTABLE_TYPES.has(ev.type)) { + notable.push(ev); + continue; + } + const star = ev.starIdx >= 0 ? this.state.galaxy.stars[ev.starIdx] : null; const name = (i) => this.state.empires[i]?.name ?? '?'; - - if (ev.type === 'techDone' && ev.empire === me) { - this.log(`Researched ${this.rules.techs[ev.techId]?.name ?? ev.techId}.`); - } else if (ev.type === 'refit' && ev.empire === me) { + if (ev.type === 'refit' && ev.empire === me) { this.log(`${ev.count} × ${this.rules.hulls[ev.hullId]?.name} refitted to Mark ${ev.toMark} at ${this.state.galaxy.stars[ev.starIdx]?.name} (${ev.cost} BC).`); } else if (ev.type === 'combat' && (ev.attacker === me || ev.defender === me)) { this.log(`Battle at ${star?.name}: ${ev.winner === 'attacker' ? name(ev.attacker) : name(ev.defender)} holds the field.`); if (star) this.fx?.ping(star.x, star.y, 0xffa050); - } else if (ev.type === 'captured') { - this.log(`${name(ev.empire)} has taken ${star?.name} from ${name(ev.from)}.`); - } else if (ev.type === 'colonyDestroyed') { - this.log(`${star?.name} has been bombed out of existence by ${name(ev.empire)}.`); - } else if (ev.type === 'colonised' && ev.empire === me) { - this.log(`Colony founded at ${star?.name}.`); - } else if (ev.type === 'contact') { - this.log(`We have made contact with the ${name(ev.other === me ? ev.empire : ev.other)}.`); - } else if (ev.type === 'warDeclared') { - this.log(`${name(ev.empire)} declares war on ${name(ev.other)}.`); - } else if (ev.type === 'councilRefused') { - this.log(`${name(ev.empire)} refuses to submit to ${name(ev.winner)}. The Council is void.`); - } else if (ev.type === 'council' && ev.winner >= 0) { - this.log(`${name(ev.winner)} is elected High Guardian of the Galaxy.`); - } else if (ev.type === 'eliminated') { - this.log(`The ${name(ev.empire)} are no more.`); } } + return notable; } finishGame() { diff --git a/src/games/mastervega/VegaColonyView.js b/src/games/mastervega/VegaColonyView.js index 77ee30d..c8458ea 100644 --- a/src/games/mastervega/VegaColonyView.js +++ b/src/games/mastervega/VegaColonyView.js @@ -35,7 +35,9 @@ import * as Phaser from 'phaser'; import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; import { Button } from '../../ui/Button.js'; import { FONT, D, ORBIT, slider } from './VegaScreens.js'; -import { worldBackground, buildingFrame, shipFrame } from './VegaArt.js'; +import { worldBackground, buildingFrame } from './VegaArt.js'; +import { createShipMediaPool, makeShipIcon } from './VegaShipMedia.js'; +import { openShipDetail } from './VegaShipDetail.js'; import { CHANNELS, colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories, colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany, @@ -68,13 +70,14 @@ export const etaText = (turns) => (Number.isFinite(turns) /** * A vertically scrolling column. This is the first one in the game: every other * list either fits or drops its tail on the floor (`VegaSystemView` used to - * `break` out of the catalogue mid-loop). Kept local until a second caller - * wants it rather than promoted to src/ui/ on spec. + * `break` out of the catalogue mid-loop). Exported for reuse (VegaScreens.js's + * turn-report popup) rather than promoted to src/ui/, since this is still the + * only game that needs it. * * The mask is a Graphics with its own world transform, so it does NOT follow a * moving parent — `syncMask` is what keeps it aligned while the flyout slides. */ -function scrollColumn(scene, parent, x, y, w, h) { +export function scrollColumn(scene, parent, x, y, w, h) { // The catcher goes in FIRST so it sits below the rows: it exists only to tell // the wheel handler whether the pointer is over this column, and an // interactive rectangle added afterwards would swallow every row click. @@ -124,7 +127,7 @@ function scrollColumn(scene, parent, x, y, w, h) { // --------------------------------------------------------------------------- /** The holographic frame both the panel and the flyout are drawn in. */ -function frame(scene, layer, x, y, w, h, alpha) { +export function frame(scene, layer, x, y, w, h, alpha) { const box = scene.add.rectangle(x, y, w, h, PANEL, alpha).setOrigin(0, 0); box.setStrokeStyle(1.5, ACCENT, 0.55); layer.add(box); @@ -214,13 +217,34 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) { const syncMasks = (dx) => scrollers.forEach((s) => s.syncMask(dx)); const dropScrollers = () => { scrollers.forEach((s) => s.destroy()); scrollers = []; }; + // Commander videos for the catalogue. The flyout wipes itself with + // removeAll(true) on every enqueue, so the pool's layer is lifted out first + // and re-homed into the fresh scroll column afterwards — it has to live in + // there to be scrolled and masked with the rows it belongs to. + const pool = createShipMediaPool(scene, rules, art); + let detail = null; + const openDetail = (hullId) => { + if (detail) return; + pool.setPaused(true); + detail = openShipDetail(scene, rules, state, art, + { empireIdx: colony.empireIdx, hullId }, + () => { detail = null; if (flyOpen) pool.setPaused(false); }); + }; + /** Take the pool out of whatever is about to be destroyed. */ + const detachPool = () => pool.layer.parentContainer?.remove(pool.layer); + // ------------------------------------------------------------------ close let closed = false; function close() { if (closed) return; closed = true; + detail?.close(); dropScrollers(); + // Out of the tree first, or root.destroy() takes it and pool.destroy() + // then runs against a corpse. + detachPool(); + pool.destroy(); scene.input.keyboard?.off('keydown-ESC', onEsc); root.destroy(); onClose?.(); @@ -416,6 +440,8 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) { onComplete: () => { flyLayer.setVisible(false); dropScrollers(); + detachPool(); + pool.setPaused(true); flyLayer.removeAll(true); }, }); @@ -434,6 +460,9 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) { // back to the top under the player's cursor. Put them back where they were. const keep = scrollers.map((s) => s.offset); dropScrollers(); + detachPool(); + pool.beginFrame(); + if (!detail) pool.setPaused(false); flyLayer.removeAll(true); frame(scene, flyLayer, FLY_X, PANEL_Y, FLY_W, PANEL_H, 0.9); @@ -526,15 +555,38 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) { // concern only, so the visible band has to be checked explicitly. bg.on('pointerup', (p) => { if (scroller.contains(p)) onAdd(); }); scroller.content.add(bg); - // Both sheets are optional and procedural until real art lands, so this - // always resolves to something. - scroller.content.add(scene.add.image(28, cy + 26, icon.key, icon.frame) - .setDisplaySize(40, 40)); - scroller.content.add(scene.add.text(58, cy + 5, label, { + // A ship gets its commanding officer beside the hull; a building keeps + // the single icon it always had. Both sheets are optional and procedural + // until real art lands, so either always resolves to something. + let textX = 58; + if (icon.hullId) { + pool.get(emp.speciesId, icon.hullId, 26, cy + 26, 40); + scroller.content.add( + makeShipIcon(scene, rules, art, emp.speciesId, icon.hullId, 72, cy + 26, 40), + ); + // Its own target over the two thumbnails, so the row's primary action + // stays "queue one" and inspecting is a deliberate second gesture. It + // is added after bg, which is what puts it on top — and which is also + // why it has to light itself up: sitting over bg, it swallows the + // row's own hover and would otherwise read as dead space. Same + // masked-row guard as every other handler in this flyout. + const info = scene.add.rectangle(0, cy, 100, 52, 0x6fc4ff, 0.0) + .setOrigin(0, 0).setInteractive({ useHandCursor: true }); + info.on('pointerover', () => info.setFillStyle(0x6fc4ff, 0.16)); + info.on('pointerout', () => info.setFillStyle(0x6fc4ff, 0.0)); + info.on('pointerup', (p) => { if (scroller.contains(p)) openDetail(icon.hullId); }); + scroller.content.add(info); + textX = 108; + } else { + scroller.content.add(scene.add.image(28, cy + 26, icon.key, icon.frame) + .setDisplaySize(40, 40)); + } + scroller.content.add(scene.add.text(textX, cy + 5, label, { fontFamily: FONT, fontSize: '17px', color: colour, })); - scroller.content.add(scene.add.text(58, cy + 27, sub, { - fontFamily: FONT, fontSize: '13px', color: '#7f97b3', wordWrap: { width: w - 180 }, + scroller.content.add(scene.add.text(textX, cy + 27, sub, { + fontFamily: FONT, fontSize: '13px', color: '#7f97b3', + wordWrap: { width: w - textX - 70 }, })); if (onAddFive) { // The repeat count. Its own button so the row click stays "add one". @@ -555,7 +607,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) { for (const hull of rules.hullList) { const d = empireDesign(rules, state, colony.empireIdx, hull.id); - entry({ key: art.ships, frame: shipFrame(rules, emp.speciesId, hull.id) }, + entry({ hullId: hull.id }, d.name, `${d.cost} BC · ${turns(d.cost)} · ${hull.desc}`, '#9fd8ff', () => { enqueue(rules, state, colony, 'ship', hull.id); rebuildFlyout(); }, () => { enqueueMany(rules, state, colony, 'ship', hull.id, 5); rebuildFlyout(); }); @@ -570,6 +622,12 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) { queued ? '#6f8aa3' : '#ffd88a', () => { if (enqueue(rules, state, colony, 'building', b.id)) rebuildFlyout(); }); } + // Back into the live scroll column, after the rows so the portraits draw + // over the row backgrounds, and inside `content` so they scroll and mask + // with the list they belong to. + scroller.content.add(pool.layer); + pool.endFrame(); + scroller.contentHeight = cy; scroller.setOffset(keep[1] ?? 0); } diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js index 8cf2d3c..aea69f1 100644 --- a/src/games/mastervega/VegaLogic.js +++ b/src/games/mastervega/VegaLogic.js @@ -1021,8 +1021,12 @@ function moveFleets(rules, state, e) { f.fromStar = -1; f.progress = 0; f.total = 0; + const wasExplored = !!state.empires[e].explored[f.starIdx]; state.empires[e].explored[f.starIdx] = true; pushEvent(state, { type: 'arrive', empire: e, starIdx: f.starIdx, fleetId: f.id, turn: state.turn }); + if (!wasExplored) { + pushEvent(state, { type: 'discovered', empire: e, starIdx: f.starIdx, turn: state.turn }); + } makeContactAt(rules, state, e, f.starIdx); } } diff --git a/src/games/mastervega/VegaScreens.js b/src/games/mastervega/VegaScreens.js index 4bf27b9..8561cbf 100644 --- a/src/games/mastervega/VegaScreens.js +++ b/src/games/mastervega/VegaScreens.js @@ -18,8 +18,10 @@ import { makeSpeciesPortrait } from './VegaArt.js'; export const FONT = '"Julius Sans One"'; // `colony` sits above `modal` so the colony screen can stack over the system -// view that opened it without either having to be torn down. -export const D = { map: 1, hud: 30, modal: 60, colony: 70, toast: 80 }; +// view that opened it without either having to be torn down. `detail` is the +// ship pop-over, which opens from the star map's side panel AND from inside the +// colony screen, so it has to clear both. +export const D = { map: 1, hud: 30, modal: 60, colony: 70, detail: 76, toast: 80 }; /** * Orbit numerals. Worldgen rolls at most five planets, but diff --git a/src/games/mastervega/VegaShipDetail.js b/src/games/mastervega/VegaShipDetail.js new file mode 100644 index 0000000..a5314c8 --- /dev/null +++ b/src/games/mastervega/VegaShipDetail.js @@ -0,0 +1,235 @@ +// Master of Vega — the ship detail pop-over. +// +// Clicking any ship row anywhere opens this: the commanding officer at the full +// 256px the clips are recorded at, the hull at 192px, and everything the empire +// knows about the class — the hull's own description from the rules file, the +// stat block the Mark currently resolves to, and the loadout the knapsack put +// on it. +// +// It deliberately does NOT go through MasterOfVegaGame.openModal, which refuses +// a second modal while one is up. This window opens from the star map's side +// panel (nothing else open) and from inside the colony screen (which is itself +// a full-screen layer that must stay up), so it draws its own veil at D.detail +// and clears both. +// +// Chrome and the grow-from-nothing entrance mirror openSpeciesDetail: built at +// full size and scaled up, so the type renders crisp at its final size instead +// of as a magnified thumbnail. + +import * as Phaser from 'phaser'; +import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { markNumeral } from './VegaRules.js'; +import { empireDesign } from './VegaLogic.js'; +import { refitCost } from './VegaShips.js'; +import { makeCommanderPortrait, makeShipIcon } from './VegaShipMedia.js'; +import { FONT, D } from './VegaScreens.js'; + +const ACCENT = 0x6fc4ff; +const PANEL = 0x0b1220; +const PANEL_W = 1000; +const PANEL_H = 700; + +const ROLE_NAMES = { + recon: 'Reconnaissance', colony: 'Colonisation', troops: 'Troop carrier', + warship: 'Warship', base: 'Orbital fortress', +}; + +/** + * @param {object} opts { empireIdx, hullId, mark } — `mark` is the stack's own + * Mark where the caller has one (a fleet row); omit it for a catalogue entry, + * which is always quoted at the current Mark. + * @param {function} onClose called after the window has finished closing. + * @returns {{ close: function }} + */ +export function openShipDetail(scene, rules, state, art, opts, onClose) { + const { empireIdx, hullId, mark = null } = opts; + const emp = state.empires[empireIdx]; + const species = rules.species[emp.speciesId]; + const design = empireDesign(rules, state, empireIdx, hullId); + const hull = design.hull; + + const halfW = PANEL_W / 2; + const halfH = PANEL_H / 2; + const accent = Phaser.Display.Color.HexStringToColor(species.color).color; + + const layer = scene.add.container(0, 0).setDepth(D.detail); + const veil = scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x00060e, 0) + .setOrigin(0, 0).setInteractive(); + layer.add(veil); + scene.tweens.add({ targets: veil, fillAlpha: 0.8, duration: 180 }); + + const pop = scene.add.container(GAME_WIDTH / 2, GAME_HEIGHT / 2); + pop.setScale(0.2).setAlpha(0.3); + layer.add(pop); + + const panel = scene.add.rectangle(-halfW, -halfH, PANEL_W, PANEL_H, PANEL, 0.97).setOrigin(0, 0); + panel.setStrokeStyle(2, accent, 0.85); + pop.add(panel); + + const ticks = scene.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); + + // ------------------------------------------------------------ left column + // The officer at full recording resolution, the hull beneath them. This is + // the window's own portrait, not one borrowed from the caller's pool — the + // caller pauses its thumbnails while we are up, so nothing decodes twice. + const artX = -halfW + 176; + pop.add(makeCommanderPortrait(scene, rules, art, emp.speciesId, hullId, artX, -halfH + 190, 256)); + + pop.add(scene.add.text(artX, -halfH + 336, 'COMMANDING OFFICER', { + fontFamily: FONT, fontSize: '14px', color: '#6f8aa3', + }).setOrigin(0.5)); + + pop.add(makeShipIcon(scene, rules, art, emp.speciesId, hullId, artX, -halfH + 470, 192)); + + pop.add(scene.add.text(artX, halfH - 78, species.name.toUpperCase(), { + fontFamily: FONT, fontSize: '18px', color: species.color, + }).setOrigin(0.5)); + + // ----------------------------------------------------------- right column + const colX = -halfW + 340; + const colW = PANEL_W - 40 - (colX + halfW); + let y = -halfH + 44; + + const text = (str, size, colour, gap = 6, wrap = colW) => { + const o = scene.add.text(colX, y, str, { + fontFamily: FONT, fontSize: `${size}px`, color: colour, lineSpacing: 3, + wordWrap: { width: wrap }, + }); + pop.add(o); + y += o.height + gap; + return o; + }; + + const heading = (label) => { + y += 10; + pop.add(scene.add.text(colX, y, label, { + fontFamily: FONT, fontSize: '14px', color: '#6f8aa3', + })); + y += 22; + pop.add(scene.add.rectangle(colX, y, colW, 1, ACCENT, 0.22).setOrigin(0, 0)); + y += 10; + }; + + text(design.name, 38, '#cfe8ff', 2); + text(`${ROLE_NAMES[hull.role] ?? hull.role} · ${design.cost} BC`, 17, '#9fd8ff', 8); + text(hull.desc, 18, '#a8c4e0', 4); + + // --- the stat block, two columns of label/value pairs + heading('SPECIFICATION'); + const stats = [ + ['Hull', `${design.hp} HP`], + ['Armour', design.armorName], + ['Shields', design.shield > 0 ? `Class ${design.shield}` : 'None'], + ['Engines', design.engineName], + ['Speed', design.immobile ? 'Immobile' : `${design.speed}`], + ['Range', design.immobile ? '—' : `${design.range} parsecs`], + ['Targeting', `+${design.targeting}`], + ['Initiative', `${design.initiative}`], + ]; + if (design.attack || design.defense) { + stats.push(['Attack', `${design.attack >= 0 ? '+' : ''}${design.attack}%`]); + stats.push(['Defense', `${design.defense >= 0 ? '+' : ''}${design.defense}%`]); + } + if (design.repairPerRound > 0) { + stats.push(['Repair', `${Math.round(design.repairPerRound * 100)}% / round`]); + } + if (design.troops > 0) stats.push(['Marines', `${design.troops} divisions`]); + + const statTop = y; + const cellW = colW / 2; + stats.forEach((pair, i) => { + const cx = colX + (i % 2) * cellW; + const cy = statTop + Math.floor(i / 2) * 30; + pop.add(scene.add.text(cx, cy, pair[0], { + fontFamily: FONT, fontSize: '15px', color: '#6f8aa3', + })); + pop.add(scene.add.text(cx + 120, cy, String(pair[1]), { + fontFamily: FONT, fontSize: '16px', color: '#c8dcf0', + })); + }); + y = statTop + Math.ceil(stats.length / 2) * 30 + 4; + + const flags = []; + if (design.cloaked) flags.push('Cloaked'); + if (design.planetCracker) flags.push('Planet cracker'); + if (design.immobile) flags.push('Never leaves its system'); + if (flags.length) text(flags.join(' · '), 15, '#ffd88a', 4); + + // --- the loadout the knapsack settled on + heading('LOAD-OUT'); + if (hull.space <= 0) { + text('Unarmed — this hull carries no weapon tonnage.', 16, '#7f97b3', 4); + } else if (!design.mounts.length) { + text('No weapons researched yet. This hull builds, and flies empty.', + 16, '#e08a8a', 4); + } else { + for (const m of design.mounts) { + const w = m.weapon; + const dmg = w.min === w.max ? `${w.min}` : `${w.min}–${w.max}`; + const tail = w.kind === 'missile' + ? `${w.shots} salvo${w.shots === 1 ? '' : 'es'}` + : 'every round'; + text(`${m.count} × ${w.name} · ${dmg} damage · ${tail}` + + (w.shieldPierce ? ` · pierces ${w.shieldPierce} shield` : ''), + 17, '#9fd8ff', 3); + } + y += 4; + const summary = []; + if (design.beamDamage > 0) summary.push(`Beams ${Math.round(design.beamDamage)} per round`); + if (design.missileSalvo > 0) { + summary.push(`Missiles ${Math.round(design.missileSalvo)} × ${design.missileSalvos} salvoes`); + } + summary.push(`${hull.space} tonnage`); + text(summary.join(' · '), 15, '#7f97b3', 4); + } + + // A stack that has not been refitted yet is flying an older Mark. The older + // Mark's LOADOUT cannot be reconstructed — designFor only ever builds at the + // empire's current tech — so say plainly that the block above is the new one. + if (mark !== null && mark < design.mark) { + const cost = refitCost(rules, emp.known, hullId, mark, species.traits); + text(`This stack is still Mark ${markNumeral(mark)}. The figures above are the ` + + `Mark ${markNumeral(design.mark)} refit, which costs ${cost} BC and happens ` + + 'automatically over a friendly colony.', 15, '#ffd88a', 4); + } + + // ------------------------------------------------------------------ close + let closing = false; + const close = () => { + if (closing) return; + closing = true; + scene.tweens.add({ targets: veil, fillAlpha: 0, duration: 160 }); + scene.tweens.add({ + targets: pop, + scale: 0.2, + alpha: 0.25, + duration: 180, + ease: 'Cubic.easeIn', + onComplete: () => { + layer.destroy(); + onClose?.(); + }, + }); + }; + + pop.add(new Button(scene, halfW - 110, halfH - 52, 'Close', close, + { width: 180, height: 52, fontSize: 22, variant: 'ghost' })); + veil.on('pointerup', close); + + scene.tweens.add({ + targets: pop, scale: 1, alpha: 1, duration: 260, ease: 'Back.easeOut', + }); + + return { close }; +} diff --git a/src/games/mastervega/VegaShipMedia.js b/src/games/mastervega/VegaShipMedia.js new file mode 100644 index 0000000..27eec58 --- /dev/null +++ b/src/games/mastervega/VegaShipMedia.js @@ -0,0 +1,175 @@ +// Master of Vega — ship pictures and ship-commander videos. +// +// Two pieces of art travel together everywhere a ship is listed: the hull +// itself, cut from the `ships` sheet, and a looping video of the officer who +// flies it. This module is the one place that knows how either is addressed. +// +// The commander clips follow the same drop-in contract as the species +// portraits, one tier deeper: +// +// 1. this species' clip for this hull (shipVideos[species][hull]) +// 2. the species' own portrait video (portraitVideos[species]) +// 3. its high-resolution still (portraitStills[species]) +// 4. a frame on the procedural sheet (always present) +// +// Tiers 2-4 are exactly makeSpeciesPortrait's ladder, so a species with no ship +// clips recorded still shows a moving face on every row — which is why only +// `human` is filled in today and the other nine are a single `null` each in the +// artwork manifest. +// +// Ships are drawn facing UP on the sheet. Thumbnails and the detail window turn +// them a full 180°, which is a ROTATION and not a mirror: setFlipY would flip +// the hull's asymmetries with it. + +import { makeSpeciesPortrait, sizeSpeciesPortrait, shipFrame } from './VegaArt.js'; + +/** Source resolution of the commander videos, and of a `ships` sheet frame. */ +const COMMANDER_VIDEO_PX = 256; +const SHIP_FRAME_PX = 192; + +export const shipVideoKey = (speciesId, hullId) => `vega-ship-${speciesId}-${hullId}`; + +export function hasShipVideo(scene, speciesId, hullId) { + return !!scene.cache.video?.exists(shipVideoKey(speciesId, hullId)); +} + +/** + * The hull itself, `size` px square and turned upside down, centred on (x, y). + * + * Scales from the texture width rather than calling setDisplaySize so repeated + * resizes stay correct — displayWidth changes as it is scaled, width does not. + */ +export function makeShipIcon(scene, rules, art, speciesId, hullId, x, y, size) { + const img = scene.add.image(x, y, art.ships, shipFrame(rules, speciesId, hullId)); + img.setScale(size / (img.width || SHIP_FRAME_PX)); + img.setAngle(180); + return img; +} + +/** + * The commanding officer, `size` px square and centred on (x, y). Returns a + * Phaser Video or Image — the caller adds it to its own container and never has + * to know which tier it got. + * + * `onReplaced` fires if the clip is present but the browser refuses to decode + * it and the portrait is swapped underneath; a pool holding a reference needs + * to hear about that, or it keeps handing out a destroyed object. + */ +export function makeCommanderPortrait( + scene, rules, art, speciesId, hullId, x, y, size, onReplaced, +) { + if (!hasShipVideo(scene, speciesId, hullId)) { + return makeSpeciesPortrait(scene, rules, art, speciesId, x, y, size); + } + const v = scene.add.video(x, y, shipVideoKey(speciesId, hullId)); + 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 || COMMANDER_VIDEO_PX)); + v.play(true); + v.once('error', () => { + if (!v.scene) return; + const fallback = makeSpeciesPortrait(scene, rules, art, speciesId, x, y, size); + v.parentContainer?.add(fallback); + v.destroy(); + onReplaced?.(fallback); + }); + return v; +} + +/** + * A scene-lifetime cache of commander portraits, keyed by species and hull. + * + * Every view that lists ships rebuilds its whole body on each click — the side + * panel on every − / +, the colony catalogue on every enqueue. Creating the + * portraits inline would tear down and re-create a decoder per row per click, + * restarting every loop and burning CPU for no visible gain. So the portraits + * live in `layer`, which the caller parents wherever it likes and deliberately + * does NOT clear during a rebuild: + * + * pool.beginFrame(); + * ... rows call pool.get(...) ... + * pool.endFrame(); + * + * Anything not claimed this frame is hidden and paused rather than destroyed, + * so dialling a stack to zero and back does not restart its loop. + * + * One pool per view. Two pools may each hold a portrait on the same cached + * video: verified against the Phaser 3.90 source, every Video game object + * creates its own HTMLVideoElement and destroys only that one, so this costs + * decode time and nothing else. + */ +export function createShipMediaPool(scene, rules, art) { + const entries = new Map(); + const claimed = new Set(); + const layer = scene.add.container(0, 0); + let paused = false; + // A view can be torn down while one of its own close tweens is still + // running, so every entry point has to survive being called after destroy(). + let dead = false; + + const play = (e) => { if (e.isVideo && e.obj.scene) e.obj.resume?.(); }; + const halt = (e) => { if (e.isVideo && e.obj.scene) e.obj.pause?.(); }; + + return { + /** Parent this where the portraits should draw; never removeAll(true) it. */ + layer, + + beginFrame() { claimed.clear(); }, + + get(speciesId, hullId, x, y, size) { + if (dead) return null; + const key = `${speciesId}|${hullId}`; + let e = entries.get(key); + // A destroyed object still answers to its variable. Phaser nulls `scene` + // on destroy, which is the only reliable liveness check we have. + if (e && !e.obj.scene) { entries.delete(key); e = null; } + if (!e) { + const obj = makeCommanderPortrait( + scene, rules, art, speciesId, hullId, x, y, size, + (replacement) => { + const cur = entries.get(key); + if (cur) entries.set(key, { obj: replacement, isVideo: false }); + }, + ); + e = { obj, isVideo: obj.type === 'Video' }; + entries.set(key, e); + layer.add(obj); + } + claimed.add(key); + e.obj.setPosition(x, y); + sizeSpeciesPortrait(e.obj, size); + e.obj.setVisible(true); + if (!paused) play(e); + return e.obj; + }, + + endFrame() { + for (const [key, e] of entries) { + if (claimed.has(key)) continue; + e.obj.setVisible(false); + halt(e); + } + }, + + /** Stop decoding entirely — the view is hidden, or something is over it. */ + setPaused(value) { + if (dead) return; + paused = !!value; + for (const [key, e] of entries) { + if (paused || !claimed.has(key)) halt(e); + else play(e); + } + }, + + destroy() { + if (dead) return; + dead = true; + layer.destroy(); + entries.clear(); + claimed.clear(); + }, + }; +} diff --git a/src/games/mastervega/VegaSidePanel.js b/src/games/mastervega/VegaSidePanel.js index ed10c4b..c8cfad4 100644 --- a/src/games/mastervega/VegaSidePanel.js +++ b/src/games/mastervega/VegaSidePanel.js @@ -31,6 +31,8 @@ import { colonyDefenseCap, habitableForEmpire, reachableStars, atWar, } from './VegaLogic.js'; import { FONT, D, ORBIT } from './VegaScreens.js'; +import { createShipMediaPool, makeShipIcon } from './VegaShipMedia.js'; +import { openShipDetail } from './VegaShipDetail.js'; const W = 400; const PAD = 18; @@ -94,8 +96,25 @@ export default class VegaSidePanel { this.body = scene.add.container(0, 0); this.root.add(this.body); + + // The commander videos outlive the body. Every − / + rebuilds `body` from + // scratch, and re-creating a decoder per row per click would restart every + // loop, so they live in their own layer that the rebuild never touches. + // It is added AFTER body because a Container renders its children in + // insertion order and ignores their depth. + this.pool = createShipMediaPool(scene, rules, art); + this.root.add(this.pool.layer); + this.detail = null; } + /** + * True while the ship detail pop-over is up. It veils the whole screen, so + * the star map has to be told to keep still everywhere and not just under + * the panel's own footprint — the same job `modalOpen` does for the modals + * this window deliberately does not go through. + */ + get detailOpen() { return !!this.detail; } + // Used by the star map to keep a drag or a wheel over the panel from panning // and zooming the galaxy underneath it. containsPoint(x, y) { @@ -131,6 +150,8 @@ export default class VegaSidePanel { hide() { if (!this.root.visible) return; + // Nothing should be decoding while the panel is parked off-screen. + this.pool.setPaused(true); this.mode = null; this.fleet = null; this.starIdx = -1; @@ -148,6 +169,7 @@ export default class VegaSidePanel { } open() { + if (!this.detail) this.pool.setPaused(false); if (this.root.visible && Math.abs(this.root.x - this.x0) < 0.5) return; if (!this.root.visible) { this.root.setVisible(true); @@ -179,7 +201,11 @@ export default class VegaSidePanel { this.rebuild(); } - destroy() { this.root.destroy(); } + destroy() { + this.detail?.close(); + this.pool.destroy(); + this.root.destroy(); + } // -------------------------------------------------------- text primitives @@ -255,10 +281,90 @@ export default class VegaSidePanel { rebuild() { this.body.removeAll(true); + // The pool survives the wipe; claim what this pass actually uses and let + // endFrame() hide and pause the rest. + this.pool.beginFrame(); this.y = 90; if (this.mode === 'star') this.buildStar(); else if (this.mode === 'fleet') this.buildFleet(); else if (this.mode === 'order') this.buildOrder(); + this.pool.endFrame(); + } + + // ------------------------------------------------------------ ship media + + /** Species flying a given fleet — the row art is keyed on it. */ + speciesOf(empireIdx) { return this.state.empires[empireIdx].speciesId; } + + /** + * Commander video + upside-down hull, side by side, vertically centred on + * `cy`. The video comes from the pool; the hull is a plain Image and is + * cheap enough to rebuild with the rest of the body. They land in different + * containers but share the panel's local coordinates, so they stay aligned. + */ + shipThumbs(empireIdx, hullId, x, cy, size) { + const speciesId = this.speciesOf(empireIdx); + this.pool.get(speciesId, hullId, x + size / 2, cy, size); + this.body.add(makeShipIcon( + this.scene, this.rules, this.art, speciesId, hullId, + x + size * 1.5 + 6, cy, size, + )); + } + + /** A transparent click target over a row, opening the detail window. */ + detailHit(x, y, w, h, empireIdx, hullId, mark) { + const r = this.scene.add.rectangle(x, y, w, h, 0xffffff, 0.001) + .setOrigin(0, 0).setInteractive({ useHandCursor: true }); + r.on('pointerup', () => this.openDetail(empireIdx, hullId, mark)); + this.body.add(r); + return r; + } + + /** + * The full-resolution pop-over. The panel's own thumbnails stop decoding + * while it is up — the window has its own portrait at 256px and there is no + * point running both. + */ + openDetail(empireIdx, hullId, mark = null) { + if (this.detail) return; + this.pool.setPaused(true); + this.detail = openShipDetail( + this.scene, this.rules, this.state, this.art, + { empireIdx, hullId, mark }, + () => { + this.detail = null; + if (this.root.visible) this.pool.setPaused(false); + }, + ); + } + + /** + * A read-only `N × Name` line with thumbnails — the in-transit list, the + * garrison and the order screen's task force all take this shape. The + * task-force rows in fleet mode need the − / + cluster and build their own. + */ + shipLine(empireIdx, hullId, mark, label, opts = {}) { + const { color = '#c8dcf0', size = 40, sub = null } = opts; + const rowY = this.y; + const textX = PAD + size * 2 + 20; + const name = this.scene.add.text(textX, rowY, label, { + fontFamily: FONT, fontSize: '15px', color, lineSpacing: 2, + wordWrap: { width: PAD + COL - textX }, + }); + this.body.add(name); + let textH = name.height; + if (sub) { + const s = this.scene.add.text(textX, rowY + name.height + 2, sub, { + fontFamily: FONT, fontSize: '12px', color: '#6f8aa3', + wordWrap: { width: PAD + COL - textX }, + }); + this.body.add(s); + textH += s.height + 2; + } + const rowH = Math.max(size, textH); + this.shipThumbs(empireIdx, hullId, PAD, rowY + rowH / 2, size); + this.detailHit(PAD, rowY, COL, rowH, empireIdx, hullId, mark); + this.y = rowY + rowH + 8; } get viewer() { @@ -473,8 +579,9 @@ export default class VegaSidePanel { if (garrison.length) { this.heading('Garrison'); for (const s of garrison) { - this.line(`${s.count} × ${this.designName(s.hullId, s.mark)} — holds this system`, - { size: 13, color: '#8fa8c0', gap: 2 }); + this.shipLine(fleet.empireIdx, s.hullId, s.mark, + `${s.count} × ${this.designName(s.hullId, s.mark)}`, + { color: '#8fa8c0', sub: 'Holds this system' }); } } @@ -485,31 +592,46 @@ export default class VegaSidePanel { this.action('Done', () => this.cb.onClose?.(), { variant: 'ghost' }); } - /** A ship stack with a −/+ count selector. */ + /** + * A ship stack with a −/+ count selector. The name sits on the first line and + * the counter on the second, so the thumbnails can take the left gutter + * without squeezing either into a column too narrow to read. + */ stackRow(s, index) { const rowY = this.y; - const name = this.scene.add.text(PAD, rowY, `${this.designName(s.hullId, s.mark)}`, { + const MEDIA = 60; + const textX = PAD + MEDIA * 2 + 20; + const name = this.scene.add.text(textX, rowY, `${this.designName(s.hullId, s.mark)}`, { fontFamily: FONT, fontSize: '15px', color: s.count > 0 ? '#c8dcf0' : '#5d7085', - wordWrap: { width: COL - 130 }, + wordWrap: { width: PAD + COL - textX }, }); this.body.add(name); const boxSize = 26; const right = PAD + COL; + const ctrlY = rowY + name.height + 6 + boxSize / 2; + const rowH = Math.max(MEDIA, name.height + 6 + boxSize); + + this.shipThumbs(this.fleet.empireIdx, s.hullId, PAD, rowY + rowH / 2, MEDIA); + + // The hit target goes down BEFORE the buttons — it covers the whole row, so + // anything added after it stays clickable and anything before it does not. + this.detailHit(PAD, rowY, COL, rowH, this.fleet.empireIdx, s.hullId, s.mark); + const set = (v) => { this.sel[index].count = Phaser.Math.Clamp(v, 0, s.max); this.rebuild(); }; - this.tinyButton(right - boxSize / 2, rowY + 12, boxSize, '+', () => set(s.count + 1), + this.tinyButton(right - boxSize / 2, ctrlY, boxSize, '+', () => set(s.count + 1), s.count < s.max); - this.tinyButton(right - boxSize * 2.9, rowY + 12, boxSize, '–', () => set(s.count - 1), + this.tinyButton(right - boxSize * 2.9, ctrlY, boxSize, '–', () => set(s.count - 1), s.count > 0); - const count = this.scene.add.text(right - boxSize * 1.7, rowY + 11, `${s.count} / ${s.max}`, { + const count = this.scene.add.text(right - boxSize * 1.7, ctrlY - 1, `${s.count} / ${s.max}`, { fontFamily: FONT, fontSize: '15px', color: s.count > 0 ? '#e8f4ff' : '#5d7085', }).setOrigin(0.5); this.body.add(count); - this.y = rowY + Math.max(name.height, boxSize) + 8; + this.y = rowY + rowH + 10; } selectionSummary() { @@ -544,8 +666,8 @@ export default class VegaSidePanel { this.heading('Ships'); for (const s of this.fleet.ships) { if (s.count <= 0) continue; - this.line(`${s.count} × ${this.designName(s.hullId, s.mark)}`, - { size: 14, color: '#c8dcf0', gap: 2 }); + this.shipLine(this.fleet.empireIdx, s.hullId, s.mark, + `${s.count} × ${this.designName(s.hullId, s.mark)}`); } } @@ -579,8 +701,8 @@ export default class VegaSidePanel { this.line('No ships selected.', { size: 15, color: '#e08a8a' }); } else { for (const s of ships) { - this.line(`${s.count} × ${this.designName(s.hullId, s.mark)}`, - { size: 14, color: '#c8dcf0', gap: 2 }); + this.shipLine(fleet.empireIdx, s.hullId, s.mark, + `${s.count} × ${this.designName(s.hullId, s.mark)}`); } this.y += 4; this.line(`Power ${fleetPower(rules, state, { ...fleet, ships })}`, diff --git a/src/games/mastervega/VegaTurnReport.js b/src/games/mastervega/VegaTurnReport.js new file mode 100644 index 0000000..3e8e53e --- /dev/null +++ b/src/games/mastervega/VegaTurnReport.js @@ -0,0 +1,216 @@ +// Master of Vega — turn-report classification and detail text. Headless, no +// Phaser: this only turns entries from the event bus (`state.events`, pushed +// by `pushEvent` in VegaLogic.js) into the headline+lines the "New Turn" +// popup renders. Keeping the per-type text formatting out of +// MasterOfVegaGame.js/VegaScreens.js keeps those files from ballooning. + +import { habitableForEmpire } from './VegaLogic.js'; + +// Events worth interrupting the player for. Everything else (buildingDone, +// shipDone, refit, spyCaught, techStolen, invasionFailed, leaderHired, +// victory — which already has its own showVictoryOverlay) stays in the +// small ticker log only. +export const NOTABLE_TYPES = new Set([ + 'discovered', 'techDone', 'contact', 'colonised', 'captured', + 'colonyDestroyed', 'warDeclared', 'peace', 'alliance', 'council', + 'councilRefused', 'eliminated', +]); + +// Exploring, researching and founding a colony are personal — only the +// empire that did them cares. Everything else (contact, territory changing +// hands, diplomacy, Council results, eliminations) is galaxy news broadcast +// to the player regardless of who it happened to, matching the precedent +// the old ticker-log code set (MasterOfVegaGame.processTurnEvents(), née +// announceEvents()) for captured/colonyDestroyed/warDeclared/contact. +const PERSONAL_TYPES = new Set(['discovered', 'techDone', 'colonised']); + +export function isRelevantToHuman(ev, me) { + if (PERSONAL_TYPES.has(ev.type)) return ev.empire === me; + return true; +} + +export const TYPE_LABEL = { + discovered: 'Discovery', + techDone: 'Research', + contact: 'First Contact', + colonised: 'Colony Founded', + captured: 'Colony Captured', + colonyDestroyed: 'Colony Destroyed', + warDeclared: 'War Declared', + peace: 'Peace', + alliance: 'Alliance', + council: 'Galactic Council', + councilRefused: 'Council Refused', + eliminated: 'Empire Eliminated', +}; + +// Sort weight — diplomacy/territory/council news first (most consequential), +// then discoveries, then research, mirroring how the plan orders the list. +const CATEGORY_ORDER = { + contact: 0, warDeclared: 0, peace: 0, alliance: 0, council: 0, councilRefused: 0, eliminated: 0, + colonised: 1, captured: 1, colonyDestroyed: 1, + discovered: 2, + techDone: 3, +}; +export const categoryWeight = (ev) => CATEGORY_ORDER[ev.type] ?? 9; + +const line = (text, color) => ({ text, color }); + +function describeDiscovered(rules, state, ev) { + const star = state.galaxy.stars[ev.starIdx]; + const cls = rules.starClasses[star.classId]; + const lines = [line(`${cls.name} star — ${cls.desc}`, '#9fb6cc')]; + if (!star.planets.length) { + lines.push(line('No planets in this system.', '#6b7f96')); + } else { + for (const p of star.planets) { + const type = rules.planetTypes[p.typeId]; + const size = rules.planetSizes[p.sizeId]; + const rich = rules.richness[p.richId]; + const grav = rules.gravity[p.gravId]; + const habitable = habitableForEmpire(rules, state, ev.empire, ev.starIdx, p.orbit); + lines.push(line( + `Planet ${p.orbit + 1}: ${size.name} ${type.name}, ${rich.name} minerals, ${grav.name}` + + `${habitable ? ' — habitable' : ''}`, + habitable ? '#7fd8a0' : '#9fb6cc', + )); + } + } + return { headline: `Our ships have discovered the ${star.name} system.`, lines }; +} + +function describeTechDone(rules, state, ev) { + const tech = rules.techs[ev.techId]; + const emp = state.empires[ev.empire]; + const gate = rules.techGates[ev.techId] ?? { buildings: [], prereqOf: [] }; + const lines = [line(tech.desc, '#9fb6cc')]; + const effects = Object.entries(tech.effects ?? {}); + if (effects.length) { + lines.push(line(`Effects: ${effects.map(([k, v]) => `${k} ${v > 0 ? '+' : ''}${v}`).join(', ')}`, '#7fd8a0')); + } + const newBuildings = gate.buildings.map((id) => rules.buildings[id]?.name).filter(Boolean); + const newTechs = gate.prereqOf + .filter((id) => emp.available[id] && !emp.known[id]) + .map((id) => rules.techs[id]?.name) + .filter(Boolean); + const unlocks = [...newBuildings, ...newTechs]; + if (unlocks.length) { + lines.push(line(`Now available: ${unlocks.join(', ')}`, '#ffd88a')); + } + return { headline: `Research completed: ${tech.name}.`, lines }; +} + +function describeContact(rules, state, ev, name) { + const me = state.humanIndex; + let otherIdx; + if (ev.empire === me) otherIdx = ev.other; + else if (ev.other === me) otherIdx = ev.empire; + + if (otherIdx === undefined) { + return { + headline: `The ${name(ev.empire)} and the ${name(ev.other)} have made contact.`, + lines: [], + }; + } + const other = state.empires[otherIdx]; + const spec = rules.species[other.speciesId]; + const lines = [line(spec.desc, '#9fb6cc')]; + if (spec.strengths?.length) lines.push(line(`Strengths: ${spec.strengths.join(', ')}`, '#7fd8a0')); + if (spec.weaknesses?.length) lines.push(line(`Weaknesses: ${spec.weaknesses.join(', ')}`, '#e08a8a')); + return { headline: `First contact: the ${spec.name}.`, lines }; +} + +function describeColonised(rules, state, ev) { + const star = state.galaxy.stars[ev.starIdx]; + const planet = star.planets[ev.orbit]; + const lines = []; + if (planet) { + const type = rules.planetTypes[planet.typeId]; + const size = rules.planetSizes[planet.sizeId]; + const rich = rules.richness[planet.richId]; + lines.push(line(`${size.name} ${type.name}, ${rich.name} minerals.`, '#9fb6cc')); + } + return { headline: `Colony founded at ${star.name}.`, lines }; +} + +function describeCaptured(rules, state, ev, name) { + const me = state.humanIndex; + const star = state.galaxy.stars[ev.starIdx]; + let headline; + if (ev.empire === me) headline = `We have captured ${star.name} from the ${name(ev.from)}.`; + else if (ev.from === me) headline = `The ${name(ev.empire)} have captured ${star.name} from us.`; + else headline = `The ${name(ev.empire)} have captured ${star.name} from the ${name(ev.from)}.`; + return { headline, lines: [] }; +} + +function describeColonyDestroyed(rules, state, ev, name) { + const me = state.humanIndex; + const star = state.galaxy.stars[ev.starIdx]; + let headline; + if (ev.target === me) headline = `The ${name(ev.empire)} have bombed ${star.name} out of existence — our colony is lost.`; + else if (ev.empire === me) headline = `We have bombed ${star.name} out of existence, destroying the ${name(ev.target)}'s colony.`; + else headline = `${star.name} has been bombed out of existence by the ${name(ev.empire)}.`; + return { headline, lines: [] }; +} + +function describeTreaty(rules, state, ev, name, verb) { + const me = state.humanIndex; + let headline; + if (ev.empire === me) headline = `We ${verb} the ${name(ev.other)}.`; + else if (ev.other === me) headline = `The ${name(ev.empire)} ${verb} us.`; + else headline = `The ${name(ev.empire)} ${verb} the ${name(ev.other)}.`; + return { headline, lines: [] }; +} + +function describeCouncil(rules, state, ev, name) { + const need = Math.ceil(ev.totalPop * rules.council.winFraction); + const lines = (ev.candidates ?? []).map((idx) => line( + `${name(idx)}: ${Math.round(ev.votes?.[idx] ?? 0)} votes`, state.empires[idx]?.color, + )); + lines.push(line(`Abstained: ${Math.round(ev.abstained ?? 0)} · ${need} votes of ${Math.round(ev.totalPop)} needed`, '#7f97b3')); + const headline = ev.winner >= 0 + ? `${name(ev.winner)} is elected High Guardian of the Galaxy.` + : (ev.refused + ? 'The Council election was refused — war has begun.' + : 'The Council failed to elect a High Guardian.'); + return { headline, lines }; +} + +function describeCouncilRefused(rules, state, ev, name) { + const me = state.humanIndex; + let headline; + if (ev.empire === me) headline = `We refuse to submit to the ${name(ev.winner)}. The Council election is void.`; + else if (ev.winner === me) headline = `The ${name(ev.empire)} refuse to submit to us. The Council election is void — war has begun.`; + else headline = `The ${name(ev.empire)} refuse to submit to the ${name(ev.winner)}. The Council election is void.`; + return { headline, lines: [] }; +} + +function describeEliminated(rules, state, ev, name) { + const me = state.humanIndex; + const headline = ev.empire === me + ? 'We have been eliminated from the galaxy.' + : `The ${name(ev.empire)} have been eliminated from the galaxy.`; + return { headline, lines: [] }; +} + +/** Turns one event into {category, headline, lines} for the turn-report row. */ +export function describeEvent(rules, state, ev) { + const name = (i) => state.empires[i]?.name ?? '?'; + let out; + switch (ev.type) { + case 'discovered': out = describeDiscovered(rules, state, ev); break; + case 'techDone': out = describeTechDone(rules, state, ev); break; + case 'contact': out = describeContact(rules, state, ev, name); break; + case 'colonised': out = describeColonised(rules, state, ev); break; + case 'captured': out = describeCaptured(rules, state, ev, name); break; + case 'colonyDestroyed': out = describeColonyDestroyed(rules, state, ev, name); break; + case 'warDeclared': out = describeTreaty(rules, state, ev, name, 'declare war on'); break; + case 'peace': out = describeTreaty(rules, state, ev, name, 'make peace with'); break; + case 'alliance': out = describeTreaty(rules, state, ev, name, 'form an alliance with'); break; + case 'council': out = describeCouncil(rules, state, ev, name); break; + case 'councilRefused': out = describeCouncilRefused(rules, state, ev, name); break; + case 'eliminated': out = describeEliminated(rules, state, ev, name); break; + default: out = { headline: ev.type, lines: [] }; + } + return { category: TYPE_LABEL[ev.type] ?? ev.type, ...out }; +} diff --git a/src/games/mastervega/VegaTurnReportScreen.js b/src/games/mastervega/VegaTurnReportScreen.js new file mode 100644 index 0000000..18357b9 --- /dev/null +++ b/src/games/mastervega/VegaTurnReportScreen.js @@ -0,0 +1,103 @@ +// Master of Vega — the "New Turn" popup. Lists everything notable that +// happened since the player's last turn (VegaTurnReport.js classifies and +// writes the text); click a row to expand its synopsis in place. +// +// Lives outside VegaScreens.js because it needs VegaColonyView.js's +// scrollColumn — importing that back into VegaScreens.js (which every other +// screen file, including VegaColonyView.js itself, imports chrome FROM) +// would make the two files import each other. Same one-way-dependency shape +// as VegaSystemView.js, which already imports from both. + +import { modalShell, FONT } from './VegaScreens.js'; +import { scrollColumn } from './VegaColonyView.js'; +import { turnToYear } from './VegaRules.js'; +import { Button } from '../../ui/Button.js'; +import { describeEvent, categoryWeight } from './VegaTurnReport.js'; + +const ACCENT = 0x6fc4ff; +const ROW_BG = 0x142238; + +export function openTurnReportScreen(scene, rules, state, events, onClose) { + const shell = modalShell(scene, `New Turn: ${turnToYear(state.turn)}`, onClose, { width: 1180, height: 760 }); + + const ordered = events + .map((ev, i) => ({ ev, i, desc: describeEvent(rules, state, ev) })) + .sort((a, b) => categoryWeight(a.ev) - categoryWeight(b.ev) || a.i - b.i); + + const listH = shell.body.h - 56; + const col = scrollColumn(scene, shell.layer, shell.body.x, shell.body.y, shell.body.w, listH); + const w = shell.body.w; + // Everything starts expanded — the player came here to read the news, not + // to hunt for it — but each row stays individually collapsible. + const expanded = new Set(ordered.map((o) => o.i)); + + const render = () => { + col.content.removeAll(true); + let cy = 0; + for (const { i, desc } of ordered) { + const rowTop = cy; + const isOpen = expanded.has(i); + // bg has to be built AFTER the row's text so its final height is known + // — Rectangle Shapes in this Phaser build don't tolerate a post-construction + // .setSize() (throws inside Phaser's own setSize). So it's built last and + // inserted at this index, which puts it behind this row's text instead + // of on top of it. + const bgIndex = col.content.length; + + const tag = scene.add.text(0, cy, desc.category.toUpperCase(), { + fontFamily: FONT, fontSize: '13px', color: '#7fb8e8', + }); + col.content.add(tag); + const chevron = scene.add.text(w - 20, cy, isOpen ? '▾' : '▸', { + fontFamily: FONT, fontSize: '16px', color: '#7f97b3', + }); + col.content.add(chevron); + cy += tag.height + 4; + + const headline = scene.add.text(0, cy, desc.headline, { + fontFamily: FONT, fontSize: '19px', color: '#e8f4ff', wordWrap: { width: w - 30 }, + }); + col.content.add(headline); + cy += headline.height + 10; + + if (isOpen) { + for (const ln of desc.lines) { + const t = scene.add.text(16, cy, ln.text, { + fontFamily: FONT, fontSize: '15px', color: ln.color ?? '#9fb6cc', wordWrap: { width: w - 46 }, + }); + col.content.add(t); + cy += t.height + 6; + } + cy += 4; + } + + const bg = scene.add.rectangle(0, rowTop, w, cy - rowTop, ROW_BG, 0).setOrigin(0, 0) + .setInteractive({ useHandCursor: true }); + col.content.addAt(bg, bgIndex); + bg.on('pointerover', () => bg.setFillStyle(ROW_BG, 0.4)); + bg.on('pointerout', () => bg.setFillStyle(ROW_BG, 0)); + // A mask is a rendering concern only — a row scrolled out of the visible + // band is still hit-testable, so every handler has to ask the column. + bg.on('pointerup', (p) => { + if (!col.contains(p)) return; + if (expanded.has(i)) expanded.delete(i); else expanded.add(i); + render(); + }); + + col.content.add(scene.add.rectangle(0, cy, w, 1, ACCENT, 0.18).setOrigin(0, 0)); + cy += 16; + } + col.contentHeight = cy; + col.setOffset(col.offset); + }; + render(); + + const btn = new Button(scene, shell.x + shell.width / 2, shell.y + shell.height - 34, 'Continue', () => { + col.destroy(); + shell.destroy(); + onClose?.(); + }, { width: 220, height: 44 }); + shell.add(btn); + + return shell; +} diff --git a/src/games/mastervega/sprites.md b/src/games/mastervega/sprites.md index 80c8ed8..b69f2c0 100644 --- a/src/games/mastervega/sprites.md +++ b/src/games/mastervega/sprites.md @@ -18,12 +18,12 @@ world backdrops (§2b) are opaque 1920 × 1080 scenes for the colony screen. --- -## 1. `ships` — ⬜ `assets/images/vega/ships.png` +## 1. `ships` — ✅ `assets/images/vega/ships.png` | | | |---|---| -| Sheet size | **768 × 960** | -| Frame | **96 × 96** | +| Sheet size | **1536 × 1920** | +| Frame | **192 × 192** | | Grid | 8 cols × 10 rows = 80 frames | **One row per species, one column per hull.** Frame = `species.shipFrame × 8 + hull.frame`. @@ -44,9 +44,61 @@ Columns (hull): Rows (species): 0 Human, 1 Kestrelli, 2 Ursaal, 3 Umbrix, 4 Kkrix, 5 Mekhan, 6 Rrashaa, 7 Cerebrai, 8 Ssakar, 9 Lithox. -**Ships face UP.** The star map and battle screen rotate them. Each species has -a colour in the rules file — art can ignore it (the sprite is used as-is) or -lean into it. Silhouette matters more than detail: these draw at ~24–58 px. +**Ships face UP.** The battle screen rotates them ±90° so the two sides face each +other. Each species has a colour in the rules file — art can ignore it (the +sprite is used as-is) or lean into it. + +Only the human row is painted so far; the other nine fall back to the procedural +stand-in, which is cut at the same 192px geometry. + +> The **thumbnails and the detail window turn the hull a full 180°** — a +> deliberate look, not a correction. `makeShipIcon` in `VegaShipMedia.js` is the +> one place that does it, with `setAngle(180)` rather than `setFlipY`: a flip +> would mirror the hull's asymmetries, a rotation does not. + +--- + +### 1b. Ship commander videos — 🟨 `assets/videos/vega/ship--.mp4` + +**Not part of the sheet.** One looping **256 × 256** muted clip per species *per +hull* — the officer who flies that class — shown beside the hull on every ship +row and at full size in the detail window. + +Declared in `shipVideos` in `data/mastervega-artwork.json`, nested +`species → hull id → { key, path }`. A species with none recorded is a single +`null`. Same drop-in contract as everything else: drop the files, add the lines, +no code changes. + +The fallback ladder is one tier deeper than the species portraits: + +1. this species' clip for this hull — `shipVideos[species][hull]` +2. the species' own portrait video — `portraitVideos[species]` (§ below) +3. its 1024px still — `portraitStills[species]` +4. a frame on the procedural `portraits` sheet + +So a gap is never a hole: every row shows a moving face from day one, and each +new race lights up one clip at a time. `starbase` has no clip for anyone and is +expected to keep falling through. + +| Hull id | File suffix | Human | +|---|---|---| +| `scout` | `ship-human-scout.mp4` | ✅ | +| `colonyship` | ⚠️ `ship-human-**colony**.mp4` | ✅ | +| `transport` | `ship-human-transport.mp4` | ✅ | +| `frigate` | `ship-human-frigate.mp4` | ✅ | +| `destroyer` | `ship-human-destroyer.mp4` | ✅ | +| `cruiser` | `ship-human-cruiser.mp4` | ✅ | +| `battleship` | `ship-human-battleship.mp4` | ✅ | +| `starbase` | — | ⬜ falls back | + +> ⚠️ The colony ship's file is `ship-human-colony.mp4` while its hull id is +> `colonyship`. Same class of mismatch as `char-cerebai.mp4`: the manifest points +> at the real filename and the verifier confirms both the file and the derived +> texture key, so wiring it the wrong way round fails immediately rather than +> 404ing in the browser. + +Rows: kestrelli, ursaal, umbrix, kkrix, mekhan, rrashaa, cerebrai, ssakar and +lithox are all ⬜ — nine species × seven hulls outstanding. --- diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index c6161ca..e177537 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -39,6 +39,9 @@ import { ensureSheets, shipFrame, planetFrame, techFrame, buildingFrame, speciesVideoKey, speciesStillKey, speciesSpeechClip, UI_SPEECH, worldBgKey, } from '../src/games/mastervega/VegaArt.js'; +// Ship media is addressed here and nowhere else, so the key convention is +// checkable without a canvas. +import { shipVideoKey } from '../src/games/mastervega/VegaShipMedia.js'; const QUICK = process.argv.includes('--quick'); const gamesArg = process.argv.find((a) => a.startsWith('--games=')); @@ -254,6 +257,56 @@ section('2. Procedural art'); check(`species ${s.id} has a portraitStills entry`, stillIds.includes(s.id)); } + // Ship commander videos, one per species PER HULL. Unlike the three blocks + // above, an absent entry here is not a defect — it falls back to that + // species' own portrait video, which is why nine of the ten species are a + // single `null`. So the count below is reported rather than asserted, and + // what IS asserted is that nothing declared is wrong: a key that does not + // match shipVideoKey() loads a video nothing will ever ask for, and is + // exactly the mistake the colonyship/`ship-human-colony.mp4` filename + // mismatch invites. + const shipVids = artJson.shipVideos ?? {}; + const shipVidSpecies = Object.keys(shipVids).filter((k) => !k.startsWith('_')); + let shipClips = 0; + for (const id of shipVidSpecies) { + check(`shipVideos entry ${id} is a known species`, !!RULES.species[id]); + const hulls = shipVids[id]; + if (!hulls) continue; + for (const hullId of Object.keys(hulls).filter((k) => !k.startsWith('_'))) { + const v = hulls[hullId]; + check(`shipVideos ${id}.${hullId} is a known hull`, !!RULES.hulls[hullId]); + check(`shipVideos ${id}.${hullId} key matches the loader's key`, + v?.key === shipVideoKey(id, hullId), `${v?.key} vs ${shipVideoKey(id, hullId)}`); + if (v?.path) { + shipClips += 1; + check(`shipVideos ${id}.${hullId} file exists`, + existsSync(join(root, v.path)), v.path); + check(`shipVideos ${id}.${hullId} is an mp4`, v.path.endsWith('.mp4')); + } + } + } + for (const s of RULES.speciesList) { + // Declared-or-null, so adding a species cannot silently skip the question. + check(`species ${s.id} has a shipVideos entry (possibly null)`, + shipVidSpecies.includes(s.id)); + // The fallback the gaps rely on. Without it a missing clip is a hole. + check(`species ${s.id} has a shipVideos fallback portrait`, !!vids[s.id]?.path); + } + console.log(` (${shipClips}/${RULES.speciesList.length * RULES.hullList.length} ` + + 'ship commander clips recorded; the rest fall back to the species portrait)'); + + // Every species x hull must land inside the declared `ships` grid, or a row + // of the fleet panel draws a frame that does not exist. + const shipSheet = artJson.sheets?.ships; + const shipCells = (shipSheet?.cols ?? 0) * (shipSheet?.rows ?? 0); + for (const s of RULES.speciesList) { + for (const h of RULES.hullList) { + const f = shipFrame(RULES, s.id, h.id); + check(`shipFrame ${s.id}/${h.id} is inside the ships sheet`, + f >= 0 && f < shipCells, `${f} of ${shipCells}`); + } + } + // World backdrops for the colony screen. These are 1920x1080 opaque images, // NOT frames on the `planets` sheet — that one holds transparent 192px discs // for the orrery, which is a different picture of the same world. Same