diff --git a/data/mastervega-artwork.json b/data/mastervega-artwork.json index 041a982..d4862fb 100644 --- a/data/mastervega-artwork.json +++ b/data/mastervega-artwork.json @@ -223,6 +223,20 @@ "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-.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": { "ships": { "key": "vega-ships", diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js index 0d1fa17..d798f82 100644 --- a/src/games/mastervega/MasterOfVegaGame.js +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -31,6 +31,7 @@ import { FONT, D, openDiplomacyScreen, openCouncilScreen, openLeaderScreen, openSaveScreen, openLoadScreen, showVictoryOverlay, } from './VegaScreens.js'; +import { openColoniesScreen } from './VegaColoniesScreen.js'; import { openResearchScreen } from './VegaResearchScreen.js'; import { openResearchChoiceScreen } from './VegaResearchChoiceScreen.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 // buttons; consolidated into one "Empire" dropdown (openEmpireMenu, // near the game menu below) to leave room in the bar for more empire- - // management buttons later (Brian's plan: "Colonies" next) without the - // row running out of width. Parked immediately left of End Turn, same + // management buttons later without the row running out of width — + // 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/ // EMPIRE_OPEN_LABEL, swapped by open/closeEmpireMenu) is this button's // own open/closed indicator, not the same glyph VegaTurnReportScreen.js @@ -760,10 +762,11 @@ export default class MasterOfVegaGame extends Phaser.Scene { // -------------------------------------------------------------- empire menu /** Empire toggles a small popover dropping down from directly below it: - * Research, Diplomacy, Leaders, Council, in that order (Brian's ask). - * Same shape as the game menu above — click-anywhere-to-dismiss veil, - * ghost-variant item buttons — just anchored under the HUD bar instead of - * above the ☰ button. Gated by modalOpen like every other HUD button. */ + * Research, Diplomacy, Leaders, Colonies, Council, in that order (Brian's + * ask). Same shape as the game menu above — click-anywhere-to-dismiss + * veil, ghost-variant item buttons — just anchored under the HUD bar + * instead of above the ☰ button. Gated by modalOpen like every other HUD + * button. */ toggleEmpireMenu() { if (this.modalOpen) return; if (this.empireMenuLayer) { this.closeEmpireMenu(); return; } @@ -789,6 +792,11 @@ export default class MasterOfVegaGame extends Phaser.Scene { ['Leaders', () => this.openModal((done) => openLeaderScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done, () => 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) => openCouncilScreen(this, this.rules, this.state, done))], ]; diff --git a/src/games/mastervega/VegaArt.js b/src/games/mastervega/VegaArt.js index d7a0ee6..97f1398 100644 --- a/src/games/mastervega/VegaArt.js +++ b/src/games/mastervega/VegaArt.js @@ -704,6 +704,16 @@ export function hasSpeciesResearchVideo(scene, 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. export const shipFrame = (rules, speciesId, hullId) => { const s = rules.species[speciesId]; diff --git a/src/games/mastervega/VegaColoniesScreen.js b/src/games/mastervega/VegaColoniesScreen.js new file mode 100644 index 0000000..e059285 --- /dev/null +++ b/src/games/mastervega/VegaColoniesScreen.js @@ -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 }; +} diff --git a/src/games/mastervega/VegaColonyView.js b/src/games/mastervega/VegaColonyView.js index 65fe7f2..2b07658 100644 --- a/src/games/mastervega/VegaColonyView.js +++ b/src/games/mastervega/VegaColonyView.js @@ -46,7 +46,7 @@ import { colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany, dequeue, collapseQueue, moveQueueRun, queueItemCost, queueEtas, empireDesign, empireColonies, maxSendablePopulation, sendPopulation, etaTo, - recommendColonyFocus, recommendAllocationFocus, + recommendColonyFocus, recommendAllocationFocus, applyColonyFocus, applyAllocationFocus, } from './VegaLogic.js'; const ACCENT = 0x6fc4ff; @@ -78,7 +78,7 @@ export const CHANNEL_COLOUR = { // Colony Focus — see autoQueueColonies (VegaLogic.js) for what each value // 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: '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.' }, @@ -90,7 +90,7 @@ const COLONY_FOCUS_OPTIONS = [ // Allocation Focus — one-time slider presets (see buildAllocationFocusFlyout). // 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: '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 } }, @@ -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) .setOrigin(0, 0).setStrokeStyle(1, ACCENT, selected ? 0.7 : 0.2).setInteractive({ useHandCursor: true }); row.on('pointerup', () => { - colony.focus = opt.value; - colony.advisor.focusQuietUntil = state.turn + 15; + applyColonyFocus(state, colony, opt.value); buildColonyFocusFlyout(); }); 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) .setOrigin(0, 0).setStrokeStyle(1, ACCENT, matches ? 0.7 : 0.2).setInteractive({ useHandCursor: true }); row.on('pointerup', () => { - colony.sliders = { ...opt.sliders }; - colony.advisor.allocKey = opt.key; - colony.advisor.allocQuietUntil = state.turn + 15; + applyAllocationFocus(state, colony, opt); onChanged?.(); buildPanel(); // redraws the five sliders with their new values buildAllocationFocusFlyout(); // refreshes which row is highlighted diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js index 99c1fa3..fe19bab 100644 --- a/src/games/mastervega/VegaLogic.js +++ b/src/games/mastervega/VegaLogic.js @@ -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 colonyAt = (state, starIdx) => state.colonies.find((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 starOf = (state, i) => state.galaxy.stars[i]; 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 * been armed (a Quiet* field is non-null, meaning the player picked diff --git a/src/games/mastervega/VegaSidePanel.js b/src/games/mastervega/VegaSidePanel.js index c8e855d..dd16a18 100644 --- a/src/games/mastervega/VegaSidePanel.js +++ b/src/games/mastervega/VegaSidePanel.js @@ -272,6 +272,26 @@ export default class VegaSidePanel { 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. */ action(label, fn, 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); this.tinyButton(right - boxSize * 2.9, ctrlY, boxSize, '–', () => set(s.count - 1), 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}`, { fontFamily: FONT, fontSize: '15px', color: s.count > 0 ? '#e8f4ff' : '#5d7085', }).setOrigin(0.5); diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index c05732d..f1e7d62 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -42,7 +42,7 @@ import { import { ensureSheets, shipFrame, planetFrame, techFrame, buildingFrame, speciesVideoKey, speciesStillKey, speciesSpeechClip, UI_SPEECH, worldBgKey, - colonyVideoKey, audienceVideoKey, sourceWidth, + colonyVideoKey, audienceVideoKey, sourceWidth, advisorVideoKey, } from '../src/games/mastervega/VegaArt.js'; import { CHAT } from '../src/games/mastervega/VegaChat.js'; // 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; ` + '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 // clips a game barely touches, so VegaColonyIntro.ensureColonyVideo() fetches // 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-')); check('audience mood clips are not eager-loaded', eagerAudience.length === 0, 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 vignette opens. 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]); check('advisorRecommendation renders a headline naming the colony', 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;