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

770 lines
30 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 star map's right-hand command panel.
//
// MOO1 puts every decision you can make about the thing you just clicked into
// one docked column, and shows nothing at all when nothing is selected. This is
// that panel. It has three modes:
//
// star — read-only summary of a system: class, worlds, colonies, garrisons,
// plus the one action a system offers ("View System").
// fleet — the ships in a selected fleet, each stack with a count you can dial
// down. The counts ARE the split: what you leave at zero stays home.
// order — a destination has been clicked. Distance, ETA and who is waiting
// there, behind an explicit Accept / Cancel.
//
// It owns no game state. The scene decides what is selected and calls showStar /
// showFleet / showOrder; the panel only ever reads the engine and reports clicks
// back through `cb`. That is what keeps the turn driver in MasterOfVegaGame.
//
// Layout note: this is one container positioned at the panel's top-left with
// every child in LOCAL coordinates. Interactive children are Rectangles and
// Buttons, never bare Containers — a Container's hit area is locked to its
// centre and is the classic way to get an unclickable widget here.
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from './VegaButton.js';
import { markNumeral } from './VegaRules.js';
import { parsecs } from './VegaGalaxyGen.js';
import {
coloniesAt, fleetsAt, empireDesign, fleetPower, fleetSpeed, fleetEta, etaTo,
colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories,
colonyDefenseCap, habitableForEmpire, reachableStars, atWar, queueItemEta,
} from './VegaLogic.js';
import { FONT, D, ORBIT, uiClick } from './VegaScreens.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { etaText } from './VegaColonyView.js';
import { createShipMediaPool, makeShipIcon } from './VegaShipMedia.js';
import { openShipDetail } from './VegaShipDetail.js';
const W = 400;
const PAD = 18;
const COL = W - PAD * 2;
const ACCENT = 0x6fc4ff;
const PANEL = 0x0b1220;
export default class VegaSidePanel {
constructor(scene, rules, state, art, cb = {}) {
this.scene = scene;
this.rules = rules;
this.state = state;
this.art = art;
this.cb = cb;
this.viewerIdx = cb.viewerIdx ?? state.humanIndex;
this.x0 = GAME_WIDTH - W - 16;
this.y0 = 78;
this.h = GAME_HEIGHT - this.y0 - 24;
this.mode = null;
this.starIdx = -1;
this.fleet = null;
this.orderStar = -1;
this.sel = [];
// Parked off-screen right; open() slides it in.
this.root = scene.add.container(this.x0 + W + 60, this.y0)
.setDepth(D.hud + 1).setVisible(false);
const panel = scene.add.rectangle(0, 0, W, this.h, PANEL, 0.96).setOrigin(0, 0);
panel.setStrokeStyle(1.5, ACCENT, 0.55);
this.root.add(panel);
// Same corner ticks as modalShell, so the panel and the modals read as one
// instrument rather than two different dialog systems.
const ticks = scene.add.graphics();
ticks.lineStyle(2.5, ACCENT, 0.9);
const t = 22;
for (const [cx, cy, dx, dy] of [
[0, 0, 1, 1], [W, 0, -1, 1], [0, this.h, 1, -1], [W, this.h, -1, -1],
]) {
ticks.lineBetween(cx, cy, cx + dx * t, cy);
ticks.lineBetween(cx, cy, cx, cy + dy * t);
}
this.root.add(ticks);
this.titleText = scene.add.text(PAD, 16, '', {
fontFamily: FONT, fontSize: '25px', color: '#cfe8ff',
});
this.root.add(this.titleText);
this.subText = scene.add.text(PAD, 48, '', {
fontFamily: FONT, fontSize: '14px', color: '#7f97b3',
});
this.root.add(this.subText);
this.root.add(scene.add.rectangle(PAD, 72, COL, 1, ACCENT, 0.4).setOrigin(0, 0));
this.root.add(new Button(scene, W - 32, 30, '✕', () => {
playSound(scene, SFX.VEGA_CLOSE);
this.cb.onClose?.();
}, { width: 38, height: 34, fontSize: 18, variant: 'ghost' }));
this.body = scene.add.container(0, 0);
this.root.add(this.body);
// The commander videos outlive the body. Every / + rebuilds `body` from
// scratch, and re-creating a decoder per row per click would restart every
// loop, so they live in their own layer that the rebuild never touches.
// It is added AFTER body because a Container renders its children in
// insertion order and ignores their depth.
this.pool = createShipMediaPool(scene, rules, art);
this.root.add(this.pool.layer);
this.detail = null;
}
/**
* True while the ship detail pop-over is up. It veils the whole screen, so
* the star map has to be told to keep still everywhere and not just under
* the panel's own footprint — the same job `modalOpen` does for the modals
* this window deliberately does not go through.
*/
get detailOpen() { return !!this.detail; }
// Used by the star map to keep a drag or a wheel over the panel from panning
// and zooming the galaxy underneath it.
containsPoint(x, y) {
if (!this.root.visible) return false;
return x >= this.root.x && x <= this.root.x + W
&& y >= this.y0 && y <= this.y0 + this.h;
}
// ------------------------------------------------------------------ modes
showStar(starIdx) {
this.mode = 'star';
this.starIdx = starIdx;
this.rebuild();
this.open();
}
showFleet(fleet) {
this.mode = 'fleet';
this.fleet = fleet;
// A fresh selection always starts at full strength: any dial-down from a
// prior selection (this fleet or another) must not carry over here.
this.sel = [];
this.syncSelection();
this.rebuild();
this.open();
}
showOrder(toStarIdx) {
if (!this.fleet) return;
this.mode = 'order';
this.orderStar = toStarIdx;
this.rebuild();
this.open();
}
hide() {
if (!this.root.visible) return;
// Nothing should be decoding while the panel is parked off-screen.
this.pool.setPaused(true);
this.mode = null;
this.fleet = null;
this.starIdx = -1;
// Kill the opening tween first: two tweens on the same x fight, and the
// loser here is a hide that completes after a reopen and leaves the panel
// invisible but still selected.
this.scene.tweens.killTweensOf(this.root);
this.scene.tweens.add({
targets: this.root,
x: this.x0 + W + 60,
duration: 160,
ease: 'Sine.easeIn',
onComplete: () => this.root.setVisible(false),
});
}
open() {
if (!this.detail) this.pool.setPaused(false);
if (this.root.visible && Math.abs(this.root.x - this.x0) < 0.5) return;
if (!this.root.visible) {
this.root.setVisible(true);
this.root.x = this.x0 + W + 60;
}
this.scene.tweens.killTweensOf(this.root);
this.scene.tweens.add({
targets: this.root, x: this.x0, duration: 200, ease: 'Cubic.easeOut',
});
}
/**
* Re-read the engine. Called after every turn and every action, so a fleet
* that was destroyed, merged or that arrived somewhere does not leave a stale
* panel behind: an unusable selection reports back and closes.
*/
refresh() {
if (!this.mode) return;
if ((this.mode === 'fleet' || this.mode === 'order')
&& (!this.fleet || !this.state.fleets.includes(this.fleet))) {
this.cb.onSelectionLost?.();
this.hide();
return;
}
// An order is quoted from where the fleet stands; if it is no longer
// standing anywhere, there is nothing left to confirm.
if (this.mode === 'order' && this.fleet.starIdx < 0) this.mode = 'fleet';
if (this.mode !== 'star') this.syncSelection();
this.rebuild();
}
destroy() {
this.detail?.close();
this.pool.destroy();
this.root.destroy();
}
// -------------------------------------------------------- text primitives
setHead(title, sub) {
this.titleText.setText(String(title).toUpperCase());
this.subText.setText(sub ?? '');
}
line(text, opts = {}) {
const {
size = 15, color = '#c8dcf0', x = PAD, gap = 4, wrap = COL - (x - PAD),
} = opts;
const t = this.scene.add.text(x, this.y, text, {
fontFamily: FONT, fontSize: `${size}px`, color, lineSpacing: 3,
wordWrap: { width: wrap },
});
this.body.add(t);
this.y += t.height + gap;
return t;
}
/** Is there room for another `px` of content above the action buttons? */
room(px) { return this.y + px < this.h - 90; }
heading(text) {
this.y += 10;
this.line(text.toUpperCase(), { size: 13, color: '#6f8aa3', gap: 2 });
this.body.add(this.scene.add.rectangle(PAD, this.y, COL, 1, ACCENT, 0.18).setOrigin(0, 0));
this.y += 8;
}
/** A framed one-line callout — used for hints and refusals. */
callout(text, colour) {
const t = this.scene.add.text(PAD + 10, this.y + 8, text, {
fontFamily: FONT, fontSize: '14px', color: colour, wordWrap: { width: COL - 20 },
});
const box = this.scene.add.rectangle(PAD, this.y, COL, t.height + 16,
Phaser.Display.Color.HexStringToColor(colour).color, 0.1).setOrigin(0, 0);
box.setStrokeStyle(1, Phaser.Display.Color.HexStringToColor(colour).color, 0.4);
this.body.add(box);
this.body.add(t);
this.y += t.height + 22;
}
/** A small square push-button; Button is too heavy for a /+ pair. */
tinyButton(x, y, size, label, fn, enabled = true) {
const r = this.scene.add.rectangle(x, y, size, size, enabled ? 0x16253c : 0x101a29)
.setStrokeStyle(1, ACCENT, enabled ? 0.55 : 0.18);
const t = this.scene.add.text(x, y - 1, label, {
fontFamily: FONT, fontSize: '18px', color: enabled ? '#cfe8ff' : '#3c4c60',
}).setOrigin(0.5);
this.body.add(r);
this.body.add(t);
if (!enabled) return;
r.setInteractive({ useHandCursor: true });
r.on('pointerover', () => r.setFillStyle(0x22405f));
r.on('pointerout', () => r.setFillStyle(0x16253c));
r.on('pointerup', fn);
}
/** Full-width action button parked at the bottom of the column. */
action(label, fn, opts = {}) {
const { enabled = true, width = COL, x = PAD + COL / 2, variant = 'solid' } = opts;
const b = new Button(this.scene, x, this.y + 24, label, enabled ? uiClick(this.scene, fn) : null,
{ width, height: 48, fontSize: 20, variant });
if (!enabled) b.setEnabled?.(false);
this.body.add(b);
this.y += 60;
return b;
}
// ------------------------------------------------------------ the rebuild
rebuild() {
this.body.removeAll(true);
// The pool survives the wipe; claim what this pass actually uses and let
// endFrame() hide and pause the rest.
this.pool.beginFrame();
this.y = 90;
if (this.mode === 'star') this.buildStar();
else if (this.mode === 'fleet') this.buildFleet();
else if (this.mode === 'order') this.buildOrder();
this.pool.endFrame();
}
// ------------------------------------------------------------ ship media
/** Species flying a given fleet — the row art is keyed on it. */
speciesOf(empireIdx) { return this.state.empires[empireIdx].speciesId; }
/**
* Commander video + upside-down hull, side by side, vertically centred on
* `cy`. The video comes from the pool; the hull is a plain Image and is
* cheap enough to rebuild with the rest of the body. They land in different
* containers but share the panel's local coordinates, so they stay aligned.
*/
shipThumbs(empireIdx, hullId, x, cy, size) {
const speciesId = this.speciesOf(empireIdx);
this.pool.get(speciesId, hullId, x + size / 2, cy, size);
this.body.add(makeShipIcon(
this.scene, this.rules, this.art, speciesId, hullId,
x + size * 1.5 + 6, cy, size,
));
}
/** A transparent click target over a row, opening the detail window. */
detailHit(x, y, w, h, empireIdx, hullId, mark) {
const r = this.scene.add.rectangle(x, y, w, h, 0xffffff, 0.001)
.setOrigin(0, 0).setInteractive({ useHandCursor: true });
r.on('pointerup', () => this.openDetail(empireIdx, hullId, mark));
this.body.add(r);
return r;
}
/**
* The full-resolution pop-over. The panel's own thumbnails stop decoding
* while it is up — the window has its own portrait at 256px and there is no
* point running both.
*/
openDetail(empireIdx, hullId, mark = null) {
if (this.detail) return;
this.pool.setPaused(true);
this.detail = openShipDetail(
this.scene, this.rules, this.state, this.art,
{ empireIdx, hullId, mark },
() => {
this.detail = null;
if (this.root.visible) this.pool.setPaused(false);
},
);
}
/**
* A read-only `N × Name` line with thumbnails — the in-transit list, the
* garrison and the order screen's task force all take this shape. The
* task-force rows in fleet mode need the / + cluster and build their own.
*/
shipLine(empireIdx, hullId, mark, label, opts = {}) {
const { color = '#c8dcf0', size = 40, sub = null } = opts;
const rowY = this.y;
const textX = PAD + size * 2 + 20;
const name = this.scene.add.text(textX, rowY, label, {
fontFamily: FONT, fontSize: '15px', color, lineSpacing: 2,
wordWrap: { width: PAD + COL - textX },
});
this.body.add(name);
let textH = name.height;
if (sub) {
const s = this.scene.add.text(textX, rowY + name.height + 2, sub, {
fontFamily: FONT, fontSize: '12px', color: '#6f8aa3',
wordWrap: { width: PAD + COL - textX },
});
this.body.add(s);
textH += s.height + 2;
}
const rowH = Math.max(size, textH);
this.shipThumbs(empireIdx, hullId, PAD, rowY + rowH / 2, size);
this.detailHit(PAD, rowY, COL, rowH, empireIdx, hullId, mark);
this.y = rowY + rowH + 8;
}
get viewer() {
return this.viewerIdx >= 0 ? this.state.empires[this.viewerIdx] : null;
}
habitableCount(star) {
const viewer = this.viewer;
return viewer
? star.planets.filter((p, orbit) =>
habitableForEmpire(this.rules, this.state, viewer.idx, star.idx, orbit)).length
: star.planets.filter((p) => this.rules.planetTypes[p.typeId].colonizable).length;
}
// ---------------------------------------------------------------- STAR
buildStar() {
const { rules, state } = this;
const star = state.galaxy.stars[this.starIdx];
const cls = rules.starClasses[star.classId];
const viewer = this.viewer;
const explored = !viewer || viewer.explored[this.starIdx];
if (!explored) {
this.setHead('Unexplored', `${cls.name} star`);
this.line(cls.desc, { size: 14, color: '#8fa8c0' });
this.y += 8;
this.callout('Send a scout ship to chart this system.', '#e0b08a');
return;
}
this.setHead(star.name, `${cls.name} star`);
this.line(cls.desc, { size: 13, color: '#6f8aa3' });
this.y += 6;
this.line(star.planets.length
? `${star.planets.length} world${star.planets.length === 1 ? '' : 's'} · ${this.habitableCount(star)} habitable`
: 'No planets in this system.', { size: 15, color: '#9fb6cc' });
const colonies = coloniesAt(state, this.starIdx);
if (colonies.length) {
this.heading('Colonies');
for (const colony of colonies) this.colonyBlock(colony);
}
// A crowded system (several colonies, six worlds, a stack of fleets) can
// out-run the column. The panel does not scroll, so the lower sections give
// way rather than spilling out past the frame.
if (star.planets.length && this.room(40 + star.planets.length * 20)) {
this.heading('Worlds');
star.planets.forEach((planet, orbit) => {
const type = rules.planetTypes[planet.typeId];
const settled = colonies.some((c) => c.orbit === orbit);
const open = !settled && viewer
&& habitableForEmpire(rules, state, viewer.idx, this.starIdx, orbit);
this.line(
`${ORBIT[orbit] ?? orbit + 1} · ${type.name} · ${rules.planetSizes[planet.sizeId]?.name ?? ''}`
+ ` · ${rules.richness[planet.richId]?.name ?? ''}`,
{ size: 13, color: open ? '#7fd8a0' : (settled ? '#c8dcf0' : '#6f8aa3'), gap: 2 },
);
});
}
// The system is explored, so its orbits are visible — the same rule the map
// uses to decide which fleet markers to draw.
const fleets = fleetsAt(state, this.starIdx);
if (fleets.length && this.room(40 + fleets.length * 22)) {
this.heading('Forces in orbit');
for (const fleet of fleets) this.fleetRow(fleet);
}
// The system view is the only action a star itself offers; everything else
// there (sliders, build queue, invasion) lives inside it.
this.y = Math.max(this.y + 10, this.h - 76);
this.action('View System', () => this.cb.onViewSystem?.(this.starIdx));
}
colonyBlock(colony) {
const { rules, state } = this;
const owner = state.empires[colony.empireIdx];
const mine = colony.empireIdx === this.viewerIdx;
const planet = state.galaxy.stars[colony.starIdx].planets[colony.orbit];
if (colony.name) this.line(colony.name, { size: 16, color: '#e8f4ff', gap: 2 });
this.line(
`${owner.name}${colony.capital ? ' — capital' : ''}`
+ ` · ${ORBIT[colony.orbit] ?? colony.orbit + 1} ${rules.planetTypes[planet.typeId].name}`,
{ size: colony.name ? 13 : 16, color: owner.color, gap: 2 },
);
if (!mine) {
// What a rival tells you about their colony is what you can see from
// orbit: how many people live there, and whether they are shooting.
this.line(`Population ${colony.pop.toFixed(1)}`, { size: 14, color: '#9fb6cc', gap: 2 });
if (this.viewerIdx >= 0 && atWar(state, this.viewerIdx, colony.empireIdx)) {
this.line('At war — bombard and invade from the system view.',
{ size: 13, color: '#e08a8a' });
} else {
this.y += 6;
}
return;
}
const prod = colonyProduction(rules, state, colony);
this.line(
`Population ${colony.pop.toFixed(1)} / ${colonyMaxPop(rules, state, colony)}\n`
+ `Factories ${Math.floor(effectiveFactories(rules, state, colony))} / ${colonyFactoryCap(rules, state, colony)}\n`
+ `Output ${prod.toFixed(1)} BC per turn\n`
+ `Defences ${Math.round(colony.defenseHp)} / ${colonyDefenseCap(rules, state, colony)}`
+ (colony.waste > 0.5 ? `\nUncleaned waste ${colony.waste.toFixed(1)}` : ''),
{ size: 14, color: '#c8dcf0', gap: 4 },
);
if (colony.queue.length) {
const item = colony.queue[0];
const name = item.kind === 'building'
? rules.buildings[item.id].name
: empireDesign(rules, state, colony.empireIdx, item.id).name;
const eta = queueItemEta(rules, state, colony, item);
this.line(`Building ${name}${etaText(eta)}`, { size: 13, color: '#ffd88a' });
} else {
this.line('Idle — output spills into research.', { size: 13, color: '#6f8aa3' });
}
}
/** One clickable line per fleet in a system. */
fleetRow(fleet) {
const { rules, state } = this;
const emp = state.empires[fleet.empireIdx];
const mine = fleet.empireIdx === this.viewerIdx;
const ships = fleet.ships.reduce((t, s) => t + s.count, 0);
const label = `${mine ? 'Your fleet' : emp.name}${ships} ship${ships === 1 ? '' : 's'}`
+ ` · power ${fleetPower(rules, state, fleet)}`;
const t = this.line(label, { size: 14, color: mine ? '#9fd8ff' : emp.color, gap: 3 });
if (!mine) return;
t.setInteractive({ useHandCursor: true });
t.on('pointerover', () => t.setColor('#ffffff'));
t.on('pointerout', () => t.setColor('#9fd8ff'));
t.on('pointerup', () => this.cb.onSelectFleet?.(fleet));
}
// --------------------------------------------------------------- FLEET
/**
* Rebuild the per-stack selection, keeping whatever the player had already
* dialled in where the stack still exists. This carry-over only matters for
* refresh() (same selection, engine state changed under us); showFleet()
* clears this.sel first so a fresh selection always starts at full
* strength. Immobile hulls (star bases) are excluded outright rather than
* shown at zero: they defend the system they were built in and can never
* be part of a move order.
*/
syncSelection() {
const fleet = this.fleet;
if (!fleet) { this.sel = []; return; }
const prev = new Map(this.sel.map((s) => [`${s.hullId}|${s.mark}`, s.count]));
this.sel = [];
for (const s of fleet.ships) {
if (s.count <= 0) continue;
const d = empireDesign(this.rules, this.state, fleet.empireIdx, s.hullId);
if (d.immobile) continue;
const key = `${s.hullId}|${s.mark}`;
const want = prev.has(key) ? Math.min(prev.get(key), s.count) : s.count;
this.sel.push({ hullId: s.hullId, mark: s.mark, count: want, max: s.count });
}
}
selectedShips() {
return this.sel.filter((s) => s.count > 0).map((s) => ({ ...s }));
}
designName(hullId, mark) {
const d = empireDesign(this.rules, this.state, this.fleet.empireIdx, hullId);
if (d.mark === mark || d.hull.space <= 0) return d.name;
// A stack that has not been refitted yet keeps its own Mark in its name.
return `${d.hull.name} Mark ${markNumeral(mark)}`;
}
buildFleet() {
const { rules, state } = this;
const fleet = this.fleet;
const total = fleet.ships.reduce((t, s) => t + s.count, 0);
if (fleet.starIdx < 0) {
const to = state.galaxy.stars[fleet.toStar];
const from = state.galaxy.stars[fleet.fromStar];
this.setHead('Fleet', 'Under way');
this.line(`${from?.name ?? '?'}${to?.name ?? '?'}`, { size: 17, color: '#cfe8ff' });
this.etaLine(fleetEta(rules, state, fleet));
this.shipList();
this.y += 8;
this.callout('A fleet in transit cannot be redirected until it arrives.', '#e0b08a');
return;
}
const star = state.galaxy.stars[fleet.starIdx];
this.setHead('Fleet', `In orbit at ${star.name}`);
this.line(`${total} ship${total === 1 ? '' : 's'} · power ${fleetPower(rules, state, fleet)}`,
{ size: 15, color: '#9fb6cc' });
this.heading('Task force');
if (!this.sel.length) {
this.line('No mobile ships — this force holds the system.',
{ size: 14, color: '#e08a8a' });
} else {
this.line('Dial a stack down to leave those ships behind.',
{ size: 13, color: '#6f8aa3' });
this.y += 4;
this.sel.forEach((s, i) => this.stackRow(s, i));
this.y += 6;
this.selectionSummary();
}
// Garrison hulls, listed so their absence from the task force is explained
// rather than looking like ships that went missing.
const garrison = fleet.ships.filter((s) => s.count > 0
&& empireDesign(rules, state, fleet.empireIdx, s.hullId).immobile);
if (garrison.length) {
this.heading('Garrison');
for (const s of garrison) {
this.shipLine(fleet.empireIdx, s.hullId, s.mark,
`${s.count} × ${this.designName(s.hullId, s.mark)}`,
{ color: '#8fa8c0', sub: 'Holds this system' });
}
}
this.y = Math.max(this.y + 12, this.h - 138);
if (this.sel.length) {
this.callout('Click a destination star to plot a course.', '#6fc4ff');
}
this.action('Done', () => this.cb.onClose?.(), { variant: 'ghost' });
}
/**
* A ship stack with a /+ count selector. The name sits on the first line and
* the counter on the second, so the thumbnails can take the left gutter
* without squeezing either into a column too narrow to read.
*/
stackRow(s, index) {
const rowY = this.y;
const MEDIA = 60;
const textX = PAD + MEDIA * 2 + 20;
const name = this.scene.add.text(textX, rowY, `${this.designName(s.hullId, s.mark)}`, {
fontFamily: FONT, fontSize: '15px', color: s.count > 0 ? '#c8dcf0' : '#5d7085',
wordWrap: { width: PAD + COL - textX },
});
this.body.add(name);
const boxSize = 26;
const right = PAD + COL;
const ctrlY = rowY + name.height + 6 + boxSize / 2;
const rowH = Math.max(MEDIA, name.height + 6 + boxSize);
this.shipThumbs(this.fleet.empireIdx, s.hullId, PAD, rowY + rowH / 2, MEDIA);
// The hit target goes down BEFORE the buttons — it covers the whole row, so
// anything added after it stays clickable and anything before it does not.
this.detailHit(PAD, rowY, COL, rowH, this.fleet.empireIdx, s.hullId, s.mark);
const set = (v) => {
this.sel[index].count = Phaser.Math.Clamp(v, 0, s.max);
this.rebuild();
};
this.tinyButton(right - boxSize / 2, ctrlY, boxSize, '+', () => set(s.count + 1),
s.count < s.max);
this.tinyButton(right - boxSize * 2.9, ctrlY, boxSize, '', () => set(s.count - 1),
s.count > 0);
const count = this.scene.add.text(right - boxSize * 1.7, ctrlY - 1, `${s.count} / ${s.max}`, {
fontFamily: FONT, fontSize: '15px', color: s.count > 0 ? '#e8f4ff' : '#5d7085',
}).setOrigin(0.5);
this.body.add(count);
this.y = rowY + rowH + 10;
}
selectionSummary() {
const { rules, state } = this;
const ships = this.selectedShips();
const n = ships.reduce((t, s) => t + s.count, 0);
if (!n) {
this.line('Nothing selected.', { size: 14, color: '#e08a8a' });
return;
}
const probe = { ...this.fleet, ships };
const speed = fleetSpeed(rules, state, probe);
this.line(
`Selected ${n} ship${n === 1 ? '' : 's'} · power ${fleetPower(rules, state, probe)}`
+ ` · speed ${speed}`,
{ size: 14, color: '#9fd8ff' },
);
const left = this.fleet.ships.reduce((t, s) => t + s.count, 0) - n;
if (left > 0) {
this.line(`${left} ship${left === 1 ? '' : 's'} would stay behind as a separate fleet.`,
{ size: 13, color: '#6f8aa3' });
}
}
etaLine(eta) {
this.line(Number.isFinite(eta) ? `ETA ${eta} turn${eta === 1 ? '' : 's'}` : 'ETA — unreachable', {
size: 26, color: Number.isFinite(eta) ? '#7fd8a0' : '#e08a8a',
});
}
shipList() {
this.heading('Ships');
for (const s of this.fleet.ships) {
if (s.count <= 0) continue;
this.shipLine(this.fleet.empireIdx, s.hullId, s.mark,
`${s.count} × ${this.designName(s.hullId, s.mark)}`);
}
}
// --------------------------------------------------------------- ORDER
buildOrder() {
const { rules, state } = this;
const fleet = this.fleet;
const dest = state.galaxy.stars[this.orderStar];
const from = state.galaxy.stars[fleet.starIdx];
const viewer = this.viewer;
const explored = !viewer || viewer.explored[this.orderStar];
this.setHead(explored ? dest.name : 'Unexplored', 'Fleet order');
this.line(`${from?.name ?? '?'}${explored ? dest.name : 'unknown system'}`,
{ size: 17, color: '#cfe8ff' });
const ships = this.selectedShips();
const n = ships.reduce((t, s) => t + s.count, 0);
const distance = parsecs(state.galaxy, fleet.starIdx, this.orderStar);
const inRange = !!reachableStars(rules, state, fleet.empireIdx)[this.orderStar];
const sameStar = fleet.starIdx === this.orderStar;
const eta = n ? etaTo(rules, state, fleet, this.orderStar, ships) : Infinity;
this.line(`${distance.toFixed(1)} parsecs`, { size: 14, color: '#8fa8c0' });
this.y += 4;
this.etaLine(eta);
this.heading('Task force');
if (!n) {
this.line('No ships selected.', { size: 15, color: '#e08a8a' });
} else {
for (const s of ships) {
this.shipLine(fleet.empireIdx, s.hullId, s.mark,
`${s.count} × ${this.designName(s.hullId, s.mark)}`);
}
this.y += 4;
this.line(`Power ${fleetPower(rules, state, { ...fleet, ships })}`,
{ size: 14, color: '#9fd8ff' });
}
// What is waiting there, as far as the player is allowed to know.
if (explored) {
const colonies = coloniesAt(state, this.orderStar);
const hostiles = colonies.filter((c) => c.empireIdx !== this.viewerIdx);
const enemyFleets = fleetsAt(state, this.orderStar)
.filter((f) => f.empireIdx !== this.viewerIdx);
if (hostiles.length || enemyFleets.length) {
this.heading('Opposition');
for (const c of hostiles) {
const owner = state.empires[c.empireIdx];
const war = this.viewerIdx >= 0 && atWar(state, this.viewerIdx, c.empireIdx);
this.line(`${owner.name} colony — pop ${c.pop.toFixed(1)}${war ? ' — at war' : ''}`,
{ size: 14, color: war ? '#e08a8a' : owner.color, gap: 2 });
}
for (const f of enemyFleets) {
const owner = state.empires[f.empireIdx];
const count = f.ships.reduce((t, s) => t + s.count, 0);
this.line(`${owner.name} fleet — ${count} ship${count === 1 ? '' : 's'}`,
{ size: 14, color: owner.color, gap: 2 });
}
}
}
let refusal = null;
if (sameStar) refusal = 'The fleet is already in this system.';
else if (!n) refusal = 'Select at least one ship to send.';
else if (!inRange) refusal = 'Beyond fuel range. Research propulsion, or plant a colony closer.';
else if (!Number.isFinite(eta)) refusal = 'Nothing in this task force can move.';
this.y = Math.max(this.y + 12, this.h - 150);
if (refusal) this.callout(refusal, '#e08a8a');
const half = (COL - 12) / 2;
const btnY = this.y;
// Not uiClick — accepting an order plays its own vega-warp cue once the
// fleet is actually sent (MasterOfVegaGame.confirmOrder), not a generic
// click, and a fuel-range refusal should stay silent either way.
const accept = new Button(this.scene, PAD + half / 2, btnY + 24, 'Accept',
refusal ? null : () => this.cb.onAcceptOrder?.(this.fleet, this.orderStar, this.selectedShips()),
{ width: half, height: 48, fontSize: 20, scheme: 'green' });
if (refusal) accept.setEnabled?.(false);
this.body.add(accept);
this.body.add(new Button(this.scene, PAD + half * 1.5 + 12, btnY + 24, 'Cancel',
uiClick(this.scene, () => this.cb.onCancelOrder?.()), { width: half, height: 48, fontSize: 20, variant: 'ghost', scheme: 'red' }));
this.y = btnY + 60;
}
}