// Master of Vega — the Galactic News Network: a full-screen broadcast of // diplomacy/tech/territorial/espionage headlines plus a MOO1-style rotating // rankings browser. Layout, left to right: // // the anchor desk — a looping muted 2:3 video (assets/videos/vega/gnn.mp4), // playing for the entire time the screen is open, // regardless of which page is showing on the right, // with a green-screen "ticker" terminal underneath // (reused from the Colony Advisor's own terminal — // VegaColoniesScreen.js's createAdvisorTerminal) // bottom-aligned with the right panel, narrating // whatever the current page is showing // the broadcast — one page per pending/replayed story, followed by // one page per MOO1-style ranking metric, then the // always-accessible relations page, paged with // Prev/Next at the bottom // // Story pages come from VegaGnn.js's pendingGnnStories/describeGnnStory; // ranking pages from its RANKING_METRICS/rankingRows. Full-screen takeover // container pattern borrowed from VegaColoniesScreen.js (edge-to-edge veil + // explicit close()), not modalShell — GNN is a takeover, not a centred // dialog. See VegaGnn.js's header comment for why GNN tracks its own // event-consumption flag separately from VegaTurnReport.js's. import * as Phaser from 'phaser'; import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; import { Button } from './VegaButton.js'; import { playSound, SFX } from '../../ui/Sounds.js'; import { FONT, D } from './VegaScreens.js'; import { frame } from './VegaColonyView.js'; import { makeSpeciesPortrait, sourceWidth, techFrame } from './VegaArt.js'; import { createAdvisorTerminal } from './VegaColoniesScreen.js'; import * as Gnn from './VegaGnn.js'; const ACCENT = 0x6fc4ff; const PANEL = 0x0b1220; const GNN_ANCHOR_KEY = 'vega-gnn-anchor'; // gnn.mp4 is 544x800 (2:3 portrait) — the fallback sourceWidth() uses before // the video's first frame decodes (see VegaArt.js's sourceWidth for why a // fresh Video's own .width cannot be trusted that early). const GNN_ANCHOR_SRC_W = 544; const LEFT_X = 40; const CONTENT_TOP = 130; const LEFT_W = 460; const LEFT_H = Math.round(LEFT_W * (800 / 544)); const RIGHT_X = LEFT_X + LEFT_W + 48; const RIGHT_W = GAME_WIDTH - RIGHT_X - 40; const FOOTER_Y = GAME_HEIGHT - 100; const RIGHT_H = FOOTER_Y - 30 - CONTENT_TOP; // The green-screen anchor-desk ticker below the video. Height is whatever's // left under the video down to the right panel's own bottom edge — computed, // not guessed — so the two columns end flush, the symmetry Brian asked for. const TERM_GAP = 20; const TERM_Y = CONTENT_TOP + LEFT_H + TERM_GAP; const TERM_H = (CONTENT_TOP + RIGHT_H) - TERM_Y; // Colors reuse existing palette conventions rather than inventing new ones: // war/red ~ VegaSidePanel.js's at-war tint, alliance/cyan ~ this game's own // ACCENT, tech/amber ~ VegaTurnReport's "Now available" highlight, // espionage/magenta ~ VegaButton's magenta scheme, grim ~ VegaButton's // desaturated disabled-glow tone. const STORY_COLORS = { war: 0xff3355, peace: 0x2bff9e, alliance: 0x6fc4ff, tech: 0xffd88a, grim: 0x8a94a8, espionage: 0xff2fd0, }; const hex = (c) => `#${c.toString(16).padStart(6, '0')}`; const fmtValue = (v) => Math.round(v).toLocaleString(); // Story kinds (Gnn.describeGnnStory's `kind` field) that read as a targeted, // one-off alert rather than a newscast — openGnnScreen skips the ranking and // relations pages for these so a fresh notification doesn't get buried under // unrelated charts. See openGnnScreen's isAlertPage for the full reasoning. const ALERT_STORY_KINDS = new Set(['espionage', 'espionageResult', 'tech']); // -------------------------------------------------------------- anchor panel function buildAnchorFallback(scene, container, w, h) { container.add(scene.add.rectangle(w / 2, h / 2, w, h, PANEL, 1).setStrokeStyle(2, ACCENT, 0.5)); container.add(scene.add.text(w / 2, h / 2, 'GNN', { fontFamily: FONT, fontSize: '72px', color: '#6fc4ff', }).setOrigin(0.5)); } /** The persistent looping anchor-desk video. Built once at screen open, * never touched again by page changes — the clip is eager-loaded at * game-room entry (src/data/assetManifest.js) so there is no JIT-fetch path * to wait on here, unlike VegaResearchScreen.js's per-species clips. */ function buildAnchorPanel(scene, w, h) { const container = scene.add.container(0, 0); if (scene.cache.video?.exists(GNN_ANCHOR_KEY)) { const v = scene.add.video(w / 2, h / 2, GNN_ANCHOR_KEY); v.setMute(true); v.setLoop(true); const fit = () => v.setScale(w / sourceWidth(v, GNN_ANCHOR_SRC_W)); fit(); v.on('created', fit); v.on('playing', fit); v.play(true); // A missing/corrupt clip never leaves a hole where the anchor should be. v.once('error', () => { if (!v.scene) return; container.removeAll(true); buildAnchorFallback(scene, container, w, h); }); container.add(v); } else { buildAnchorFallback(scene, container, w, h); } return container; } // ----------------------------------------------------------- story chrome function storyFrame(scene, container, x, y, w, h, color) { const box = scene.add.rectangle(x, y, w, h, 0x0b1220, 0.9).setOrigin(0, 0); box.setStrokeStyle(4, color, 0.9); container.add(box); return box; } function addPortraitBlock(scene, rules, art, container, cx, cy, size, color, speciesId, label, labelColor) { const half = size / 2; storyFrame(scene, container, cx - half, cy - half, size, size, color); const portrait = makeSpeciesPortrait(scene, rules, art, speciesId, cx, cy, size - 20); container.add(portrait); if (label) { container.add(scene.add.text(cx, cy + half + 16, label, { fontFamily: FONT, fontSize: '22px', color: labelColor ?? '#e8f4ff', }).setOrigin(0.5, 0)); } } function addHeadline(scene, container, cy, text, color = '#e8f4ff', fontSize = '28px') { container.add(scene.add.text(RIGHT_W / 2, cy, text, { fontFamily: FONT, fontSize, color, align: 'center', wordWrap: { width: RIGHT_W - 120 }, }).setOrigin(0.5, 0)); } // ------------------------------------------------------------ story pages function renderDiplomacyStory(scene, rules, state, art, container, desc) { const color = STORY_COLORS[desc.accent]; const size = 420; const cy = 60 + size / 2; const leftCx = 60 + size / 2; const rightCx = RIGHT_W - 60 - size / 2; const empA = state.empires[desc.a]; const empB = state.empires[desc.b]; addPortraitBlock(scene, rules, art, container, leftCx, cy, size, color, empA.speciesId, empA.name, empA.color); addPortraitBlock(scene, rules, art, container, rightCx, cy, size, color, empB.speciesId, empB.name, empB.color); const g = scene.add.graphics(); g.lineStyle(4, color, 0.85); g.lineBetween(leftCx + size / 2, cy, rightCx - size / 2, cy); container.add(g); const verb = scene.add.text((leftCx + rightCx) / 2, cy, desc.verb, { fontFamily: FONT, fontSize: '28px', color: hex(color), fontStyle: 'bold', }).setOrigin(0.5); container.add(verb); scene.tweens.add({ targets: verb, scale: { from: 1, to: 1.1 }, duration: 900, yoyo: true, repeat: -1, ease: 'Sine.easeInOut', }); addHeadline(scene, container, cy + size / 2 + 70, desc.headline); } function renderTechStory(scene, rules, state, art, container, desc) { const color = STORY_COLORS.tech; const size = 420; const cy = 60 + size / 2; const leftCx = 60 + size / 2; const rightCx = RIGHT_W - 60 - size / 2; const emp = state.empires[desc.empire]; addPortraitBlock(scene, rules, art, container, leftCx, cy, size, color, emp.speciesId, emp.name, emp.color); storyFrame(scene, container, rightCx - size / 2, cy - size / 2, size, size, color); // Breakthrough flourish: expanding outline rings behind the icon, pure // procedural tween — no new asset pipeline needed. for (let i = 0; i < 3; i += 1) { const ring = scene.add.circle(rightCx, cy, 100, color, 0).setStrokeStyle(3, color, 0.8); container.add(ring); ring.setScale(0.6); scene.tweens.add({ targets: ring, scale: 1.7, alpha: { from: 0.8, to: 0 }, duration: 1800, delay: i * 600, repeat: -1, ease: 'Sine.easeOut', }); } const icon = scene.add.image(rightCx, cy, art.techicons, techFrame(rules, desc.techId)); icon.setDisplaySize(220, 220); container.add(icon); addHeadline(scene, container, cy + size / 2 + 70, desc.headline); if (desc.sub) addHeadline(scene, container, cy + size / 2 + 116, desc.sub, '#9fb6cc', '20px'); } function renderTerritoryStory(scene, rules, state, art, container, desc) { const color = STORY_COLORS.grim; const hasAttacker = desc.attacker >= 0; const victim = state.empires[desc.victim]; const size = hasAttacker ? 380 : 460; const cy = 80 + size / 2; const cx = hasAttacker ? RIGHT_W / 2 - 140 : RIGHT_W / 2; storyFrame(scene, container, cx - size / 2, cy - size / 2, size, size, color); const portrait = makeSpeciesPortrait(scene, rules, art, victim.speciesId, cx, cy, size - 20); container.add(portrait); // A somber "falling" read that works on both Video and Image portraits — // a dark overlay rectangle rather than setTint, which Phaser's Video game // object does not reliably support. container.add(scene.add.rectangle(cx, cy, size - 20, size - 20, 0x000000, 0.4)); container.add(scene.add.text(cx, cy + size / 2 + 16, victim.name, { fontFamily: FONT, fontSize: '22px', color: victim.color, }).setOrigin(0.5, 0)); if (hasAttacker) { const attacker = state.empires[desc.attacker]; const asize = 220; const acx = RIGHT_W / 2 + 210; addPortraitBlock(scene, rules, art, container, acx, cy, asize, STORY_COLORS.war, attacker.speciesId, attacker.name, attacker.color); const g = scene.add.graphics(); g.lineStyle(4, STORY_COLORS.war, 0.85); g.lineBetween(acx - asize / 2 - 8, cy, cx + size / 2 + 8, cy); container.add(g); } addHeadline(scene, container, cy + size / 2 + 70, desc.headline, '#c7d3e0'); } function renderEspionageStory(scene, rules, state, art, container, desc) { const color = STORY_COLORS.espionage; const perp = state.empires[desc.perpetrator]; const victim = state.empires[desc.victim]; const size = 380; const cy = 70 + size / 2; const leftCx = 60 + size / 2; const rightCx = RIGHT_W - 60 - size / 2; // "Caught red-handed" — a harsh magenta vignette behind the perpetrator's // portrait with a brief flash on open. const vignette = scene.add.rectangle(leftCx, cy, size + 60, size + 60, color, 0.5); container.add(vignette); scene.tweens.add({ targets: vignette, alpha: { from: 0.75, to: 0.2 }, duration: 450, yoyo: true, repeat: 2 }); addPortraitBlock(scene, rules, art, container, leftCx, cy, size, color, perp.speciesId, perp.name, perp.color); addPortraitBlock(scene, rules, art, container, rightCx, cy, size, ACCENT, victim.speciesId, victim.name, victim.color); addHeadline(scene, container, cy + size / 2 + 70, desc.headline); addHeadline(scene, container, cy + size / 2 + 116, desc.sub, '#ff9fd8', '20px'); } // A SUCCESSFUL sabotage/theft — unlike renderEspionageStory above (a CAUGHT // spy, perpetrator named and shamed), the culprit is never shown here except // when the viewing human IS the culprit (desc.attributed). Anonymous case // reuses `renderTerritoryStory`'s single-portrait-plus-vignette read but with // a "?" mark standing in for the unknown hand behind it, rather than an // attacker portrait. function renderEspionageResultStory(scene, rules, state, art, container, desc) { const color = STORY_COLORS.espionage; const victim = state.empires[desc.victim]; if (desc.attributed) { const perp = state.empires[desc.perpetrator]; const size = 380; const cy = 70 + size / 2; const leftCx = 60 + size / 2; const rightCx = RIGHT_W - 60 - size / 2; addPortraitBlock(scene, rules, art, container, leftCx, cy, size, color, perp.speciesId, perp.name, perp.color); addPortraitBlock(scene, rules, art, container, rightCx, cy, size, color, victim.speciesId, victim.name, victim.color); const g = scene.add.graphics(); g.lineStyle(4, color, 0.85); g.lineBetween(leftCx + size / 2, cy, rightCx - size / 2, cy); container.add(g); const verb = scene.add.text((leftCx + rightCx) / 2, cy, desc.type === 'sabotage' ? 'SABOTAGED' : 'STOLEN', { fontFamily: FONT, fontSize: '24px', color: hex(color), fontStyle: 'bold', }).setOrigin(0.5); container.add(verb); addHeadline(scene, container, cy + size / 2 + 70, desc.headline); return; } const size = 420; const cx = RIGHT_W / 2; const cy = 60 + size / 2; storyFrame(scene, container, cx - size / 2, cy - size / 2, size, size, color); const portrait = makeSpeciesPortrait(scene, rules, art, victim.speciesId, cx, cy, size - 20); container.add(portrait); container.add(scene.add.rectangle(cx, cy, size - 20, size - 20, 0x000000, 0.35)); container.add(scene.add.text(cx, cy + size / 2 + 16, victim.name, { fontFamily: FONT, fontSize: '22px', color: victim.color, }).setOrigin(0.5, 0)); // The unknown culprit — a stark "?" in the corner rather than an attacker // portrait, since there's nobody to name. container.add(scene.add.text(cx + size / 2 - 34, cy - size / 2 + 34, '?', { fontFamily: FONT, fontSize: '52px', color: hex(color), fontStyle: 'bold', }).setOrigin(0.5)); addHeadline(scene, container, cy + size / 2 + 70, desc.headline, '#c7d3e0'); } function renderUnknownStory(scene, rules, state, art, container, desc) { addHeadline(scene, container, RIGHT_H / 2 - 20, desc.headline ?? 'Unknown event.'); } const STORY_RENDERERS = { diplomacy: renderDiplomacyStory, tech: renderTechStory, territory: renderTerritoryStory, espionage: renderEspionageStory, espionageResult: renderEspionageResultStory, unknown: renderUnknownStory, }; // ----------------------------------------------------------- ranking page const RANKING_ANIM_MS = 4000; // How long a single row's position swap takes once it's actually triggered // — deliberately much shorter than RANKING_ANIM_MS so a crossing reads as a // discrete "it just happened" event rather than a slow continuous drift. const RANKING_SWAP_MS = 400; /** * Renders a ranking page AND kicks off its 4-second "count-up race" * animation, returning a `{stop()}` handle so the caller can cancel it — * both the counter and every in-flight row-swap tween — if the player * pages away before it finishes (see openGnnScreen's activeRankingAnim). * * Every row starts alphabetically ordered and at zero, then climbs toward * its own final value at one shared rate (value per second — `max/4`, not * a shared duration), so the leading empire's bar/number are still moving * right up to the 4-second mark while shorter bars arrive early and hold. * A row's on-screen position only moves when its LIVE rank actually * changes — ties (which is what every row is, at the start, and again * whenever a plateaued row is still equal to one still climbing) resolve * back to alphabetical order via a stable sort of the same fixed-order row * list every tick, so nothing reorders until a real numeric crossover * happens, and the swap itself fires as one short, snappy tween starting * exactly on the tick that crossover is detected — not a continuous chase. */ function renderRankingPage(scene, rules, state, art, container, metric) { container.add(scene.add.text(RIGHT_W / 2, 16, metric.label, { fontFamily: FONT, fontSize: '32px', color: '#cfe8ff', fontStyle: 'bold', }).setOrigin(0.5, 0)); const finalRows = Gnn.rankingRows(rules, state, metric.id); const me = state.humanIndex; const max = Math.max(1, finalRows[0]?.value ?? 0); const barX = 100; const barW = RIGHT_W - barX - 140; const rowH = 78; const startY = 90; const alphaOrder = [...finalRows].sort((a, b) => a.name.localeCompare(b.name)); const finalOrderIdx = new Map(finalRows.map((r, i) => [r.idx, i])); const rowVisuals = alphaOrder.map((row, i) => { const isMe = row.idx === me; const rowContainer = scene.add.container(0, startY + i * rowH); container.add(rowContainer); const colorInt = Phaser.Display.Color.HexStringToColor(row.color).color; // Thick species-color frame around the portrait thumbnail. rowContainer.add(scene.add.rectangle(36, 18, 60, 60, 0x000000, 0).setStrokeStyle(4, colorInt, 1)); rowContainer.add(makeSpeciesPortrait(scene, rules, art, row.speciesId, 36, 18, 56)); rowContainer.add(scene.add.text(barX, 0, row.name + (isMe ? ' ★' : ''), { fontFamily: FONT, fontSize: '20px', color: row.color, })); rowContainer.add(scene.add.rectangle(barX, 32, barW, 16, 0x1b2b42).setOrigin(0, 0)); // Horizontal gradient — primarily the species color, brightening toward // the bar's own tip for a little flair. Drawn once at the row's FULL // final width and revealed left-to-right via scaleX rather than redrawn // every frame: a Graphics object scales around its own local (0,0), // which is the bar's left edge, so the bright tip always lands exactly // at the bar's true end instead of stretching across whatever sliver is // currently visible. const finalW = barW * Phaser.Math.Clamp(row.value / max, 0, 1); const leftColor = Phaser.Display.Color.HexStringToColor(row.color).darken(8).color; const rightColor = Phaser.Display.Color.HexStringToColor(row.color).lighten(30).color; const barG = scene.add.graphics(); barG.fillGradientStyle(leftColor, rightColor, leftColor, rightColor, 1); barG.fillRect(0, 0, Math.max(finalW, 0.01), 16); if (isMe) { barG.lineStyle(2, 0xffffff, 0.9); barG.strokeRect(0, 0, Math.max(finalW, 0.01), 16); } barG.setPosition(barX, 32); barG.scaleX = 0; rowContainer.add(barG); const valueText = scene.add.text(barX + barW, 2, '0', { fontFamily: FONT, fontSize: '20px', color: '#e8f4ff', }).setOrigin(1, 0); rowContainer.add(valueText); // `rank` starts at this row's alphabetical slot (i) — matches the // position it's actually drawn in above, so the first tick never sees // a spurious "rank changed" and fires a swap tween nothing asked for. return { idx: row.idx, finalValue: row.value, rowContainer, barG, valueText, rank: i, posTween: null, }; }); const rate = max / (RANKING_ANIM_MS / 1000); // units/sec — the SAME rate for every row // Not the fire-and-forget playSound() helper — that goes through // scene.sound.play(), which pools playback and hands back no handle to // stop just this instance. scene.sound.add() gives us our own Sound // object instead (see playSoundEx's own comment on the tradeoff), which // the returned stop() below cuts off if the player pages away before it // finishes. const counterSound = scene.sound.add(SFX.VEGA_COUNTER, { volume: 0.9 }); counterSound.play(); function movePosition(rv, targetRank) { rv.rank = targetRank; rv.posTween?.stop(); rv.posTween = scene.tweens.add({ targets: rv.rowContainer, y: startY + targetRank * rowH, duration: RANKING_SWAP_MS, ease: 'Cubic.easeOut', }); } const counterTween = scene.tweens.addCounter({ from: 0, to: 1, duration: RANKING_ANIM_MS, ease: 'Linear', onUpdate: (tw) => { const t = tw.getValue() * (RANKING_ANIM_MS / 1000); // Built fresh from `rowVisuals` (always in fixed alphabetical order) // every tick, with NO secondary tiebreak — Array.sort is stable, so // any rows still exactly tied fall back to alphabetical order every // time rather than whatever the previous tick's order happened to // be. Only a genuine value difference (which the shared-rate design // only produces once a row plateaus at its own final value while // another keeps climbing past it) moves a row in this sort. const current = rowVisuals.map((rv) => ({ rv, value: Math.min(rv.finalValue, rate * t) })); current.sort((a, b) => b.value - a.value); current.forEach(({ rv, value }, i) => { if (!rv.valueText.scene) return; // page changed mid-tween; onUpdate still fires once more rv.valueText.setText(fmtValue(value)); rv.barG.scaleX = rv.finalValue > 0 ? value / rv.finalValue : 0; if (i !== rv.rank) movePosition(rv, i); }); }, onComplete: () => { rowVisuals.forEach((rv) => { if (!rv.valueText.scene) return; rv.valueText.setText(fmtValue(rv.finalValue)); rv.barG.scaleX = 1; rv.posTween?.stop(); rv.rank = finalOrderIdx.get(rv.idx); rv.rowContainer.y = startY + rv.rank * rowH; }); }, }); return { stop() { counterTween.stop(); rowVisuals.forEach((rv) => rv.posTween?.stop()); counterSound.stop(); counterSound.destroy(); }, }; } // ---------------------------------------------------------- relations page // Column layout: species name/portrait, then three independent relationship // lists — a treaty is a single rung (war/peace/alliance) but a trade // agreement coexists with any of them, so it always gets its own column // rather than folding into the treaty state (see VegaGnn.js's relationsRows). const RELATIONS_COLS = [ { key: 'atWar', label: 'AT WAR WITH', color: () => STORY_COLORS.war }, { key: 'trade', label: 'TRADE AGREEMENTS', color: () => ACCENT }, { key: 'allied', label: 'ALLIANCES', color: () => STORY_COLORS.peace }, ]; /** Always-accessible reference page — who's at war, trading, or allied with * whom, across every empire still in the galaxy. Not contact-gated, unlike * the ranking pages: see VegaGnn.js's relationsRows for why. */ function renderRelationsPage(scene, rules, state, art, container) { container.add(scene.add.text(RIGHT_W / 2, 16, 'DIPLOMATIC RELATIONS', { fontFamily: FONT, fontSize: '32px', color: '#cfe8ff', fontStyle: 'bold', }).setOrigin(0.5, 0)); const rows = Gnn.relationsRows(rules, state); const nameX = 0; const nameW = 230; const gap = 24; const colW = (RIGHT_W - nameW - gap * 4) / 3; const colX = RELATIONS_COLS.map((_, i) => nameX + nameW + gap + i * (colW + gap)); const headerY = 70; container.add(scene.add.text(nameX, headerY, 'EMPIRE', { fontFamily: FONT, fontSize: '15px', color: '#6f8aa3', fontStyle: 'bold', })); RELATIONS_COLS.forEach((col, i) => { container.add(scene.add.text(colX[i], headerY, col.label, { fontFamily: FONT, fontSize: '15px', color: hex(col.color()), fontStyle: 'bold', })); }); container.add(scene.add.rectangle(nameX, headerY + 26, RIGHT_W, 1, ACCENT, 0.3).setOrigin(0, 0)); let y = headerY + 40; for (const row of rows) { const portrait = makeSpeciesPortrait(scene, rules, art, row.speciesId, nameX + 24, y + 22, 42); container.add(portrait); container.add(scene.add.text(nameX + 52, y + 8, row.name, { fontFamily: FONT, fontSize: '19px', color: row.color, })); const cellTexts = RELATIONS_COLS.map((col, i) => { const list = row[col.key]; const t = scene.add.text(colX[i], y + 6, list.length ? list.join(', ') : '—', { fontFamily: FONT, fontSize: '15px', color: list.length ? hex(col.color()) : '#5a6b80', wordWrap: { width: colW }, }); container.add(t); return t; }); const rowH = Math.max(56, ...cellTexts.map((t) => t.height + 20)); y += rowH; container.add(scene.add.rectangle(nameX, y - 8, RIGHT_W, 1, ACCENT, 0.12).setOrigin(0, 0)); } if (!rows.length) { container.add(scene.add.text(RIGHT_W / 2, RIGHT_H / 2, 'No empires remain.', { fontFamily: FONT, fontSize: '20px', color: '#7f97b3', }).setOrigin(0.5)); } } // ----------------------------------------------------------------- pager /** * Opens the full-screen GNN takeover. `opts.onClose` fires once, when the * screen is dismissed. Consumes every currently-pending story (marking it * `gnnAnnounced`) the instant it opens — a story is "told" the moment this * screen is shown, whether or not the player pages all the way through it. */ export function openGnnScreen(scene, rules, state, art, opts = {}) { const { onClose = null } = opts; // Duck the regular soundtrack for the whole broadcast: a one-shot opening // sting, then a looping ambience that takes over the instant the sting // finishes and keeps playing until the screen closes — same pause()/ // resume() ducking VegaColonyIntro.js's founding cue already uses, just // handed off to a loop afterward instead of letting the soundtrack right // back in. `closed` is declared further down but this callback only ever // fires asynchronously, well after that declaration has run, so the // closure over it is safe (same reasoning VegaColonyIntro.js's own // resumeMusic() relies on). scene.music?.pause(); let gnnSting = null; let gnnLoop = null; function startGnnLoop() { if (!scene.cache.audio?.exists(SFX.VEGA_GNN_LOOP)) return; gnnLoop = scene.sound.add(SFX.VEGA_GNN_LOOP, { loop: true, volume: 0.5 }); gnnLoop.play(); } if (scene.cache.audio?.exists(SFX.VEGA_GNN_STING)) { gnnSting = scene.sound.add(SFX.VEGA_GNN_STING, { volume: 0.8 }); gnnSting.once(Phaser.Sound.Events.COMPLETE, () => { gnnSting = null; if (closed) return; // screen already closed before the sting finished startGnnLoop(); }); gnnSting.play(); } else { startGnnLoop(); } const pending = Gnn.pendingGnnStories(rules, state); const usingHistory = pending.length === 0; if (pending.length) Gnn.consumeGnnStories(state, pending); const storyEvents = usingHistory ? (state.gnn?.history ?? []) : pending; const storyPages = storyEvents.map((ev) => ({ kind: 'story', desc: Gnn.describeGnnStory(rules, state, ev) })); // A spying/sabotage notification — or a tech breakthrough — is a targeted, // one-off alert, not a newscast — skip the ranking/relations pages so it // reads as "here's what just happened," not "here's what just happened, // now flip through five unrelated charts" (Brian's ask; tech added to the // same set on the same ask, 2026-08-14). ALERT_STORY_KINDS is the place to // add another kind later. Only suppresses on a FRESH pending notification; // reopening GNN on demand with nothing pending (usingHistory) always gets // the full experience back — that reopen IS the escape hatch for a player // who does want the charts. const isAlertPage = !usingHistory && storyPages.some((p) => ALERT_STORY_KINDS.has(p.desc.kind)); // The charts rank the human against every empire it has met (rankingRows // is contact-gated) — with nobody met yet that's just a one-bar "race" // against yourself, which reads as broken rather than informative. So the // whole ranking rotation stays off the page list until first contact // (Brian's ask), same suppression flag as the alert-page case above. const me = state.humanIndex; const hasMetAnyone = state.empires.some((o) => o.alive && o.idx !== me && state.empires[me].contacted[o.idx]); const rankingPages = (isAlertPage || !hasMetAnyone) ? [] : Gnn.RANKING_METRICS.map((metric) => ({ kind: 'ranking', metric })); // Always-accessible otherwise: present every time GNN opens, independent // of whether there's a pending story — same footing as the ranking pages. const relationsPage = isAlertPage ? null : { kind: 'relations' }; const pages = [...storyPages, ...rankingPages, ...(relationsPage ? [relationsPage] : [])]; const root = scene.add.container(0, 0).setDepth(D.gnn); root.add(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x030509, 0.99) .setOrigin(0, 0).setInteractive()); root.add(scene.add.text(LEFT_X, 34, 'GALACTIC NEWS NETWORK', { fontFamily: FONT, fontSize: '34px', color: '#e8f4ff', })); root.add(new Button(scene, GAME_WIDTH - 24 - 20, 44, '✕', () => { playSound(scene, SFX.VEGA_CLOSE); close(); }, { width: 40, height: 36, fontSize: 18, variant: 'ghost' })); // Left: the persistent looping anchor video — built once, untouched by // subsequent page renders, so it keeps playing "for the entire time GNN // is open, regardless of which page is showing." frame(scene, root, LEFT_X, CONTENT_TOP, LEFT_W, LEFT_H, 0); const anchorPanel = buildAnchorPanel(scene, LEFT_W, LEFT_H); anchorPanel.setPosition(LEFT_X, CONTENT_TOP); root.add(anchorPanel); // Green-screen ticker under the video, bottom-aligned with the right // panel (TERM_H is computed for exactly that — see its own comment). // Reuses the Colony Advisor's terminal chrome (VegaColoniesScreen.js) — // same scanlines/glitch/typing-cursor treatment, different narration. const terminal = createAdvisorTerminal(scene, LEFT_X, TERM_Y, LEFT_W, TERM_H); root.add(terminal.container); // Right: per-page content. frame(scene, root, RIGHT_X, CONTENT_TOP, RIGHT_W, RIGHT_H, 0.82); const content = scene.add.container(RIGHT_X, CONTENT_TOP); root.add(content); const pageLabel = scene.add.text(GAME_WIDTH / 2, FOOTER_Y + 18, '', { fontFamily: FONT, fontSize: '18px', color: '#7f97b3', }).setOrigin(0.5, 0); // "First page is never blank": with nothing new to report, land on a // random ranking page (MOO1's own random-stat-on-open convention) rather // than page 0 of a replay list the player didn't ask to review — Prev // still walks back into `state.gnn.history` from there, so replaying // recent stories stays reachable. let idx = usingHistory ? storyPages.length + Math.floor(Math.random() * rankingPages.length) : 0; // The ranking page's 3-second count-up race (renderRankingPage) — tracked // here so paging away or closing mid-animation stops it rather than // leaving a tween running against a page that's no longer on screen. let activeRankingAnim = null; function renderPage() { activeRankingAnim?.stop(); activeRankingAnim = null; content.removeAll(true); const page = pages[idx]; if (page.kind === 'story') { const renderer = STORY_RENDERERS[page.desc.kind] ?? renderUnknownStory; renderer(scene, rules, state, art, content, page.desc); } else if (page.kind === 'ranking') { activeRankingAnim = renderRankingPage(scene, rules, state, art, content, page.metric); } else { renderRelationsPage(scene, rules, state, art, content); } // Re-picked and re-typed on every page change — never cached — so // paging back to an earlier page can land on a different phrasing. terminal.setLines([Gnn.anchorLine(rules, state, page)]); pageLabel.setText(`${idx + 1} / ${pages.length}`); prevBtn.setEnabled(idx > 0); nextBtn.setEnabled(idx < pages.length - 1); } // First pagination control in the codebase — per the project's own // "don't build a shared component until a second consumer needs it" rule, // this stays two plain Button instances with a closure-local index rather // than a new shared pager component. const prevBtn = new Button(scene, GAME_WIDTH / 2 - 140, FOOTER_Y + 18, '‹ Prev', () => { if (idx <= 0) return; idx -= 1; playSound(scene, SFX.VEGA_SELECT); renderPage(); }, { width: 160, height: 46, fontSize: 18, variant: 'ghost' }); const nextBtn = new Button(scene, GAME_WIDTH / 2 + 140, FOOTER_Y + 18, 'Next ›', () => { if (idx >= pages.length - 1) return; idx += 1; playSound(scene, SFX.VEGA_SELECT); renderPage(); }, { width: 160, height: 46, fontSize: 18, variant: 'ghost' }); root.add(prevBtn); root.add(nextBtn); root.add(pageLabel); renderPage(); let closed = false; function close() { if (closed) return; closed = true; scene.input.keyboard?.off('keydown-ESC', onEsc); // Explicit, not left to root.destroy()'s cascade — the terminal owns // its own timers/tween/scroll-mask (see createAdvisorTerminal's own // destroy(), VegaColoniesScreen.js), same as every other consumer of it. terminal.destroy(); activeRankingAnim?.stop(); gnnSting?.stop(); gnnLoop?.stop(); scene.music?.resume(); root.destroy(); onClose?.(); } function onEsc() { close(); } scene.input.keyboard?.on('keydown-ESC', onEsc); return { root, close }; }