770 lines
30 KiB
JavaScript
770 lines
30 KiB
JavaScript
// 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;
|
||
}
|
||
}
|