// 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; }