1059 lines
46 KiB
JavaScript
1059 lines
46 KiB
JavaScript
// 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;
|
||
}
|