Compare commits

...

4 Commits

Author SHA1 Message Date
Brian Fertig 508feea4c9 feat(mastervega): split colony management into full-screen colony view
Move colony management (sliders, build queue, catalogue) from the
system-view modal into a dedicated full-screen colony view
(VegaColonyView.js). The system view now inspects worlds read-only
with a "View Colony" button to open the management screen.

Key changes:
- Add VegaColonyView.js with colony screen, build queue flyout,
  scrolling catalogue, allocation sliders with padlocks, and
  planet-type backdrops
- Add world backdrop artwork support (1920×1080, one per planet type)
  declared in mastervega-artwork.json worldBackgrounds
- Add headless queue helpers to VegaLogic.js: collapseQueue,
  moveQueueRun, colonyBuildRate, queueEtas, enqueueMany
- Fix star map pointer blocking (drags no longer pan map through modal)
- Move ORBIT constant to VegaScreens.js, add D.colony depth layer
- Update docs (build-plan, sprites) and verification suite

The colony screen stacks over the system view without requiring a
second modal, hiding the system layer and pausing the orrery while
open.
2026-08-02 11:49:33 -06:00
Brian Fertig c42851f09c feat: add flowing route previews and fix orrery depth sorting
Replace the static destination ring with a flowing dashed line that indicates the fleet's path and direction of travel.
Introduce setSelectedFleet to highlight specific fleet markers and track live routes for fleets already underway.
Fix depth sorting in the system view (orrery) by grouping bodies and sorting by Y position on every frame.
2026-08-02 09:01:43 -06:00
Brian Fertig b0efbe3a44 feat(mastervega): add star map command panel and fleet detachment system
Introduce VegaSidePanel, a right-hand command panel with three modes:
star (system summary), fleet (ship count selectors), and order
(distance, ETA, Accept/Cancel). Replaces the inline fleet-order flow
with a callback-driven architecture where the scene owns selection
state and the panel only reads the engine and reports clicks.

Add fleet detachment logic to VegaLogic:
- splitFleet: carve a new idle fleet from an existing one
- sendDetachment: validate against actual stacks, normalize duplicates,
  probe reachability on a copy, and rollback on refusal
- etaTo: compute ETA scoped to the selected detachment (slowest hull
  in the selection, not the whole fleet)

VegaStarMap gains blockPointer (prevent pan/zoom over the panel),
onEmptyClick (dismiss panel by clicking void), and a rotating selection
reticle.

Section 4b adds ~32 verification checks covering ETA accuracy, ship
conservation, out-of-range refusals, duplicate normalization, star-base
immobility, and the idle-fleet merge rule.

Updates mastervega-build-plan.md to document the new panel and
verification section.
2026-08-02 08:38:22 -06:00
Brian Fertig a16e59067b refactor(mastervega): remove Tooltip dependency, show unexplored stars, and use tech-aware habitability
- Remove Tooltip import and onStarHover callback from MasterOfVegaGame; system view now stays closed for unexplored stars until a scout arrives.
- Make VegaStarMap own its Tooltip via attachTo, replacing the removed onStarHover handler.
- Show unexplored stars at all zoom levels (dimmed) so they remain hoverable targets and display an UNEXPLORED tooltip.
- Split out habitableForEmpire from canColonize so the star map and tooltips use the same tech-aware habitability check (planet type + current planetology tech) instead of the static colonizable flag.
- Change starting exploration: only the home system is explored; all other stars remain unexplored until a scout physically arrives.
2026-08-02 08:10:01 -06:00
29 changed files with 2560 additions and 145 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -30,6 +30,25 @@
"lithox": { "key": "vega-still-lithox", "path": "assets/images/vega/char-lithox.png" }
},
"_worldBackgroundsReadme": "Full-screen 1920x1080 OPAQUE backdrops for the colony screen, one per planetType id. These are NOT the 'planets' sheet — that one holds transparent 192x192 discs for the orrery. A null path falls back to a gradient painted from planetTypes[].color, so the roster can be finished one world at a time. The '-01' suffix is a variant slot. NOTE: the gas giant's file is spelled 'world-gas-giant-01.png' with a hyphen while the rules id is 'gasgiant' — the path below points at the real filename. 'jungle' is the one colonisable type still outstanding.",
"worldBackgrounds": {
"terran": { "key": "vega-world-terran", "path": "assets/images/vega/world-terran-01.png" },
"ocean": { "key": "vega-world-ocean", "path": "assets/images/vega/world-ocean-01.png" },
"jungle": { "key": "vega-world-jungle", "path": null },
"steppe": { "key": "vega-world-steppe", "path": "assets/images/vega/world-steppe-01.png" },
"arid": { "key": "vega-world-arid", "path": "assets/images/vega/world-arid-01.png" },
"desert": { "key": "vega-world-desert", "path": "assets/images/vega/world-desert-01.png" },
"tundra": { "key": "vega-world-tundra", "path": "assets/images/vega/world-tundra-01.png" },
"minimal": { "key": "vega-world-minimal", "path": "assets/images/vega/world-minimal-01.png" },
"barren": { "key": "vega-world-barren", "path": "assets/images/vega/world-barren-01.png" },
"dead": { "key": "vega-world-dead", "path": "assets/images/vega/world-dead-01.png" },
"inferno": { "key": "vega-world-inferno", "path": "assets/images/vega/world-inferno-01.png" },
"toxic": { "key": "vega-world-toxic", "path": "assets/images/vega/world-toxic-01.png" },
"radiated": { "key": "vega-world-radiated", "path": "assets/images/vega/world-radiated-01.png" },
"gasgiant": { "key": "vega-world-gasgiant", "path": "assets/images/vega/world-gas-giant-01.png" },
"asteroids": { "key": "vega-world-asteroids", "path": null }
},
"sheets": {
"ships": {
"key": "vega-ships",
@ -43,7 +62,7 @@
},
"planets": {
"key": "vega-planets",
"path": null,
"path": "assets/images/vega/planets.png",
"kind": "planet",
"frameWidth": 192,
"frameHeight": 192,

View File

@ -31,8 +31,97 @@ than `civilization/` (whose scene grew to 1792 lines).
| `VegaZoom.js` | Star-map zoom ladder (Phaser-free so it is headlessly checkable) |
**Render tier:** `MasterOfVegaGame.js` (scene), `VegaStarMap.js`,
`VegaNebula.js`, `VegaSystemView.js`, `VegaCombatView.js`, `VegaScreens.js`,
`VegaArt.js`, `VegaFx.js`.
`VegaSidePanel.js`, `VegaNebula.js`, `VegaSystemView.js`, `VegaColonyView.js`,
`VegaCombatView.js`, `VegaScreens.js`, `VegaArt.js`, `VegaFx.js`.
### The star map's command panel
`VegaSidePanel.js` is MOO1's right-hand column: docked, hidden until something
is selected, three modes — **star** (class, worlds, colonies with their
production read-out, forces in orbit, and a *View System* button), **fleet**
(one row per ship stack with a /+ count), **order** (distance, ETA, who is
waiting there, Accept / Cancel).
The panel owns no state. `MasterOfVegaGame` decides what is selected and calls
`showStar` / `showFleet` / `showOrder`; the panel only reads the engine and
reports clicks back through callbacks. Selecting a fleet arms it: the next star
click is a destination, not an inspection — except the fleet's *own* star, which
drops the selection and shows the system, because otherwise a fleet could never
be deselected by clicking where it already is.
**Splitting is the count selector, not a separate command.** `sendDetachment()`
sends the selection and leaves the rest behind as its own fleet. There is
deliberately no "split and stay" button: `consolidateFleets` merges idle fleets
in the same system at the start of the owner's next turn (trap 17), so a
detachment that does not leave immediately simply un-splits itself. A detachment
gets a fresh fleet id and therefore does **not** inherit the parent's fleet
leader.
Three things in `sendDetachment` are load-bearing and each is verified in
section 4b:
* The request is validated **against the actual stacks before totals are
compared** — otherwise asking for nine of a stack of three reads as "all of
them" and silently sends the whole fleet.
* Duplicate entries for one stack are **summed** before the availability check,
or 2 + 2 of a stack of 3 passes twice.
* A refusal is **total**: the reachability probe runs on a copy, so a rejected
order never leaves the fleet carved in two with the pieces going nowhere.
The star map gained `blockPointer` (a drag or a wheel starting over the panel
must not pan or zoom the galaxy underneath it) and `onEmptyClick`, which uses
the `currentlyOver` list Phaser passes to the input plugin's `pointerup` — an
empty list is a genuine click on the void, which is how the panel is dismissed.
### Colony management is two screens, not one
Everything about a world used to be crammed into the right-hand column of the
system-view modal: header, summary, five live sliders, the build queue, *and*
the whole add-to-queue catalogue. It did not fit, and it knew it — the catalogue
loop ended in a bare `break` once it ran off the bottom of the panel, silently
dropping buildings the player was entitled to build.
It is now split by what the player is actually doing:
* **`VegaSystemView.js`** *inspects* a world. The right column shows the planet
at the full 192px the `planets` sheet is cut at, its type/size/richness/
gravity, and — for our own colonies — a **read-only** picture of the same
numbers: allocation as five tinted bars, the queue as four lines with
cumulative ETAs. Nothing there responds to a click except **View Colony**.
* **`VegaColonyView.js`** *runs* one. Full screen, on the world's own backdrop
art, with a semi-transparent panel carrying the live sliders (each with a
padlock) and a **Build Queue** button that flies out a 760px queue manager to
its left: reorder with ▲▼, remove with ✕, and a scrolling catalogue where every
buildable quotes its cost, its ETA at the current construction rate, and what
it does.
The colony screen opens *directly* from the system view rather than through
`MasterOfVegaGame.openModal`, which refuses a second modal while one is up — and
this one has to stay up, because it is what keeps the star map inert underneath.
`D.colony` (70) sits above `D.modal` (60) so the two stack; the system view hides
its layer and pauses its orrery tick while the colony screen is in front, and
rebuilds on the way back.
Repeat counts are a **display** concern only. `enqueueMany` pushes N separate
entries, so `processColony` and the save format know nothing about repeats;
`collapseQueue` folds a run of identical ships back into one "× N" row for
rendering. A part-built entry never joins a run, so the head item's progress bar
always means something.
Four functions back all of this and they all live in `VegaLogic.js`, not in the
views — `collapseQueue`, `moveQueueRun`, `colonyBuildRate` and `queueEtas` — for
two reasons. The two screens quote the same numbers and must agree, and the
index arithmetic in `moveQueueRun` fails *silently* when it is wrong (see below),
so it needs to be somewhere the Node verifier can reach.
> `moveQueueRun` is the sharp edge. Moving a "× 5" row is five splices, and the
> two directions do **not** use the same indices: going **up**, the copies still
> behind keep their slots so both ends advance together (`+ k`); going **down**,
> pulling the head copy out slides the rest of the run into the slot it vacated,
> so the *same* pair of indices moves every copy. Using `+ k` for both — the
> obvious first guess — interleaves the run with its neighbour
> (`s,s,s,f,f` → `s,f,s,s,f`) without throwing. Section 6 asserts the
> contiguous result in both directions.
State is plain JSON with the RNG cursor inside it (`state.rngState`,
explicit-step mulberry32), so replaying a seed reproduces the galaxy, the
@ -156,6 +245,24 @@ Each of these was a real bug that produced a plausible-looking but broken game.
clamp has no slack. Kept Phaser-free so the verifier can assert the
invariant directly (section 3b).
21. **`slider()` only works inside a container at the origin.** Its drag maths
is `apply(p.x - c.x)` — a *screen* coordinate minus a *local* one. That is
only correct while the parent container sits at (0, 0), which every
`modalShell` layer does. Nest one inside a panel container at x=1416, or
anything that tweens, and the knob jumps by the parent's offset and drifts
during the animation. It is why `VegaColonyView`'s `panelLayer` is at the
origin with every child in absolute coordinates, and why the flyout — the
one layer that moves — deliberately contains no sliders.
22. **A mask is a rendering concern; Phaser still hit-tests through it.** The
colony screen's catalogue is the game's first scrolling list, and a row
scrolled out of sight remains perfectly clickable unless something says
otherwise. Every row handler asks `scroller.contains(pointer)` first. Two
related traps in the same helper: the wheel catcher must be added *below*
the content or it swallows every row click, and a `GeometryMask`'s Graphics
has its own world transform and does **not** follow a moving parent — the
flyout tween drags it along by hand in `onUpdate`.
## Balance reference (27-game AI soak)
```
@ -176,13 +283,26 @@ wins spread across 8 of 10 species
## Verification
```bash
node tools/verifyMasterOfVega.js # 972 checks, ~60s
node tools/verifyMasterOfVega.js --quick # 971 checks, ~15s
node tools/verifyMasterOfVega.js # ~1082 checks, ~60s
node tools/verifyMasterOfVega.js --quick # 1081 checks, ~15s
node tools/verifyMasterOfVega.js --games=50 # a deeper soak
```
Eleven sections; section 2 runs the real procedural painters against a Proxy fake
canvas, section 10 is the self-play soak with invariants and a turn-time budget.
Twelve sections; section 2 runs the real procedural painters against a Proxy fake
canvas and cross-checks every declared artwork path against the filesystem
(portraits, stills, world backdrops), section 4b is the fleet-order engine behind
the command panel, section 6 covers the colony economy plus the slider padlocks
and the queue reorder/repeat API the colony screen drives, and section 10 is the
self-play soak with invariants and a turn-time budget.
Note for section 6: the padlock and queue blocks mutate `st.colonies[0]`, and the
soak below them measures every colony against its own ceiling — so they snapshot
that colony's sliders/locked/queue and put them back. A unit test that leaves a
colony reconfigured makes the soak measure something else.
Note for 4b and anything like it: `addFleet` **merges into an existing fleet at
the same star**, so a test that adds ships to a homeworld is really testing "the
starting fleet plus mine" and its counts mean nothing. Clear `st.fleets` first.
**Never browser-tested.** Everything above is engine- and Node-verified only.

View File

@ -99,6 +99,7 @@ export const MANIFEST = {
(scene) => sheetsFrom(scene, 'mastervega-artwork', ['sheets']),
(scene) => videosFrom(scene, 'mastervega-artwork', 'portraitVideos'),
(scene) => imagesFromMap(scene, 'mastervega-artwork', 'portraitStills'),
(scene) => imagesFromMap(scene, 'mastervega-artwork', 'worldBackgrounds'),
image('vega-menu-bg', 'assets/images/vega/background-menu.png'),
image('vega-menu-title', 'assets/images/vega/menu-title.png'),
{ type: 'audio', key: 'laser-zap', path: 'assets/fx/laser-zap.mp3' },

View File

@ -7,7 +7,6 @@
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { Tooltip } from '../../ui/Tooltip.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { getGameSoundtrack } from '../../services/soundtrack.js';
import { playSound, SFX } from '../../ui/Sounds.js';
@ -21,6 +20,7 @@ import * as Logic from './VegaLogic.js';
import { runAITurn } from './VegaAI.js';
import VegaStarMap from './VegaStarMap.js';
import VegaSidePanel from './VegaSidePanel.js';
import VegaFx from './VegaFx.js';
import { openSystemView } from './VegaSystemView.js';
import { openCombatView } from './VegaCombatView.js';
@ -53,8 +53,6 @@ export default class MasterOfVegaGame extends Phaser.Scene {
console.info(`[MasterOfVega] procedural art for: ${procedural.join(', ')}`);
}
this.tooltip = new Tooltip(this, { depth: 70 });
try {
const { tracks, volume } = getGameSoundtrack(this);
if (tracks?.length) this.music = new MusicPlayer(this, tracks, volume);
@ -77,6 +75,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
teardown() {
resetSpeechQueue();
this.panel?.destroy();
this.map?.destroy();
this.fx?.destroy();
this.music?.destroy?.();
@ -467,9 +466,27 @@ export default class MasterOfVegaGame extends Phaser.Scene {
this.map = new VegaStarMap(this, this.rules, this.state, this.art, {
onStarClick: (idx) => this.onStarClick(idx),
onStarHover: (idx) => this.onStarHover(idx),
onFleetClick: (fleet) => this.onFleetClick(fleet),
onEmptyClick: () => this.clearSelection(),
blockWheel: () => this.modalOpen,
// A modal blocks the map outright; otherwise only the side panel's own
// footprint does (so dragging a slider there doesn't pan the galaxy
// underneath it). The old `!this.modalOpen && ...` form always
// short-circuited to false while a modal was open, which let drags
// pan the star map right through the System View window.
blockPointer: (p) => this.modalOpen || !!this.panel?.containsPoint(p.x, p.y),
});
// The command panel is the only place a selection is acted on; the scene
// just decides what is selected and hands it over.
this.panel = new VegaSidePanel(this, this.rules, this.state, this.art, {
viewerIdx: this.state.humanIndex,
onViewSystem: (idx) => this.openSystemViewFor(idx),
onSelectFleet: (fleet) => this.onFleetClick(fleet),
onAcceptOrder: (fleet, toStar, ships) => this.confirmOrder(fleet, toStar, ships),
onCancelOrder: () => { this.map?.clearRoutePreview(); this.panel.showFleet(this.selectedFleet); },
onSelectionLost: () => { this.selectedFleet = null; this.map?.setSelectedStar(-1); },
onClose: () => this.clearSelection(),
});
this.buildHud();
@ -544,6 +561,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
refreshAll() {
this.map?.refresh();
this.panel?.refresh();
this.refreshHud();
}
@ -560,26 +578,57 @@ export default class MasterOfVegaGame extends Phaser.Scene {
});
}
// Clicking a star means one of two things, and which one depends entirely on
// whether a fleet of ours is selected: with a fleet in hand the star is a
// destination and the panel asks for confirmation, otherwise it is just
// something to look at.
onStarClick(idx) {
if (this.modalOpen || this.busy) return;
// A pending fleet order consumes the click instead of opening the system.
if (this.pendingOrder && this.selectedFleet) {
const fleet = this.selectedFleet;
this.pendingOrder = false;
this.selectedFleet = null;
if (Logic.canSendFleet(this.rules, this.state, fleet, idx)) {
Logic.sendFleet(this.rules, this.state, fleet, idx);
const eta = Logic.fleetEta(this.rules, this.state, fleet);
this.log(`Fleet away — ${this.state.galaxy.stars[idx].name} in ${eta} turns.`);
playSound(this, 'ta-rocket-1');
this.refreshAll();
} else {
this.log('Out of fuel range. Research propulsion, or plant a colony closer.');
}
// Clicking the system the fleet is already sitting in is not a move order —
// it is a request to look at the place, so the selection is dropped and the
// star's own panel comes up (which lists the fleet, one click from getting
// it back). Clicking anywhere else with a fleet in hand quotes a route: the
// fleet keeps its own ring and a flowing dashed line marks the course
// instead of ringing the destination star.
if (this.selectedFleet && this.state.fleets.includes(this.selectedFleet)
&& this.selectedFleet.starIdx >= 0 && this.selectedFleet.starIdx !== idx) {
this.map.setRoutePreview(this.selectedFleet.starIdx, idx);
this.panel.showOrder(idx);
return;
}
this.map.setSelectedStar(idx);
this.selectedFleet = null;
this.panel.showStar(idx);
}
onFleetClick(fleet) {
if (this.modalOpen || this.busy) return;
// Someone else's fleet is not something we can give orders to, but the
// system it is sitting in is — clicking it reads as clicking that system,
// which with a fleet in hand is how an attack gets ordered.
if (fleet.empireIdx !== this.state.humanIndex) {
if (fleet.starIdx >= 0) this.onStarClick(fleet.starIdx);
return;
}
this.selectedFleet = fleet;
this.map.setSelectedFleet(fleet);
this.panel.showFleet(fleet);
}
clearSelection() {
if (this.modalOpen) return;
this.selectedFleet = null;
this.map?.setSelectedStar(-1);
this.panel?.hide();
}
openSystemViewFor(idx) {
const emp = this.state.empires[this.state.humanIndex];
// The system view stays closed until a scout has actually arrived; the
// panel is what an unexplored star has to say for itself.
if (emp && !emp.explored[idx]) return;
this.openModal((done) => openSystemView(this, this.rules, this.state, idx, this.art, {
viewerIdx: this.state.humanIndex,
onChanged: () => this.refreshAll(),
@ -587,19 +636,22 @@ export default class MasterOfVegaGame extends Phaser.Scene {
}));
}
onStarHover(idx) {
if (idx < 0) return;
const star = this.state.galaxy.stars[idx];
const emp = this.state.empires[this.state.humanIndex];
if (!star || (emp && !emp.explored[idx])) return;
// The panel's Accept. `ships` is the task force the player dialled in — the
// ships left out stay behind as a fleet of their own.
confirmOrder(fleet, toStar, ships) {
const dest = this.state.galaxy.stars[toStar];
const sent = Logic.sendDetachment(this.rules, this.state, fleet, toStar, ships);
if (!sent) {
this.log('Out of fuel range. Research propulsion, or plant a colony closer.');
return;
}
onFleetClick(fleet) {
if (this.modalOpen || this.busy) return;
if (fleet.empireIdx !== this.state.humanIndex) return;
this.selectedFleet = fleet;
this.log(`Fleet selected — click a star within range to send it.`);
this.pendingOrder = true;
const eta = Logic.fleetEta(this.rules, this.state, sent);
this.log(`Fleet away — ${dest.name} in ${eta} turn${eta === 1 ? '' : 's'}.`);
playSound(this, 'ta-rocket-1');
this.selectedFleet = null;
this.panel.hide();
this.map.setSelectedStar(toStar);
this.refreshAll();
}
// ----------------------------------------------------------- turn driver

View File

@ -626,6 +626,26 @@ export function sizeSpeciesPortrait(obj, size) {
return obj;
}
// --------------------------------------------------------------------------
// World backdrops
//
// Full-screen 1920x1080 opaque art for the colony screen, one per planet type,
// declared in the artwork manifest's `worldBackgrounds` block. Deliberately NOT
// part of the `planets` sheet: that one holds transparent 192px discs for the
// orrery, which is a different picture of the same world.
//
// Same drop-in contract as the species portraits — a type with no art returns
// null and the caller paints a gradient from the type's own colour, so the set
// can be finished one world at a time with no code change.
export const worldBgKey = (typeId) => `vega-world-${typeId}`;
/** Loaded backdrop texture key for a planet type, or null if there is no art. */
export function worldBackground(scene, typeId) {
const key = worldBgKey(typeId);
return scene.textures.exists(key) ? key : null;
}
// Frame helpers — the one place that knows how the sheets are indexed.
export const shipFrame = (rules, speciesId, hullId) => {
const s = rules.species[speciesId];

View File

@ -0,0 +1,611 @@
// 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 '../../ui/Button.js';
import { FONT, D, ORBIT, slider } from './VegaScreens.js';
import { worldBackground, buildingFrame, shipFrame } from './VegaArt.js';
import {
CHANNELS, colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories,
colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany,
dequeue, collapseQueue, moveQueueRun, queueItemCost, queueEtas, empireDesign,
} 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;
/** Matches the read-only allocation bars in the system view. */
export const CHANNEL_COLOUR = {
ships: 0x6fc4ff, defense: 0xe08a8a, industry: 0xffd88a, ecology: 0x7fd8a0, research: 0xb89cff,
};
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). Kept local until a second caller
* wants it rather than promoted to src/ui/ on spec.
*
* 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.
*/
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. */
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);
// --- 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
root.add(scene.add.text(48, 40, `${star.name} ${ORBIT[colony.orbit] ?? colony.orbit + 1}`, {
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' : ''}`, {
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.
const flyLayer = scene.add.container(0, 0).setVisible(false);
root.add(flyLayer);
const panelLayer = scene.add.container(0, 0);
root.add(panelLayer);
const FLY_HIDDEN = PANEL_X - FLY_X; // parked exactly behind the panel
flyLayer.x = FLY_HIDDEN;
let flyOpen = false;
// 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 = []; };
// ------------------------------------------------------------------ close
let closed = false;
function close() {
if (closed) return;
closed = true;
dropScrollers();
scene.input.keyboard?.off('keydown-ESC', onEsc);
root.destroy();
onClose?.();
}
function onEsc() {
if (flyOpen) { toggleFlyout(); return; }
close();
}
scene.input.keyboard?.on('keydown-ESC', onEsc);
// ------------------------------------------------------------- the panel
function buildPanel() {
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, '✕', 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);
// 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;
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);
}
}
// Lowest priority: it is reference, and the flyout's catalogue already
// hides anything already built here.
if (colony.buildings.length && room(60)) {
heading('Built here');
text(colony.buildings.map((b) => rules.buildings[b].name).join(' · '),
13, '#ffd88a', 2, w);
}
y = buttonY;
add(new Button(scene, PANEL_X + PANEL_W / 2, y + 24,
flyOpen ? 'Close Build Queue' : 'Build Queue', toggleFlyout,
{ width: w, height: 48, fontSize: 20 }));
}
// ------------------------------------------------------------- the flyout
function toggleFlyout() {
flyOpen = !flyOpen;
scene.tweens.killTweensOf(flyLayer);
if (flyOpen) {
flyLayer.setVisible(true);
buildFlyout();
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 {
scene.tweens.add({
targets: flyLayer,
x: FLY_HIDDEN,
duration: 180,
ease: 'Sine.easeIn',
onUpdate: () => syncMasks(flyLayer.x),
onComplete: () => {
flyLayer.setVisible(false);
dropScrollers();
flyLayer.removeAll(true);
},
});
}
buildPanel();
}
function rebuildFlyout() {
buildFlyout();
buildPanel();
onChanged?.();
}
function buildFlyout() {
// 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);
dropScrollers();
flyLayer.removeAll(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, '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)) onAdd(); });
scroller.content.add(bg);
// Both sheets are optional and procedural until real art lands, so this
// always resolves to something.
scroller.content.add(scene.add.image(28, cy + 26, icon.key, icon.frame)
.setDisplaySize(40, 40));
scroller.content.add(scene.add.text(58, cy + 5, label, {
fontFamily: FONT, fontSize: '17px', color: colour,
}));
scroller.content.add(scene.add.text(58, cy + 27, sub, {
fontFamily: FONT, fontSize: '13px', color: '#7f97b3', wordWrap: { width: w - 180 },
}));
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)) 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) {
const d = empireDesign(rules, state, colony.empireIdx, hull.id);
entry({ key: art.ships, frame: shipFrame(rules, emp.speciesId, 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(); });
}
scroller.contentHeight = cy;
scroller.setOffset(keep[1] ?? 0);
}
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) {
const r = scene.add.rectangle(x, y, 26, 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: '15px', 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(); });
}
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;
}

View File

@ -166,6 +166,54 @@ export function colonyDefenseCap(rules, state, colony) {
return Math.round((100 + comps.planetaryShield * 30) * buildingMult(rules, colony, 'defense'));
}
/**
* BC per turn actually reaching the build queue, used for the "N turns" figures
* the colony screen quotes. That is the `ships` share PLUS the two spillovers
* `processColony` folds in: industry whose factories are already capped, and
* defence whose batteries are already topped up.
*
* It has to live here rather than in a view because two different screens quote
* the same ETA and they must agree. It is an estimate by nature mandatory
* ecology cleanup is taken pro-rata off every channel first, and how much that
* costs depends on waste generated during the turn so it is deliberately
* quoted before cleanup rather than trying to predict it.
*/
export function colonyBuildRate(rules, state, colony) {
const prod = colonyProduction(rules, state, colony);
let rate = prod * (colony.sliders.ships ?? 0);
if (colony.factories >= colonyFactoryCap(rules, state, colony)) {
rate += prod * (colony.sliders.industry ?? 0);
}
if (colony.defenseHp >= colonyDefenseCap(rules, state, colony)) {
rate += prod * (colony.sliders.defense ?? 0);
}
return Math.max(0, rate);
}
/** Turns for a queue item to finish at the current rate, or Infinity if idle. */
export function queueItemEta(rules, state, colony, item, alreadyQueued = 0) {
const rate = colonyBuildRate(rules, state, colony);
if (rate <= 0) return Infinity;
const remaining = queueItemCost(rules, state, colony, item) - (item.progress ?? 0);
return Math.max(1, Math.ceil((alreadyQueued + remaining) / rate));
}
/**
* Turns until each queue entry finishes, CUMULATIVE down the queue the engine
* spends on `queue[0]` alone, so an item's ETA includes everything ahead of it.
* Indexed to match `colony.queue`, so a collapsed run reads its figure off its
* own `lastIndex`. Both the colony screen and the system view quote these and
* they have to agree, which is why it is here rather than in either view.
*/
export function queueEtas(rules, state, colony) {
const rate = colonyBuildRate(rules, state, colony);
let acc = 0;
return colony.queue.map((item) => {
acc += Math.max(0, queueItemCost(rules, state, colony, item) - (item.progress ?? 0));
return rate > 0 ? Math.max(1, Math.ceil(acc / rate)) : Infinity;
});
}
export function colonyTrade(rules, state, colony) {
const emp = state.empires[colony.empireIdx];
const spec = rules.species[emp.speciesId];
@ -197,11 +245,21 @@ export function colonyGroundDefense(rules, state, colony) {
// Can this empire settle this planet at all?
export function canColonize(rules, state, e, starIdx, orbit) {
if (colonyAt(state, starIdx) && coloniesAt(state, starIdx).some((c) => c.orbit === orbit)) return false;
return habitableForEmpire(rules, state, e, starIdx, orbit);
}
// Whether this planet is within an empire's current colonisation reach —
// type + hostility vs. current planetology tech — regardless of whether it is
// already settled. Split out of canColonize so the star map and tooltip's "N
// habitable" readout can match what the colony screen actually offers instead
// of just the planet type's static colonizable flag (which ignores tech and
// made "1 habitable" show even for worlds too hostile to settle yet).
export function habitableForEmpire(rules, state, e, starIdx, orbit) {
const planet = state.galaxy.stars[starIdx]?.planets?.[orbit];
if (!planet) return false;
const type = rules.planetTypes[planet.typeId];
if (!type.colonizable) return false;
if (colonyAt(state, starIdx) && coloniesAt(state, starIdx).some((c) => c.orbit === orbit)) return false;
const emp = state.empires[e];
const spec = rules.species[emp.speciesId];
if (spec.traits.colonizeAnything) return true;
@ -383,12 +441,8 @@ export function createGame(rules, opts) {
colony.factories = rules.economy.startingFactories;
colony.capital = true;
state.empires[e].explored[starIdx] = true;
// Neighbours are charted from the start — nobody begins truly blind.
for (let i = 0; i < state.galaxy.stars.length; i += 1) {
if (parsecs(state.galaxy, starIdx, i) <= (rules.economy.baseFuelRange ?? 4)) {
state.empires[e].explored[i] = true;
}
}
// Everything else starts unexplored — a scout has to physically arrive
// (see moveFleets' `explored[f.starIdx] = true`) before its details show.
// Starting fleet: a scout and a colony ship, plus difficulty handicap ships.
const bonus = e === humanIndex ? 0 : diff.aiStartBonus;
addFleet(rules, state, e, starIdx, [
@ -839,6 +893,124 @@ export function fleetEta(rules, state, fleet) {
return Math.max(1, Math.ceil((fleet.total - fleet.progress) / speed));
}
// How long an order WOULD take, asked before it is given — the number the star
// map's order panel shows next to Accept. `ships` scopes it to a detachment the
// player has selected, whose speed is the slowest hull in the selection rather
// than the slowest in the whole fleet: leaving the colony ships at home is how
// you make a raid arrive this decade, so the ETA has to react to the selection.
export function etaTo(rules, state, fleet, toStar, ships = null) {
const from = fleet.starIdx >= 0 ? fleet.starIdx : fleet.toStar;
if (from < 0 || from === toStar) return 0;
const speed = fleetSpeed(rules, state, ships ? { ...fleet, ships } : fleet);
if (speed <= 0) return Infinity;
return Math.max(1, Math.ceil(parsecs(state.galaxy, from, toStar) / speed));
}
// Fold a [{hullId, mark, count}] request into one entry per stack, so asking
// for the same stack twice cannot slip past the "do you have this many?" check.
function normaliseTake(take) {
const out = new Map();
for (const t of take ?? []) {
const n = Math.floor(t.count ?? 0);
if (n <= 0) continue;
const key = `${t.hullId}|${t.mark}`;
const prev = out.get(key);
if (prev) prev.count += n;
else out.set(key, { hullId: t.hullId, mark: t.mark, count: n });
}
return [...out.values()];
}
const takeTotal = (take) => take.reduce((t, s) => t + s.count, 0);
const fleetTotal = (fleet) => fleet.ships.reduce((t, s) => t + Math.max(0, s.count), 0);
/**
* Detach part of an idle fleet into a fleet of its own, parked at the same star.
* Returns the new fleet, or null if the request was not satisfiable.
*
* Two idle fleets in one system are merged again by consolidateFleets at the
* start of the owner's next turn, which is MOO1's rule and deliberate so the
* only splitting the UI offers is "send some of these ships somewhere"
* (sendDetachment below), where the detachment leaves the same instant.
*
* The detachment gets a fresh fleet id and therefore does NOT inherit the
* parent's fleet leader; the leader stays with the fleet they were posted to.
*/
export function splitFleet(rules, state, fleet, take) {
if (!fleet || fleet.starIdx < 0 || fleet.toStar >= 0) return null;
const wanted = normaliseTake(take);
const taken = takeTotal(wanted);
if (taken <= 0 || taken >= fleetTotal(fleet)) return null;
// Verify the whole request before mutating anything, or a request that is
// half-satisfiable leaves the fleet carved up and the order refused.
const pairs = [];
for (const t of wanted) {
const src = fleet.ships.find((s) => s.hullId === t.hullId && s.mark === t.mark);
if (!src || src.count < t.count) return null;
pairs.push([src, t.count]);
}
const ships = [];
for (const [src, n] of pairs) {
src.count -= n;
ships.push({ ...src, count: n });
}
fleet.ships = fleet.ships.filter((s) => s.count > 0);
const detachment = {
id: state.nextFleetId += 1,
empireIdx: fleet.empireIdx,
starIdx: fleet.starIdx,
fromStar: -1, toStar: -1, progress: 0, total: 0,
ships,
};
state.fleets.push(detachment);
return detachment;
}
/**
* Send some of a fleet's ships to a star: the selection departs, the rest stay
* behind. Selecting everything is just sendFleet. Returns the fleet that is now
* under way, or null if the order was refused.
*/
export function sendDetachment(rules, state, fleet, toStar, take) {
if (!fleet) return null;
const wanted = normaliseTake(take);
const taken = takeTotal(wanted);
if (taken <= 0) return null;
// Check the request against the actual stacks BEFORE comparing totals, or
// asking for nine of a stack of three reads as "all of them" and quietly
// sends the whole fleet instead of refusing.
for (const t of wanted) {
const src = fleet.ships.find((s) => s.hullId === t.hullId && s.mark === t.mark);
if (!src || src.count < t.count) return null;
}
if (taken === fleetTotal(fleet)) {
return sendFleet(rules, state, fleet, toStar) ? fleet : null;
}
// Ask whether the DETACHMENT could fly before splitting it out, so a refused
// order never leaves the fleet in pieces.
const probe = { ...fleet, ships: wanted.map((t) => ({ ...t })) };
if (!canSendFleet(rules, state, probe, toStar)) return null;
const detachment = splitFleet(rules, state, fleet, wanted);
if (!detachment) return null;
if (!sendFleet(rules, state, detachment, toStar)) {
// Unreachable given the probe, but a half-split fleet would be a silent
// loss of ships, so put them back rather than trust the reasoning.
for (const s of detachment.ships) {
const m = fleet.ships.find((x) => x.hullId === s.hullId && x.mark === s.mark);
if (m) m.count += s.count;
else fleet.ships.push({ ...s });
}
state.fleets = state.fleets.filter((f) => f !== detachment);
return null;
}
return detachment;
}
function moveFleets(rules, state, e) {
for (const f of empireFleets(state, e)) {
if (f.toStar < 0) continue;
@ -1455,12 +1627,98 @@ export function enqueue(rules, state, colony, kind, id) {
return true;
}
/**
* Queue `n` copies of the same thing the repeat-count button on the colony
* screen. They are stored as n SEPARATE entries rather than one entry with a
* count, so `processColony` and the save format need no knowledge of repeats at
* all; the view is what collapses a run of identical ships into a "x N" row.
* Buildings are unique per colony, so `enqueue` refuses every copy after the
* first and this returns 1. Returns how many were actually added.
*/
export function enqueueMany(rules, state, colony, kind, id, n) {
let added = 0;
for (let i = 0; i < Math.max(0, Math.floor(n)); i += 1) {
if (!enqueue(rules, state, colony, kind, id)) break;
added += 1;
}
return added;
}
export function dequeue(rules, state, colony, index) {
if (index < 0 || index >= colony.queue.length) return false;
colony.queue.splice(index, 1);
return true;
}
/**
* Reorder the queue. `progress` is a property of the item object, so it travels
* with the move for free demoting the half-built head item banks its BC
* rather than losing them, and promoting something else does not hand it the
* head's progress. `processColony` only ever looks at `queue[0]`, so a splice
* between turns is safe.
*/
export function moveQueueItem(rules, state, colony, from, to) {
const n = colony.queue.length;
if (from < 0 || from >= n) return false;
const dest = Math.max(0, Math.min(n - 1, to));
if (dest === from) return true;
const [item] = colony.queue.splice(from, 1);
colony.queue.splice(dest, 0, item);
return true;
}
/**
* Collapse the queue into display runs: consecutive identical ships become one
* `{ index, lastIndex, item, count }` row. `enqueueMany` stores a repeat as N
* separate entries so the turn processor and the save format never learn about
* repeats, and this is what folds them back up for a UI.
*
* A part-built entry never joins a run its progress has to stay individually
* legible, and it is always at the head anyway, since slot 0 is the only one
* the engine ever spends on.
*/
export function collapseQueue(colony) {
const rows = [];
colony.queue.forEach((item, index) => {
const last = rows[rows.length - 1];
const runnable = item.kind === 'ship' && (item.progress ?? 0) === 0;
if (last && runnable && last.item.kind === 'ship' && last.item.id === item.id
&& (last.item.progress ?? 0) === 0) {
last.count += 1;
last.lastIndex = index;
return;
}
rows.push({ index, lastIndex: index, item, count: 1 });
});
return rows;
}
/**
* Move a whole collapsed run one row up (dir -1) or down (dir +1), where `rows`
* came from `collapseQueue`. A "x 5" row is five queue entries, so this is five
* splices and the two directions do NOT use the same indices, because every
* splice shifts what comes after it:
*
* up the copies still waiting behind keep their original slots, so both
* ends of the move advance together (`+ k`).
* down pulling the head copy out slides the rest of the run down into the
* slot it just vacated, so the SAME pair of indices moves every copy.
*
* Getting this wrong does not throw; it silently interleaves the run with its
* neighbour, which is why it is here in the headless tier and checked in the
* verifier rather than left in the view.
*/
export function moveQueueRun(rules, state, colony, rows, ri, dir) {
const run = rows[ri];
const neighbour = rows[ri + dir];
if (!run || !neighbour) return false;
for (let k = 0; k < run.count; k += 1) {
if (dir < 0) moveQueueItem(rules, state, colony, run.index + k, neighbour.index + k);
else moveQueueItem(rules, state, colony, run.index, neighbour.lastIndex);
}
return true;
}
export function hireLeader(rules, state, e, leaderId) {
const emp = state.empires[e];
const leader = rules.leaders[leaderId];

View File

@ -17,7 +17,16 @@ import { leaderOffers } from './VegaLeaders.js';
import { makeSpeciesPortrait } from './VegaArt.js';
export const FONT = '"Julius Sans One"';
export const D = { map: 1, hud: 30, modal: 60, toast: 80 };
// `colony` sits above `modal` so the colony screen can stack over the system
// view that opened it without either having to be torn down.
export const D = { map: 1, hud: 30, modal: 60, colony: 70, toast: 80 };
/**
* Orbit numerals. Worldgen rolls at most five planets, but
* `guaranteeNearbyWorlds` can append more to a star that needed help, so the
* list runs long and every caller still falls back to `orbit + 1`.
*/
export const ORBIT = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII'];
const ACCENT = 0x6fc4ff;
const PANEL = 0x0b1220;

View File

@ -0,0 +1,633 @@
// 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 '../../ui/Button.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,
} from './VegaLogic.js';
import { FONT, D, ORBIT } from './VegaScreens.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, '✕', () => {
this.cb.onClose?.();
}, { width: 38, height: 34, fontSize: 18, variant: 'ghost' }));
this.body = scene.add.container(0, 0);
this.root.add(this.body);
}
// 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;
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;
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.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.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 ? 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);
this.y = 90;
if (this.mode === 'star') this.buildStar();
else if (this.mode === 'fleet') this.buildFleet();
else if (this.mode === 'order') this.buildOrder();
}
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];
this.line(
`${owner.name}${colony.capital ? ' — capital' : ''}`
+ ` · ${ORBIT[colony.orbit] ?? colony.orbit + 1} ${rules.planetTypes[planet.typeId].name}`,
{ size: 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;
this.line(`Building ${name}`, { 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. 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.line(`${s.count} × ${this.designName(s.hullId, s.mark)} — holds this system`,
{ size: 13, color: '#8fa8c0', gap: 2 });
}
}
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. */
stackRow(s, index) {
const rowY = this.y;
const name = this.scene.add.text(PAD, rowY, `${this.designName(s.hullId, s.mark)}`, {
fontFamily: FONT, fontSize: '15px', color: s.count > 0 ? '#c8dcf0' : '#5d7085',
wordWrap: { width: COL - 130 },
});
this.body.add(name);
const boxSize = 26;
const right = PAD + COL;
const set = (v) => {
this.sel[index].count = Phaser.Math.Clamp(v, 0, s.max);
this.rebuild();
};
this.tinyButton(right - boxSize / 2, rowY + 12, boxSize, '+', () => set(s.count + 1),
s.count < s.max);
this.tinyButton(right - boxSize * 2.9, rowY + 12, boxSize, '', () => set(s.count - 1),
s.count > 0);
const count = this.scene.add.text(right - boxSize * 1.7, rowY + 11, `${s.count} / ${s.max}`, {
fontFamily: FONT, fontSize: '15px', color: s.count > 0 ? '#e8f4ff' : '#5d7085',
}).setOrigin(0.5);
this.body.add(count);
this.y = rowY + Math.max(name.height, boxSize) + 8;
}
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.line(`${s.count} × ${this.designName(s.hullId, s.mark)}`,
{ size: 14, color: '#c8dcf0', gap: 2 });
}
}
// --------------------------------------------------------------- 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.line(`${s.count} × ${this.designName(s.hullId, s.mark)}`,
{ size: 14, color: '#c8dcf0', gap: 2 });
}
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;
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 });
if (refusal) accept.setEnabled?.(false);
this.body.add(accept);
this.body.add(new Button(this.scene, PAD + half * 1.5 + 12, btnY + 24, 'Cancel',
() => this.cb.onCancelOrder?.(), { width: half, height: 48, fontSize: 20, variant: 'ghost' }));
this.y = btnY + 60;
}
}

View File

@ -13,11 +13,15 @@
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Tooltip } from '../../ui/Tooltip.js';
import { makeNebula } from './VegaNebula.js';
import { PARSEC_PX, parsecs, mulberry32 } from './VegaGalaxyGen.js';
import { reachableStars, coloniesAt, empireColonies, fleetEta } from './VegaLogic.js';
import {
reachableStars, coloniesAt, empireColonies, fleetEta, habitableForEmpire,
} from './VegaLogic.js';
import { starFrame } from './VegaArt.js';
import { buildZoomLadder, DEFAULT_ZOOM_INDEX } from './VegaZoom.js';
import { describeStarTooltip } from './VegaTooltips.js';
const FONT = '"Julius Sans One"';
@ -54,6 +58,8 @@ export default class VegaStarMap {
this.zoomIndex = Math.min(DEFAULT_ZOOM_INDEX, this.zooms.length - 1);
this.zoom = this.zooms[this.zoomIndex];
this.selectedStar = -1;
this.selectedFleet = null;
this.routePreview = null;
this.hoverStar = -1;
this.rangeDirty = true;
this.territoryDirty = true;
@ -65,6 +71,8 @@ export default class VegaStarMap {
ensureSoftDisc(scene, 'vega-soft-disc', 256);
this.tooltip = new Tooltip(scene, { depth: 70 });
// --- screen-space backdrop
this.bgLayer = scene.add.container(0, 0).setDepth(0);
this.nebula = makeNebula(scene, this.bgLayer, {
@ -95,6 +103,12 @@ export default class VegaStarMap {
this.starLayer = scene.add.container(0, 0);
this.root.add(this.starLayer);
// The selection reticle sits above the stars and below the fleet markers,
// so a fleet parked over its own star is never hidden by its own highlight.
this.selGfx = scene.add.graphics();
this.root.add(this.selGfx);
this.routeGfx = scene.add.graphics();
this.root.add(this.routeGfx);
this.fleetLayer = scene.add.container(0, 0);
this.root.add(this.fleetLayer);
this.labelLayer = scene.add.container(0, 0);
@ -178,6 +192,10 @@ export default class VegaStarMap {
hit.on('pointerover', () => { this.hoverStar = star.idx; this.cb.onStarHover?.(star.idx); });
hit.on('pointerout', () => { if (this.hoverStar === star.idx) this.hoverStar = -1; this.cb.onStarHover?.(-1); });
hit.on('pointerup', (p) => { if (!this.dragged) this.cb.onStarClick?.(star.idx, p); });
this.tooltip.attachTo(hit, () => {
const viewer = this.viewerIdx >= 0 ? this.state.empires[this.viewerIdx] : null;
return describeStarTooltip(this.rules, this.state, viewer, star.idx);
});
container.add(hit);
this.starLayer.add(container);
@ -274,7 +292,10 @@ export default class VegaStarMap {
const viewer = this.viewerIdx >= 0 ? state.empires[this.viewerIdx] : null;
for (const s of this.starSprites) {
const explored = !viewer || viewer.explored[s.star.idx];
s.container.setVisible(explored || this.isFarZoom);
// Unexplored stars stay visible (dimmed) at every zoom level so there is
// always something to hover/click for the UNEXPLORED tooltip and a scout
// target — only their name, ring and system contents are hidden.
s.container.setVisible(true);
s.body.setAlpha(explored ? 1 : 0.25);
const cols = coloniesAt(state, s.star.idx);
@ -394,7 +415,13 @@ export default class VegaStarMap {
// Closest zoom adds the system's contents.
if (this.isNearZoom) {
const habitable = s.star.planets.filter((p) => this.rules.planetTypes[p.typeId].colonizable).length;
// "Habitable" here means settleable at the viewer's CURRENT
// planetology, not just the planet type's static ceiling — a Radiated
// world is colonizable in principle but still shows as 0/1 until tech
// catches up, matching what the system view's colonize button says.
const habitable = viewer
? s.star.planets.filter((p, orbit) => habitableForEmpire(this.rules, state, viewer.idx, s.star.idx, orbit)).length
: s.star.planets.filter((p) => this.rules.planetTypes[p.typeId].colonizable).length;
if (s.star.planets.length) {
const sub = this.scene.add.text(
s.star.x, s.star.y + s.cls.radius * 2.2 + 32,
@ -407,12 +434,177 @@ export default class VegaStarMap {
}
}
// ---------------------------------------------------------- selection
/** Ring the system the command panel is currently talking about. -1 clears. */
setSelectedStar(idx) {
this.selectedStar = idx ?? -1;
this.selectedFleet = null;
this.clearRoutePreview();
this.drawSelection();
}
/**
* Ring a specific fleet marker instead of the star it happens to be at.
* A fleet that is already underway (or has a standing order) gets the same
* flowing route drawn to its live destination tracked by reference so it
* keeps pace with the fleet as it moves, not a snapshot of where it was.
*/
setSelectedFleet(fleet) {
this.selectedFleet = fleet ?? null;
this.selectedStar = -1;
if (fleet && fleet.toStar >= 0) {
this.routePreview = { fleet };
} else {
this.routePreview = null;
}
this.routeGfx.clear();
this.drawSelection();
if (this.routePreview) this.drawRoutePreview();
}
/** Flowing dashes from `fromIdx` to `toIdx` — the route a pending order will fly. */
setRoutePreview(fromIdx, toIdx) {
if (fromIdx == null || toIdx == null || fromIdx < 0 || toIdx < 0) {
this.clearRoutePreview();
return;
}
this.routePreview = { from: fromIdx, to: toIdx };
this.drawRoutePreview();
}
clearRoutePreview() {
this.routePreview = null;
this.routeGfx.clear();
}
/** Resolve the preview's start/end points either a fixed star pair (a
* pending click-to-order) or a live fleet reference (an underway fleet
* tracked to its destination as it flies). */
resolveRouteEndpoints() {
const rp = this.routePreview;
if (!rp) return null;
if (rp.fleet) {
const f = rp.fleet;
if (!this.state.fleets.includes(f) || f.toStar < 0) return null;
const dest = this.state.galaxy.stars[f.toStar];
if (!dest) return null;
let ax;
let ay;
if (f.starIdx >= 0) {
const star = this.state.galaxy.stars[f.starIdx];
if (!star) return null;
// Match the fleet marker's own offset from the star it sits at.
ax = star.x + 26;
ay = star.y - 22;
} else {
const from = this.state.galaxy.stars[f.fromStar];
if (!from) return null;
const t = f.total > 0 ? Phaser.Math.Clamp(f.progress / f.total, 0, 1) : 0;
ax = from.x + (dest.x - from.x) * t;
ay = from.y + (dest.y - from.y) * t;
}
return {
ax, ay, bx: dest.x, by: dest.y,
};
}
const from = this.state.galaxy.stars[rp.from];
const to = this.state.galaxy.stars[rp.to];
if (!from || !to) return null;
return {
ax: from.x, ay: from.y, bx: to.x, by: to.y,
};
}
drawRoutePreview() {
const g = this.routeGfx;
g.clear();
const pts = this.resolveRouteEndpoints();
if (!pts) return;
const a = { x: pts.ax, y: pts.ay };
const b = { x: pts.bx, y: pts.by };
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy);
if (len < 1) return;
const ux = dx / len;
const uy = dy / len;
// Marching dashes: a fixed dash/gap pattern whose phase slides toward the
// destination, reading as a current flowing along the route rather than a
// static line — the "ship will fly this way" cue the ring around the
// target star used to give less directly.
const dash = 22;
const gap = 16;
const period = dash + gap;
const speed = 90;
// d advances with time (not -phase) so the dashes drift from `a` toward
// `b` — matching the ship's actual direction of travel, not the reverse.
const phase = ((this.time / 1000) * speed) % period;
g.lineStyle(3, 0x9fd8ff, 0.85);
for (let d = phase - period; d < len; d += period) {
const s = Math.max(d, 0);
const e = Math.min(d + dash, len);
if (e <= s) continue;
g.beginPath();
g.moveTo(a.x + ux * s, a.y + uy * s);
g.lineTo(a.x + ux * e, a.y + uy * e);
g.strokePath();
}
// Arrowhead planted on the destination star, pointing along the route.
const ang = Math.atan2(dy, dx);
const ah = 11;
const tip = { x: b.x - ux * 6, y: b.y - uy * 6 };
g.fillStyle(0x9fd8ff, 0.9);
g.beginPath();
g.moveTo(tip.x, tip.y);
g.lineTo(tip.x - Math.cos(ang - 2.6) * ah, tip.y - Math.sin(ang - 2.6) * ah);
g.lineTo(tip.x - Math.cos(ang + 2.6) * ah, tip.y - Math.sin(ang + 2.6) * ah);
g.closePath();
g.fillPath();
}
drawSelection() {
const g = this.selGfx;
g.clear();
let cx;
let cy;
let r;
if (this.selectedFleet) {
const marker = this.fleetMarkers?.find((m) => m.fleet === this.selectedFleet);
if (!marker) return;
cx = marker.container.x;
cy = marker.container.y;
r = 20;
} else {
const s = this.starSprites?.[this.selectedStar];
if (!s) return;
cx = s.star.x;
cy = s.star.y;
r = s.cls.radius * 2.2 + 20;
}
// Four arcs rather than a circle: a broken reticle stays legible on top of
// an ownership ring, which a second full circle would not.
const spin = (this.time / 1000) * 0.5;
g.lineStyle(2, 0x9fd8ff, 0.95);
for (let i = 0; i < 4; i += 1) {
const a = spin + (i * Math.PI) / 2;
g.beginPath();
g.arc(cx, cy, r, a, a + 0.7);
g.strokePath();
}
}
// ------------------------------------------------------------- animation
update(_time, delta) {
this.time += delta;
const t = this.time / 1000;
if (this.selectedStar >= 0 || this.selectedFleet) this.drawSelection();
if (this.routePreview) this.drawRoutePreview();
for (const s of this.starSprites) {
if (s.cls.special === 'pulsar') {
s.body.setRotation(t * 1.4);
@ -507,6 +699,9 @@ export default class VegaStarMap {
let originY = 0;
scene.input.on('pointerdown', (p) => {
// A drag that starts on the command panel scrolls nothing — otherwise
// dialling a ship count would pan the galaxy out from under the panel.
if (this.cb.blockPointer?.(p)) return;
dragging = true;
this.dragged = false;
startX = p.x; startY = p.y;
@ -523,9 +718,17 @@ export default class VegaStarMap {
this.root.y = originY + dy;
this.clampPan();
});
scene.input.on('pointerup', () => { dragging = false; });
// Phaser emits the game objects' own pointerup handlers first and then this
// one, with everything under the cursor in `objs` — so an empty list is a
// genuine click on the void, which is how the panel gets dismissed.
scene.input.on('pointerup', (p, objs) => {
dragging = false;
if (!this.dragged && (!objs || objs.length === 0) && !this.cb.blockPointer?.(p)) {
this.cb.onEmptyClick?.(p);
}
});
scene.input.on('wheel', (p, _objs, _dx, dy) => {
if (this.cb.blockWheel?.(p)) return;
if (this.cb.blockWheel?.(p) || this.cb.blockPointer?.(p)) return;
this.applyZoom(this.zoomIndex + (dy > 0 ? -1 : 1), p.x, p.y);
});
}
@ -533,6 +736,7 @@ export default class VegaStarMap {
setViewer(idx) { this.viewerIdx = idx; this.rangeDirty = true; this.refresh(); }
destroy() {
this.tooltip.destroy();
this.nebula?.destroy();
this.bgLayer.destroy();
this.starfield.destroy();

View File

@ -1,25 +1,29 @@
// Master of Vega — the system view: a live orrery on the left, and the colony
// console on the right when the selected world is yours.
// Master of Vega — the system view: a live orrery on the left, and a planet
// inspector on the right.
//
// The orrery is where the closest semantic-zoom step lands, and it is the
// drop-in point for planet artwork: every body is a frame on the `planets`
// sheet, procedural until real art arrives.
//
// The right-hand column INSPECTS a world; it does not run one. Running a colony
// — the five live sliders and the build queue with its catalogue — needs far
// more room than one column of a modal, and now lives on its own full-screen
// screen behind the "View Colony" button (VegaColonyView.js). What is left here
// is a read-only picture of the same numbers, which is what you want when the
// question is "which of these six worlds should I be looking at".
import * as Phaser from 'phaser';
import { Button } from '../../ui/Button.js';
import { modalShell, slider, FONT } from './VegaScreens.js';
import { modalShell, FONT, ORBIT } from './VegaScreens.js';
import { planetFrame, starFrame } from './VegaArt.js';
import { openColonyView, CHANNEL_COLOUR, itemName, etaText } from './VegaColonyView.js';
import {
CHANNELS, coloniesAt, canColonize, colonize, colonyMaxPop, colonyProduction,
colonyFactoryCap, effectiveFactories, colonyDefenseCap, setSlider, enqueue, dequeue,
queueItemCost, empireDesign, invasionForecast, invade, bombard, atWar,
colonyFactoryCap, effectiveFactories, colonyDefenseCap, colonyBuildRate,
collapseQueue, queueItemCost, queueEtas, invasionForecast, invade, bombard, atWar,
} from './VegaLogic.js';
const CHANNEL_COLOUR = {
ships: 0x6fc4ff, defense: 0xe08a8a, industry: 0xffd88a, ecology: 0x7fd8a0, research: 0xb89cff,
};
export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
const { viewerIdx = state.humanIndex, onChanged = null, onClose = null } = opts;
const star = state.galaxy.stars[starIdx];
@ -30,10 +34,21 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
const orreryCY = shell.body.y + 380;
// --- star at the centre of the orrery
//
// The star and every planet share one sub-container so they can be
// depth-sorted by screen Y each tick — a Phaser Container renders its
// children in LIST order and ignores .depth entirely, so without an
// explicit sort a planet lower on screen (nearer the "camera" in this
// pseudo-3D tilted view) could render behind one higher up, whichever
// happened to be added first. orbitGfx sits fixed at y=0, below every body
// and the star, so it never needs re-sorting itself.
const orrery = scene.add.container(0, 0);
shell.add(orrery);
const starImg = scene.add.image(orreryCX, orreryCY, art.stars, Math.max(0, starFrame(rules, star.classId)))
.setDisplaySize(cls.radius * 9, cls.radius * 9)
.setBlendMode(cls.special === 'blackhole' ? Phaser.BlendModes.NORMAL : Phaser.BlendModes.ADD);
shell.add(starImg);
orrery.add(starImg);
shell.add(scene.add.text(shell.body.x, shell.body.y, `${cls.name} star — ${cls.desc}`, {
fontFamily: FONT, fontSize: '16px', color: '#7f97b3', wordWrap: { width: 700 },
@ -42,19 +57,47 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
// --- orbits and planets
const bodies = [];
const orbitGfx = scene.add.graphics();
shell.add(orbitGfx);
orrery.add(orbitGfx);
let selected = null;
star.planets.forEach((planet, i) => {
const type = rules.planetTypes[planet.typeId];
const size = 34 + (rules.planetSizes[planet.sizeId]?.basePop ?? 40) * 0.18;
// Orbit ellipses are stroked every tick alongside the bodies themselves.
const img = scene.add.image(0, 0, art.planets, planetFrame(rules, planet.typeId))
.setDisplaySize(34 + (rules.planetSizes[planet.sizeId]?.basePop ?? 40) * 0.18,
34 + (rules.planetSizes[planet.sizeId]?.basePop ?? 40) * 0.18)
.setDisplaySize(size, size)
.setInteractive({ useHandCursor: true });
img.on('pointerup', () => select(i));
shell.add(img);
bodies.push({ planet, img, type, angle: planet.orbitAngle, radius: planet.orbitRadius * 1.35 });
// The planet and its colony marker move together, so they share one
// container — that container's position (not the image's) is both what
// the tick loop sets and what orrery.sort('y') depth-sorts on.
const body = scene.add.container(0, 0);
body.add(img);
orrery.add(body);
// A small downward-pointing arrow hovering over any colonised world,
// tinted to its owner's colour. Hidden by default; the tick loop toggles
// it on/off (and recolours it) every frame so a colony founded or
// captured mid-view picks up the right owner immediately without
// rebuilding the orrery.
//
// The three points MUST be given in Phaser's own 0-to-width/0-to-height
// space (as its Triangle examples do), not pre-centred about (0,0).
// FillPathWebGL renders each raw point shifted by `-displayOriginX`
// (= originX * width) — with origin 0.5 that already re-centres a
// 0..width shape. Points that are already centred get shifted a SECOND
// time, landing the whole triangle half its width off to one side.
const markerBaseY = -(size / 2) - 13;
const marker = scene.add.triangle(0, markerBaseY, 8, 18, 0, 0, 16, 0, 0xffffff)
.setOrigin(0.5)
.setStrokeStyle(1.5, 0x0a1420, 0.85)
.setVisible(false);
body.add(marker);
bodies.push({
planet, img, body, marker, markerBaseY, type, angle: planet.orbitAngle, radius: planet.orbitRadius * 1.35,
});
});
// --- right-hand console
@ -77,18 +120,22 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
const colony = coloniesAt(state, starIdx).find((c) => c.orbit === selected);
let y = shell.body.y + 10;
console_.add(scene.add.text(panelX, y, `${star.name} ${['I', 'II', 'III', 'IV', 'V', 'VI'][selected] ?? selected + 1}`, {
fontFamily: FONT, fontSize: '28px', color: '#cfe8ff',
// The world at full resolution. The `planets` sheet is cut at 192x192 and
// every other use of it scales down, so this is the one place the art is
// seen at the size it was drawn — hence no setDisplaySize.
console_.add(scene.add.image(panelX + 96, y + 96, art.planets, planetFrame(rules, planet.typeId)));
console_.add(scene.add.text(panelX + 208, y + 18,
`${star.name} ${ORBIT[selected] ?? selected + 1}`, {
fontFamily: FONT, fontSize: '30px', color: '#cfe8ff',
}));
y += 40;
console_.add(scene.add.text(panelX, y,
`${type.name} · ${rules.planetSizes[planet.sizeId]?.name ?? ''} · `
console_.add(scene.add.text(panelX + 208, y + 60,
`${type.name} · ${rules.planetSizes[planet.sizeId]?.name ?? ''}\n`
+ `${rules.richness[planet.richId]?.name ?? ''} · ${rules.gravity[planet.gravId]?.name ?? ''}`, {
fontFamily: FONT, fontSize: '16px', color: '#9fb6cc',
fontFamily: FONT, fontSize: '17px', color: '#9fb6cc', lineSpacing: 4,
}));
y += 26;
y += 200;
console_.add(scene.add.text(panelX, y, type.desc, {
fontFamily: FONT, fontSize: '14px', color: '#6f8aa3', wordWrap: { width: panelW },
fontFamily: FONT, fontSize: '15px', color: '#6f8aa3', wordWrap: { width: panelW },
}));
y += 44;
@ -151,84 +198,86 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
return;
}
// --- our colony: the five MOO1 allocation sliders
// --- our colony: a read-out, not a console. Everything below is what the
// colony screen shows you editable; nothing here responds to a click.
const rate = colonyBuildRate(rules, state, colony);
console_.add(scene.add.text(panelX, y, 'ALLOCATION', {
fontFamily: FONT, fontSize: '17px', color: '#cfe8ff',
}));
y += 28;
const sliders = [];
CHANNELS.forEach((ch, i) => {
const s = slider(scene, panelX, y, panelW - 40,
rules.economy.channelNames[ch] ?? ch, colony.sliders[ch] ?? 0, (v) => {
setSlider(rules, state, colony, ch, v);
sliders.forEach((other, j) => {
if (j !== i) other.setValue(colony.sliders[CHANNELS[j]] ?? 0);
});
onChanged?.();
}, CHANNEL_COLOUR[ch]);
console_.add(s.container);
sliders.push(s);
y += 52;
});
console_.add(scene.add.text(panelX, y,
'Ecology is funded first — a shortfall is taken from the other channels automatically.', {
fontFamily: FONT, fontSize: '13px', color: '#6f8aa3', wordWrap: { width: panelW - 40 },
y += 30;
const barW = panelW - 200;
CHANNELS.forEach((ch) => {
const v = colony.sliders[ch] ?? 0;
console_.add(scene.add.text(panelX, y - 3, rules.economy.channelNames[ch] ?? ch, {
fontFamily: FONT, fontSize: '15px', color: '#a8c4e0',
}));
y += 34;
console_.add(scene.add.rectangle(panelX + 160, y + 4, barW, 8, 0x1b2b42).setOrigin(0, 0.5));
console_.add(scene.add.rectangle(panelX + 160, y + 4, barW * v, 8, CHANNEL_COLOUR[ch])
.setOrigin(0, 0.5));
console_.add(scene.add.text(panelX + panelW, y - 3,
`${Math.round(v * 100)}%${colony.locked[ch] ? ' ■' : ''}`, {
fontFamily: FONT, fontSize: '15px', color: colony.locked[ch] ? '#ffd88a' : '#e8f4ff',
}).setOrigin(1, 0));
y += 28;
});
y += 14;
// --- build queue
// --- build queue, read-only
console_.add(scene.add.text(panelX, y, 'BUILD QUEUE', {
fontFamily: FONT, fontSize: '17px', color: '#cfe8ff',
}));
// The number every ETA below is derived from — without it the turn counts
// look arbitrary.
console_.add(scene.add.text(panelX + panelW, y + 2,
`${rate.toFixed(1)} BC per turn`, {
fontFamily: FONT, fontSize: '15px', color: '#9fd8ff',
}).setOrigin(1, 0));
y += 28;
const rows = collapseQueue(colony);
if (!rows.length) {
console_.add(scene.add.text(panelX, y, 'Idle — construction spills into research.', {
fontFamily: FONT, fontSize: '15px', color: '#6f8aa3',
}));
y += 26;
if (!colony.queue.length) {
console_.add(scene.add.text(panelX, y, 'Idle — output spills into research.', {
}
// Cumulative, so a run's figure is when the LAST of it is done — which is
// why each row reads its ETA off its own lastIndex.
const etas = queueEtas(rules, state, colony);
rows.slice(0, 4).forEach((r, ri) => {
const cost = queueItemCost(rules, state, colony, r.item);
console_.add(scene.add.text(panelX, y,
`${itemName(rules, state, colony, r.item)}${r.count > 1 ? ` x ${r.count}` : ''}`
+ (ri === 0 ? ` ${Math.round(r.item.progress ?? 0)} / ${Math.round(cost)} BC` : '')
+ ` · ${etaText(etas[r.lastIndex])}`, {
fontFamily: FONT, fontSize: '15px', color: ri === 0 ? '#ffd88a' : '#8fa8c0',
}));
y += 24;
});
if (rows.length > 4) {
console_.add(scene.add.text(panelX, y, `…and ${rows.length - 4} more`, {
fontFamily: FONT, fontSize: '14px', color: '#6f8aa3',
}));
y += 24;
}
colony.queue.forEach((item, i) => {
const cost = queueItemCost(rules, state, colony, item);
const name = item.kind === 'building'
? rules.buildings[item.id].name
: empireDesign(rules, state, colony.empireIdx, item.id).name;
const row = scene.add.text(panelX, y, `${name} ${Math.round(item.progress)}/${Math.round(cost)}`, {
fontFamily: FONT, fontSize: '15px', color: '#c8dcf0',
}).setInteractive({ useHandCursor: true });
row.on('pointerup', () => { dequeue(rules, state, colony, i); onChanged?.(); rebuild(); });
console_.add(row);
y += 22;
});
y += 12;
// --- what can be added
console_.add(scene.add.text(panelX, y, 'ADD TO QUEUE (click to queue)', {
fontFamily: FONT, fontSize: '15px', color: '#8fa8c0',
}));
y += 24;
const emp = state.empires[colony.empireIdx];
let cx = panelX;
for (const hull of rules.hullList) {
const d = empireDesign(rules, state, colony.empireIdx, hull.id);
const t = scene.add.text(cx, y, `${d.name} (${d.cost})`, {
fontFamily: FONT, fontSize: '13px', color: '#9fd8ff',
}).setInteractive({ useHandCursor: true });
t.on('pointerup', () => { enqueue(rules, state, colony, 'ship', hull.id); onChanged?.(); rebuild(); });
console_.add(t);
y += 19;
}
y += 8;
for (const b of rules.buildingList) {
if (colony.buildings.includes(b.id)) continue;
if (b.prereq && !emp.known[b.prereq]) continue;
const t = scene.add.text(cx, y, `${b.name} (${b.cost})`, {
fontFamily: FONT, fontSize: '13px', color: '#ffd88a',
}).setInteractive({ useHandCursor: true });
t.on('pointerup', () => { enqueue(rules, state, colony, 'building', b.id); onChanged?.(); rebuild(); });
console_.add(t);
y += 19;
if (y > shell.y + shell.height - 60) break;
}
// The one action: hand the whole colony over to its own screen. Opened
// directly rather than through MasterOfVegaGame.openModal, which refuses a
// second modal while one is up — and this one has to stay up, because it is
// what is keeping the star map behind it inert.
y = Math.max(y + 20, shell.y + shell.height - 96);
console_.add(new Button(scene, panelX + panelW / 2, y + 24, 'View Colony', () => {
shell.layer.setVisible(false);
tick.paused = true;
openColonyView(scene, rules, state, colony, art, {
onChanged,
onClose: () => {
shell.layer.setVisible(true);
tick.paused = false;
rebuild();
},
});
}, { width: panelW, height: 50, fontSize: 21 }));
}
function select(i) {
@ -240,20 +289,36 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
if (star.planets.length) select(0);
// The orrery turns in real time; planets are where the drop-in art lands.
let pulseT = 0;
const tick = scene.time.addEvent({
delay: 33,
loop: true,
callback: () => {
orbitGfx.clear();
orbitGfx.lineStyle(1, 0x2c4468, 0.55);
for (const b of bodies) {
pulseT += 33;
// Subtle vertical bob, independent of orbit speed so every marker
// pulses at the same gentle rate regardless of which world it's over.
const bob = Math.sin(pulseT / 260) * 3;
const colonyByOrbit = new Map(coloniesAt(state, starIdx).map((c) => [c.orbit, c]));
bodies.forEach((b, i) => {
b.angle += b.planet.orbitSpeed * 0.012;
orbitGfx.strokeEllipse(orreryCX, orreryCY, b.radius * 2, b.radius * 1.1);
b.img.setPosition(
b.body.setPosition(
orreryCX + Math.cos(b.angle) * b.radius,
orreryCY + Math.sin(b.angle) * b.radius * 0.55,
);
const colony = colonyByOrbit.get(i);
b.marker.setVisible(!!colony);
if (colony) {
const owner = state.empires[colony.empireIdx];
b.marker.setFillStyle(Phaser.Display.Color.HexStringToColor(owner.color).color, 1);
b.marker.setPosition(0, b.markerBaseY + bob);
}
});
// Re-sort every tick: whichever body (or the star) sits lowest on
// screen this frame draws last, i.e. on top.
orrery.sort('y');
},
});

View File

@ -0,0 +1,53 @@
// Master of Vega — hover-tooltip content, brought over from the civilization
// game's tooltip system (src/games/civilization/CivilizationTooltips.js) and
// adapted for the star map. Rendering itself is the shared src/ui/Tooltip.js
// component; these functions only build the {title, lines} content it wants.
import { COLORS } from '../../config.js';
import { coloniesAt, habitableForEmpire } from './VegaLogic.js';
// Hover tooltip for a star on the map. `viewerEmp` is the human empire (or
// null in observer mode, which sees everything). An unexplored star only
// ever reveals its class — no name, no colonies, no planets — since that is
// the one thing visible from a distance without a scout in the system.
export function describeStarTooltip(rules, state, viewerEmp, starIdx) {
const star = state.galaxy.stars[starIdx];
const cls = rules.starClasses[star.classId];
const explored = !viewerEmp || viewerEmp.explored[starIdx];
if (!explored) {
return {
title: 'UNEXPLORED',
titleColor: COLORS.dangerHex,
lines: [
{ text: `${cls.name} star`, color: COLORS.goldHex },
{ text: cls.desc },
{ text: 'Send a scout ship to chart this system.', color: COLORS.mutedHex },
],
};
}
const lines = [{ text: `${cls.name} star`, color: COLORS.goldHex }];
const cols = coloniesAt(state, starIdx);
for (const c of cols) {
const owner = state.empires[c.empireIdx];
lines.push({
text: `${owner.name} colony — pop ${c.pop.toFixed(1)}${c.capital ? ' (capital)' : ''}`,
color: owner.color,
});
}
if (star.planets.length) {
// Matches the system view's colonize button: habitable at the viewer's
// CURRENT planetology, not just the planet type's static ceiling.
const habitable = viewerEmp
? star.planets.filter((p, orbit) => habitableForEmpire(rules, state, viewerEmp.idx, starIdx, orbit)).length
: star.planets.filter((p) => rules.planetTypes[p.typeId].colonizable).length;
lines.push({ text: `${star.planets.length} world${star.planets.length === 1 ? '' : 's'} · ${habitable} habitable` });
} else {
lines.push({ text: 'No planets in this system.', color: COLORS.mutedHex });
}
return { title: star.name, lines };
}

View File

@ -12,6 +12,10 @@ mismaps every piece of artwork already drawn.
Status: ⬜ = not painted yet (procedural fallback in use).
Two different pictures of a planet are used and they are **not** interchangeable:
the `planets` sheet (§2) is a transparent 192 px disc for the orrery, and the
world backdrops (§2b) are opaque 1920 × 1080 scenes for the colony screen.
---
## 1. `ships` — ⬜ `assets/images/vega/ships.png`
@ -78,6 +82,50 @@ both place these on a black background and scale them.
---
### 2b. World backdrops — ✅ mostly painted, `assets/images/vega/world-<type>-01.png`
**Not part of the `planets` sheet.** These are full-screen **1920 × 1080 opaque**
backdrops for the colony screen (`VegaColonyView.js`) — the same worlds seen from
the ground rather than from orbit. The sheet above stays what it is: transparent
192 px discs for the orrery.
Declared per planet type in `worldBackgrounds` in `data/mastervega-artwork.json`,
exactly like the species stills. A `null` path is a legitimate "not painted yet"
and falls back to a gradient built from that type's `color` in the rules file, so
the set can be finished one world at a time with no code change.
The `-01` suffix is a **variant slot** — nothing reads a variant index yet, but
the naming leaves room for `world-terran-02.png` and a roll.
| Type | File | |
|---|---|---|
| Terran | `world-terran-01.png` | ✅ |
| Ocean | `world-ocean-01.png` | ✅ |
| **Jungle** | — | ⬜ **outstanding** |
| Steppe | `world-steppe-01.png` | ✅ |
| Arid | `world-arid-01.png` | ✅ |
| Desert | `world-desert-01.png` | ✅ |
| Tundra | `world-tundra-01.png` | ✅ |
| Minimal | `world-minimal-01.png` | ✅ |
| Barren | `world-barren-01.png` | ✅ |
| Dead | `world-dead-01.png` | ✅ |
| Inferno | `world-inferno-01.png` | ✅ |
| Toxic | `world-toxic-01.png` | ✅ |
| Radiated | `world-radiated-01.png` | ✅ |
| Gas Giant | ⚠️ `world-gas-**giant**-01.png` | ✅ |
| Asteroid Belt | — | ⬜ not needed — neither type is colonisable, so the colony screen never opens on one |
> ⚠️ The gas giant's filename is `world-gas-giant-01.png` **with a hyphen** while
> its rules id is `gasgiant`. Same class of mismatch as `char-kestralli.png`: the
> manifest points at the real filename, and the verifier fails immediately if a
> declared path stops resolving.
Text is drawn over the top-left of these (name, owner, world details) with a drop
shadow, and the right ~500 px sits under a semi-transparent panel — so keep the
top-left readable and do not put the subject of the picture on the far right.
---
## 3. `stars` — ⬜ `assets/images/vega/stars.png`
| | |
@ -224,6 +272,7 @@ column 2 → pixel origin **(88, 264)**. **Painted.**
|---|---|---|
| `assets/images/vega/background-menu.png` | 1920 × 1080 | Backdrop for both menus — dimmed 34% on the landing screen, 62% on the setup screen so the species cards stay legible |
| `assets/images/vega/menu-title.png` | 1368 × 768 | Title logo. Large on the **left** of the landing screen (contained in 900 × 560 → 900 × 505), and small as the setup screen's header (contained in 760 × 132). Always contained, never stretched. |
| `assets/images/vega/world-*-01.png` | 1920 × 1080 | Colony-screen backdrops, one per planet type — see §2b. |
## Speech — ✅ `assets/speech/vega/`

View File

@ -37,7 +37,7 @@ import { buildZoomLadder, minZoomFor, MAX_ZOOM, DEFAULT_ZOOM_INDEX } from '../sr
// them is checkable headlessly and worth checking.
import {
ensureSheets, shipFrame, planetFrame, techFrame, buildingFrame,
speciesVideoKey, speciesStillKey, speciesSpeechClip, UI_SPEECH,
speciesVideoKey, speciesStillKey, speciesSpeechClip, UI_SPEECH, worldBgKey,
} from '../src/games/mastervega/VegaArt.js';
const QUICK = process.argv.includes('--quick');
@ -254,6 +254,33 @@ section('2. Procedural art');
check(`species ${s.id} has a portraitStills entry`, stillIds.includes(s.id));
}
// World backdrops for the colony screen. These are 1920x1080 opaque images,
// NOT frames on the `planets` sheet — that one holds transparent 192px discs
// for the orrery, which is a different picture of the same world. Same
// drop-in contract as the portraits: every type must be declared so a new one
// cannot be silently forgotten, a null path is a legitimate "not painted yet"
// that falls back to a gradient, and any path that IS set must resolve.
const worlds = artJson.worldBackgrounds ?? {};
const worldIds = Object.keys(worlds).filter((k) => !k.startsWith('_'));
for (const id of worldIds) {
check(`worldBackgrounds entry ${id} is a known planet type`, !!RULES.planetTypes[id]);
check(`worldBackgrounds ${id} key matches the loader's key`,
worlds[id].key === worldBgKey(id), `${worlds[id].key} vs ${worldBgKey(id)}`);
if (worlds[id].path) {
check(`worldBackgrounds ${id} file exists`, existsSync(join(root, worlds[id].path)),
worlds[id].path);
}
}
for (const p of RULES.planetTypeList) {
check(`planet type ${p.id} has a worldBackgrounds entry`, worldIds.includes(p.id));
}
const worldsPainted = worldIds.filter((id) => worlds[id].path).length;
const colonisablePainted = RULES.planetTypeList
.filter((p) => p.colonizable && worlds[p.id]?.path).length;
const colonisableTotal = RULES.planetTypeList.filter((p) => p.colonizable).length;
console.log(` (${worldsPainted}/${worldIds.length} world backdrops painted; `
+ `${colonisablePainted}/${colonisableTotal} of the colonisable types)`);
const recorded = vidIds.filter((id) => vids[id].path).length;
const stillCount = stillIds.filter((id) => stills[id].path).length;
console.log(` (${recorded}/${RULES.speciesList.length} portraits are video, `
@ -480,6 +507,179 @@ section('4. Ship Marks');
Ships.refitCost(RULES, known, 'cruiser', Ships.MAX_MARK, RULES.species.human.traits) === 0);
}
// ---------------------------------------------------------------------------
section('4b. Fleet orders and detachments');
// ---------------------------------------------------------------------------
{
// The star map's command panel gives orders through sendDetachment: the ships
// the player dialled in depart, the rest stay behind. Ships are the one thing
// in the game that cannot be conjured or lost silently, so every path here is
// checked for conservation as well as for doing the right thing.
const mk = () => {
const st = Logic.createGame(RULES, {
sizeId: 'medium', shapeId: 'spiral', seed: 77, difficultyId: 'normal',
speciesIds: ['human', 'kkrix', 'lithox'], humanIndex: 0,
});
st.rules = RULES;
// addFleet MERGES into an existing fleet at the same star, so the starting
// fleet has to go or every case below is really testing "starting fleet
// plus mine" and the counts stop meaning anything.
st.fleets = [];
return st;
};
const countShips = (st, e) => Logic.empireFleets(st, e)
.reduce((t, f) => t + f.ships.reduce((n, s) => n + s.count, 0), 0);
// A star this empire can actually reach, other than the one it is sitting on.
const targetFor = (st, fleet) => Object.keys(Logic.reachableStars(RULES, st, fleet.empireIdx))
.map(Number).find((i) => i !== fleet.starIdx);
{
const st = mk();
const home = st.galaxy.homeIdx[0];
const fleet = Logic.addFleet(RULES, st, 0, home,
[{ hullId: 'frigate', mark: 1, count: 4 }, { hullId: 'scout', mark: 1, count: 2 }]);
const before = countShips(st, 0);
const to = targetFor(st, fleet);
// ETA is asked BEFORE the order is given, and must react to the selection:
// a scout is faster than a frigate, so sending scouts alone is never slower.
const etaAll = Logic.etaTo(RULES, st, fleet, to);
const etaScouts = Logic.etaTo(RULES, st, fleet, to, [{ hullId: 'scout', mark: 1, count: 2 }]);
check('pre-order ETA is a positive whole number of turns',
Number.isInteger(etaAll) && etaAll >= 1, `${etaAll}`);
check('a faster detachment never arrives later than the whole fleet',
etaScouts <= etaAll, `${etaScouts} vs ${etaAll}`);
check('ETA to the star you are already at is zero',
Logic.etaTo(RULES, st, fleet, fleet.starIdx) === 0);
const sent = Logic.sendDetachment(RULES, st, fleet, to, [{ hullId: 'scout', mark: 1, count: 2 }]);
check('a detachment departs', !!sent && sent.toStar === to);
check('the detachment is a NEW fleet', sent !== fleet);
check('the detachment carries exactly what was asked for',
sent.ships.length === 1 && sent.ships[0].hullId === 'scout' && sent.ships[0].count === 2);
check('the rest of the fleet stays put', fleet.starIdx === home && fleet.toStar < 0);
check('the parent fleet keeps the ships that were left behind',
fleet.ships.reduce((t, s) => t + s.count, 0) === 4);
check('splitting conserves ships', countShips(st, 0) === before, `${countShips(st, 0)} vs ${before}`);
check('the committed ETA matches the one the panel quoted',
Logic.fleetEta(RULES, st, sent) === etaScouts,
`${Logic.fleetEta(RULES, st, sent)} vs ${etaScouts}`);
}
{
// Selecting everything is a plain move — no stray empty fleet left behind.
const st = mk();
const home = st.galaxy.homeIdx[0];
const fleet = Logic.addFleet(RULES, st, 0, home, [{ hullId: 'frigate', mark: 1, count: 3 }]);
const fleetsBefore = st.fleets.length;
const to = targetFor(st, fleet);
const sent = Logic.sendDetachment(RULES, st, fleet, to, [{ hullId: 'frigate', mark: 1, count: 3 }]);
check('selecting the whole fleet moves that fleet itself', sent === fleet);
check('a whole-fleet move creates no extra fleet', st.fleets.length === fleetsBefore);
}
{
// Refusals must be total: a rejected order leaves the fleet untouched
// rather than carved in two with the pieces going nowhere.
const st = mk();
const home = st.galaxy.homeIdx[0];
const fleet = Logic.addFleet(RULES, st, 0, home, [{ hullId: 'frigate', mark: 1, count: 3 }]);
const before = countShips(st, 0);
const fleetsBefore = st.fleets.length;
const reach = Logic.reachableStars(RULES, st, 0);
const far = st.galaxy.stars.findIndex((s) => !reach[s.idx]);
if (far >= 0) {
check('an out-of-range order is refused',
Logic.sendDetachment(RULES, st, fleet, far, [{ hullId: 'frigate', mark: 1, count: 1 }]) === null);
check('a refused order leaves the fleet whole',
st.fleets.length === fleetsBefore && countShips(st, 0) === before);
}
const to = targetFor(st, fleet);
check('an empty selection is refused',
Logic.sendDetachment(RULES, st, fleet, to, []) === null);
check('asking for more ships than exist is refused',
Logic.sendDetachment(RULES, st, fleet, to, [{ hullId: 'frigate', mark: 1, count: 9 }]) === null);
check('asking for a stack that is not there is refused',
Logic.sendDetachment(RULES, st, fleet, to, [{ hullId: 'battleship', mark: 1, count: 1 }]) === null);
check('every refusal conserved the fleet',
st.fleets.length === fleetsBefore && countShips(st, 0) === before);
}
{
// The same stack named twice must be summed, not checked twice against the
// same stock — otherwise 2 + 2 of a stack of 3 would both pass.
const st = mk();
const home = st.galaxy.homeIdx[0];
const fleet = Logic.addFleet(RULES, st, 0, home, [{ hullId: 'frigate', mark: 1, count: 3 }]);
const to = targetFor(st, fleet);
check('a duplicated stack request is summed before it is checked',
Logic.sendDetachment(RULES, st, fleet, to, [
{ hullId: 'frigate', mark: 1, count: 2 }, { hullId: 'frigate', mark: 1, count: 2 },
]) === null);
check('the fleet survived the duplicated request',
fleet.ships.reduce((t, s) => t + s.count, 0) === 3);
}
{
// A star base cannot sail. Sending the mobile half of a mixed fleet must
// leave the base behind, and a fleet of nothing but bases cannot be sent.
const st = mk();
const home = st.galaxy.homeIdx[0];
const fleet = Logic.addFleet(RULES, st, 0, home,
[{ hullId: 'starbase', mark: 1, count: 1 }, { hullId: 'frigate', mark: 1, count: 2 }]);
const before = countShips(st, 0);
const to = targetFor(st, fleet);
const sent = Logic.sendDetachment(RULES, st, fleet, to, [{ hullId: 'frigate', mark: 1, count: 2 }]);
check('the mobile half of a mixed fleet can be sent', !!sent && sent.toStar === to);
check('the star base is not carried along',
!!sent && sent.ships.every((s) => s.hullId !== 'starbase'));
check('the star base is still at home',
Logic.fleetsAt(st, home).some((f) => f.ships.some((s) => s.hullId === 'starbase')));
check('a mixed-fleet split conserves ships', countShips(st, 0) === before);
const bases = Logic.addFleet(RULES, st, 0, st.galaxy.homeIdx[0],
[{ hullId: 'starbase', mark: 1, count: 1 }]);
check('a fleet of nothing but star bases cannot be sent',
Logic.sendDetachment(RULES, st, bases, to, [{ hullId: 'starbase', mark: 1, count: 1 }]) === null);
}
{
// splitFleet on its own: the two halves must both be real fleets, and a
// fleet already under way cannot be split at all.
const st = mk();
const home = st.galaxy.homeIdx[0];
const fleet = Logic.addFleet(RULES, st, 0, home, [{ hullId: 'frigate', mark: 1, count: 5 }]);
const before = countShips(st, 0);
const half = Logic.splitFleet(RULES, st, fleet, [{ hullId: 'frigate', mark: 1, count: 2 }]);
check('splitFleet returns a new fleet at the same star',
!!half && half.starIdx === home && half.id !== fleet.id);
check('splitFleet conserves ships', countShips(st, 0) === before);
check('splitting off everything is refused',
Logic.splitFleet(RULES, st, fleet, [{ hullId: 'frigate', mark: 1, count: 3 }]) === null);
const to = targetFor(st, fleet);
Logic.sendFleet(RULES, st, fleet, to);
check('a fleet under way cannot be split',
Logic.splitFleet(RULES, st, fleet, [{ hullId: 'frigate', mark: 1, count: 1 }]) === null);
check('a fleet under way cannot be given a new order',
Logic.sendDetachment(RULES, st, fleet, home, [{ hullId: 'frigate', mark: 1, count: 1 }]) === null);
}
{
// Two idle fleets in one system are merged again at the start of the next
// turn (trap 17). That is the rule the panel is designed around — the only
// split it offers is one that departs immediately — so it has to hold.
const st = mk();
const home = st.galaxy.homeIdx[0];
const fleet = Logic.addFleet(RULES, st, 0, home, [{ hullId: 'frigate', mark: 1, count: 4 }]);
Logic.splitFleet(RULES, st, fleet, [{ hullId: 'frigate', mark: 1, count: 1 }]);
check('a split leaves two fleets in the system',
Logic.fleetsAt(st, home).filter((f) => f.empireIdx === 0).length === 2);
Logic.beginEmpireTurn(RULES, st, 0);
check('idle detachments merge back at the start of the turn',
Logic.fleetsAt(st, home).filter((f) => f.empireIdx === 0).length === 1);
}
}
// ---------------------------------------------------------------------------
section('5. Combat');
// ---------------------------------------------------------------------------
@ -591,6 +791,127 @@ section('6. Colony economy');
Logic.setSlider(RULES, st, colony, 'industry', 5);
check('slider values are clamped', colony.sliders.industry <= 1);
// The two blocks below poke at one colony's allocation and queue. The
// 200-turn soak further down measures every colony against its own ceiling,
// so put this one back exactly as it was found rather than handing the soak a
// colony configured by a unit test.
const restore = {
sliders: { ...colony.sliders }, locked: { ...colony.locked }, queue: [...colony.queue],
};
// --- padlocks. `colony.locked` has been honoured by setSlider since the
// engine was written but had no UI until the colony screen, so this is the
// first thing that proves the behaviour is what that screen assumes.
{
const c = st.colonies[0];
Logic.CHANNELS.forEach((ch) => { delete c.locked[ch]; });
Logic.setSlider(RULES, st, c, 'research', 0.2);
c.locked.research = true;
const pinned = c.sliders.research;
Logic.setSlider(RULES, st, c, 'industry', 0.7);
check('a locked channel holds its share', Math.abs(c.sliders.research - pinned) < 1e-9,
`${c.sliders.research} vs ${pinned}`);
const lockedSum = Logic.CHANNELS.reduce((t, ch) => t + c.sliders[ch], 0);
check('locking still normalises the whole allocation to 1',
Math.abs(lockedSum - 1) < 1e-6, `${lockedSum}`);
// A channel cannot claim room a lock has already spoken for. The colony
// screen relies on this to keep the last channel unlockable.
c.locked.ecology = true;
c.locked.defense = true;
Logic.setSlider(RULES, st, c, 'industry', 1);
const room = 1 - (c.sliders.research + c.sliders.ecology + c.sliders.defense);
check('a slider cannot take room the locks reserved',
c.sliders.industry <= room + 1e-9, `${c.sliders.industry} vs ${room}`);
Logic.CHANNELS.forEach((ch) => { delete c.locked[ch]; });
Logic.setSlider(RULES, st, c, 'industry', 0.4);
}
// --- build queue: repeats, reordering, and the ETA the colony screen quotes.
{
const c = st.colonies[0];
c.queue.length = 0;
check('enqueueMany queues every copy it was asked for',
Logic.enqueueMany(RULES, st, c, 'ship', 'scout', 3) === 3, `${c.queue.length}`);
check('repeats are stored as separate entries', c.queue.length === 3);
// Buildings are unique per colony, so a repeat request stops after the first.
const bId = RULES.buildingList.find((b) => !b.prereq).id;
check('enqueueMany refuses duplicate buildings',
Logic.enqueueMany(RULES, st, c, 'building', bId, 4) === 1);
c.queue.length = 0;
Logic.enqueue(RULES, st, c, 'ship', 'scout');
Logic.enqueue(RULES, st, c, 'ship', 'frigate');
Logic.enqueue(RULES, st, c, 'ship', 'destroyer');
c.queue[0].progress = 7;
check('moveQueueItem rejects an out-of-range source',
!Logic.moveQueueItem(RULES, st, c, 9, 0) && !Logic.moveQueueItem(RULES, st, c, -1, 0));
check('moving an item onto itself is a no-op that still succeeds',
Logic.moveQueueItem(RULES, st, c, 1, 1) && c.queue[1].id === 'frigate');
Logic.moveQueueItem(RULES, st, c, 2, 0);
check('moveQueueItem reorders the queue',
c.queue.map((q) => q.id).join(',') === 'destroyer,scout,frigate',
c.queue.map((q) => q.id).join(','));
// Progress lives on the item, so demoting the half-built head banks its BC
// rather than handing them to whatever was promoted over it.
check('progress travels with the item it belongs to',
c.queue[1].id === 'scout' && c.queue[1].progress === 7 && c.queue[0].progress === 0,
`${c.queue[0].id}:${c.queue[0].progress} ${c.queue[1].id}:${c.queue[1].progress}`);
check('a destination past the end clamps to the last slot',
Logic.moveQueueItem(RULES, st, c, 0, 99) && c.queue[2].id === 'destroyer');
// --- collapsed runs. A "x N" row is N queue entries, and moving one is N
// splices whose indices differ by direction — get it wrong and the run
// silently interleaves with its neighbour instead of throwing.
c.queue.length = 0;
Logic.enqueueMany(RULES, st, c, 'ship', 'scout', 3);
Logic.enqueueMany(RULES, st, c, 'ship', 'frigate', 2);
let runs = Logic.collapseQueue(c);
check('identical consecutive ships collapse into one run',
runs.length === 2 && runs[0].count === 3 && runs[1].count === 2,
runs.map((r) => `${r.item.id}x${r.count}`).join(','));
check('a run records the span it covers',
runs[0].index === 0 && runs[0].lastIndex === 2 && runs[1].index === 3);
Logic.moveQueueRun(RULES, st, c, runs, 0, 1);
check('moving a run down keeps it contiguous',
c.queue.map((q) => q.id).join(',') === 'frigate,frigate,scout,scout,scout',
c.queue.map((q) => q.id).join(','));
runs = Logic.collapseQueue(c);
Logic.moveQueueRun(RULES, st, c, runs, 1, -1);
check('moving a run back up restores the original order',
c.queue.map((q) => q.id).join(',') === 'scout,scout,scout,frigate,frigate',
c.queue.map((q) => q.id).join(','));
check('moving a run off either end is refused',
!Logic.moveQueueRun(RULES, st, c, runs, 0, -1)
&& !Logic.moveQueueRun(RULES, st, c, runs, runs.length - 1, 1));
// A part-built entry must stand alone: its progress bar has to stay
// legible, and it is the one slot the engine actually spends on.
c.queue[0].progress = 5;
runs = Logic.collapseQueue(c);
check('a part-built entry does not join the run behind it',
runs[0].count === 1 && runs[0].item.progress === 5 && runs[1].count === 2,
runs.map((r) => `${r.item.id}x${r.count}`).join(','));
c.queue[0].progress = 0;
const rate = Logic.colonyBuildRate(RULES, st, c);
check('colonyBuildRate is finite and non-negative', Number.isFinite(rate) && rate >= 0, `${rate}`);
const eta = Logic.queueItemEta(RULES, st, c, c.queue[0]);
check('a funded queue quotes a whole number of turns',
rate <= 0 || (Number.isInteger(eta) && eta >= 1), `${eta}`);
// Nothing reaching construction must read as "never", not as 0 turns.
const idle = { ...c, sliders: { ...c.sliders, ships: 0, industry: 0, defense: 0 } };
check('an unfunded queue reports no ETA rather than an instant one',
Logic.queueItemEta(RULES, st, idle, c.queue[0]) === Infinity);
}
colony.sliders = restore.sliders;
colony.locked = restore.locked;
colony.queue = restore.queue;
// Run a long stretch and assert the colony stays healthy without any AI.
for (let i = 0; i < 200 * st.empires.length; i += 1) {
Logic.beginEmpireTurn(RULES, st, st.current);