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

1059 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// Master of Vega — the 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;
}