730 lines
32 KiB
JavaScript
730 lines
32 KiB
JavaScript
// 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 };
|
||
}
|