feat(mastervega): add Colonies screen for empire-wide colony management

Introduce VegaColoniesScreen.js, a spreadsheet-style screen that displays
and manages all of the player's colonies at once, grouped by star and
sorted by population. Features include:

- JIT-loaded advisor video panel (one 1:1 muted loop per species, falling
  back to the species portrait when no clip is recorded)
- Retro terminal with typing animation, scanlines, and glitch effects
  that displays advisorColonyReport commentary for the selected colony
- Inline Colony Focus and Allocation Focus pills with dropdown pickers
  showing the advisor's live recommendation outlined in yellow
- Population transfer (arm-and-target idiom matching fleet orders)
- Manage button delegating to the existing single-colony flyout

Refactor VegaColonyView.js to use shared applyColonyFocus and
applyAllocationFocus setters from VegaLogic.js so both the flyout and
inline pills apply identical side effects. Add empireColoniesByStar
grouping helper for the spreadsheet view. Wire a "Colonies" entry into
the Empire dropdown menu in MasterOfVegaGame.js. Add advisorVideos
entries to mastervega-artwork.json with JIT-loading helpers in VegaArt.js.
Update verifyMasterOfVega.js to validate the new assets, lazy-loading
behavior, and all new logic functions.
This commit is contained in:
Brian Fertig 2026-08-11 20:10:40 -06:00
parent f150737004
commit 72f44178b0
8 changed files with 1042 additions and 15 deletions

View File

@ -223,6 +223,20 @@
"lithox": { "key": "vega-research-lithox", "path": "assets/videos/vega/research-lithox.mp4" } "lithox": { "key": "vega-research-lithox", "path": "assets/videos/vega/research-lithox.mp4" }
}, },
"_advisorVideosReadme": "Colonies-screen advisor loops (VegaColoniesScreen.js) — one per species, a distinct COLONIAL ADVISOR persona, not the leader portrait (portraitVideos) and not the research scientist (researchVideos). Muted, looping continuously. Recorded 1:1 (square), like the leader portraits, not the 2:3 research clips. JIT-loaded like colonyVideos/audienceVideos/researchVideos, NOT eager via assetManifest.js — the Colonies screen only ever shows the human player's own species. A species with no clip recorded falls back to its portraitVideos clip, then portraitStills — same three-tier fallback convention as makeSpeciesPortrait. To add or replace one: drop the file and set its path here; nothing else changes. Naming: assets/videos/vega/advisor-<speciesId>.mp4.",
"advisorVideos": {
"human": { "key": "vega-advisor-human", "path": null },
"kestrelli": { "key": "vega-advisor-kestrelli", "path": null },
"ursaal": { "key": "vega-advisor-ursaal", "path": null },
"umbrix": { "key": "vega-advisor-umbrix", "path": null },
"kkrix": { "key": "vega-advisor-kkrix", "path": null },
"mekhan": { "key": "vega-advisor-mekhan", "path": null },
"rrashaa": { "key": "vega-advisor-rrashaa", "path": null },
"cerebrai": { "key": "vega-advisor-cerebrai", "path": null },
"ssakar": { "key": "vega-advisor-ssakar", "path": null },
"lithox": { "key": "vega-advisor-lithox", "path": null }
},
"sheets": { "sheets": {
"ships": { "ships": {
"key": "vega-ships", "key": "vega-ships",

View File

@ -31,6 +31,7 @@ import {
FONT, D, openDiplomacyScreen, openCouncilScreen, openLeaderScreen, FONT, D, openDiplomacyScreen, openCouncilScreen, openLeaderScreen,
openSaveScreen, openLoadScreen, showVictoryOverlay, openSaveScreen, openLoadScreen, showVictoryOverlay,
} from './VegaScreens.js'; } from './VegaScreens.js';
import { openColoniesScreen } from './VegaColoniesScreen.js';
import { openResearchScreen } from './VegaResearchScreen.js'; import { openResearchScreen } from './VegaResearchScreen.js';
import { openResearchChoiceScreen } from './VegaResearchChoiceScreen.js'; import { openResearchChoiceScreen } from './VegaResearchChoiceScreen.js';
import { openTurnReportScreen } from './VegaTurnReportScreen.js'; import { openTurnReportScreen } from './VegaTurnReportScreen.js';
@ -662,8 +663,9 @@ export default class MasterOfVegaGame extends Phaser.Scene {
// Research/Diplomacy/Leaders/Council used to be four separate HUD // Research/Diplomacy/Leaders/Council used to be four separate HUD
// buttons; consolidated into one "Empire" dropdown (openEmpireMenu, // buttons; consolidated into one "Empire" dropdown (openEmpireMenu,
// near the game menu below) to leave room in the bar for more empire- // near the game menu below) to leave room in the bar for more empire-
// management buttons later (Brian's plan: "Colonies" next) without the // management buttons later without the row running out of width —
// row running out of width. Parked immediately left of End Turn, same // Colonies (VegaColoniesScreen.js) is that next button. Parked
// immediately left of End Turn, same
// width, so the two read as a pair — the leading ▸/▾ (EMPIRE_CLOSED_LABEL/ // width, so the two read as a pair — the leading ▸/▾ (EMPIRE_CLOSED_LABEL/
// EMPIRE_OPEN_LABEL, swapped by open/closeEmpireMenu) is this button's // EMPIRE_OPEN_LABEL, swapped by open/closeEmpireMenu) is this button's
// own open/closed indicator, not the same glyph VegaTurnReportScreen.js // own open/closed indicator, not the same glyph VegaTurnReportScreen.js
@ -760,10 +762,11 @@ export default class MasterOfVegaGame extends Phaser.Scene {
// -------------------------------------------------------------- empire menu // -------------------------------------------------------------- empire menu
/** Empire toggles a small popover dropping down from directly below it: /** Empire toggles a small popover dropping down from directly below it:
* Research, Diplomacy, Leaders, Council, in that order (Brian's ask). * Research, Diplomacy, Leaders, Colonies, Council, in that order (Brian's
* Same shape as the game menu above click-anywhere-to-dismiss veil, * ask). Same shape as the game menu above click-anywhere-to-dismiss
* ghost-variant item buttons just anchored under the HUD bar instead of * veil, ghost-variant item buttons just anchored under the HUD bar
* above the button. Gated by modalOpen like every other HUD button. */ * instead of above the button. Gated by modalOpen like every other HUD
* button. */
toggleEmpireMenu() { toggleEmpireMenu() {
if (this.modalOpen) return; if (this.modalOpen) return;
if (this.empireMenuLayer) { this.closeEmpireMenu(); return; } if (this.empireMenuLayer) { this.closeEmpireMenu(); return; }
@ -789,6 +792,11 @@ export default class MasterOfVegaGame extends Phaser.Scene {
['Leaders', () => this.openModal((done) => ['Leaders', () => this.openModal((done) =>
openLeaderScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done, openLeaderScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done,
() => this.refreshHud()))], () => this.refreshHud()))],
['Colonies', () => this.openModal((done) =>
openColoniesScreen(this, this.rules, this.state, this.state.humanIndex, this.art, {
onChanged: () => this.refreshAll(),
onClose: done,
}))],
['Council', () => this.openModal((done) => ['Council', () => this.openModal((done) =>
openCouncilScreen(this, this.rules, this.state, done))], openCouncilScreen(this, this.rules, this.state, done))],
]; ];

View File

@ -704,6 +704,16 @@ export function hasSpeciesResearchVideo(scene, speciesId) {
return !!scene.cache.video?.exists(speciesResearchVideoKey(speciesId)); return !!scene.cache.video?.exists(speciesResearchVideoKey(speciesId));
} }
// Colonies-screen advisor loops (VegaColoniesScreen.js) — one per species,
// a distinct Colonial Advisor persona (not the leader portrait, not the
// research scientist). JIT-loaded the same way, no mood dimension, loops
// continuously for as long as the screen is open.
export const advisorVideoKey = (speciesId) => `vega-advisor-${speciesId}`;
export function hasAdvisorVideo(scene, speciesId) {
return !!scene.cache.video?.exists(advisorVideoKey(speciesId));
}
// Frame helpers — the one place that knows how the sheets are indexed. // Frame helpers — the one place that knows how the sheets are indexed.
export const shipFrame = (rules, speciesId, hullId) => { export const shipFrame = (rules, speciesId, hullId) => {
const s = rules.species[speciesId]; const s = rules.species[speciesId];

View File

@ -0,0 +1,795 @@
// Master of Vega — the Colonies screen: manage every colony in the empire
// at once, rather than one at a time through VegaColonyView.js.
//
// Layout, left to right:
//
// the advisor — a looping muted 1:1 video of the species' Colonial
// Advisor, JIT-loaded exactly like
// VegaResearchScreen.js's research clips
// the terminal — a green phosphor retro-terminal text box below the
// advisor's video, where they comment on whichever
// colony is selected
// the spreadsheet — every colony the player owns, grouped by star,
// star groups ordered by that star's total
// population (highest first)
//
// Selecting a row updates the terminal via VegaLogic.js's
// advisorColonyReport(), which reuses the same recommendColonyFocus/
// recommendAllocationFocus judgment the single-colony flyouts already make.
// Each row also carries inline Colony Focus / Allocation Focus pills — click
// one to open a small dropdown of every option, the advisor's recommended
// one outlined in yellow, picking one applies it via the shared
// applyColonyFocus/applyAllocationFocus setters (same side effects
// VegaColonyView.js's flyouts apply) — a Manage button that hands off to the
// full single-colony view for slider/build-queue editing, and a send icon
// that arms arm-and-target population transfer — the same "select, then
// click a destination" idiom VegaSidePanel.js already uses for fleet orders.
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from './VegaButton.js';
import { Tooltip } from '../../ui/Tooltip.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { FONT, D, ORBIT, uiClick } from './VegaScreens.js';
import {
makeSpeciesPortrait, sourceWidth, advisorVideoKey, hasAdvisorVideo, planetFrame,
} from './VegaArt.js';
import {
scrollColumn, frame, openColonyView, itemName, etaText,
COLONY_FOCUS_OPTIONS, ALLOCATION_FOCUS_OPTIONS,
} from './VegaColonyView.js';
import {
empireColonies, empireColoniesByStar, colonyMaxPop, colonyFactoryCap, effectiveFactories,
colonyDefenseCap, colonyTrade, collapseQueue, queueEtas, CHANNELS,
applyColonyFocus, applyAllocationFocus, advisorColonyReport,
recommendColonyFocus, recommendAllocationFocus,
sendPopulation, maxSendablePopulation,
} from './VegaLogic.js';
const ACCENT = 0x6fc4ff;
const PANEL = 0x0b1220;
const PAD = 22;
const LEFT_X = 24;
const LEFT_W = 460;
const CONTENT_TOP = 108;
const VIDEO_GAP = 20;
const RIGHT_X = LEFT_X + LEFT_W + 24;
const RIGHT_W = GAME_WIDTH - RIGHT_X - 24;
const RIGHT_H = GAME_HEIGHT - CONTENT_TOP - 24;
const ROW_H = 60;
const ROW_GAP = 8;
const ROW_ICON = 40;
const ROW_ICON_PAD = 8;
const ROW_TEXT_X = ROW_ICON_PAD + ROW_ICON + 10; // where name/star text starts, past the icon
// ---------------------------------------------------------------------------
// Advisor video — JIT-loaded, one-for-one with VegaResearchScreen.js's
// warmBytes/ensureOneResearchVideo pair, minus the mood dimension: the
// Colonies screen only ever shows the human player's own species.
const ADVISOR_VIDEO_SRC_PX = 512;
function advisorVideoPath(scene, speciesId) {
return scene.cache.json.get('mastervega-artwork')?.advisorVideos?.[speciesId]?.path ?? null;
}
const warmed = new WeakMap();
function warmBytes(scene, key, path) {
let store = warmed.get(scene);
if (!store) { store = new Map(); warmed.set(scene, store); }
if (store.has(key)) return;
try {
const el = document.createElement('video');
el.preload = 'auto';
el.muted = true;
el.setAttribute('playsinline', 'playsinline');
el.src = path;
el.load();
store.set(key, el);
} catch (err) { /* prefetch is an optimisation, never a requirement */ }
}
const inFlight = new WeakMap();
function pendingFor(scene) {
let set = inFlight.get(scene);
if (!set) { set = new Set(); inFlight.set(scene, set); }
return set;
}
function ensureOneAdvisorVideo(scene, speciesId, onDone) {
const key = advisorVideoKey(speciesId);
const path = advisorVideoPath(scene, speciesId);
if (path) warmBytes(scene, key, path);
if (hasAdvisorVideo(scene, speciesId) || !path) { onDone?.(); return; }
const pending = pendingFor(scene);
const doneEvent = `filecomplete-video-${key}`;
if (onDone) {
let onLoad;
let onError;
const settle = () => {
scene.load.off(doneEvent, onLoad);
scene.load.off(Phaser.Loader.Events.FILE_LOAD_ERROR, onError);
onDone();
};
onLoad = () => settle();
onError = (file) => { if (file?.key === key) settle(); };
scene.load.once(doneEvent, onLoad);
scene.load.on(Phaser.Loader.Events.FILE_LOAD_ERROR, onError);
}
if (pending.has(key)) return;
pending.add(key);
let clearDone;
let clearError;
const clear = () => {
scene.load.off(doneEvent, clearDone);
scene.load.off(Phaser.Loader.Events.FILE_LOAD_ERROR, clearError);
pending.delete(key);
};
clearDone = () => clear();
clearError = (file) => { if (file?.key === key) clear(); };
scene.load.once(doneEvent, clearDone);
scene.load.on(Phaser.Loader.Events.FILE_LOAD_ERROR, clearError);
scene.load.video(key, path, true); // noAudio: true — ambient loop, silent
if (!scene.load.isLoading()) scene.load.start();
}
/** 1:1 advisor portrait — video if recorded, else the usual still/frame ladder. */
function buildAdvisorVideoPanel(scene, rules, art, speciesId, size) {
const container = scene.add.container(0, 0);
const half = size / 2;
function showFallback() {
container.removeAll(true);
container.add(makeSpeciesPortrait(scene, rules, art, speciesId, half, half, size));
}
function showVideo() {
container.removeAll(true);
const v = scene.add.video(half, half, advisorVideoKey(speciesId));
v.setMute(true);
v.setLoop(true);
const fit = () => v.setScale(size / sourceWidth(v, ADVISOR_VIDEO_SRC_PX));
fit();
v.on('created', fit);
v.on('playing', fit);
v.play(true);
v.once('error', () => { if (v.scene) showFallback(); });
container.add(v);
}
showFallback();
ensureOneAdvisorVideo(scene, speciesId, () => {
if (!container.scene) return; // screen closed before the fetch landed
if (hasAdvisorVideo(scene, speciesId)) showVideo();
});
return container;
}
// ---------------------------------------------------------------------------
// The retro terminal panel — a deliberate one-off style break from this
// game's normal clean sci-fi HUD, confined to this one box. Not promoted to
// src/ui/: same "grow it out only when a second consumer needs it" call
// already made for scrollColumn (VegaColonyView.js).
// Thick, bright bands (not a subtle 2px hairline) so the scanlines read as a
// real screen surface. Drawn as a FIXED overlay — a sibling of the scrolled
// text column, added last so it paints on top and visibly streaks over the
// glyphs, but never masked or scrolled with the content underneath it.
function scanTextureKey(size) { return `vega-advisor-scan2-${size}`; }
function ensureScanTexture(scene, size) {
const key = scanTextureKey(size);
if (scene.textures.exists(key)) return key;
const g = scene.make.graphics({ x: 0, y: 0 }, false);
g.fillStyle(0x39ff14, 0.34);
g.fillRect(0, 0, size, 5);
g.generateTexture(key, size, 10);
g.destroy();
return key;
}
const CURSOR_CHAR = '█';
// `x, y` are ABSOLUTE screen coordinates, not local ones, and `container`
// stays parked at world (0, 0) forever — same convention VegaColonyView.js's
// panelLayer/flyLayer use and for the same reason: scrollColumn's scroll
// mask is a Graphics with its own world transform, painted once from the
// x/y passed to it. It does not follow a moving parent, so a container built
// at local (0,0) and repositioned afterward (as this one originally was)
// clips its own content to the wrong part of the screen — invisible, not
// just faint. Building everything here in absolute coordinates sidesteps
// that instead of trying to keep a mask in sync with a container that moves.
function createAdvisorTerminal(scene, x, y, size) {
const container = scene.add.container(0, 0);
const inner = size - PAD * 2;
container.add(scene.add.rectangle(x, y, size, size, 0x02120a, 0.96)
.setOrigin(0, 0).setStrokeStyle(1.5, 0x39ff14, 0.4));
// --- scrollable text column, so a long report doesn't run off the box
const termScroll = scrollColumn(scene, container, x + PAD, y + PAD, inner, inner);
const text = scene.add.text(0, 0, '', {
fontFamily: 'monospace', fontSize: '16px', color: '#39ff14', lineSpacing: 8,
wordWrap: { width: inner },
});
termScroll.content.add(text);
let ghost = null;
// --- scanline overlay, added AFTER the scroll column so it sits on top
const scanKey = ensureScanTexture(scene, size);
const scan = scene.add.tileSprite(x + size / 2, y + size / 2, size - 6, size - 6, scanKey).setAlpha(0.55);
scan.setBlendMode(Phaser.BlendModes.ADD);
container.add(scan);
const scanTween = scene.tweens.add({
targets: scan, tilePositionY: size, duration: 5000, repeat: -1, ease: 'Linear',
});
function glitch() {
if (!container.scene) return;
scan.setAlpha(1);
scene.tweens.add({ targets: scan, alpha: 0.55, duration: 260 });
const shiftedX = Phaser.Math.Between(-4, 4);
text.x = shiftedX;
scene.time.delayedCall(90, () => { if (text.scene) text.x = 0; });
ghost?.destroy();
ghost = scene.add.text(2, 1, text.text, {
fontFamily: 'monospace', fontSize: '16px', color: '#9dffb0', lineSpacing: 8,
wordWrap: { width: inner },
}).setAlpha(0.22);
ghost.setBlendMode(Phaser.BlendModes.ADD);
termScroll.content.add(ghost);
scene.time.delayedCall(140, () => { ghost?.destroy(); ghost = null; });
}
let glitchTimer = null;
function scheduleGlitch() {
glitchTimer = scene.time.delayedCall(Phaser.Math.Between(2600, 5200), () => {
glitch();
scheduleGlitch();
});
}
scheduleGlitch();
// --- typing animation + a blinking block cursor riding the end of the text
//
// The cursor is appended straight into the string (CURSOR_CHAR, or a plain
// space of the same monospace width while off) rather than positioned as
// a separate object — Phaser's own word-wrap then places it exactly where
// the text flow actually ends, including mid-line, with no per-line metrics
// math to get wrong.
let revealed = '';
let cursorOn = true;
function render(autoFollow) {
text.setText(revealed + (cursorOn ? CURSOR_CHAR : ' '));
termScroll.contentHeight = text.height;
if (autoFollow) termScroll.setOffset(-Math.max(0, termScroll.contentHeight - inner));
}
const blinkTimer = scene.time.addEvent({
delay: 500, loop: true, callback: () => { cursorOn = !cursorOn; render(false); },
});
// A fast reselect must not let a stale typewriter callback stomp fresh
// text — revealToken is compared before every character step, same shape
// as the `container.scene`/`closed` guards used elsewhere in this game to
// protect against stale async callbacks.
let revealToken = 0;
let revealTimer = null;
function setLines(lines) {
revealToken += 1;
const myToken = revealToken;
revealTimer?.remove();
const full = lines.join('\n\n');
revealed = '';
cursorOn = true;
render(true);
let i = 0;
revealTimer = scene.time.addEvent({
delay: 14,
loop: true,
callback: () => {
if (myToken !== revealToken) { revealTimer.remove(); return; }
i += 1;
revealed = full.slice(0, i);
cursorOn = true;
render(true);
if (i >= full.length) revealTimer.remove();
},
});
}
function destroy() {
revealTimer?.remove();
blinkTimer.remove(false);
glitchTimer?.remove(false);
scanTween.remove();
ghost?.destroy();
termScroll.destroy();
container.destroy();
}
return { container, setLines, destroy };
}
// ---------------------------------------------------------------------------
const colonyDisplayName = (state, colony) => {
const star = state.galaxy.stars[colony.starIdx];
return colony.name ?? `${star.name} ${ORBIT[colony.orbit] ?? colony.orbit + 1}`;
};
/**
* @param opts.onChanged fired after every mutation so the map/panel/HUD refresh
* @param opts.onClose fired once, when the screen is dismissed
*/
export function openColoniesScreen(scene, rules, state, e, art, opts = {}) {
const { onChanged = null, onClose = null } = opts;
const emp = state.empires[e];
const root = scene.add.container(0, 0).setDepth(D.colony);
const tooltip = new Tooltip(scene, { depth: D.colony + 5 });
root.add(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x030812, 0.985)
.setOrigin(0, 0).setInteractive());
root.add(scene.add.text(LEFT_X, 32, 'COLONIES', {
fontFamily: FONT, fontSize: '40px', 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 column: advisor video + terminal
const videoPanel = buildAdvisorVideoPanel(scene, rules, art, emp.speciesId, LEFT_W);
videoPanel.setPosition(LEFT_X, CONTENT_TOP);
root.add(videoPanel);
frame(scene, root, LEFT_X, CONTENT_TOP, LEFT_W, LEFT_W, 0);
const termY = CONTENT_TOP + LEFT_W + VIDEO_GAP;
const terminal = createAdvisorTerminal(scene, LEFT_X, termY, LEFT_W);
root.add(terminal.container);
terminal.setLines(["Let's take a look at our colonies. Select a colony to the right."]);
// --- right column: spreadsheet
frame(scene, root, RIGHT_X, CONTENT_TOP, RIGHT_W, RIGHT_H, 0.82);
const bannerText = scene.add.text(RIGHT_X + PAD, CONTENT_TOP + 14, '', {
fontFamily: FONT, fontSize: '15px', color: '#ffd88a',
}).setVisible(false);
root.add(bannerText);
const listX = RIGHT_X + PAD;
const HEADER_Y = CONTENT_TOP + 42;
const listY = HEADER_Y + 32;
const listW = RIGHT_W - PAD * 2;
const listH = RIGHT_H - (listY - CONTENT_TOP) - PAD;
// Column offsets, relative to each row's own x=0. Built additively so the
// Focus/Allocation pills (previously 176px/110px — visibly lopsided) get
// equal, generous width, and the dead gap that used to sit between
// BUILDING and COLONY FOCUS goes to the columns that actually need room
// (colony name, building name) instead of empty space.
const COL_NAME = 0;
const COL_POP = 280;
const COL_FACT = 410;
const COL_BUILD = 540;
const COL_FOCUS = COL_BUILD + 250;
const COL_ALLOC = COL_FOCUS + 195;
const COL_SEND = COL_ALLOC + 195;
const COL_MANAGE = COL_SEND + 44 + 14;
for (const [label, cx] of [
['COLONY', COL_NAME], ['POPULATION', COL_POP], ['FACTORIES', COL_FACT],
['BUILDING', COL_BUILD], ['COLONY FOCUS', COL_FOCUS], ['ALLOCATION FOCUS', COL_ALLOC],
]) {
root.add(scene.add.text(listX + cx, HEADER_Y, label, {
fontFamily: FONT, fontSize: '13px', color: '#6f8aa3',
}));
}
root.add(scene.add.rectangle(listX, listY - 8, listW, 1, ACCENT, 0.3).setOrigin(0, 0));
const scroller = scrollColumn(scene, root, listX, listY, listW, listH);
let selectedId = null;
let armedSourceId = null;
let pickerLayer = null;
let dropdownLayer = null;
let manageOpen = false;
function refreshTerminal() {
const colony = state.colonies.find((c) => c.id === selectedId);
if (!colony) {
terminal.setLines(["Let's take a look at our colonies. Select a colony to the right."]);
return;
}
const lines = [`${colonyDisplayName(state, colony)}`, ...advisorColonyReport(rules, state, colony)];
terminal.setLines(lines);
}
function selectColony(id) {
selectedId = id;
refreshTerminal();
rebuildRows();
}
function refreshBanner() {
if (armedSourceId == null) { bannerText.setVisible(false); return; }
const src = state.colonies.find((c) => c.id === armedSourceId);
bannerText.setText(src
? `Sending population from ${colonyDisplayName(state, src)} — click a destination colony (click it again to cancel).`
: '');
bannerText.setVisible(!!src);
}
function closeAmountPicker() {
pickerLayer?.destroy();
pickerLayer = null;
}
function openAmountPicker(fromId, toId) {
const fromC = state.colonies.find((c) => c.id === fromId);
const toC = state.colonies.find((c) => c.id === toId);
if (!fromC || !toC) return;
closeAmountPicker();
closeDropdown();
let amount = 0;
const AW = 460;
const AH = 300;
const ax = (GAME_WIDTH - AW) / 2;
const ay = (GAME_HEIGHT - AH) / 2;
pickerLayer = scene.add.container(0, 0);
root.add(pickerLayer);
const render = () => {
pickerLayer.removeAll(true);
pickerLayer.add(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x00060e, 0.65)
.setOrigin(0, 0).setInteractive());
frame(scene, pickerLayer, ax, ay, AW, AH, 0.97);
const x = ax + PAD;
const w = AW - PAD * 2;
let y = ay + 24;
pickerLayer.add(scene.add.text(x, y, 'SEND POPULATION', {
fontFamily: FONT, fontSize: '22px', color: '#cfe8ff',
}));
y += 40;
pickerLayer.add(scene.add.text(x, y,
`${colonyDisplayName(state, fromC)}${colonyDisplayName(state, toC)}`, {
fontFamily: FONT, fontSize: '17px', color: '#9fd8ff', wordWrap: { width: w },
}));
y += 44;
const cap = maxSendablePopulation(fromC);
amount = Phaser.Math.Clamp(amount, 0, cap);
pickerLayer.add(scene.add.text(x, y, amount.toFixed(1), {
fontFamily: FONT, fontSize: '30px', color: '#ffd88a',
}));
const setAmount = (v) => { amount = Phaser.Math.Clamp(v, 0, cap); render(); };
stepper(pickerLayer, x + 167, y + 15, '5', () => setAmount(amount - 5), amount > 0, 34, 13);
stepper(pickerLayer, x + 203, y + 15, '', () => setAmount(amount - 1), amount > 0);
stepper(pickerLayer, x + 235, y + 15, '+', () => setAmount(amount + 1), amount < cap);
stepper(pickerLayer, x + 271, y + 15, '+5', () => setAmount(amount + 5), amount < cap, 34, 13);
pickerLayer.add(new Button(scene, x + 331, y + 15, 'MAX', uiClick(scene, () => setAmount(cap)),
{ width: 66, height: 32, fontSize: 14, variant: 'ghost' }));
y += 40;
pickerLayer.add(scene.add.text(x, y, `${cap.toFixed(1)} available to send`, {
fontFamily: FONT, fontSize: '13px', color: '#6f8aa3',
}));
y += 50;
const canSend = amount > 0;
const sendBtn = new Button(scene, ax + AW / 2 - 90, y + 22, 'Send', uiClick(scene, () => {
if (sendPopulation(rules, state, e, fromId, toId, amount)) {
closeAmountPicker();
armedSourceId = null;
refreshBanner();
rebuildRows();
onChanged?.();
}
}), { width: 150, height: 44, fontSize: 18 });
if (!canSend) sendBtn.setEnabled(false);
pickerLayer.add(sendBtn);
pickerLayer.add(new Button(scene, ax + AW / 2 + 90, y + 22, 'Cancel', uiClick(scene, () => {
closeAmountPicker();
armedSourceId = null;
refreshBanner();
rebuildRows();
}), { width: 150, height: 44, fontSize: 18, variant: 'ghost' }));
};
render();
}
// Dismissal is a global 'pointerdown' + geometry check, NOT a full-screen
// invisible "catcher" rectangle sitting underneath the option rows. A
// catcher covering the option rows raced with them: both are interactive
// and overlap, and if the catcher's own pointerup happened to be processed
// first it destroyed dropdownLayer (and the row that was ABOUT to fire)
// before the row's handler ever ran — the dropdown would close, but
// applyColonyFocus/applyAllocationFocus never got called. Checking pointer
// position against the panel's bounds instead means the option rows'
// pointerup handlers are the only thing that ever touch state.
let dropdownDismiss = null;
function closeDropdown() {
if (dropdownDismiss) { scene.input.off('pointerdown', dropdownDismiss); dropdownDismiss = null; }
dropdownLayer?.destroy();
dropdownLayer = null;
}
/**
* Colony Focus / Allocation Focus pills open this instead of blindly
* cycling: every option at once, the advisor's live recommendation
* (recommendColonyFocus/recommendAllocationFocus the same judgment
* VegaColonyView.js's flyouts already show) outlined in yellow, the
* colony's current setting filled. Picking a row applies it through the
* shared applyColonyFocus/applyAllocationFocus setters.
*/
function openFocusDropdown(colony, kind, anchorX, anchorY) {
closeDropdown();
const options = kind === 'colony' ? COLONY_FOCUS_OPTIONS : ALLOCATION_FOCUS_OPTIONS;
const rec = kind === 'colony'
? recommendColonyFocus(rules, state, colony)
: recommendAllocationFocus(rules, state, colony);
const isRec = (opt) => (kind === 'colony' ? opt.value === rec.value : opt.key === rec.key);
const isCurrent = (opt) => (kind === 'colony'
? opt.value === (colony.focus ?? 'manual') : opt.key === colony.advisor?.allocKey);
const subtitle = (opt) => (kind === 'colony' ? opt.desc
: CHANNELS.map((ch) => `${rules.economy.channelNames[ch] ?? ch} ${Math.round(opt.sliders[ch] * 100)}%`)
.join(' · '));
const DW = 380;
const DPAD = 12;
const rowH = 64;
const panelH = DPAD * 2 + options.length * rowH;
const px = Phaser.Math.Clamp(anchorX - DW / 2, 12, GAME_WIDTH - DW - 12);
const py = Phaser.Math.Clamp(anchorY, 12, GAME_HEIGHT - panelH - 12);
dropdownLayer = scene.add.container(0, 0);
root.add(dropdownLayer);
frame(scene, dropdownLayer, px, py, DW, panelH, 0.98);
let ry = py + DPAD;
for (const opt of options) {
const recommended = isRec(opt);
const current = isCurrent(opt);
const row = scene.add.rectangle(px + 6, ry, DW - 12, rowH - 6,
current ? 0x22405f : 0x0f1a2c, current ? 0.9 : 0.7).setOrigin(0, 0)
.setStrokeStyle(recommended ? 2.5 : 1, recommended ? 0xffd88a : ACCENT, recommended ? 1 : 0.3)
.setInteractive({ useHandCursor: true });
dropdownLayer.add(row);
row.on('pointerover', () => row.setFillStyle(current ? 0x22405f : 0x18293f, current ? 0.9 : 0.85));
row.on('pointerout', () => row.setFillStyle(current ? 0x22405f : 0x0f1a2c, current ? 0.9 : 0.7));
row.on('pointerup', () => {
if (kind === 'colony') applyColonyFocus(state, colony, opt.value);
else applyAllocationFocus(state, colony, opt);
onChanged?.();
closeDropdown();
rebuildRows();
if (colony.id === selectedId) refreshTerminal();
});
dropdownLayer.add(scene.add.text(px + 16, ry + 8, opt.label, {
fontFamily: FONT, fontSize: '16px', color: recommended ? '#ffd88a' : '#e8f4ff',
}));
dropdownLayer.add(scene.add.text(px + 16, ry + 32, subtitle(opt), {
fontFamily: FONT, fontSize: '12px', color: '#8fa8c0', wordWrap: { width: DW - 32 },
}));
ry += rowH;
}
const bounds = new Phaser.Geom.Rectangle(px, py, DW, panelH);
dropdownDismiss = (pointer) => { if (!bounds.contains(pointer.x, pointer.y)) closeDropdown(); };
scene.input.on('pointerdown', dropdownDismiss);
}
/** A small guarded pill: rect + centred label, cycling/acting on click. */
function pill(parent, x, y, w, h, label, textColor, bgColor, onClick, opts = {}) {
const { enabled = true, fontSize = 14 } = opts;
const r = scene.add.rectangle(x, y, w, h, enabled ? bgColor : 0x101a29, enabled ? 0.85 : 0.5)
.setOrigin(0, 0).setStrokeStyle(1, ACCENT, enabled ? 0.5 : 0.18);
parent.add(r);
parent.add(scene.add.text(x + w / 2, y + h / 2, label, {
fontFamily: FONT, fontSize: `${fontSize}px`, color: enabled ? textColor : '#3c4c60',
}).setOrigin(0.5));
if (!enabled) return r;
r.setInteractive({ useHandCursor: true });
r.on('pointerover', () => r.setFillStyle(bgColor, 1));
r.on('pointerout', () => r.setFillStyle(bgColor, 0.85));
r.on('pointerup', (p) => { if (scroller.contains(p)) onClick(p); });
return r;
}
/** Same small square push-button style as VegaColonyView.js's pusher(), for the amount picker (not inside a scrollColumn, so no guard). */
function stepper(parent, x, y, label, fn, enabled, w = 26, fontSize = 15) {
const r = scene.add.rectangle(x, y, w, 26, enabled ? 0x16253c : 0x101a29)
.setStrokeStyle(1, ACCENT, enabled ? 0.55 : 0.18);
parent.add(r);
parent.add(scene.add.text(x, y - 1, label, {
fontFamily: FONT, fontSize: `${fontSize}px`, color: enabled ? '#cfe8ff' : '#3c4c60',
}).setOrigin(0.5));
if (!enabled) return;
r.setInteractive({ useHandCursor: true });
r.on('pointerover', () => r.setFillStyle(0x22405f));
r.on('pointerout', () => r.setFillStyle(0x16253c));
r.on('pointerup', () => fn());
}
function openManage(colony) {
closeDropdown();
closeAmountPicker();
manageOpen = true;
root.setVisible(false);
openColonyView(scene, rules, state, colony, art, {
onChanged: () => onChanged?.(),
onClose: () => {
manageOpen = false;
root.setVisible(true);
rebuildRows();
},
});
}
function buildColonyRow(colony, y, w) {
const star = state.galaxy.stars[colony.starIdx];
const planet = star.planets[colony.orbit];
const type = rules.planetTypes[planet.typeId];
const selected = colony.id === selectedId;
const armed = colony.id === armedSourceId;
const bg = scene.add.rectangle(0, y, w, ROW_H, selected ? 0x22405f : 0x0f1a2c,
selected ? 0.85 : 0.55).setOrigin(0, 0)
.setStrokeStyle(1, armed ? 0xffd88a : ACCENT, armed ? 0.85 : (selected ? 0.6 : 0.2))
.setInteractive({ useHandCursor: true });
scroller.content.add(bg);
bg.on('pointerover', () => bg.setFillStyle(selected ? 0x22405f : 0x18293f, selected ? 0.85 : 0.8));
bg.on('pointerout', () => bg.setFillStyle(selected ? 0x22405f : 0x0f1a2c, selected ? 0.85 : 0.55));
bg.on('pointerup', (p) => {
if (!scroller.contains(p)) return;
if (armedSourceId != null) {
if (colony.id === armedSourceId) { armedSourceId = null; refreshBanner(); rebuildRows(); return; }
openAmountPicker(armedSourceId, colony.id);
}
selectColony(colony.id);
});
tooltip.attachTo(bg, () => ({
title: colonyDisplayName(state, colony),
lines: [
{ text: type.name },
{ text: `Trade ${colonyTrade(rules, state, colony).toFixed(1)} BC` },
{
text: `Defences ${Math.round(colony.defenseHp)} / ${colonyDefenseCap(rules, state, colony)}`,
},
colony.waste > 0.5
? { text: `Uncleaned waste ${colony.waste.toFixed(1)}`, color: '#e08a8a' } : null,
].filter(Boolean),
}));
// Small planet-type portrait, same sheet/frame the star map's orrery
// discs and system view use (art.planets + planetFrame), just scaled
// down to row height instead of their native 192px.
scroller.content.add(scene.add.image(COL_NAME + ROW_ICON_PAD + ROW_ICON / 2, y + ROW_H / 2,
art.planets, planetFrame(rules, planet.typeId)).setDisplaySize(ROW_ICON, ROW_ICON));
scroller.content.add(scene.add.text(COL_NAME + ROW_TEXT_X, y + 10, colonyDisplayName(state, colony), {
fontFamily: FONT, fontSize: '18px', color: selected ? '#ffd88a' : '#e8f4ff',
}));
scroller.content.add(scene.add.text(COL_NAME + ROW_TEXT_X, y + 34,
`${star.name}${colony.capital ? ' · capital' : ''}`, {
fontFamily: FONT, fontSize: '13px', color: '#7f97b3',
}));
const maxPop = colonyMaxPop(rules, state, colony);
const popPct = colony.pop / Math.max(1, maxPop);
scroller.content.add(scene.add.text(COL_POP, y + 20, `${colony.pop.toFixed(1)} / ${maxPop}`, {
fontFamily: FONT, fontSize: '16px',
color: popPct < 0.5 ? '#e08a8a' : (popPct < 0.7 ? '#e0b08a' : '#c8dcf0'),
}));
const factoryCap = colonyFactoryCap(rules, state, colony);
const effF = effectiveFactories(rules, state, colony);
scroller.content.add(scene.add.text(COL_FACT, y + 20, `${Math.floor(effF)} / ${factoryCap}`, {
fontFamily: FONT, fontSize: '16px',
color: effF < factoryCap * 0.5 ? '#e08a8a' : (effF < factoryCap * 0.85 ? '#e0b08a' : '#c8dcf0'),
}));
const rows = collapseQueue(colony);
if (!rows.length) {
scroller.content.add(scene.add.text(COL_BUILD, y + 20, 'Idle', {
fontFamily: FONT, fontSize: '15px', color: '#6f8aa3',
}));
} else {
const etas = queueEtas(rules, state, colony);
const head = rows[0];
scroller.content.add(scene.add.text(COL_BUILD, y + 12,
`${itemName(rules, state, colony, head.item)}${head.count > 1 ? ` x ${head.count}` : ''}`, {
fontFamily: FONT, fontSize: '15px', color: '#ffd88a', wordWrap: { width: COL_FOCUS - COL_BUILD - 12 },
}));
scroller.content.add(scene.add.text(COL_BUILD, y + 34, etaText(etas[head.lastIndex]), {
fontFamily: FONT, fontSize: '13px', color: '#8fa8c0',
}));
}
const focusOpt = COLONY_FOCUS_OPTIONS.find((o) => o.value === (colony.focus ?? 'manual'))
?? COLONY_FOCUS_OPTIONS[0];
pill(scroller.content, COL_FOCUS, y + 10, COL_ALLOC - COL_FOCUS - 14, 40, focusOpt.label,
'#e8f4ff', 0x16253c, (p) => openFocusDropdown(colony, 'colony', p.x, p.y), { fontSize: 14 });
const allocOpt = ALLOCATION_FOCUS_OPTIONS.find((o) => o.key === colony.advisor?.allocKey)
?? ALLOCATION_FOCUS_OPTIONS[0];
pill(scroller.content, COL_ALLOC, y + 10, COL_SEND - COL_ALLOC - 14, 40, allocOpt.label,
'#e8f4ff', 0x16253c, (p) => openFocusDropdown(colony, 'alloc', p.x, p.y), { fontSize: 14 });
const canSend = maxSendablePopulation(colony) > 0 && empireColonies(state, e).length > 1;
pill(scroller.content, COL_SEND, y + 10, 44, 40, '⇄', armed ? '#0b1220' : '#cfe8ff',
armed ? 0xffd88a : 0x16253c, () => {
armedSourceId = armed ? null : colony.id;
refreshBanner();
rebuildRows();
}, { enabled: canSend, fontSize: 18 });
pill(scroller.content, COL_MANAGE, y + 10, 100, 40, 'Manage', '#cfe8ff', 0x16253c,
() => openManage(colony));
return y + ROW_H + ROW_GAP;
}
function rebuildRows() {
const keep = scroller.offset;
scroller.content.removeAll(true);
let y = 0;
const groups = empireColoniesByStar(state, e);
if (!groups.length) {
scroller.content.add(scene.add.text(0, 0, 'No colonies yet.', {
fontFamily: FONT, fontSize: '17px', color: '#6f8aa3',
}));
y = 30;
}
for (const g of groups) {
const star = state.galaxy.stars[g.starIdx];
scroller.content.add(scene.add.text(0, y,
`${star.name.toUpperCase()} · ${g.colonies.length} colon${g.colonies.length === 1 ? 'y' : 'ies'}`
+ ` · ${Math.round(g.totalPop)} population`, {
fontFamily: FONT, fontSize: '15px', color: '#6f8aa3',
}));
y += 22;
scroller.content.add(scene.add.rectangle(0, y, listW, 1, ACCENT, 0.22).setOrigin(0, 0));
y += 10;
for (const colony of g.colonies) y = buildColonyRow(colony, y, listW);
y += 14;
}
scroller.contentHeight = y;
scroller.setOffset(keep);
}
// ------------------------------------------------------------------ close
let closed = false;
function close() {
if (closed) return;
closed = true;
closeAmountPicker();
closeDropdown();
scroller.destroy();
terminal.destroy();
tooltip.destroy();
scene.input.keyboard?.off('keydown-ESC', onEsc);
root.destroy();
onClose?.();
}
function onEsc() {
if (manageOpen) return; // VegaColonyView owns ESC while it's on top
if (dropdownLayer) { closeDropdown(); return; }
if (pickerLayer) { closeAmountPicker(); return; }
if (armedSourceId != null) { armedSourceId = null; refreshBanner(); rebuildRows(); return; }
close();
}
scene.input.keyboard?.on('keydown-ESC', onEsc);
rebuildRows();
return { root, close };
}

View File

@ -46,7 +46,7 @@ import {
colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany, colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany,
dequeue, collapseQueue, moveQueueRun, queueItemCost, queueEtas, empireDesign, dequeue, collapseQueue, moveQueueRun, queueItemCost, queueEtas, empireDesign,
empireColonies, maxSendablePopulation, sendPopulation, etaTo, empireColonies, maxSendablePopulation, sendPopulation, etaTo,
recommendColonyFocus, recommendAllocationFocus, recommendColonyFocus, recommendAllocationFocus, applyColonyFocus, applyAllocationFocus,
} from './VegaLogic.js'; } from './VegaLogic.js';
const ACCENT = 0x6fc4ff; const ACCENT = 0x6fc4ff;
@ -78,7 +78,7 @@ export const CHANNEL_COLOUR = {
// Colony Focus — see autoQueueColonies (VegaLogic.js) for what each value // Colony Focus — see autoQueueColonies (VegaLogic.js) for what each value
// actually does; this is just the picker's label/description copy. // actually does; this is just the picker's label/description copy.
const COLONY_FOCUS_OPTIONS = [ export const COLONY_FOCUS_OPTIONS = [
{ value: 'manual', label: 'Manual', desc: 'No automation — you queue everything yourself.' }, { value: 'manual', label: 'Manual', desc: 'No automation — you queue everything yourself.' },
{ value: 'improvement', label: 'Colony Improvement', desc: 'Industry buildings first, then any other building not yet built.' }, { value: 'improvement', label: 'Colony Improvement', desc: 'Industry buildings first, then any other building not yet built.' },
{ value: 'research', label: 'Research Focus', desc: 'Only queues research buildings. Once none are left, the queue stays empty and construction spills into research.' }, { value: 'research', label: 'Research Focus', desc: 'Only queues research buildings. Once none are left, the queue stays empty and construction spills into research.' },
@ -90,7 +90,7 @@ const COLONY_FOCUS_OPTIONS = [
// Allocation Focus — one-time slider presets (see buildAllocationFocusFlyout). // Allocation Focus — one-time slider presets (see buildAllocationFocusFlyout).
// Every row sums to 1.0. // Every row sums to 1.0.
const ALLOCATION_FOCUS_OPTIONS = [ export const ALLOCATION_FOCUS_OPTIONS = [
{ key: 'default', label: 'Default', sliders: { ships: 0.20, defense: 0.10, industry: 0.40, ecology: 0.10, research: 0.20 } }, { key: 'default', label: 'Default', sliders: { ships: 0.20, defense: 0.10, industry: 0.40, ecology: 0.10, research: 0.20 } },
{ key: 'research', label: 'Research Focus', sliders: { ships: 0.10, defense: 0.05, industry: 0.20, ecology: 0.15, research: 0.50 } }, { key: 'research', label: 'Research Focus', sliders: { ships: 0.10, defense: 0.05, industry: 0.20, ecology: 0.15, research: 0.50 } },
{ key: 'growth', label: 'Population Growth', sliders: { ships: 0.15, defense: 0.10, industry: 0.30, ecology: 0.35, research: 0.10 } }, { key: 'growth', label: 'Population Growth', sliders: { ships: 0.15, defense: 0.10, industry: 0.30, ecology: 0.35, research: 0.10 } },
@ -809,8 +809,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
const row = scene.add.rectangle(x, y, w, rowH - 8, selected ? 0x22405f : 0x0f1a2c, selected ? 0.9 : 0.5) const row = scene.add.rectangle(x, y, w, rowH - 8, selected ? 0x22405f : 0x0f1a2c, selected ? 0.9 : 0.5)
.setOrigin(0, 0).setStrokeStyle(1, ACCENT, selected ? 0.7 : 0.2).setInteractive({ useHandCursor: true }); .setOrigin(0, 0).setStrokeStyle(1, ACCENT, selected ? 0.7 : 0.2).setInteractive({ useHandCursor: true });
row.on('pointerup', () => { row.on('pointerup', () => {
colony.focus = opt.value; applyColonyFocus(state, colony, opt.value);
colony.advisor.focusQuietUntil = state.turn + 15;
buildColonyFocusFlyout(); buildColonyFocusFlyout();
}); });
flyLayer.add(row); flyLayer.add(row);
@ -865,9 +864,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
const row = scene.add.rectangle(x, y, w, rowH - 8, matches ? 0x22405f : 0x0f1a2c, matches ? 0.9 : 0.5) const row = scene.add.rectangle(x, y, w, rowH - 8, matches ? 0x22405f : 0x0f1a2c, matches ? 0.9 : 0.5)
.setOrigin(0, 0).setStrokeStyle(1, ACCENT, matches ? 0.7 : 0.2).setInteractive({ useHandCursor: true }); .setOrigin(0, 0).setStrokeStyle(1, ACCENT, matches ? 0.7 : 0.2).setInteractive({ useHandCursor: true });
row.on('pointerup', () => { row.on('pointerup', () => {
colony.sliders = { ...opt.sliders }; applyAllocationFocus(state, colony, opt);
colony.advisor.allocKey = opt.key;
colony.advisor.allocQuietUntil = state.turn + 15;
onChanged?.(); onChanged?.();
buildPanel(); // redraws the five sliders with their new values buildPanel(); // redraws the five sliders with their new values
buildAllocationFocusFlyout(); // refreshes which row is highlighted buildAllocationFocusFlyout(); // refreshes which row is highlighted

View File

@ -53,6 +53,26 @@ export const empireColonies = (state, e) => state.colonies.filter((c) => c.empir
export const empireFleets = (state, e) => state.fleets.filter((f) => f.empireIdx === e); export const empireFleets = (state, e) => state.fleets.filter((f) => f.empireIdx === e);
export const colonyAt = (state, starIdx) => state.colonies.find((c) => c.starIdx === starIdx); export const colonyAt = (state, starIdx) => state.colonies.find((c) => c.starIdx === starIdx);
export const coloniesAt = (state, starIdx) => state.colonies.filter((c) => c.starIdx === starIdx); export const coloniesAt = (state, starIdx) => state.colonies.filter((c) => c.starIdx === starIdx);
/**
* Colonies grouped by star for one empire, star groups ordered by that
* star's total population (highest first) the shape the Colonies
* screen's spreadsheet (VegaColoniesScreen.js) needs. Colonies within a
* group are also ordered by population, highest first.
*/
export function empireColoniesByStar(state, e) {
const groups = new Map();
for (const colony of empireColonies(state, e)) {
let g = groups.get(colony.starIdx);
if (!g) { g = { starIdx: colony.starIdx, colonies: [], totalPop: 0 }; groups.set(colony.starIdx, g); }
g.colonies.push(colony);
g.totalPop += colony.pop;
}
const out = [...groups.values()];
out.forEach((g) => g.colonies.sort((a, b) => b.pop - a.pop));
out.sort((a, b) => b.totalPop - a.totalPop);
return out;
}
export const fleetsAt = (state, starIdx) => state.fleets.filter((f) => f.starIdx === starIdx); export const fleetsAt = (state, starIdx) => state.fleets.filter((f) => f.starIdx === starIdx);
export const starOf = (state, i) => state.galaxy.stars[i]; export const starOf = (state, i) => state.galaxy.stars[i];
export const planetOf = (state, colony) => state.galaxy.stars[colony.starIdx].planets[colony.orbit]; export const planetOf = (state, colony) => state.galaxy.stars[colony.starIdx].planets[colony.orbit];
@ -2231,6 +2251,75 @@ export function recommendAllocationFocus(rules, state, colony) {
}; };
} }
/**
* Set a colony's Colony Focus autopilot and arm the advisor's quiet timer
* shared by VegaColonyView.js's flyout and VegaColoniesScreen.js's inline
* pill so both apply exactly the same side effect rather than two copies of
* it drifting apart.
*/
export function applyColonyFocus(state, colony, focusValue) {
colony.focus = focusValue;
colony.advisor.focusQuietUntil = state.turn + 15;
}
/**
* Overwrite a colony's allocation sliders with a preset and arm the
* advisor's quiet timer. A deliberate bulk overwrite that BYPASSES
* colony.locked (unlike setSlider) `opt` is one of VegaColonyView.js's
* ALLOCATION_FOCUS_OPTIONS, `{ key, sliders }`.
*/
export function applyAllocationFocus(state, colony, opt) {
colony.sliders = { ...opt.sliders };
colony.advisor.allocKey = opt.key;
colony.advisor.allocQuietUntil = state.turn + 15;
}
/**
* The Colonies screen's (VegaColoniesScreen.js) advisor commentary for one
* colony: the same recommended Colony Focus / Allocation Focus judgment the
* single-colony flyouts already make, plus explicit deficiency call-outs,
* as an ordered array of short lines for the terminal panel to type out.
* Pure never mutates.
*/
export function advisorColonyReport(rules, state, colony) {
const maxPop = colonyMaxPop(rules, state, colony);
const factoryCap = colonyFactoryCap(rules, state, colony);
const effF = effectiveFactories(rules, state, colony);
const defenseCap = colonyDefenseCap(rules, state, colony);
const lines = [];
const focus = recommendColonyFocus(rules, state, colony);
lines.push(`COLONY FOCUS: ${focus.label}`);
lines.push(focus.reason);
const alloc = recommendAllocationFocus(rules, state, colony);
lines.push(`ALLOCATION FOCUS: ${alloc.label}`);
lines.push(alloc.reason);
const deficiencies = [];
const popPct = Math.round((colony.pop / Math.max(1, maxPop)) * 100);
if (popPct < 70) {
deficiencies.push(`Population is at ${popPct}% of what this world can support.`);
}
if (effF < factoryCap * 0.7) {
deficiencies.push(`Factories are under-built: ${Math.floor(effF)} of ${factoryCap}.`);
}
if (colony.factories > effF + 0.5) {
deficiencies.push(`${Math.floor(colony.factories - effF)} factories sit mothballed — too few people to staff them.`);
}
if (colony.waste > 3) {
deficiencies.push(`Uncleaned waste (${colony.waste.toFixed(1)}) is capping population growth.`);
}
if (colony.defenseHp < defenseCap * 0.5) {
deficiencies.push(`Defences are thin: ${Math.round(colony.defenseHp)} of ${defenseCap}.`);
}
if (deficiencies.length) {
lines.push('DEFICIENCIES:');
lines.push(...deficiencies);
}
return lines;
}
/** /**
* Once-a-turn re-analysis. Only touches a colony whose advisor tracking has * Once-a-turn re-analysis. Only touches a colony whose advisor tracking has
* been armed (a Quiet* field is non-null, meaning the player picked * been armed (a Quiet* field is non-null, meaning the player picked

View File

@ -272,6 +272,26 @@ export default class VegaSidePanel {
r.on('pointerup', fn); r.on('pointerup', fn);
} }
/**
* Same footprint as tinyButton, but round and tinted red "drop this
* whole stack" rather than "nudge the count by one", so it needs to read
* as a different, more drastic kind of button at a glance.
*/
tinyCircleButton(x, y, size, label, fn, enabled = true) {
const r = this.scene.add.circle(x, y, size / 2, enabled ? 0x16253c : 0x101a29)
.setStrokeStyle(1, enabled ? 0xe08a8a : 0x3c4c60, enabled ? 0.7 : 0.18);
const t = this.scene.add.text(x, y - 1, label, {
fontFamily: FONT, fontSize: '15px', color: enabled ? '#e08a8a' : '#3c4c60',
}).setOrigin(0.5);
this.body.add(r);
this.body.add(t);
if (!enabled) return;
r.setInteractive({ useHandCursor: true });
r.on('pointerover', () => r.setFillStyle(0x3a1f28));
r.on('pointerout', () => r.setFillStyle(0x16253c));
r.on('pointerup', fn);
}
/** Full-width action button parked at the bottom of the column. */ /** Full-width action button parked at the bottom of the column. */
action(label, fn, opts = {}) { action(label, fn, opts = {}) {
const { enabled = true, width = COL, x = PAD + COL / 2, variant = 'solid' } = opts; const { enabled = true, width = COL, x = PAD + COL / 2, variant = 'solid' } = opts;
@ -637,6 +657,8 @@ export default class VegaSidePanel {
s.count < s.max); s.count < s.max);
this.tinyButton(right - boxSize * 2.9, ctrlY, boxSize, '', () => set(s.count - 1), this.tinyButton(right - boxSize * 2.9, ctrlY, boxSize, '', () => set(s.count - 1),
s.count > 0); s.count > 0);
this.tinyCircleButton(right - boxSize * 4.3, ctrlY, boxSize, '✕', () => set(0),
s.count > 0);
const count = this.scene.add.text(right - boxSize * 1.7, ctrlY - 1, `${s.count} / ${s.max}`, { const count = this.scene.add.text(right - boxSize * 1.7, ctrlY - 1, `${s.count} / ${s.max}`, {
fontFamily: FONT, fontSize: '15px', color: s.count > 0 ? '#e8f4ff' : '#5d7085', fontFamily: FONT, fontSize: '15px', color: s.count > 0 ? '#e8f4ff' : '#5d7085',
}).setOrigin(0.5); }).setOrigin(0.5);

View File

@ -42,7 +42,7 @@ import {
import { import {
ensureSheets, shipFrame, planetFrame, techFrame, buildingFrame, ensureSheets, shipFrame, planetFrame, techFrame, buildingFrame,
speciesVideoKey, speciesStillKey, speciesSpeechClip, UI_SPEECH, worldBgKey, speciesVideoKey, speciesStillKey, speciesSpeechClip, UI_SPEECH, worldBgKey,
colonyVideoKey, audienceVideoKey, sourceWidth, colonyVideoKey, audienceVideoKey, sourceWidth, advisorVideoKey,
} from '../src/games/mastervega/VegaArt.js'; } from '../src/games/mastervega/VegaArt.js';
import { CHAT } from '../src/games/mastervega/VegaChat.js'; import { CHAT } from '../src/games/mastervega/VegaChat.js';
// Turn-report classification is Phaser-free, so what the "New Turn" popup will // Turn-report classification is Phaser-free, so what the "New Turn" popup will
@ -663,6 +663,29 @@ section('2. Procedural art');
console.log(` (${colClips}/${colonisableTotal} colony-founding clips recorded; ` console.log(` (${colClips}/${colonisableTotal} colony-founding clips recorded; `
+ 'the rest fall back to the world backdrop)'); + 'the rest fall back to the world backdrop)');
// Colonies-screen advisor loops (VegaColoniesScreen.js), one per species.
// Declared-or-null like shipVideos/audienceVideos: a gap is legal (falls
// back to the species portrait), but a key that does not match
// advisorVideoKey() loads a video the screen will never find.
const advisorVids = artJson.advisorVideos ?? {};
const advisorVidIds = Object.keys(advisorVids).filter((k) => !k.startsWith('_'));
for (const id of advisorVidIds) {
check(`advisorVideos entry ${id} is a known species`, !!RULES.species[id]);
check(`advisorVideos ${id} key matches the loader's key`,
advisorVids[id]?.key === advisorVideoKey(id), `${advisorVids[id]?.key} vs ${advisorVideoKey(id)}`);
if (advisorVids[id]?.path) {
check(`advisorVideos ${id} file exists`, existsSync(join(root, advisorVids[id].path)),
advisorVids[id].path);
check(`advisorVideos ${id} is an mp4`, advisorVids[id].path.endsWith('.mp4'));
}
}
for (const s of RULES.speciesList) {
check(`species ${s.id} has an advisorVideos entry (possibly null)`, advisorVidIds.includes(s.id));
}
const advisorClips = advisorVidIds.filter((id) => advisorVids[id]?.path).length;
console.log(` (${advisorClips}/${RULES.speciesList.length} `
+ 'colonial advisor clips recorded; the rest fall back to the species portrait)');
// …and they must stay OUT of the eager manifest. The whole set is ~19 MB for // …and they must stay OUT of the eager manifest. The whole set is ~19 MB for
// clips a game barely touches, so VegaColonyIntro.ensureColonyVideo() fetches // clips a game barely touches, so VegaColonyIntro.ensureColonyVideo() fetches
// one on demand. Resolving the block here again would quietly put all of it // one on demand. Resolving the block here again would quietly put all of it
@ -682,6 +705,12 @@ section('2. Procedural art');
const eagerAudience = eager.filter((d) => d.type === 'video' && d.key.startsWith('vega-audience-')); const eagerAudience = eager.filter((d) => d.type === 'video' && d.key.startsWith('vega-audience-'));
check('audience mood clips are not eager-loaded', eagerAudience.length === 0, check('audience mood clips are not eager-loaded', eagerAudience.length === 0,
eagerAudience.map((d) => d.key).join(' ')); eagerAudience.map((d) => d.key).join(' '));
// Same reasoning again: the Colonies screen only ever needs the human
// player's own species, so the other nine advisor clips (once recorded)
// must not ride the game-room load either.
const eagerAdvisor = eager.filter((d) => d.type === 'video' && d.key.startsWith('vega-advisor-'));
check('colonial advisor clips are not eager-loaded', eagerAdvisor.length === 0,
eagerAdvisor.map((d) => d.key).join(' '));
// The cue is the opposite case: small, and it has to be ready the instant // The cue is the opposite case: small, and it has to be ready the instant
// the vignette opens. // the vignette opens.
check('the colony-founding cue IS eager-loaded', check('the colony-founding cue IS eager-loaded',
@ -1842,6 +1871,69 @@ section('6. Colony economy');
const desc = describeEvent(RULES, st2, fired[fired.length - 1]); const desc = describeEvent(RULES, st2, fired[fired.length - 1]);
check('advisorRecommendation renders a headline naming the colony', check('advisorRecommendation renders a headline naming the colony',
desc.headline.includes(c.name ?? ''), desc.headline); desc.headline.includes(c.name ?? ''), desc.headline);
// empireColoniesByStar — the grouping VegaColoniesScreen.js's
// spreadsheet needs: one group per star this empire holds, ordered by
// that star's total population (highest first). A minimal synthetic
// second colony is enough — the function only reads empireIdx/starIdx/pop.
{
const otherStar = st2.galaxy.stars.findIndex((s, i) => i !== c.starIdx
&& s.planets.some((p) => RULES.planetTypes[p.typeId]?.colonizable));
check('a second colonisable star exists for the grouping test', otherStar >= 0);
if (otherStar >= 0) {
st2.colonies.push({ id: -1, empireIdx: c.empireIdx, starIdx: otherStar, pop: c.pop + 50 });
const groups = Logic.empireColoniesByStar(st2, c.empireIdx);
check('empireColoniesByStar returns one group per distinct star',
groups.length === new Set(Logic.empireColonies(st2, c.empireIdx).map((cc) => cc.starIdx)).size);
check('empireColoniesByStar orders star groups by total population, highest first',
groups.every((g, i) => i === 0 || groups[i - 1].totalPop >= g.totalPop));
check("empireColoniesByStar only includes this empire's colonies",
groups.every((g) => g.colonies.every((cc) => cc.empireIdx === c.empireIdx)));
st2.colonies.pop();
}
}
// applyColonyFocus / applyAllocationFocus — the shared setters
// VegaColonyView.js's flyouts and VegaColoniesScreen.js's inline pills
// both call, so a fix to one path fixes the other by construction.
{
c.advisor.focusQuietUntil = null;
Logic.applyColonyFocus(st2, c, 'trade');
check('applyColonyFocus sets the focus value', c.focus === 'trade');
check('applyColonyFocus arms the advisor quiet timer', c.advisor.focusQuietUntil === st2.turn + 15);
c.advisor.allocQuietUntil = null;
c.advisor.allocKey = null;
const opt = {
key: 'production',
sliders: {
ships: 0.2, defense: 0.05, industry: 0.55, ecology: 0.1, research: 0.1,
},
};
Logic.applyAllocationFocus(st2, c, opt);
check('applyAllocationFocus overwrites the sliders',
Object.keys(opt.sliders).every((ch) => Math.abs(c.sliders[ch] - opt.sliders[ch]) < 1e-9));
check('applyAllocationFocus records the preset key', c.advisor.allocKey === 'production');
check('applyAllocationFocus arms the advisor quiet timer', c.advisor.allocQuietUntil === st2.turn + 15);
}
// advisorColonyReport — combines both recommendations plus deficiency
// call-outs into the lines VegaColoniesScreen.js's terminal types out.
{
const lines = Logic.advisorColonyReport(RULES, st2, c);
check('advisorColonyReport returns a non-empty array of strings',
Array.isArray(lines) && lines.length > 0 && lines.every((l) => typeof l === 'string'));
check('advisorColonyReport mentions the recommended Colony Focus label',
lines.some((l) => l.includes(Logic.recommendColonyFocus(RULES, st2, c).label)));
check('advisorColonyReport mentions the recommended Allocation Focus label',
lines.some((l) => l.includes(Logic.recommendAllocationFocus(RULES, st2, c).label)));
const savedWaste = c.waste;
c.waste = 10;
const dirtyLines = Logic.advisorColonyReport(RULES, st2, c);
check('advisorColonyReport calls out uncleaned waste as a deficiency',
dirtyLines.some((l) => l.toLowerCase().includes('waste')));
c.waste = savedWaste;
}
} }
colony.sliders = restore.sliders; colony.sliders = restore.sliders;