feat(mastervega): split colony management into full-screen colony view
Move colony management (sliders, build queue, catalogue) from the system-view modal into a dedicated full-screen colony view (VegaColonyView.js). The system view now inspects worlds read-only with a "View Colony" button to open the management screen. Key changes: - Add VegaColonyView.js with colony screen, build queue flyout, scrolling catalogue, allocation sliders with padlocks, and planet-type backdrops - Add world backdrop artwork support (1920×1080, one per planet type) declared in mastervega-artwork.json worldBackgrounds - Add headless queue helpers to VegaLogic.js: collapseQueue, moveQueueRun, colonyBuildRate, queueEtas, enqueueMany - Fix star map pointer blocking (drags no longer pan map through modal) - Move ORBIT constant to VegaScreens.js, add D.colony depth layer - Update docs (build-plan, sprites) and verification suite The colony screen stacks over the system view without requiring a second modal, hiding the system layer and pausing the orrery while open.
|
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,8 @@ 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`,
|
||||
`VegaSidePanel.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
|
||||
|
||||
|
|
@ -73,6 +73,56 @@ 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
|
||||
battles and the winner exactly. `serialize()` strips `rules` and every
|
||||
|
|
@ -195,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)
|
||||
|
||||
```
|
||||
|
|
@ -215,14 +283,22 @@ wins spread across 8 of 10 species
|
|||
## Verification
|
||||
|
||||
```bash
|
||||
node tools/verifyMasterOfVega.js # ~1004 checks, ~60s
|
||||
node tools/verifyMasterOfVega.js --quick # 1003 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
|
||||
```
|
||||
|
||||
Twelve sections; section 2 runs the real procedural painters against a Proxy fake
|
||||
canvas, section 4b is the fleet-order engine behind the command panel, section 10
|
||||
is the self-play soak with invariants and a turn-time budget.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -469,7 +469,12 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
|||
onFleetClick: (fleet) => this.onFleetClick(fleet),
|
||||
onEmptyClick: () => this.clearSelection(),
|
||||
blockWheel: () => this.modalOpen,
|
||||
blockPointer: (p) => !this.modalOpen && !!this.panel?.containsPoint(p.x, p.y),
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
@ -1579,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;
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import {
|
|||
colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories,
|
||||
colonyDefenseCap, habitableForEmpire, reachableStars, atWar,
|
||||
} from './VegaLogic.js';
|
||||
import { FONT, D } from './VegaScreens.js';
|
||||
import { FONT, D, ORBIT } from './VegaScreens.js';
|
||||
|
||||
const W = 400;
|
||||
const PAD = 18;
|
||||
|
|
@ -38,8 +38,6 @@ const COL = W - PAD * 2;
|
|||
const ACCENT = 0x6fc4ff;
|
||||
const PANEL = 0x0b1220;
|
||||
|
||||
const ORBIT = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII'];
|
||||
|
||||
export default class VegaSidePanel {
|
||||
constructor(scene, rules, state, art, cb = {}) {
|
||||
this.scene = scene;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
@ -58,14 +62,42 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
|
|||
|
||||
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));
|
||||
orrery.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
|
||||
|
|
@ -88,18 +120,22 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
|
|||
const colony = coloniesAt(state, starIdx).find((c) => c.orbit === selected);
|
||||
let y = shell.body.y + 10;
|
||||
|
||||
console_.add(scene.add.text(panelX, y, `${star.name} ${['I', 'II', 'III', 'IV', 'V', 'VI'][selected] ?? selected + 1}`, {
|
||||
fontFamily: FONT, fontSize: '28px', color: '#cfe8ff',
|
||||
// The world at full resolution. The `planets` sheet is cut at 192x192 and
|
||||
// every other use of it scales down, so this is the one place the art is
|
||||
// seen at the size it was drawn — hence no setDisplaySize.
|
||||
console_.add(scene.add.image(panelX + 96, y + 96, art.planets, planetFrame(rules, planet.typeId)));
|
||||
console_.add(scene.add.text(panelX + 208, y + 18,
|
||||
`${star.name} ${ORBIT[selected] ?? selected + 1}`, {
|
||||
fontFamily: FONT, fontSize: '30px', color: '#cfe8ff',
|
||||
}));
|
||||
y += 40;
|
||||
console_.add(scene.add.text(panelX, y,
|
||||
`${type.name} · ${rules.planetSizes[planet.sizeId]?.name ?? ''} · `
|
||||
console_.add(scene.add.text(panelX + 208, y + 60,
|
||||
`${type.name} · ${rules.planetSizes[planet.sizeId]?.name ?? ''}\n`
|
||||
+ `${rules.richness[planet.richId]?.name ?? ''} · ${rules.gravity[planet.gravId]?.name ?? ''}`, {
|
||||
fontFamily: FONT, fontSize: '16px', color: '#9fb6cc',
|
||||
fontFamily: FONT, fontSize: '17px', color: '#9fb6cc', lineSpacing: 4,
|
||||
}));
|
||||
y += 26;
|
||||
y += 200;
|
||||
console_.add(scene.add.text(panelX, y, type.desc, {
|
||||
fontFamily: FONT, fontSize: '14px', color: '#6f8aa3', wordWrap: { width: panelW },
|
||||
fontFamily: FONT, fontSize: '15px', color: '#6f8aa3', wordWrap: { width: panelW },
|
||||
}));
|
||||
y += 44;
|
||||
|
||||
|
|
@ -162,84 +198,86 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
|
|||
return;
|
||||
}
|
||||
|
||||
// --- our colony: the five MOO1 allocation sliders
|
||||
// --- our colony: a read-out, not a console. Everything below is what the
|
||||
// colony screen shows you editable; nothing here responds to a click.
|
||||
const rate = colonyBuildRate(rules, state, colony);
|
||||
|
||||
console_.add(scene.add.text(panelX, y, 'ALLOCATION', {
|
||||
fontFamily: FONT, fontSize: '17px', color: '#cfe8ff',
|
||||
}));
|
||||
y += 28;
|
||||
const sliders = [];
|
||||
CHANNELS.forEach((ch, i) => {
|
||||
const s = slider(scene, panelX, y, panelW - 40,
|
||||
rules.economy.channelNames[ch] ?? ch, colony.sliders[ch] ?? 0, (v) => {
|
||||
setSlider(rules, state, colony, ch, v);
|
||||
sliders.forEach((other, j) => {
|
||||
if (j !== i) other.setValue(colony.sliders[CHANNELS[j]] ?? 0);
|
||||
});
|
||||
onChanged?.();
|
||||
}, CHANNEL_COLOUR[ch]);
|
||||
console_.add(s.container);
|
||||
sliders.push(s);
|
||||
y += 52;
|
||||
});
|
||||
console_.add(scene.add.text(panelX, y,
|
||||
'Ecology is funded first — a shortfall is taken from the other channels automatically.', {
|
||||
fontFamily: FONT, fontSize: '13px', color: '#6f8aa3', wordWrap: { width: panelW - 40 },
|
||||
y += 30;
|
||||
const barW = panelW - 200;
|
||||
CHANNELS.forEach((ch) => {
|
||||
const v = colony.sliders[ch] ?? 0;
|
||||
console_.add(scene.add.text(panelX, y - 3, rules.economy.channelNames[ch] ?? ch, {
|
||||
fontFamily: FONT, fontSize: '15px', color: '#a8c4e0',
|
||||
}));
|
||||
y += 34;
|
||||
console_.add(scene.add.rectangle(panelX + 160, y + 4, barW, 8, 0x1b2b42).setOrigin(0, 0.5));
|
||||
console_.add(scene.add.rectangle(panelX + 160, y + 4, barW * v, 8, CHANNEL_COLOUR[ch])
|
||||
.setOrigin(0, 0.5));
|
||||
console_.add(scene.add.text(panelX + panelW, y - 3,
|
||||
`${Math.round(v * 100)}%${colony.locked[ch] ? ' ■' : ''}`, {
|
||||
fontFamily: FONT, fontSize: '15px', color: colony.locked[ch] ? '#ffd88a' : '#e8f4ff',
|
||||
}).setOrigin(1, 0));
|
||||
y += 28;
|
||||
});
|
||||
y += 14;
|
||||
|
||||
// --- build queue
|
||||
// --- build queue, read-only
|
||||
console_.add(scene.add.text(panelX, y, 'BUILD QUEUE', {
|
||||
fontFamily: FONT, fontSize: '17px', color: '#cfe8ff',
|
||||
}));
|
||||
// The number every ETA below is derived from — without it the turn counts
|
||||
// look arbitrary.
|
||||
console_.add(scene.add.text(panelX + panelW, y + 2,
|
||||
`${rate.toFixed(1)} BC per turn`, {
|
||||
fontFamily: FONT, fontSize: '15px', color: '#9fd8ff',
|
||||
}).setOrigin(1, 0));
|
||||
y += 28;
|
||||
const rows = collapseQueue(colony);
|
||||
if (!rows.length) {
|
||||
console_.add(scene.add.text(panelX, y, 'Idle — construction spills into research.', {
|
||||
fontFamily: FONT, fontSize: '15px', color: '#6f8aa3',
|
||||
}));
|
||||
y += 26;
|
||||
if (!colony.queue.length) {
|
||||
console_.add(scene.add.text(panelX, y, 'Idle — output spills into research.', {
|
||||
}
|
||||
// Cumulative, so a run's figure is when the LAST of it is done — which is
|
||||
// why each row reads its ETA off its own lastIndex.
|
||||
const etas = queueEtas(rules, state, colony);
|
||||
rows.slice(0, 4).forEach((r, ri) => {
|
||||
const cost = queueItemCost(rules, state, colony, r.item);
|
||||
console_.add(scene.add.text(panelX, y,
|
||||
`${itemName(rules, state, colony, r.item)}${r.count > 1 ? ` x ${r.count}` : ''}`
|
||||
+ (ri === 0 ? ` ${Math.round(r.item.progress ?? 0)} / ${Math.round(cost)} BC` : '')
|
||||
+ ` · ${etaText(etas[r.lastIndex])}`, {
|
||||
fontFamily: FONT, fontSize: '15px', color: ri === 0 ? '#ffd88a' : '#8fa8c0',
|
||||
}));
|
||||
y += 24;
|
||||
});
|
||||
if (rows.length > 4) {
|
||||
console_.add(scene.add.text(panelX, y, `…and ${rows.length - 4} more`, {
|
||||
fontFamily: FONT, fontSize: '14px', color: '#6f8aa3',
|
||||
}));
|
||||
y += 24;
|
||||
}
|
||||
colony.queue.forEach((item, i) => {
|
||||
const cost = queueItemCost(rules, state, colony, item);
|
||||
const name = item.kind === 'building'
|
||||
? rules.buildings[item.id].name
|
||||
: empireDesign(rules, state, colony.empireIdx, item.id).name;
|
||||
const row = scene.add.text(panelX, y, `${name} ${Math.round(item.progress)}/${Math.round(cost)}`, {
|
||||
fontFamily: FONT, fontSize: '15px', color: '#c8dcf0',
|
||||
}).setInteractive({ useHandCursor: true });
|
||||
row.on('pointerup', () => { dequeue(rules, state, colony, i); onChanged?.(); rebuild(); });
|
||||
console_.add(row);
|
||||
y += 22;
|
||||
});
|
||||
y += 12;
|
||||
|
||||
// --- what can be added
|
||||
console_.add(scene.add.text(panelX, y, 'ADD TO QUEUE (click to queue)', {
|
||||
fontFamily: FONT, fontSize: '15px', color: '#8fa8c0',
|
||||
}));
|
||||
y += 24;
|
||||
const emp = state.empires[colony.empireIdx];
|
||||
let cx = panelX;
|
||||
for (const hull of rules.hullList) {
|
||||
const d = empireDesign(rules, state, colony.empireIdx, hull.id);
|
||||
const t = scene.add.text(cx, y, `${d.name} (${d.cost})`, {
|
||||
fontFamily: FONT, fontSize: '13px', color: '#9fd8ff',
|
||||
}).setInteractive({ useHandCursor: true });
|
||||
t.on('pointerup', () => { enqueue(rules, state, colony, 'ship', hull.id); onChanged?.(); rebuild(); });
|
||||
console_.add(t);
|
||||
y += 19;
|
||||
}
|
||||
y += 8;
|
||||
for (const b of rules.buildingList) {
|
||||
if (colony.buildings.includes(b.id)) continue;
|
||||
if (b.prereq && !emp.known[b.prereq]) continue;
|
||||
const t = scene.add.text(cx, y, `${b.name} (${b.cost})`, {
|
||||
fontFamily: FONT, fontSize: '13px', color: '#ffd88a',
|
||||
}).setInteractive({ useHandCursor: true });
|
||||
t.on('pointerup', () => { enqueue(rules, state, colony, 'building', b.id); onChanged?.(); rebuild(); });
|
||||
console_.add(t);
|
||||
y += 19;
|
||||
if (y > shell.y + shell.height - 60) break;
|
||||
}
|
||||
// The one action: hand the whole colony over to its own screen. Opened
|
||||
// directly rather than through MasterOfVegaGame.openModal, which refuses a
|
||||
// second modal while one is up — and this one has to stay up, because it is
|
||||
// what is keeping the star map behind it inert.
|
||||
y = Math.max(y + 20, shell.y + shell.height - 96);
|
||||
console_.add(new Button(scene, panelX + panelW / 2, y + 24, 'View Colony', () => {
|
||||
shell.layer.setVisible(false);
|
||||
tick.paused = true;
|
||||
openColonyView(scene, rules, state, colony, art, {
|
||||
onChanged,
|
||||
onClose: () => {
|
||||
shell.layer.setVisible(true);
|
||||
tick.paused = false;
|
||||
rebuild();
|
||||
},
|
||||
});
|
||||
}, { width: panelW, height: 50, fontSize: 21 }));
|
||||
}
|
||||
|
||||
function select(i) {
|
||||
|
|
@ -251,20 +289,33 @@ 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');
|
||||
|
|
|
|||
|
|
@ -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, `
|
||||
|
|
@ -764,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);
|
||||
|
|
|
|||