From d4daa990a15caa2e15e2801b9e44d636dec3affd Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Tue, 4 Aug 2026 21:37:20 -0600 Subject: [PATCH] feat(mastervega): add visual reports for building and ship completion Promote buildingDone and shipDone events from ticker-only to notable interrupting events with dedicated report rows. Each row now displays: - A building icon or ship hull icon + commander portrait - Clickable ship detail overlay (video/image portrait) - "View Star System" shortcut button for quick navigation Mirrors the existing treatment of discoveries and research completions, giving players immediate visual feedback on production milestones. --- src/games/mastervega/VegaTurnReport.js | 44 +++++--- src/games/mastervega/VegaTurnReportScreen.js | 105 ++++++++++++++++++- 2 files changed, 134 insertions(+), 15 deletions(-) diff --git a/src/games/mastervega/VegaTurnReport.js b/src/games/mastervega/VegaTurnReport.js index 831a15a..2239a13 100644 --- a/src/games/mastervega/VegaTurnReport.js +++ b/src/games/mastervega/VegaTurnReport.js @@ -6,10 +6,9 @@ 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. +// Events worth interrupting the player for. Everything else (refit, +// spyCaught, techStolen, invasionFailed, leaderHired, victory — which +// already has its own showVictoryOverlay) stays in the small ticker log only. // // `colonised` is deliberately NOT here. Founding a colony gets its own // full-screen vignette the instant it happens (VegaColonyIntro.js), and a row @@ -17,21 +16,22 @@ import { habitableForEmpire } from './VegaLogic.js'; // second time with less weight than a finished refit. Do not add it back // without taking the vignette out. export const NOTABLE_TYPES = new Set([ - 'discovered', 'techDone', 'contact', 'captured', + 'discovered', 'techDone', 'buildingDone', 'shipDone', 'contact', '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. +// Exploring, researching, founding a colony and finishing a build-queue item +// 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. // // `colonised` stays classified here even though it is no longer notable: the // ticker log runs every event past isRelevantToHuman() too. -const PERSONAL_TYPES = new Set(['discovered', 'techDone', 'colonised']); +const PERSONAL_TYPES = new Set(['discovered', 'techDone', 'colonised', 'buildingDone', 'shipDone']); export function isRelevantToHuman(ev, me) { if (PERSONAL_TYPES.has(ev.type)) return ev.empire === me; @@ -41,6 +41,8 @@ export function isRelevantToHuman(ev, me) { export const TYPE_LABEL = { discovered: 'Discovery', techDone: 'Research', + buildingDone: 'Building Complete', + shipDone: 'Ship Complete', contact: 'First Contact', captured: 'Colony Captured', colonyDestroyed: 'Colony Destroyed', @@ -53,12 +55,14 @@ export const TYPE_LABEL = { }; // Sort weight — diplomacy/territory/council news first (most consequential), -// then discoveries, then research, mirroring how the plan orders the list. +// then discoveries, then research, then finished production, mirroring how +// the plan orders the list. const CATEGORY_ORDER = { contact: 0, warDeclared: 0, peace: 0, alliance: 0, council: 0, councilRefused: 0, eliminated: 0, captured: 1, colonyDestroyed: 1, discovered: 2, techDone: 3, + buildingDone: 4, shipDone: 4, }; export const categoryWeight = (ev) => CATEGORY_ORDER[ev.type] ?? 9; @@ -108,6 +112,18 @@ function describeTechDone(rules, state, ev) { return { headline: `Research completed: ${tech.name}.`, lines }; } +function describeBuildingDone(rules, state, ev) { + const b = rules.buildings[ev.buildingId]; + const star = state.galaxy.stars[ev.starIdx]; + return { headline: `${b.name} completed at ${star.name}.`, lines: [line(b.desc, '#9fb6cc')] }; +} + +function describeShipDone(rules, state, ev) { + const h = rules.hulls[ev.hullId]; + const star = state.galaxy.stars[ev.starIdx]; + return { headline: `${h.name} completed at ${star.name}.`, lines: [line(h.desc, '#9fb6cc')] }; +} + function describeContact(rules, state, ev, name) { const me = state.humanIndex; let otherIdx; @@ -195,6 +211,8 @@ export function describeEvent(rules, state, ev) { switch (ev.type) { case 'discovered': out = describeDiscovered(rules, state, ev); break; case 'techDone': out = describeTechDone(rules, state, ev); break; + case 'buildingDone': out = describeBuildingDone(rules, state, ev); break; + case 'shipDone': out = describeShipDone(rules, state, ev); break; case 'contact': out = describeContact(rules, state, ev, name); break; case 'captured': out = describeCaptured(rules, state, ev, name); break; case 'colonyDestroyed': out = describeColonyDestroyed(rules, state, ev, name); break; diff --git a/src/games/mastervega/VegaTurnReportScreen.js b/src/games/mastervega/VegaTurnReportScreen.js index 18357b9..b5afd7a 100644 --- a/src/games/mastervega/VegaTurnReportScreen.js +++ b/src/games/mastervega/VegaTurnReportScreen.js @@ -10,6 +10,10 @@ import { modalShell, FONT } from './VegaScreens.js'; import { scrollColumn } from './VegaColonyView.js'; +import { openSystemView } from './VegaSystemView.js'; +import { makeShipIcon, makeCommanderPortrait } from './VegaShipMedia.js'; +import { openShipDetail } from './VegaShipDetail.js'; +import { buildingFrame } from './VegaArt.js'; import { turnToYear } from './VegaRules.js'; import { Button } from '../../ui/Button.js'; import { describeEvent, categoryWeight } from './VegaTurnReport.js'; @@ -17,6 +21,19 @@ import { describeEvent, categoryWeight } from './VegaTurnReport.js'; const ACCENT = 0x6fc4ff; const ROW_BG = 0x142238; +// Sizing for the built-item picture on 'buildingDone'/'shipDone' rows. A ship +// gets its hull icon plus its commander's portrait (video where the species +// has one recorded, an image otherwise — makeCommanderPortrait's own +// fallback ladder), a building just its one icon. +const MEDIA_SIZE = 72; +const HULL_ICON_SIZE = 52; +const SHIP_MEDIA_W = 150; +const BUILDING_MEDIA_W = 90; + +// Event types whose row gets a "View Star System" shortcut — anything +// personal to a single one of the human's own stars. +const VIEW_SYSTEM_TYPES = new Set(['discovered', 'buildingDone', 'shipDone']); + export function openTurnReportScreen(scene, rules, state, events, onClose) { const shell = modalShell(scene, `New Turn: ${turnToYear(state.turn)}`, onClose, { width: 1180, height: 760 }); @@ -30,11 +47,15 @@ export function openTurnReportScreen(scene, rules, state, events, onClose) { // 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)); + // One at a time, same as VegaSidePanel's own openDetail guard — and + // survives across render() calls (a row expanding/collapsing elsewhere + // rebuilds the whole column), which a variable local to render() would not. + let shipDetail = null; const render = () => { col.content.removeAll(true); let cy = 0; - for (const { i, desc } of ordered) { + for (const { i, ev, 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 @@ -61,14 +82,94 @@ export function openTurnReportScreen(scene, rules, state, events, onClose) { cy += headline.height + 10; if (isOpen) { + const linesTop = cy; + const isBuilding = ev.type === 'buildingDone'; + const isShip = ev.type === 'shipDone'; + const mediaW = isShip ? SHIP_MEDIA_W : (isBuilding ? BUILDING_MEDIA_W : 0); 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 }, + fontFamily: FONT, fontSize: '15px', color: ln.color ?? '#9fb6cc', + wordWrap: { width: w - 46 - mediaW }, }); col.content.add(t); cy += t.height + 6; } cy += 4; + + // The built item's picture, right-aligned against the synopsis text + // it sits beside. Reserving mediaW off the wrap width above (rather + // than overlaying it) keeps long descriptions from running under it. + if (isBuilding || isShip) { + cy = Math.max(cy, linesTop + MEDIA_SIZE); + const midY = linesTop + MEDIA_SIZE / 2; + if (isBuilding) { + const icon = scene.add.image( + w - 8 - MEDIA_SIZE / 2, midY, scene.art.buildings, buildingFrame(rules, ev.buildingId), + ).setDisplaySize(MEDIA_SIZE, MEDIA_SIZE); + col.content.add(icon); + } else { + const emp = state.empires[ev.empire]; + const portrait = makeCommanderPortrait( + scene, rules, scene.art, emp.speciesId, ev.hullId, + w - 8 - MEDIA_SIZE / 2, midY, MEDIA_SIZE, + ); + col.content.add(portrait); + const hullLeft = w - 8 - MEDIA_SIZE - 8 - HULL_ICON_SIZE; + const hullIcon = makeShipIcon( + scene, rules, scene.art, emp.speciesId, ev.hullId, + hullLeft + HULL_ICON_SIZE / 2, midY, HULL_ICON_SIZE, + ); + col.content.add(hullIcon); + + // Same pop-over the fleet side panel opens on a ship row click + // (VegaSidePanel.detailHit → openShipDetail). It draws its own + // veil above this modal's depth and doesn't go through + // scene.openModal, so it's safe to open without closing the + // report first. The clip already playing here pauses while the + // pop-over runs its own copy at full size, same as the side + // panel's pool does — otherwise the same clip decodes twice. + const isVideoPortrait = portrait.type === 'Video'; + const hit = scene.add.rectangle(hullLeft, linesTop, w - 8 - hullLeft, MEDIA_SIZE, 0xffffff, 0.001) + .setOrigin(0, 0).setInteractive({ useHandCursor: true }); + hit.on('pointerup', (p) => { + if (!col.contains(p) || shipDetail) return; + if (isVideoPortrait && portrait.scene) portrait.pause?.(); + shipDetail = openShipDetail( + scene, rules, state, scene.art, + { empireIdx: ev.empire, hullId: ev.hullId }, + () => { + shipDetail = null; + if (isVideoPortrait && portrait.scene) portrait.resume?.(); + }, + ); + }); + col.content.add(hit); + } + } + + // Discovery and production rows get a shortcut straight to the + // orrery — the star is always the human's own (these are all + // PERSONAL_TYPES in VegaTurnReport.js), so openSystemViewFor's + // explored guard would always pass anyway. Closing this shell first + // and handing its onClose (not scene.openModal, which would no-op + // with modalOpen already true) to openSystemView keeps the + // turn-transition bookkeeping in MasterOfVegaGame.runToHumanTurn() + // running exactly once, whichever button ends the modal. Lives at + // the bottom-right of the expanded synopsis, not the collapsed + // header, so it only appears once the player has read the row. + if (VIEW_SYSTEM_TYPES.has(ev.type)) { + const viewBtn = new Button(scene, w - 108, cy + 14, 'View Star System', () => { + col.destroy(); + shell.destroy(); + openSystemView(scene, rules, state, ev.starIdx, scene.art, { + viewerIdx: state.humanIndex, + onChanged: () => scene.refreshAll?.(), + onClose, + }); + }, { width: 168, height: 28, fontSize: 13 }); + col.content.add(viewBtn); + cy += 36; + } } const bg = scene.add.rectangle(0, rowTop, w, cy - rowTop, ROW_BG, 0).setOrigin(0, 0)