310 lines
14 KiB
JavaScript
310 lines
14 KiB
JavaScript
// 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, uiClick } from './VegaScreens.js';
|
|
import { playSound, SFX } from '../../ui/Sounds.js';
|
|
import { scrollColumn, openColonyView } from './VegaColonyView.js';
|
|
import { openSystemView } from './VegaSystemView.js';
|
|
import { openResearchScreen } from './VegaResearchScreen.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 './VegaButton.js';
|
|
import { describeEvent, categoryWeight, groupShipDoneEvents } from './VegaTurnReport.js';
|
|
import { enqueue, empireColonies, colonyTrade } from './VegaLogic.js';
|
|
|
|
// A colony's own trade minus what its existing buildings already cost to run
|
|
// — there's no per-colony BC in this economy (only the empire-wide pool), so
|
|
// this is the closest local stand-in for "can this colony carry one more
|
|
// building's upkeep on its own income."
|
|
function colonyNetIncome(rules, state, colony) {
|
|
const upkeep = colony.buildings.reduce((s, bid) => s + (rules.buildings[bid]?.upkeep ?? 0), 0);
|
|
return colonyTrade(rules, state, colony) - upkeep;
|
|
}
|
|
|
|
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;
|
|
|
|
// 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']);
|
|
// Same idea, but straight into the colony screen rather than the system view.
|
|
const VIEW_COLONY_TYPES = new Set(['advisorRecommendation']);
|
|
|
|
export function openTurnReportScreen(scene, rules, state, events, onClose) {
|
|
playSound(scene, SFX.VEGA_NEWTURN);
|
|
const shell = modalShell(scene, `New Turn: ${turnToYear(state.turn)}`, onClose, { width: 1180, height: 760 });
|
|
|
|
const ordered = groupShipDoneEvents(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));
|
|
// 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, 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
|
|
// — 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) {
|
|
const isBuilding = ev.type === 'buildingDone';
|
|
const isShip = ev.type === 'shipDone';
|
|
// Both a building's icon and a ship's hull icon + commander
|
|
// portrait now sit below their (full-width) synopsis text,
|
|
// left-aligned to match the text above them — neither needs to
|
|
// reserve wrap width off to the side any more.
|
|
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;
|
|
|
|
// A newly-researched tech that unlocks a colony building gets a
|
|
// one-click bulk-queue offer right in the report — the whole reason
|
|
// to interrupt the player for research news instead of leaving it in
|
|
// the ticker log.
|
|
if (ev.type === 'techDone') {
|
|
for (const bu of (desc.buildingUnlocks ?? [])) {
|
|
const label = scene.add.text(16, cy, `Build ${bu.name}:`, {
|
|
fontFamily: FONT, fontSize: '15px', color: '#ffd88a',
|
|
});
|
|
col.content.add(label);
|
|
cy += label.height + 6;
|
|
|
|
if (bu.desc) {
|
|
const buildingDesc = scene.add.text(16, cy, bu.desc, {
|
|
fontFamily: FONT, fontSize: '13px', color: '#9fb6cc', wordWrap: { width: w - 32 },
|
|
});
|
|
col.content.add(buildingDesc);
|
|
cy += buildingDesc.height + 8;
|
|
}
|
|
|
|
const status = scene.add.text(16, cy + 34, '', {
|
|
fontFamily: FONT, fontSize: '13px', color: '#7fd8a0', wordWrap: { width: w - 32 },
|
|
});
|
|
|
|
const queueAt = (colonies) => {
|
|
let n = 0;
|
|
for (const c of colonies) if (enqueue(rules, state, c, 'building', bu.id)) n += 1;
|
|
status.setText(n > 0
|
|
? `Queued at ${n} colon${n === 1 ? 'y' : 'ies'}.`
|
|
: 'Already built or queued at every colony.');
|
|
};
|
|
|
|
const allBtn = new Button(scene, 16 + 84, cy + 14, 'All Colonies', () => {
|
|
playSound(scene, SFX.VEGA_BUILD);
|
|
queueAt(empireColonies(state, ev.empire));
|
|
}, { width: 168, height: 28, fontSize: 13 });
|
|
col.content.add(allBtn);
|
|
|
|
const bcBtn = new Button(scene, 16 + 168 + 16 + 105, cy + 14, 'Colonies with positive BC', () => {
|
|
playSound(scene, SFX.VEGA_BUILD);
|
|
queueAt(empireColonies(state, ev.empire).filter((c) => colonyNetIncome(rules, state, c) > 0));
|
|
}, { width: 210, height: 28, fontSize: 13 });
|
|
col.content.add(bcBtn);
|
|
|
|
col.content.add(status);
|
|
cy += 28 + 14 + status.height + 10;
|
|
}
|
|
}
|
|
|
|
// A building's picture sits below its synopsis text, left-aligned
|
|
// to match the text above it — same placement as a ship's hull
|
|
// icon + commander portrait just below.
|
|
if (isBuilding) {
|
|
const mediaTop = cy;
|
|
const midY = mediaTop + MEDIA_SIZE / 2;
|
|
const icon = scene.add.image(
|
|
16 + MEDIA_SIZE / 2, midY, scene.art.buildings, buildingFrame(rules, ev.buildingId),
|
|
).setDisplaySize(MEDIA_SIZE, MEDIA_SIZE);
|
|
col.content.add(icon);
|
|
cy = mediaTop + MEDIA_SIZE + 10;
|
|
} else if (isShip) {
|
|
const mediaTop = cy;
|
|
const midY = mediaTop + MEDIA_SIZE / 2;
|
|
const emp = state.empires[ev.empire];
|
|
const hullLeft = 16;
|
|
const hullIcon = makeShipIcon(
|
|
scene, rules, scene.art, emp.speciesId, ev.hullId,
|
|
hullLeft + HULL_ICON_SIZE / 2, midY, HULL_ICON_SIZE,
|
|
);
|
|
col.content.add(hullIcon);
|
|
const portraitLeft = hullLeft + HULL_ICON_SIZE + 8;
|
|
const portrait = makeCommanderPortrait(
|
|
scene, rules, scene.art, emp.speciesId, ev.hullId,
|
|
portraitLeft + MEDIA_SIZE / 2, midY, MEDIA_SIZE,
|
|
);
|
|
col.content.add(portrait);
|
|
|
|
// 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, mediaTop, portraitLeft + MEDIA_SIZE - 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);
|
|
|
|
cy = mediaTop + MEDIA_SIZE + 10;
|
|
}
|
|
|
|
// 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', uiClick(scene, () => {
|
|
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;
|
|
}
|
|
|
|
// Same shortcut shape as View Star System above, for the one type it
|
|
// doesn't cover: a finished tech jumps straight to its own field on
|
|
// the Research screen. Also not via scene.openModal (same reasoning
|
|
// as the star-system button — modalOpen is already true), and hands
|
|
// through the same onClose so the turn-transition still completes
|
|
// exactly once, whichever button the player used to leave the report.
|
|
if (ev.type === 'techDone') {
|
|
const viewBtn = new Button(scene, w - 108, cy + 14, 'View Research', uiClick(scene, () => {
|
|
col.destroy();
|
|
shell.destroy();
|
|
openResearchScreen(scene, rules, state, ev.empire, scene.art, onClose, {
|
|
initialField: rules.techs[ev.techId].field,
|
|
});
|
|
}), { width: 168, height: 28, fontSize: 13 });
|
|
col.content.add(viewBtn);
|
|
cy += 36;
|
|
}
|
|
|
|
// Same shortcut shape again, straight into the colony screen itself
|
|
// rather than the system view around it — advisorRecommendation rows
|
|
// always carry a colonyId (checkAdvisorRecommendations in
|
|
// VegaLogic.js only ever fires for the human's own colonies).
|
|
if (VIEW_COLONY_TYPES.has(ev.type)) {
|
|
const viewBtn = new Button(scene, w - 108, cy + 14, 'View Colony', uiClick(scene, () => {
|
|
col.destroy();
|
|
shell.destroy();
|
|
const colony = state.colonies.find((c) => c.id === ev.colonyId);
|
|
openColonyView(scene, rules, state, colony, scene.art, {
|
|
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)
|
|
.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', uiClick(scene, () => {
|
|
col.destroy();
|
|
shell.destroy();
|
|
onClose?.();
|
|
}), { width: 220, height: 44 });
|
|
shell.add(btn);
|
|
|
|
return shell;
|
|
}
|