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.
This commit is contained in:
Brian Fertig 2026-08-04 21:37:20 -06:00
parent 01c5957b59
commit d4daa990a1
2 changed files with 134 additions and 15 deletions

View File

@ -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;

View File

@ -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)