Compare commits
4 Commits
7f1cfcc982
...
508feea4c9
| Author | SHA1 | Date |
|---|---|---|
|
|
508feea4c9 | |
|
|
c42851f09c | |
|
|
b0efbe3a44 | |
|
|
a16e59067b |
|
After Width: | Height: | Size: 745 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}));
|
||||
y += 40;
|
||||
console_.add(scene.add.text(panelX, y,
|
||||
`${type.name} · ${rules.planetSizes[planet.sizeId]?.name ?? ''} · `
|
||||
+ `${rules.richness[planet.richId]?.name ?? ''} · ${rules.gravity[planet.gravId]?.name ?? ''}`, {
|
||||
fontFamily: FONT, fontSize: '16px', color: '#9fb6cc',
|
||||
// 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 += 26;
|
||||
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: '17px', color: '#9fb6cc', lineSpacing: 4,
|
||||
}));
|
||||
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',
|
||||
}));
|
||||
y += 26;
|
||||
if (!colony.queue.length) {
|
||||
console_.add(scene.add.text(panelX, y, 'Idle — output spills into research.', {
|
||||
// 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;
|
||||
}
|
||||
// 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');
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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/`
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||