fertig-classic-games/src/games/mastervega/VegaGnnScreen.js

730 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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