// Master of Vega — the full-screen colony screen. // // The system view is where you look at a world; this is where you run it. It // takes the whole screen because the two things that need room — five live // allocation sliders and the build queue with everything you could add to it — // were both being squeezed into one 780px column of the system-view modal, // which visibly truncated its own catalogue rather than admitting it did not // fit. // // Layout, left to right: // // the world itself — the 1920x1080 backdrop for this planet type, or a // gradient painted from the type's colour if that art has // not been made yet // the flyout — the build-queue manager, hidden behind the panel until // the Build Queue button pulls it out // the panel — semi-transparent so the world reads through it: // summary, the five sliders with their padlocks, and a // read-out of what is being built // // TWO PHASER TRAPS THIS FILE SITS ON, both of which have bitten this game // before and are the reason for the container structure below: // // 1. `slider()` (VegaScreens.js) does its own pointer maths as `p.x - c.x`, // mixing a screen coordinate with a local one. That is only correct while // the slider's parent container sits at the origin. So `panelLayer` is at // (0, 0) with every child in ABSOLUTE coordinates, and the sliders are never // nested inside anything that moves. The flyout DOES move, which is exactly // why it contains no sliders. // 2. A Container's origin is locked at 0.5, so its hit area is always centred. // Everything interactive here is a Rectangle, a Text or a Button. 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 { FONT, D, ORBIT, slider, uiClick } from './VegaScreens.js'; import { playSound, SFX } from '../../ui/Sounds.js'; import { worldBackground, buildingFrame } from './VegaArt.js'; import { createShipMediaPool, makeShipIcon } from './VegaShipMedia.js'; import { openShipDetail } from './VegaShipDetail.js'; import { describeBuildingTooltip } from './VegaTooltips.js'; import { CHANNELS, colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories, colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany, dequeue, collapseQueue, moveQueueRun, queueItemCost, queueEtas, empireDesign, empireColonies, maxSendablePopulation, sendPopulation, etaTo, recommendColonyFocus, recommendAllocationFocus, applyColonyFocus, applyAllocationFocus, } from './VegaLogic.js'; const ACCENT = 0x6fc4ff; const PANEL = 0x0b1220; const PANEL_W = 480; const PANEL_X = GAME_WIDTH - PANEL_W - 24; const PANEL_Y = 24; const PANEL_H = GAME_HEIGHT - PANEL_Y * 2; const PAD = 22; const FLY_W = 760; const FLY_X = PANEL_X - FLY_W - 16; // Built-building icon row, bottom-left over the world art. Kept inside // roughly x=48..600 so it never reaches the flyout's column (FLY_X=640) even // with every building in the game built here, wrapping upward into a second // row rather than running off the right edge. const ICON = 52; const ICON_GAP = 10; const ICON_ROW_X = 48; const ICON_ROW_MAX_W = 552; const ICON_ROW_BOTTOM = 50; /** Matches the read-only allocation bars in the system view. */ export const CHANNEL_COLOUR = { ships: 0x6fc4ff, defense: 0xe08a8a, industry: 0xffd88a, ecology: 0x7fd8a0, research: 0xb89cff, }; // Colony Focus — see autoQueueColonies (VegaLogic.js) for what each value // actually does; this is just the picker's label/description copy. 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.' }, { value: 'fleet', label: 'Fleet Production', desc: 'Builds a diversified warship fleet at this system, targeting a 4:3:2:1 mix of frigates : destroyers : cruisers : battleships.' }, { value: 'growth', label: 'Population Growth', desc: 'Queues buildings that raise your population ceiling or growth rate.' }, { value: 'trade', label: 'Trade & Commerce', desc: 'Queues buildings that raise trade income.' }, { value: 'defense', label: 'Homeworld Defense', desc: 'Queues planetary defense buildings.' }, ]; // Allocation Focus — one-time slider presets (see buildAllocationFocusFlyout). // Every row sums to 1.0. 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 } }, { key: 'production', label: 'Production', sliders: { ships: 0.20, defense: 0.05, industry: 0.55, ecology: 0.10, research: 0.10 } }, { key: 'military', label: 'Military Buildup', sliders: { ships: 0.45, defense: 0.25, industry: 0.15, ecology: 0.10, research: 0.05 } }, ]; export const etaText = (turns) => (Number.isFinite(turns) ? `${turns} turn${turns === 1 ? '' : 's'}` : 'stalled — no construction budget'); // --------------------------------------------------------------------------- /** * A vertically scrolling column. This is the first one in the game: every other * list either fits or drops its tail on the floor (`VegaSystemView` used to * `break` out of the catalogue mid-loop). Exported for reuse (VegaScreens.js's * turn-report popup) rather than promoted to src/ui/, since this is still the * only game that needs it. * * The mask is a Graphics with its own world transform, so it does NOT follow a * moving parent — `syncMask` is what keeps it aligned while the flyout slides. */ export function scrollColumn(scene, parent, x, y, w, h) { // The catcher goes in FIRST so it sits below the rows: it exists only to tell // the wheel handler whether the pointer is over this column, and an // interactive rectangle added afterwards would swallow every row click. const catcher = scene.add.rectangle(x, y, w, h, 0xffffff, 0.001) .setOrigin(0, 0).setInteractive(); parent.add(catcher); const content = scene.add.container(x, y); parent.add(content); const shape = scene.make.graphics({ x: 0, y: 0 }, false); const paint = (dx) => { shape.clear(); shape.fillStyle(0xffffff); shape.fillRect(x + dx, y, w, h); }; paint(0); content.setMask(shape.createGeometryMask()); const api = { content, contentHeight: 0, offset: 0, /** Masks are not children, so a moving parent has to drag this along. */ syncMask(dx) { paint(dx); }, /** Phaser hit-tests through a mask, so every row handler must ask this. */ contains(pointer) { return catcher.getBounds().contains(pointer.x, pointer.y); }, scrollBy(dy) { const max = Math.max(0, api.contentHeight - h); api.offset = Phaser.Math.Clamp(api.offset + dy, -max, 0); content.y = y + api.offset; }, setOffset(v) { api.offset = 0; api.scrollBy(v); }, destroy() { shape.destroy(); }, }; const onWheel = (pointer, over, dx, dy) => { if (!api.contains(pointer)) return; api.scrollBy(-dy * 0.6); }; scene.input.on('wheel', onWheel); const origDestroy = api.destroy; api.destroy = () => { scene.input.off('wheel', onWheel); origDestroy(); }; return api; } // --------------------------------------------------------------------------- /** The holographic frame both the panel and the flyout are drawn in. */ export function frame(scene, layer, x, y, w, h, alpha) { const box = scene.add.rectangle(x, y, w, h, PANEL, alpha).setOrigin(0, 0); box.setStrokeStyle(1.5, ACCENT, 0.55); layer.add(box); const ticks = scene.add.graphics(); ticks.lineStyle(2.5, ACCENT, 0.9); const t = 24; for (const [cx, cy, dx, dy] of [ [x, y, 1, 1], [x + w, y, -1, 1], [x, y + h, 1, -1], [x + w, y + h, -1, -1], ]) { ticks.lineBetween(cx, cy, cx + dx * t, cy); ticks.lineBetween(cx, cy, cx, cy + dy * t); } layer.add(ticks); } // --------------------------------------------------------------------------- /** * @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 openColonyView(scene, rules, state, colony, art, opts = {}) { const { onChanged = null, onClose = null } = opts; const star = state.galaxy.stars[colony.starIdx]; const planet = star.planets[colony.orbit]; const type = rules.planetTypes[planet.typeId]; const emp = state.empires[colony.empireIdx]; const root = scene.add.container(0, 0).setDepth(D.colony); const tooltip = new Tooltip(scene, { depth: D.colony + 5 }); // --- the world itself const bgKey = worldBackground(scene, planet.typeId); if (bgKey) { root.add(scene.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, bgKey) .setDisplaySize(GAME_WIDTH, GAME_HEIGHT)); } else { // No backdrop art for this type yet. Paint one from the type's own colour // rather than dropping the player onto a black screen — same drop-in // contract as the procedural spritesheets. const top = Phaser.Display.Color.HexStringToColor(type.color).darken(72).color; const bottom = Phaser.Display.Color.HexStringToColor(type.color).darken(28).color; const g = scene.add.graphics(); g.fillGradientStyle(top, top, bottom, bottom, 1); g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); root.add(g); } // Interactive so nothing falls through to the system view underneath. root.add(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x00060e, 0.35) .setOrigin(0, 0).setInteractive()); // --- masthead, over the art // Colonies founded before naming existed have no `name` — the star // designation is exactly what this masthead showed for them, so it is the // fallback rather than a placeholder. const worldName = `${star.name} ${ORBIT[colony.orbit] ?? colony.orbit + 1}`; root.add(scene.add.text(48, 40, colony.name ?? worldName, { fontFamily: FONT, fontSize: '56px', color: '#e8f4ff', }).setShadow(0, 3, '#000814', 10, false, true)); root.add(scene.add.text(48, 108, `${emp.name}${colony.capital ? ' — capital world' : ''} · ${worldName}`, { fontFamily: FONT, fontSize: '24px', color: emp.color, }).setShadow(0, 2, '#000814', 8, false, true)); root.add(scene.add.text(48, 144, `${type.name} · ${rules.planetSizes[planet.sizeId]?.name ?? ''} · ` + `${rules.richness[planet.richId]?.name ?? ''} · ${rules.gravity[planet.gravId]?.name ?? ''}` + ` · Year ${2300 + state.turn}`, { fontFamily: FONT, fontSize: '19px', color: '#c8dcf0', }).setShadow(0, 2, '#000814', 8, false, true)); root.add(scene.add.text(48, 176, type.desc, { fontFamily: FONT, fontSize: '16px', color: '#9fb6cc', wordWrap: { width: 560 }, }).setShadow(0, 2, '#000814', 8, false, true)); // --- layers // // panelLayer sits at the origin FOREVER (see the header note on slider()). // flyLayer is the one that moves, and it holds no sliders. iconLayer sits // over the world art at the bottom-left, well clear of both. const flyLayer = scene.add.container(0, 0).setVisible(false); root.add(flyLayer); const panelLayer = scene.add.container(0, 0); root.add(panelLayer); const iconLayer = scene.add.container(0, 0); root.add(iconLayer); const FLY_HIDDEN = PANEL_X - FLY_X; // parked exactly behind the panel flyLayer.x = FLY_HIDDEN; let flyMode = null; // null | 'queue' | 'colonyFocus' | 'allocFocus' // Both of the flyout's lists can grow without limit — the queue as well as // the catalogue — so both scroll and both have a mask to keep in step. let scrollers = []; const syncMasks = (dx) => scrollers.forEach((s) => s.syncMask(dx)); const dropScrollers = () => { scrollers.forEach((s) => s.destroy()); scrollers = []; }; // Commander videos for the catalogue. The flyout wipes itself with // removeAll(true) on every enqueue, so the pool's layer is lifted out first // and re-homed into the fresh scroll column afterwards — it has to live in // there to be scrolled and masked with the rows it belongs to. const pool = createShipMediaPool(scene, rules, art); let detail = null; const openDetail = (hullId) => { if (detail) return; pool.setPaused(true); detail = openShipDetail(scene, rules, state, art, { empireIdx: colony.empireIdx, hullId }, () => { detail = null; if (flyMode === 'queue') pool.setPaused(false); }); }; /** Take the pool out of whatever is about to be destroyed. */ const detachPool = () => pool.layer.parentContainer?.remove(pool.layer); // ------------------------------------------------------------------ close let closed = false; function close() { if (closed) return; closed = true; detail?.close(); closeTransportPicker(); dropScrollers(); // Out of the tree first, or root.destroy() takes it and pool.destroy() // then runs against a corpse. detachPool(); pool.destroy(); tooltip.destroy(); scene.input.keyboard?.off('keydown-ESC', onEsc); root.destroy(); onClose?.(); } function onEsc() { if (flyMode) { toggleFlyout(flyMode); return; } close(); } scene.input.keyboard?.on('keydown-ESC', onEsc); // -------------------------------------------------------- built buildings // // A hoverable icon row over the world art, bottom-left — replaces the old // "Built here" text line in the panel, which had no room to say what each // building actually does. Rebuilt every time buildPanel() is, so it never // drifts out of sync with what just finished. function buildIconRow() { iconLayer.removeAll(true); tooltip.hide(); // the object under the cursor may be about to be destroyed // colony.buildings also carries the synthetic 'starbase' completion-flag // id (see VegaLogic.js's completeQueueItem) — it has no rules.buildings // entry, so it is filtered out here the same way the old text line did. const realBuildings = colony.buildings.filter((b) => rules.buildings[b]); const perRow = Math.max(1, Math.floor((ICON_ROW_MAX_W + ICON_GAP) / (ICON + ICON_GAP))); realBuildings.forEach((id, i) => { const row = Math.floor(i / perRow); const col = i % perRow; const cx = ICON_ROW_X + col * (ICON + ICON_GAP) + ICON / 2; const cy = GAME_HEIGHT - ICON_ROW_BOTTOM - ICON / 2 - row * (ICON + ICON_GAP); const icon = scene.add.image(cx, cy, art.buildings, buildingFrame(rules, id)) .setDisplaySize(ICON, ICON) .setInteractive({ useHandCursor: true }); iconLayer.add(icon); tooltip.attachTo(icon, () => describeBuildingTooltip(rules, id)); }); } // ------------------------------------------------------------- the panel function buildPanel() { buildIconRow(); panelLayer.removeAll(true); frame(scene, panelLayer, PANEL_X, PANEL_Y, PANEL_W, PANEL_H, 0.82); const x = PANEL_X + PAD; const w = PANEL_W - PAD * 2; let y = PANEL_Y + 24; const add = (obj) => { panelLayer.add(obj); return obj; }; const text = (str, size, color, gap = 6, wrap = w) => { const t = add(scene.add.text(x, y, str, { fontFamily: FONT, fontSize: `${size}px`, color, lineSpacing: 4, wordWrap: { width: wrap }, })); y += t.height + gap; return t; }; const heading = (str) => { y += 12; text(str.toUpperCase(), 14, '#6f8aa3', 4); add(scene.add.rectangle(x, y, w, 1, ACCENT, 0.22).setOrigin(0, 0)); y += 10; }; add(scene.add.text(x, y, 'COLONY', { fontFamily: FONT, fontSize: '26px', color: '#cfe8ff', })); add(new Button(scene, PANEL_X + PANEL_W - 38, y + 16, '✕', () => { playSound(scene, SFX.VEGA_CLOSE); close(); }, { width: 40, height: 36, fontSize: 18, variant: 'ghost' })); y += 48; // --- summary const maxPop = colonyMaxPop(rules, state, colony); const capF = colonyFactoryCap(rules, state, colony); const effF = effectiveFactories(rules, state, colony); const prod = colonyProduction(rules, state, colony); const rate = colonyBuildRate(rules, state, colony); heading('Status'); text(`Population ${colony.pop.toFixed(1)} / ${maxPop}`, 17, '#c8dcf0', 2); text(`Factories ${Math.floor(effF)} / ${capF}`, 17, '#c8dcf0', 2); text(`Output ${prod.toFixed(1)} BC per turn`, 17, '#c8dcf0', 2); text(`Construction ${rate.toFixed(1)} BC per turn`, 17, '#9fd8ff', 2); text(`Trade ${colonyTrade(rules, state, colony).toFixed(1)} BC`, 17, '#c8dcf0', 2); text(`Defences ${Math.round(colony.defenseHp)} / ${colonyDefenseCap(rules, state, colony)}`, 17, '#c8dcf0', 2); // Factories nobody is left to staff are mothballed rather than demolished — // worth saying out loud, because the number simply reads as wrong otherwise. if (colony.factories > effF + 0.5) { text(`${Math.floor(colony.factories - effF)} factories mothballed — too few people to staff them`, 14, '#e0b08a', 2); } if (colony.waste > 0.5) { text(`Uncleaned waste ${colony.waste.toFixed(1)} — this is capping your population`, 14, '#e08a8a', 2); } // --- allocation // // A padlock holds its channel while the others renormalise around it — // setSlider has honoured colony.locked since the engine was written, and // this is the first UI to ever set it. heading('Allocation'); const sliders = []; const lockedCount = CHANNELS.filter((c) => colony.locked[c]).length; CHANNELS.forEach((ch, i) => { const locked = !!colony.locked[ch]; // Locking the last free channel would leave setSlider with nothing to // renormalise (its `others.length === 0` early return), freezing the // allocation with no way back out. So the last one open cannot be shut. const lockable = locked || lockedCount < CHANNELS.length - 1; const box = add(scene.add.rectangle(x + 11, y + 28, 22, 22, locked ? 0x2a3550 : 0x16253c).setStrokeStyle(1, ACCENT, lockable ? 0.55 : 0.18)); add(scene.add.text(x + 11, y + 27, locked ? '■' : '□', { fontFamily: FONT, fontSize: '15px', color: locked ? '#ffd88a' : (lockable ? '#8fa8c0' : '#3c4c60'), }).setOrigin(0.5)); if (lockable) { box.setInteractive({ useHandCursor: true }); box.on('pointerup', () => { if (locked) delete colony.locked[ch]; else colony.locked[ch] = true; buildPanel(); }); } const s = slider(scene, x + 34, y, w - 34, rules.economy.channelNames[ch] ?? ch, colony.sliders[ch] ?? 0, (v) => { setSlider(rules, state, colony, ch, v); // setSlider renormalises everything else, so they all have to be // redrawn — including this one, which may have been clipped by the // room the locked channels left. sliders.forEach((other, j) => other.setValue(colony.sliders[CHANNELS[j]] ?? 0)); onChanged?.(); }, CHANNEL_COLOUR[ch]); panelLayer.add(s.container); sliders.push(s); y += 56; }); y += 4; text('Ecology is funded first — a shortfall is taken from the other channels automatically. ' + 'A locked channel holds its share while the rest renormalise.', 13, '#6f8aa3', 4); // --- focus pickers, in normal vertical flow (not pinned like the pair below) { const half = (w - 12) / 2; add(new Button(scene, x + half / 2, y + 24, flyMode === 'colonyFocus' ? 'Close Colony Focus' : 'Colony Focus', uiClick(scene, () => toggleFlyout('colonyFocus')), { width: half, height: 44, fontSize: 16 })); add(new Button(scene, x + half * 1.5 + 12, y + 24, flyMode === 'allocFocus' ? 'Close Allocation Focus' : 'Allocation Focus', uiClick(scene, () => toggleFlyout('allocFocus')), { width: half, height: 44, fontSize: 16 })); y += 60; } // The button is PINNED to the bottom of the column, and everything below // here is length-variable — a long queue, a colony with a dozen buildings. // So the two lists below give way rather than spilling past the frame, the // same way VegaSidePanel's `room()` works. The status block and the sliders // are never allowed to be the thing that gets dropped. const buttonY = PANEL_Y + PANEL_H - 76 - 56; const room = (px) => y + px < buttonY - 12; // --- what is being built heading('Building'); const rows = collapseQueue(colony); const etas = queueEtas(rules, state, colony); if (!rows.length) { text('Idle — construction spills into research.', 15, '#6f8aa3', 4); } else { const head = rows[0]; const cost = queueItemCost(rules, state, colony, head.item); text(`${itemName(rules, state, colony, head.item)}${head.count > 1 ? ` x ${head.count}` : ''}`, 19, '#ffd88a', 6); add(scene.add.rectangle(x, y, w, 8, 0x1b2b42).setOrigin(0, 0)); add(scene.add.rectangle(x, y, w * Phaser.Math.Clamp((head.item.progress ?? 0) / cost, 0, 1), 8, 0xffd88a).setOrigin(0, 0)); y += 16; text(`${Math.round(head.item.progress ?? 0)} / ${Math.round(cost)} BC · ${etaText(etas[head.lastIndex])}`, 14, '#9fb6cc', 6); let shown = 1; for (const r of rows.slice(1)) { if (!room(22)) break; text(`${itemName(rules, state, colony, r.item)}${r.count > 1 ? ` x ${r.count}` : ''}` + ` · ${etaText(etas[r.lastIndex])}`, 14, '#8fa8c0', 2); shown += 1; } if (shown < rows.length && room(22)) { text(`…and ${rows.length - shown} more — open the build queue`, 13, '#6f8aa3', 2); } } y = buttonY; const sendBtn = add(new Button(scene, PANEL_X + PANEL_W / 2, y + 22, 'Send Population', uiClick(scene, openTransportPicker), { width: w, height: 44, fontSize: 18 })); if (maxSendablePopulation(colony) <= 0 || empireColonies(state, colony.empireIdx).length < 2) { sendBtn.setEnabled(false); } y += 56; add(new Button(scene, PANEL_X + PANEL_W / 2, y + 24, flyMode === 'queue' ? 'Close Build Queue' : 'Build Queue', uiClick(scene, () => toggleFlyout('queue')), { width: w, height: 48, fontSize: 20 })); } // ------------------------------------------------------------- the flyout // // A single sliding drawer shared by three modes (Build Queue / Colony Focus // / Allocation Focus) rather than three independent panels — clicking a // different mode's button while the drawer is already open swaps its // content in place with no re-tween (flyLayer.x is already 0); clicking the // OPEN mode's own button tweens it closed; clicking from closed tweens it // open. Every content builder starts with resetFlyLayer(), since switching // modes mid-open still has to tear down whatever the PREVIOUS mode left // behind (scroll masks, the ship-media pool) before building fresh content. function resetFlyLayer() { dropScrollers(); detachPool(); flyLayer.removeAll(true); } function buildFlyoutContent() { if (flyMode === 'queue') buildQueueFlyout(); else if (flyMode === 'colonyFocus') buildColonyFocusFlyout(); else if (flyMode === 'allocFocus') buildAllocationFocusFlyout(); } function toggleFlyout(mode) { scene.tweens.killTweensOf(flyLayer); if (flyMode === mode) { // The open drawer's own button, clicked again: close it. flyMode = null; scene.tweens.add({ targets: flyLayer, x: FLY_HIDDEN, duration: 180, ease: 'Sine.easeIn', onUpdate: () => syncMasks(flyLayer.x), onComplete: () => { flyLayer.setVisible(false); dropScrollers(); detachPool(); pool.setPaused(true); flyLayer.removeAll(true); }, }); } else if (flyMode === null) { // Closed -> open with `mode`, tweening in. flyMode = mode; flyLayer.setVisible(true); buildFlyoutContent(); scene.tweens.add({ targets: flyLayer, x: 0, duration: 220, ease: 'Cubic.easeOut', // A scroll mask is a Graphics with its own world transform and does // not follow a moving parent, so it has to be dragged along by hand. onUpdate: () => syncMasks(flyLayer.x), onComplete: () => syncMasks(0), }); } else { // Already open on a DIFFERENT mode: swap content in place, no tween. flyMode = mode; buildFlyoutContent(); } buildPanel(); } function rebuildFlyout() { buildFlyoutContent(); buildPanel(); onChanged?.(); } function buildQueueFlyout() { // Queueing something rebuilds the whole flyout, so both lists would jump // back to the top under the player's cursor. Put them back where they were. const keep = scrollers.map((s) => s.offset); resetFlyLayer(); pool.beginFrame(); if (!detail) pool.setPaused(false); frame(scene, flyLayer, FLY_X, PANEL_Y, FLY_W, PANEL_H, 0.9); const x = FLY_X + PAD; const w = FLY_W - PAD * 2; flyLayer.add(scene.add.text(x, PANEL_Y + 24, 'BUILD QUEUE', { fontFamily: FONT, fontSize: '26px', color: '#cfe8ff', })); const rate = colonyBuildRate(rules, state, colony); flyLayer.add(scene.add.text(x, PANEL_Y + 60, `${rate.toFixed(1)} BC per turn reaching construction`, { fontFamily: FONT, fontSize: '15px', color: '#9fd8ff', })); // ---- in the queue let y = PANEL_Y + 100; flyLayer.add(scene.add.text(x, y, 'IN THE QUEUE', { fontFamily: FONT, fontSize: '14px', color: '#6f8aa3', })); y += 22; flyLayer.add(scene.add.rectangle(x, y, w, 1, ACCENT, 0.22).setOrigin(0, 0)); y += 12; const rows = collapseQueue(colony); const etas = queueEtas(rules, state, colony); if (!rows.length) { flyLayer.add(scene.add.text(x, y, 'Nothing queued — construction spills into research.', { fontFamily: FONT, fontSize: '15px', color: '#6f8aa3', })); } // The queue gets a fixed band of its own. Nothing bounds how long a queue // can get, and letting it push the catalogue off the bottom of the screen // would be the same failure the old system-view column had. const queueH = 340; const qs = scrollColumn(scene, flyLayer, x, y, w, queueH); scrollers.push(qs); let qy = 0; rows.forEach((r, ri) => { const cost = queueItemCost(rules, state, colony, r.item); const isHead = ri === 0; qs.content.add(scene.add.rectangle(0, qy, w, 42, 0x0f1a2c, isHead ? 0.85 : 0.5) .setOrigin(0, 0).setStrokeStyle(1, ACCENT, isHead ? 0.35 : 0.12)); qs.content.add(scene.add.text(12, qy + 5, `${itemName(rules, state, colony, r.item)}${r.count > 1 ? ` x ${r.count}` : ''}`, { fontFamily: FONT, fontSize: '17px', color: r.item.kind === 'building' ? '#ffd88a' : '#9fd8ff', })); qs.content.add(scene.add.text(12, qy + 25, (isHead ? `${Math.round(r.item.progress ?? 0)} / ${Math.round(cost)} BC · ` : `${Math.round(cost)} BC each · `) + etaText(etas[r.lastIndex]), { fontFamily: FONT, fontSize: '13px', color: '#7f97b3', })); const bx = w - 22; // Guarded like the catalogue rows: a mask hides a row, it does not stop // it being clicked. pusher(qs.content, bx, qy + 21, '✕', () => { // Remove one copy: the tail of a run, so a "x 5" ticks down to "x 4" // and the part-built head is the last thing you can lose. dequeue(rules, state, colony, r.lastIndex); rebuildFlyout(); }, true, qs); pusher(qs.content, bx - 34, qy + 21, '▼', () => moveRun(rows, ri, 1), ri < rows.length - 1, qs); pusher(qs.content, bx - 68, qy + 21, '▲', () => moveRun(rows, ri, -1), ri > 0, qs); qy += 48; }); qs.contentHeight = qy; qs.setOffset(keep[0] ?? 0); y += queueH + 6; // ---- the catalogue, in a scrolling column of its own flyLayer.add(scene.add.text(x, y, 'AVAILABLE — CLICK TO QUEUE', { fontFamily: FONT, fontSize: '14px', color: '#6f8aa3', })); y += 22; flyLayer.add(scene.add.rectangle(x, y, w, 1, ACCENT, 0.22).setOrigin(0, 0)); y += 10; const colH = PANEL_Y + PANEL_H - 24 - y; const scroller = scrollColumn(scene, flyLayer, x, y, w, colH); scrollers.push(scroller); let cy = 0; const entry = (icon, label, sub, colour, onAdd, onAddFive) => { const bg = scene.add.rectangle(0, cy, w, 52, 0x0f1a2c, 0.0) .setOrigin(0, 0).setInteractive({ useHandCursor: true }); bg.on('pointerover', () => bg.setFillStyle(0x18293f, 0.9)); bg.on('pointerout', () => bg.setFillStyle(0x0f1a2c, 0.0)); // Scrolled out of sight is still hit-testable — a mask is a rendering // concern only, so the visible band has to be checked explicitly. bg.on('pointerup', (p) => { if (scroller.contains(p)) { playSound(scene, SFX.VEGA_BUILD); onAdd(); } }); scroller.content.add(bg); // A ship gets its commanding officer beside the hull; a building keeps // the single icon it always had. Both sheets are optional and procedural // until real art lands, so either always resolves to something. let textX = 58; if (icon.hullId) { pool.get(emp.speciesId, icon.hullId, 26, cy + 26, 40); scroller.content.add( makeShipIcon(scene, rules, art, emp.speciesId, icon.hullId, 72, cy + 26, 40), ); // Its own target over the two thumbnails, so the row's primary action // stays "queue one" and inspecting is a deliberate second gesture. It // is added after bg, which is what puts it on top — and which is also // why it has to light itself up: sitting over bg, it swallows the // row's own hover and would otherwise read as dead space. Same // masked-row guard as every other handler in this flyout. const info = scene.add.rectangle(0, cy, 100, 52, 0x6fc4ff, 0.0) .setOrigin(0, 0).setInteractive({ useHandCursor: true }); info.on('pointerover', () => info.setFillStyle(0x6fc4ff, 0.16)); info.on('pointerout', () => info.setFillStyle(0x6fc4ff, 0.0)); info.on('pointerup', (p) => { if (scroller.contains(p)) openDetail(icon.hullId); }); scroller.content.add(info); textX = 108; } else { scroller.content.add(scene.add.image(28, cy + 26, icon.key, icon.frame) .setDisplaySize(40, 40)); } scroller.content.add(scene.add.text(textX, cy + 5, label, { fontFamily: FONT, fontSize: '17px', color: colour, })); scroller.content.add(scene.add.text(textX, cy + 27, sub, { fontFamily: FONT, fontSize: '13px', color: '#7f97b3', wordWrap: { width: w - textX - 70 }, })); if (onAddFive) { // The repeat count. Its own button so the row click stays "add one". const r = scene.add.rectangle(w - 40, cy + 26, 46, 26, 0x16253c) .setStrokeStyle(1, ACCENT, 0.55).setInteractive({ useHandCursor: true }); r.on('pointerover', () => r.setFillStyle(0x22405f)); r.on('pointerout', () => r.setFillStyle(0x16253c)); r.on('pointerup', (p) => { if (scroller.contains(p)) { playSound(scene, SFX.VEGA_BUILD); onAddFive(); } }); scroller.content.add(r); scroller.content.add(scene.add.text(w - 40, cy + 25, '+5', { fontFamily: FONT, fontSize: '15px', color: '#cfe8ff', }).setOrigin(0.5)); } cy += 56; }; const turns = (cost) => (rate > 0 ? `${Math.max(1, Math.ceil(cost / rate))} turns` : 'no budget'); for (const hull of rules.hullList) { // Dispatched directly by the Send Population order, never queued. if (hull.id === 'poptransport') continue; const d = empireDesign(rules, state, colony.empireIdx, hull.id); entry({ hullId: hull.id }, d.name, `${d.cost} BC · ${turns(d.cost)} · ${hull.desc}`, '#9fd8ff', () => { enqueue(rules, state, colony, 'ship', hull.id); rebuildFlyout(); }, () => { enqueueMany(rules, state, colony, 'ship', hull.id, 5); rebuildFlyout(); }); } for (const b of rules.buildingList) { if (colony.buildings.includes(b.id)) continue; if (b.prereq && !emp.known[b.prereq]) continue; const queued = colony.queue.some((q) => q.kind === 'building' && q.id === b.id); entry({ key: art.buildings, frame: buildingFrame(rules, b.id) }, `${b.name}${queued ? ' — queued' : ''}`, `${b.cost} BC · ${turns(b.cost)} · ${b.upkeep} BC upkeep · ${b.desc}`, queued ? '#6f8aa3' : '#ffd88a', () => { if (enqueue(rules, state, colony, 'building', b.id)) rebuildFlyout(); }); } // Back into the live scroll column, after the rows so the portraits draw // over the row backgrounds, and inside `content` so they scroll and mask // with the list they belong to. scroller.content.add(pool.layer); pool.endFrame(); scroller.contentHeight = cy; scroller.setOffset(keep[1] ?? 0); } // Autopilot picker: what VegaLogic.js's autoQueueColonies will build here // once a turn, when this colony's queue is empty. Short enough (7 rows) to // fit without a scrollColumn. /** A small right-pointing glyph, pulsing horizontally, pointing at the * advisors' recommended row. Lives in the PAD margin to the row's left — * no manual cleanup needed, it and its tween are destroyed together the * next time resetFlyLayer() runs. */ function addRecommendArrow(rowY, rowH) { const arrow = scene.add.text(FLY_X + PAD / 2, rowY + rowH / 2, '▶', { fontFamily: FONT, fontSize: '20px', color: '#ffd88a', }).setOrigin(0.5); flyLayer.add(arrow); scene.tweens.add({ targets: arrow, x: arrow.x + 6, duration: 500, yoyo: true, repeat: -1, ease: 'Sine.easeInOut', }); } /** A still, smaller twin of the pulsing arrow above, sat beside "YOUR * ADVISORS RECOMMEND" so the callout visibly matches the row it points at. */ function addStaticArrowGlyph(atX, atY) { const glyph = scene.add.text(atX, atY, '▶', { fontFamily: FONT, fontSize: '14px', color: '#ffd88a', }); flyLayer.add(glyph); return glyph; } function buildColonyFocusFlyout() { resetFlyLayer(); pool.setPaused(true); // no ship media in this mode frame(scene, flyLayer, FLY_X, PANEL_Y, FLY_W, PANEL_H, 0.9); const x = FLY_X + PAD; const w = FLY_W - PAD * 2; flyLayer.add(scene.add.text(x, PANEL_Y + 24, 'COLONY FOCUS', { fontFamily: FONT, fontSize: '26px', color: '#cfe8ff', })); flyLayer.add(scene.add.text(x, PANEL_Y + 60, 'Automatically queues one thing per turn when the queue is empty. Never touches the allocation sliders.', { fontFamily: FONT, fontSize: '15px', color: '#9fd8ff', wordWrap: { width: w }, })); let y = PANEL_Y + 110; const rec = recommendColonyFocus(rules, state, colony); const recGlyph = addStaticArrowGlyph(x, y); const recHeading = scene.add.text(x + recGlyph.width + 8, y, 'YOUR ADVISORS RECOMMEND:', { fontFamily: FONT, fontSize: '14px', color: '#ffd88a', }); flyLayer.add(recHeading); y += recHeading.height + 4; const recBody = scene.add.text(x, y, `${rec.label} — ${rec.reason}`, { fontFamily: FONT, fontSize: '13px', color: '#9fd8ff', wordWrap: { width: w }, lineSpacing: 3, }); flyLayer.add(recBody); y += recBody.height + 18; const rowH = 78; for (const opt of COLONY_FOCUS_OPTIONS) { const selected = (colony.focus ?? 'manual') === opt.value; 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', () => { applyColonyFocus(state, colony, opt.value); buildColonyFocusFlyout(); }); flyLayer.add(row); if (opt.value === rec.value) addRecommendArrow(y, rowH); flyLayer.add(scene.add.text(x + 14, y + 8, opt.label, { fontFamily: FONT, fontSize: '18px', color: selected ? '#ffd88a' : '#e8f4ff', })); flyLayer.add(scene.add.text(x + 14, y + 32, opt.desc, { fontFamily: FONT, fontSize: '13px', color: '#8fa8c0', wordWrap: { width: w - 28 }, })); y += rowH; } } // One-time slider presets. Picking one overwrites colony.sliders directly — // a deliberate bulk overwrite that BYPASSES colony.locked (unlike // setSlider, which honours it): this is an explicit "reset everything" // action, not a nudge to one channel, and the sliders stay freely // adjustable afterward exactly as if the player had dragged them there. function buildAllocationFocusFlyout() { resetFlyLayer(); pool.setPaused(true); frame(scene, flyLayer, FLY_X, PANEL_Y, FLY_W, PANEL_H, 0.9); const x = FLY_X + PAD; const w = FLY_W - PAD * 2; flyLayer.add(scene.add.text(x, PANEL_Y + 24, 'ALLOCATION FOCUS', { fontFamily: FONT, fontSize: '26px', color: '#cfe8ff', })); flyLayer.add(scene.add.text(x, PANEL_Y + 60, 'A one-time preset for the five sliders above — picking one does not lock the allocation.', { fontFamily: FONT, fontSize: '15px', color: '#9fd8ff', wordWrap: { width: w }, })); let y = PANEL_Y + 110; const rec = recommendAllocationFocus(rules, state, colony); const recGlyph = addStaticArrowGlyph(x, y); const recHeading = scene.add.text(x + recGlyph.width + 8, y, 'YOUR ADVISORS RECOMMEND:', { fontFamily: FONT, fontSize: '14px', color: '#ffd88a', }); flyLayer.add(recHeading); y += recHeading.height + 4; const recBody = scene.add.text(x, y, `${rec.label} — ${rec.reason}`, { fontFamily: FONT, fontSize: '13px', color: '#9fd8ff', wordWrap: { width: w }, lineSpacing: 3, }); flyLayer.add(recBody); y += recBody.height + 18; const rowH = 84; for (const opt of ALLOCATION_FOCUS_OPTIONS) { const matches = CHANNELS.every((ch) => Math.abs((colony.sliders[ch] ?? 0) - opt.sliders[ch]) < 1e-6); 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', () => { applyAllocationFocus(state, colony, opt); onChanged?.(); buildPanel(); // redraws the five sliders with their new values buildAllocationFocusFlyout(); // refreshes which row is highlighted }); flyLayer.add(row); if (opt.key === rec.key) addRecommendArrow(y, rowH); flyLayer.add(scene.add.text(x + 14, y + 8, opt.label, { fontFamily: FONT, fontSize: '18px', color: matches ? '#ffd88a' : '#e8f4ff', })); const breakdown = CHANNELS.map((ch) => `${rules.economy.channelNames[ch] ?? ch} ${Math.round(opt.sliders[ch] * 100)}%`).join(' · '); flyLayer.add(scene.add.text(x + 14, y + 34, breakdown, { fontFamily: FONT, fontSize: '13px', color: '#8fa8c0', wordWrap: { width: w - 28 }, })); y += rowH; } } const moveRun = (rows, ri, dir) => { moveQueueRun(rules, state, colony, rows, ri, dir); rebuildFlyout(); }; /** * A small square push-button; Button is too heavy for a row of these. * `guard` is the scroll column it lives in, if any — a masked row is hidden * but still hit-testable, so it has to be asked whether the click landed * inside the visible band. */ function pusher(layer, x, y, label, fn, enabled = true, guard = null, opts = {}) { const { w = 26, fontSize = 15 } = opts; const r = scene.add.rectangle(x, y, w, 26, enabled ? 0x16253c : 0x101a29) .setStrokeStyle(1, ACCENT, enabled ? 0.55 : 0.18); layer.add(r); layer.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', (p) => { if (!guard || guard.contains(p)) fn(); }); } // ------------------------------------------------- population transport // // MOO1-style: a self-contained overlay opened from the panel — pick one of // your other colonies, dial an amount up to what the source can spare, and // send it. Dispatches through Logic.sendPopulation(), which builds the // 'poptransport' fleet already in transit; delivery on arrival is automatic // (VegaLogic.js's moveFleets), so there is no separate "unload" step here. const TW = 640; const TH = 520; const TX = (GAME_WIDTH - TW) / 2; const TY = (GAME_HEIGHT - TH) / 2; let transportLayer = null; let listCol = null; // tracked so its wheel listener is unhooked on every rebuild, not just on close function closeTransportPicker() { listCol?.destroy(); listCol = null; transportLayer?.destroy(); transportLayer = null; } function openTransportPicker() { if (transportLayer) return; const others = empireColonies(state, colony.empireIdx).filter((c) => c.id !== colony.id); let dest = others[0] ?? null; let amount = 0; transportLayer = scene.add.container(0, 0); root.add(transportLayer); const renderTransport = () => { listCol?.destroy(); listCol = null; transportLayer.removeAll(true); transportLayer.add(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x00060e, 0.6) .setOrigin(0, 0).setInteractive()); frame(scene, transportLayer, TX, TY, TW, TH, 0.97); const x = TX + PAD; const w = TW - PAD * 2; let y = TY + 24; transportLayer.add(scene.add.text(x, y, 'SEND POPULATION', { fontFamily: FONT, fontSize: '24px', color: '#cfe8ff', })); transportLayer.add(new Button(scene, TX + TW - 38, y + 14, '✕', () => { playSound(scene, SFX.VEGA_CLOSE); closeTransportPicker(); }, { width: 40, height: 36, fontSize: 18, variant: 'ghost' })); y += 46; transportLayer.add(scene.add.text(x, y, `From ${colony.name ?? `${star.name} ${ORBIT[colony.orbit] ?? colony.orbit + 1}`} — ${colony.pop.toFixed(1)} population`, { fontFamily: FONT, fontSize: '15px', color: '#9fb6cc', })); y += 34; if (!others.length) { transportLayer.add(scene.add.text(x, y, 'No other colonies to send population to.', { fontFamily: FONT, fontSize: '15px', color: '#6f8aa3', })); return; } transportLayer.add(scene.add.text(x, y, 'DESTINATION', { fontFamily: FONT, fontSize: '13px', color: '#6f8aa3', })); y += 20; const listH = 220; listCol = scrollColumn(scene, transportLayer, x, y, w, listH); let ly = 0; others.forEach((c) => { const dStar = state.galaxy.stars[c.starIdx]; const selected = dest?.id === c.id; const row = scene.add.rectangle(0, ly, w, 42, 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', (p) => { if (listCol.contains(p)) { dest = c; renderTransport(); } }); listCol.content.add(row); const eta = etaTo(rules, state, { starIdx: colony.starIdx, empireIdx: colony.empireIdx }, c.starIdx, [{ hullId: 'poptransport', mark: 1, count: 1 }]); listCol.content.add(scene.add.text(12, ly + 6, c.name ?? `${dStar.name} ${ORBIT[c.orbit] ?? c.orbit + 1}`, { fontFamily: FONT, fontSize: '16px', color: '#e8f4ff', })); listCol.content.add(scene.add.text(12, ly + 25, `${c.pop.toFixed(1)} population · ${Number.isFinite(eta) ? `${eta} turn${eta === 1 ? '' : 's'}` : 'unreachable'}`, { fontFamily: FONT, fontSize: '13px', color: '#8fa8c0', })); ly += 48; }); listCol.contentHeight = ly; y += listH + 20; const cap = maxSendablePopulation(colony); amount = Phaser.Math.Clamp(amount, 0, cap); transportLayer.add(scene.add.text(x, y, 'AMOUNT TO SEND', { fontFamily: FONT, fontSize: '13px', color: '#6f8aa3', })); y += 24; transportLayer.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); renderTransport(); }; // -5/+5 flank the -1/+1 pair — wider boxes (34px) since "-5"/"+5" is two // glyphs, sized down a touch (13px) to sit comfortably inside them. pusher(transportLayer, x + 167, y + 15, '−5', () => setAmount(amount - 5), amount > 0, null, { w: 34, fontSize: 13 }); pusher(transportLayer, x + 203, y + 15, '−', () => setAmount(amount - 1), amount > 0); pusher(transportLayer, x + 235, y + 15, '+', () => setAmount(amount + 1), amount < cap); pusher(transportLayer, x + 271, y + 15, '+5', () => setAmount(amount + 5), amount < cap, null, { w: 34, fontSize: 13 }); transportLayer.add(new Button(scene, x + 331, y + 15, 'MAX', uiClick(scene, () => setAmount(cap)), { width: 66, height: 32, fontSize: 14, variant: 'ghost' })); transportLayer.add(scene.add.text(x, y + 40, `${cap.toFixed(1)} available to send`, { fontFamily: FONT, fontSize: '13px', color: '#6f8aa3', })); y += 76; const canSend = !!dest && amount > 0; const sendBtn = new Button(scene, TX + TW / 2, y + 22, 'Send', uiClick(scene, () => { if (!dest) return; if (sendPopulation(rules, state, colony.empireIdx, colony.id, dest.id, amount)) { closeTransportPicker(); buildPanel(); onChanged?.(); } }), { width: w, height: 44, fontSize: 18 }); if (!canSend) sendBtn.setEnabled(false); transportLayer.add(sendBtn); }; renderTransport(); } buildPanel(); return { root, close }; } /** Display name for a queue entry — ships carry their current Mark. */ export function itemName(rules, state, colony, item) { return item.kind === 'building' ? rules.buildings[item.id].name : empireDesign(rules, state, colony.empireIdx, item.id).name; }