// 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; } }