feat(masterofvega): add ship commander videos, detail pop-over, and turn report
Introduce looping commander videos (256×256) alongside ship thumbnails in the side panel and colony catalogue. Each species×hull combination loads its officer clip from shipVideos, falling back to portrait video → still → procedural sheet. A scene-lifetime pool (VegaShipMedia.js) prevents decoder thrash on every panel rebuild. Add VegaShipDetail — a full-resolution pop-over (D.detail depth 76) showing the commander at 256px, hull at 192px, derived stat block, and knapsack loadout. Opens from both the side panel and colony screen without stacking through openModal. Add a "New Turn" popup (VegaTurnReportScreen) that interrupts the player for notable events (discovery, first contact, diplomacy, eliminations). Events are classified and sorted by consequence in VegaTurnReport.js. Logic now emits 'discovered' events when fleets enter unexplored systems. Update asset manifest to load nested shipVideos, extend verification to validate shipVideoKey conventions, bump ships sheet to 192px frames, and document the new art contract.
This commit is contained in:
parent
6a0e8bfc47
commit
6b00371bdf
|
|
@ -19,6 +19,11 @@
|
||||||
"file": "vega/track-04.mp3",
|
"file": "vega/track-04.mp3",
|
||||||
"artist": "Cy-Bro",
|
"artist": "Cy-Bro",
|
||||||
"title": "Defend the Colony"
|
"title": "Defend the Colony"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "vega/track-05.mp3",
|
||||||
|
"artist": "Rrashaa",
|
||||||
|
"title": "Into the Void"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"volume": 0.6
|
"volume": 0.6
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,28 @@
|
||||||
"lithox": { "key": "vega-char-lithox", "path": "assets/videos/vega/char-lithox.mp4" }
|
"lithox": { "key": "vega-char-lithox", "path": "assets/videos/vega/char-lithox.mp4" }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"_shipVideosReadme": "Per-hull SHIP COMMANDER loops — the officer flying that class — 256x256, muted, ~10s, keyed species -> hull id. These are a different thing from portraitVideos above: that is the species' leader on the diplomacy screen, this is one face per hull. A species with none recorded yet is null, and a hull with no entry inside a recorded species falls back to that species' portraitVideos clip, so the roster fills one race — or one hull — at a time with no code change. NOTE: the human colony ship's file is 'ship-human-colony.mp4' while the hull id is 'colonyship'; the path below points at the real filename, same class of mismatch as char-cerebai.mp4. There is deliberately no 'starbase' clip yet — it falls back like any other gap.",
|
||||||
|
"shipVideos": {
|
||||||
|
"human": {
|
||||||
|
"scout": { "key": "vega-ship-human-scout", "path": "assets/videos/vega/ship-human-scout.mp4" },
|
||||||
|
"colonyship": { "key": "vega-ship-human-colonyship", "path": "assets/videos/vega/ship-human-colony.mp4" },
|
||||||
|
"transport": { "key": "vega-ship-human-transport", "path": "assets/videos/vega/ship-human-transport.mp4" },
|
||||||
|
"frigate": { "key": "vega-ship-human-frigate", "path": "assets/videos/vega/ship-human-frigate.mp4" },
|
||||||
|
"destroyer": { "key": "vega-ship-human-destroyer", "path": "assets/videos/vega/ship-human-destroyer.mp4" },
|
||||||
|
"cruiser": { "key": "vega-ship-human-cruiser", "path": "assets/videos/vega/ship-human-cruiser.mp4" },
|
||||||
|
"battleship": { "key": "vega-ship-human-battleship", "path": "assets/videos/vega/ship-human-battleship.mp4" }
|
||||||
|
},
|
||||||
|
"kestrelli": null,
|
||||||
|
"ursaal": null,
|
||||||
|
"umbrix": null,
|
||||||
|
"kkrix": null,
|
||||||
|
"mekhan": null,
|
||||||
|
"rrashaa": null,
|
||||||
|
"cerebrai": null,
|
||||||
|
"ssakar": null,
|
||||||
|
"lithox": null
|
||||||
|
},
|
||||||
|
|
||||||
"_portraitStillsReadme": "High-resolution (1024x1024) still portraits, one per species. Every species now has a video too, so these serve as the recovery path if a video fails to decode. NOTE: kestrelli's file is spelled 'char-kestralli.png' on disk — the path below points at the real filename; rename the file and this one line together if you want the spelling fixed.",
|
"_portraitStillsReadme": "High-resolution (1024x1024) still portraits, one per species. Every species now has a video too, so these serve as the recovery path if a video fails to decode. NOTE: kestrelli's file is spelled 'char-kestralli.png' on disk — the path below points at the real filename; rename the file and this one line together if you want the spelling fixed.",
|
||||||
"portraitStills": {
|
"portraitStills": {
|
||||||
"human": { "key": "vega-still-human", "path": "assets/images/vega/char-human.png" },
|
"human": { "key": "vega-still-human", "path": "assets/images/vega/char-human.png" },
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,47 @@ Each of these was a real bug that produced a plausible-looking but broken game.
|
||||||
has its own world transform and does **not** follow a moving parent — the
|
has its own world transform and does **not** follow a moving parent — the
|
||||||
flyout tween drags it along by hand in `onUpdate`.
|
flyout tween drags it along by hand in `onUpdate`.
|
||||||
|
|
||||||
|
### Ship rows carry video, so they have to be pooled
|
||||||
|
|
||||||
|
Every place a ship is listed — the side panel's task force, its in-transit,
|
||||||
|
garrison and order lists, and the colony catalogue — now shows a looping
|
||||||
|
**commander video** beside an upside-down picture of the hull, and opens a
|
||||||
|
centred detail window (`VegaShipDetail.js`, depth `D.detail` 76) with the clip at
|
||||||
|
its full 256px, the hull at 192px, `hull.desc`, the derived stat block and the
|
||||||
|
loadout the knapsack settled on. Everything in that window comes out of
|
||||||
|
`designFor`; no new data was added.
|
||||||
|
|
||||||
|
Two things about it are load-bearing.
|
||||||
|
|
||||||
|
**The portraits cannot be created inline.** Both hosts rebuild their entire body
|
||||||
|
on every click — `VegaSidePanel.rebuild()` on each − / +, `rebuildFlyout()` on
|
||||||
|
each enqueue — so building the portraits with the rows would tear down and
|
||||||
|
re-create a decoder per row per click, restarting every loop under the player's
|
||||||
|
cursor. `createShipMediaPool` in `VegaShipMedia.js` keeps them in a layer the
|
||||||
|
rebuild does not touch, bracketed `beginFrame()` … `endFrame()`; unclaimed
|
||||||
|
entries are hidden and paused, never destroyed. The colony catalogue wipes with
|
||||||
|
`removeAll(true)`, so its pool layer is lifted out with `detachPool()` first and
|
||||||
|
re-homed into the fresh scroll column afterwards — inside `content`, so it
|
||||||
|
scrolls and masks with the rows it belongs to.
|
||||||
|
|
||||||
|
**Duplicate Video objects on one cached video are fine — the note at the top of
|
||||||
|
`openSpeciesDetail` is more cautious than it needs to be.** Checked against the
|
||||||
|
Phaser 3.90 source: `Video.loadHandler` builds its own element with
|
||||||
|
`document.createElement('video')`, and `preDestroy` → `removeVideoElement()`
|
||||||
|
detaches only that instance's. The cache holds a URL, not a shared element. That
|
||||||
|
is what makes the fallback ladder usable at all: a species with no ship clips
|
||||||
|
shows its portrait video on all seven rows at once. The cost is decode time, and
|
||||||
|
pausing is how it is paid — the panel pauses on `hide()`, and both hosts pause
|
||||||
|
while the detail window is up, since that window has its own portrait at 256px
|
||||||
|
and there is no point running both. (The reparenting in `openSpeciesDetail` is
|
||||||
|
still fine and still halves the decoding there; it just is not a correctness
|
||||||
|
requirement.)
|
||||||
|
|
||||||
|
A missing clip is a **legitimate fallback, not a bug**, which is why the verifier
|
||||||
|
reports `7/80 recorded` rather than failing. What it does assert is that nothing
|
||||||
|
*declared* is wrong — every key must equal `shipVideoKey(species, hull)`, which
|
||||||
|
is the trap the `colonyship` / `ship-human-colony.mp4` filename mismatch sets.
|
||||||
|
|
||||||
## Balance reference (27-game AI soak)
|
## Balance reference (27-game AI soak)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -283,8 +324,8 @@ wins spread across 8 of 10 species
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/verifyMasterOfVega.js # ~1082 checks, ~60s
|
node tools/verifyMasterOfVega.js # ~1221 checks, ~60s
|
||||||
node tools/verifyMasterOfVega.js --quick # 1081 checks, ~15s
|
node tools/verifyMasterOfVega.js --quick # 1220 checks, ~15s
|
||||||
node tools/verifyMasterOfVega.js --games=50 # a deeper soak
|
node tools/verifyMasterOfVega.js --games=50 # a deeper soak
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,24 @@ function videosFrom(scene, jsonKey, field) {
|
||||||
.map(([, v]) => ({ type: 'video', key: v.key, path: v.path }));
|
.map(([, v]) => ({ type: 'video', key: v.key, path: v.path }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same contract as videosFrom, one level deeper: a block keyed
|
||||||
|
// group -> member -> entry, e.g. mastervega's shipVideos (species -> hull).
|
||||||
|
// A group whose value is null has nothing recorded yet and is skipped whole,
|
||||||
|
// which is what keeps the manifest short while nine species are outstanding.
|
||||||
|
function nestedVideosFrom(scene, jsonKey, field) {
|
||||||
|
const art = scene.cache.json.get(jsonKey);
|
||||||
|
if (!art || !art[field]) return [];
|
||||||
|
const out = [];
|
||||||
|
for (const [group, members] of Object.entries(art[field])) {
|
||||||
|
if (group.startsWith('_') || !members) continue;
|
||||||
|
for (const [id, v] of Object.entries(members)) {
|
||||||
|
if (id.startsWith('_') || !v || !v.key || !v.path) continue;
|
||||||
|
out.push({ type: 'video', key: v.key, path: v.path });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// Same shape as videosFrom, for image entries keyed by id.
|
// Same shape as videosFrom, for image entries keyed by id.
|
||||||
function imagesFromMap(scene, jsonKey, field) {
|
function imagesFromMap(scene, jsonKey, field) {
|
||||||
const art = scene.cache.json.get(jsonKey);
|
const art = scene.cache.json.get(jsonKey);
|
||||||
|
|
@ -98,6 +116,7 @@ export const MANIFEST = {
|
||||||
{ type: 'json', key: 'mastervega-rules', path: 'data/mastervega-rules.json' },
|
{ type: 'json', key: 'mastervega-rules', path: 'data/mastervega-rules.json' },
|
||||||
(scene) => sheetsFrom(scene, 'mastervega-artwork', ['sheets']),
|
(scene) => sheetsFrom(scene, 'mastervega-artwork', ['sheets']),
|
||||||
(scene) => videosFrom(scene, 'mastervega-artwork', 'portraitVideos'),
|
(scene) => videosFrom(scene, 'mastervega-artwork', 'portraitVideos'),
|
||||||
|
(scene) => nestedVideosFrom(scene, 'mastervega-artwork', 'shipVideos'),
|
||||||
(scene) => imagesFromMap(scene, 'mastervega-artwork', 'portraitStills'),
|
(scene) => imagesFromMap(scene, 'mastervega-artwork', 'portraitStills'),
|
||||||
(scene) => imagesFromMap(scene, 'mastervega-artwork', 'worldBackgrounds'),
|
(scene) => imagesFromMap(scene, 'mastervega-artwork', 'worldBackgrounds'),
|
||||||
image('vega-menu-bg', 'assets/images/vega/background-menu.png'),
|
image('vega-menu-bg', 'assets/images/vega/background-menu.png'),
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ import {
|
||||||
FONT, D, openResearchScreen, openDiplomacyScreen, openCouncilScreen, openLeaderScreen,
|
FONT, D, openResearchScreen, openDiplomacyScreen, openCouncilScreen, openLeaderScreen,
|
||||||
showVictoryOverlay,
|
showVictoryOverlay,
|
||||||
} from './VegaScreens.js';
|
} from './VegaScreens.js';
|
||||||
|
import { openTurnReportScreen } from './VegaTurnReportScreen.js';
|
||||||
|
import { NOTABLE_TYPES, isRelevantToHuman } from './VegaTurnReport.js';
|
||||||
|
|
||||||
const SAVE_KEY = 'mastervega-save';
|
const SAVE_KEY = 'mastervega-save';
|
||||||
|
|
||||||
|
|
@ -530,13 +532,16 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
onStarClick: (idx) => this.onStarClick(idx),
|
onStarClick: (idx) => this.onStarClick(idx),
|
||||||
onFleetClick: (fleet) => this.onFleetClick(fleet),
|
onFleetClick: (fleet) => this.onFleetClick(fleet),
|
||||||
onEmptyClick: () => this.clearSelection(),
|
onEmptyClick: () => this.clearSelection(),
|
||||||
blockWheel: () => this.modalOpen,
|
blockWheel: () => this.modalOpen || !!this.panel?.detailOpen,
|
||||||
// A modal blocks the map outright; otherwise only the side panel's own
|
// A modal blocks the map outright; so does the panel's ship detail
|
||||||
// footprint does (so dragging a slider there doesn't pan the galaxy
|
// pop-over, which veils the whole screen without being a modal.
|
||||||
// underneath it). The old `!this.modalOpen && ...` form always
|
// Otherwise only the side panel's own footprint does (so dragging a
|
||||||
// short-circuited to false while a modal was open, which let drags
|
// slider there doesn't pan the galaxy underneath it). The old
|
||||||
// pan the star map right through the System View window.
|
// `!this.modalOpen && ...` form always short-circuited to false while a
|
||||||
blockPointer: (p) => this.modalOpen || !!this.panel?.containsPoint(p.x, p.y),
|
// modal was open, which let drags pan the star map right through the
|
||||||
|
// System View window.
|
||||||
|
blockPointer: (p) => this.modalOpen || !!this.panel?.detailOpen
|
||||||
|
|| !!this.panel?.containsPoint(p.x, p.y),
|
||||||
});
|
});
|
||||||
|
|
||||||
// The command panel is the only place a selection is acted on; the scene
|
// The command panel is the only place a selection is acted on; the scene
|
||||||
|
|
@ -772,10 +777,23 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
if (this.state.over) { this.finishGame(); return; }
|
if (this.state.over) { this.finishGame(); return; }
|
||||||
if (this.state.current === this.state.humanIndex) {
|
if (this.state.current === this.state.humanIndex) {
|
||||||
Logic.beginEmpireTurn(this.rules, this.state, this.state.humanIndex);
|
Logic.beginEmpireTurn(this.rules, this.state, this.state.humanIndex);
|
||||||
this.announceEvents();
|
const notable = this.processTurnEvents();
|
||||||
this.refreshAll();
|
const finish = () => {
|
||||||
this.busy = false;
|
this.busy = false;
|
||||||
this.endTurnBtn.setEnabled(true);
|
this.endTurnBtn.setEnabled(true);
|
||||||
|
};
|
||||||
|
if (notable.length) {
|
||||||
|
// openModal's own done() (MasterOfVegaGame.js:openModal) clears
|
||||||
|
// modalOpen and refreshes; finish() on top of that re-arms the End
|
||||||
|
// Turn button, which openModal has no reason to touch on its own.
|
||||||
|
this.openModal((done) => openTurnReportScreen(this, this.rules, this.state, notable, () => {
|
||||||
|
done();
|
||||||
|
finish();
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
this.refreshAll();
|
||||||
|
finish();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const e = this.state.current;
|
const e = this.state.current;
|
||||||
|
|
@ -787,41 +805,33 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
step();
|
step();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Turn engine events into log lines and map pings. `announced` marks a record
|
// Turn engine events into log lines, map pings, and — for the events worth
|
||||||
// consumed, since beginEmpireTurn trims rather than clears the event list.
|
// interrupting the player for — rows in the "New Turn" popup. `announced`
|
||||||
announceEvents() {
|
// marks a record consumed, since beginEmpireTurn trims rather than clears
|
||||||
|
// the event list. Returns the events the popup should show this turn.
|
||||||
|
processTurnEvents() {
|
||||||
const me = this.state.humanIndex;
|
const me = this.state.humanIndex;
|
||||||
|
const notable = [];
|
||||||
for (const ev of this.state.events) {
|
for (const ev of this.state.events) {
|
||||||
if (ev.announced) continue;
|
if (ev.announced) continue;
|
||||||
ev.announced = true;
|
ev.announced = true;
|
||||||
|
if (!isRelevantToHuman(ev, me)) continue;
|
||||||
|
|
||||||
|
if (NOTABLE_TYPES.has(ev.type)) {
|
||||||
|
notable.push(ev);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const star = ev.starIdx >= 0 ? this.state.galaxy.stars[ev.starIdx] : null;
|
const star = ev.starIdx >= 0 ? this.state.galaxy.stars[ev.starIdx] : null;
|
||||||
const name = (i) => this.state.empires[i]?.name ?? '?';
|
const name = (i) => this.state.empires[i]?.name ?? '?';
|
||||||
|
if (ev.type === 'refit' && ev.empire === me) {
|
||||||
if (ev.type === 'techDone' && ev.empire === me) {
|
|
||||||
this.log(`Researched ${this.rules.techs[ev.techId]?.name ?? ev.techId}.`);
|
|
||||||
} else if (ev.type === 'refit' && ev.empire === me) {
|
|
||||||
this.log(`${ev.count} × ${this.rules.hulls[ev.hullId]?.name} refitted to Mark ${ev.toMark} at ${this.state.galaxy.stars[ev.starIdx]?.name} (${ev.cost} BC).`);
|
this.log(`${ev.count} × ${this.rules.hulls[ev.hullId]?.name} refitted to Mark ${ev.toMark} at ${this.state.galaxy.stars[ev.starIdx]?.name} (${ev.cost} BC).`);
|
||||||
} else if (ev.type === 'combat' && (ev.attacker === me || ev.defender === me)) {
|
} else if (ev.type === 'combat' && (ev.attacker === me || ev.defender === me)) {
|
||||||
this.log(`Battle at ${star?.name}: ${ev.winner === 'attacker' ? name(ev.attacker) : name(ev.defender)} holds the field.`);
|
this.log(`Battle at ${star?.name}: ${ev.winner === 'attacker' ? name(ev.attacker) : name(ev.defender)} holds the field.`);
|
||||||
if (star) this.fx?.ping(star.x, star.y, 0xffa050);
|
if (star) this.fx?.ping(star.x, star.y, 0xffa050);
|
||||||
} else if (ev.type === 'captured') {
|
|
||||||
this.log(`${name(ev.empire)} has taken ${star?.name} from ${name(ev.from)}.`);
|
|
||||||
} else if (ev.type === 'colonyDestroyed') {
|
|
||||||
this.log(`${star?.name} has been bombed out of existence by ${name(ev.empire)}.`);
|
|
||||||
} else if (ev.type === 'colonised' && ev.empire === me) {
|
|
||||||
this.log(`Colony founded at ${star?.name}.`);
|
|
||||||
} else if (ev.type === 'contact') {
|
|
||||||
this.log(`We have made contact with the ${name(ev.other === me ? ev.empire : ev.other)}.`);
|
|
||||||
} else if (ev.type === 'warDeclared') {
|
|
||||||
this.log(`${name(ev.empire)} declares war on ${name(ev.other)}.`);
|
|
||||||
} else if (ev.type === 'councilRefused') {
|
|
||||||
this.log(`${name(ev.empire)} refuses to submit to ${name(ev.winner)}. The Council is void.`);
|
|
||||||
} else if (ev.type === 'council' && ev.winner >= 0) {
|
|
||||||
this.log(`${name(ev.winner)} is elected High Guardian of the Galaxy.`);
|
|
||||||
} else if (ev.type === 'eliminated') {
|
|
||||||
this.log(`The ${name(ev.empire)} are no more.`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return notable;
|
||||||
}
|
}
|
||||||
|
|
||||||
finishGame() {
|
finishGame() {
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,9 @@ import * as Phaser from 'phaser';
|
||||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||||
import { Button } from '../../ui/Button.js';
|
import { Button } from '../../ui/Button.js';
|
||||||
import { FONT, D, ORBIT, slider } from './VegaScreens.js';
|
import { FONT, D, ORBIT, slider } from './VegaScreens.js';
|
||||||
import { worldBackground, buildingFrame, shipFrame } from './VegaArt.js';
|
import { worldBackground, buildingFrame } from './VegaArt.js';
|
||||||
|
import { createShipMediaPool, makeShipIcon } from './VegaShipMedia.js';
|
||||||
|
import { openShipDetail } from './VegaShipDetail.js';
|
||||||
import {
|
import {
|
||||||
CHANNELS, colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories,
|
CHANNELS, colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories,
|
||||||
colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany,
|
colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany,
|
||||||
|
|
@ -68,13 +70,14 @@ export const etaText = (turns) => (Number.isFinite(turns)
|
||||||
/**
|
/**
|
||||||
* A vertically scrolling column. This is the first one in the game: every other
|
* 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
|
* 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
|
* `break` out of the catalogue mid-loop). Exported for reuse (VegaScreens.js's
|
||||||
* wants it rather than promoted to src/ui/ on spec.
|
* turn-report popup) rather than promoted to src/ui/, since this is still the
|
||||||
|
* only game that needs it.
|
||||||
*
|
*
|
||||||
* The mask is a Graphics with its own world transform, so it does NOT follow a
|
* 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.
|
* moving parent — `syncMask` is what keeps it aligned while the flyout slides.
|
||||||
*/
|
*/
|
||||||
function scrollColumn(scene, parent, x, y, w, h) {
|
export 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 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
|
// the wheel handler whether the pointer is over this column, and an
|
||||||
// interactive rectangle added afterwards would swallow every row click.
|
// interactive rectangle added afterwards would swallow every row click.
|
||||||
|
|
@ -124,7 +127,7 @@ function scrollColumn(scene, parent, x, y, w, h) {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** The holographic frame both the panel and the flyout are drawn in. */
|
/** The holographic frame both the panel and the flyout are drawn in. */
|
||||||
function frame(scene, layer, x, y, w, h, alpha) {
|
export function frame(scene, layer, x, y, w, h, alpha) {
|
||||||
const box = scene.add.rectangle(x, y, w, h, PANEL, alpha).setOrigin(0, 0);
|
const box = scene.add.rectangle(x, y, w, h, PANEL, alpha).setOrigin(0, 0);
|
||||||
box.setStrokeStyle(1.5, ACCENT, 0.55);
|
box.setStrokeStyle(1.5, ACCENT, 0.55);
|
||||||
layer.add(box);
|
layer.add(box);
|
||||||
|
|
@ -214,13 +217,34 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
|
||||||
const syncMasks = (dx) => scrollers.forEach((s) => s.syncMask(dx));
|
const syncMasks = (dx) => scrollers.forEach((s) => s.syncMask(dx));
|
||||||
const dropScrollers = () => { scrollers.forEach((s) => s.destroy()); scrollers = []; };
|
const dropScrollers = () => { scrollers.forEach((s) => s.destroy()); scrollers = []; };
|
||||||
|
|
||||||
|
// Commander videos for the catalogue. The flyout wipes itself with
|
||||||
|
// removeAll(true) on every enqueue, so the pool's layer is lifted out first
|
||||||
|
// and re-homed into the fresh scroll column afterwards — it has to live in
|
||||||
|
// there to be scrolled and masked with the rows it belongs to.
|
||||||
|
const pool = createShipMediaPool(scene, rules, art);
|
||||||
|
let detail = null;
|
||||||
|
const openDetail = (hullId) => {
|
||||||
|
if (detail) return;
|
||||||
|
pool.setPaused(true);
|
||||||
|
detail = openShipDetail(scene, rules, state, art,
|
||||||
|
{ empireIdx: colony.empireIdx, hullId },
|
||||||
|
() => { detail = null; if (flyOpen) pool.setPaused(false); });
|
||||||
|
};
|
||||||
|
/** Take the pool out of whatever is about to be destroyed. */
|
||||||
|
const detachPool = () => pool.layer.parentContainer?.remove(pool.layer);
|
||||||
|
|
||||||
// ------------------------------------------------------------------ close
|
// ------------------------------------------------------------------ close
|
||||||
|
|
||||||
let closed = false;
|
let closed = false;
|
||||||
function close() {
|
function close() {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
closed = true;
|
closed = true;
|
||||||
|
detail?.close();
|
||||||
dropScrollers();
|
dropScrollers();
|
||||||
|
// Out of the tree first, or root.destroy() takes it and pool.destroy()
|
||||||
|
// then runs against a corpse.
|
||||||
|
detachPool();
|
||||||
|
pool.destroy();
|
||||||
scene.input.keyboard?.off('keydown-ESC', onEsc);
|
scene.input.keyboard?.off('keydown-ESC', onEsc);
|
||||||
root.destroy();
|
root.destroy();
|
||||||
onClose?.();
|
onClose?.();
|
||||||
|
|
@ -416,6 +440,8 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
flyLayer.setVisible(false);
|
flyLayer.setVisible(false);
|
||||||
dropScrollers();
|
dropScrollers();
|
||||||
|
detachPool();
|
||||||
|
pool.setPaused(true);
|
||||||
flyLayer.removeAll(true);
|
flyLayer.removeAll(true);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -434,6 +460,9 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
|
||||||
// back to the top under the player's cursor. Put them back where they were.
|
// back to the top under the player's cursor. Put them back where they were.
|
||||||
const keep = scrollers.map((s) => s.offset);
|
const keep = scrollers.map((s) => s.offset);
|
||||||
dropScrollers();
|
dropScrollers();
|
||||||
|
detachPool();
|
||||||
|
pool.beginFrame();
|
||||||
|
if (!detail) pool.setPaused(false);
|
||||||
flyLayer.removeAll(true);
|
flyLayer.removeAll(true);
|
||||||
frame(scene, flyLayer, FLY_X, PANEL_Y, FLY_W, PANEL_H, 0.9);
|
frame(scene, flyLayer, FLY_X, PANEL_Y, FLY_W, PANEL_H, 0.9);
|
||||||
|
|
||||||
|
|
@ -526,15 +555,38 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
|
||||||
// concern only, so the visible band has to be checked explicitly.
|
// concern only, so the visible band has to be checked explicitly.
|
||||||
bg.on('pointerup', (p) => { if (scroller.contains(p)) onAdd(); });
|
bg.on('pointerup', (p) => { if (scroller.contains(p)) onAdd(); });
|
||||||
scroller.content.add(bg);
|
scroller.content.add(bg);
|
||||||
// Both sheets are optional and procedural until real art lands, so this
|
// A ship gets its commanding officer beside the hull; a building keeps
|
||||||
// always resolves to something.
|
// the single icon it always had. Both sheets are optional and procedural
|
||||||
scroller.content.add(scene.add.image(28, cy + 26, icon.key, icon.frame)
|
// until real art lands, so either always resolves to something.
|
||||||
.setDisplaySize(40, 40));
|
let textX = 58;
|
||||||
scroller.content.add(scene.add.text(58, cy + 5, label, {
|
if (icon.hullId) {
|
||||||
|
pool.get(emp.speciesId, icon.hullId, 26, cy + 26, 40);
|
||||||
|
scroller.content.add(
|
||||||
|
makeShipIcon(scene, rules, art, emp.speciesId, icon.hullId, 72, cy + 26, 40),
|
||||||
|
);
|
||||||
|
// Its own target over the two thumbnails, so the row's primary action
|
||||||
|
// stays "queue one" and inspecting is a deliberate second gesture. It
|
||||||
|
// is added after bg, which is what puts it on top — and which is also
|
||||||
|
// why it has to light itself up: sitting over bg, it swallows the
|
||||||
|
// row's own hover and would otherwise read as dead space. Same
|
||||||
|
// masked-row guard as every other handler in this flyout.
|
||||||
|
const info = scene.add.rectangle(0, cy, 100, 52, 0x6fc4ff, 0.0)
|
||||||
|
.setOrigin(0, 0).setInteractive({ useHandCursor: true });
|
||||||
|
info.on('pointerover', () => info.setFillStyle(0x6fc4ff, 0.16));
|
||||||
|
info.on('pointerout', () => info.setFillStyle(0x6fc4ff, 0.0));
|
||||||
|
info.on('pointerup', (p) => { if (scroller.contains(p)) openDetail(icon.hullId); });
|
||||||
|
scroller.content.add(info);
|
||||||
|
textX = 108;
|
||||||
|
} else {
|
||||||
|
scroller.content.add(scene.add.image(28, cy + 26, icon.key, icon.frame)
|
||||||
|
.setDisplaySize(40, 40));
|
||||||
|
}
|
||||||
|
scroller.content.add(scene.add.text(textX, cy + 5, label, {
|
||||||
fontFamily: FONT, fontSize: '17px', color: colour,
|
fontFamily: FONT, fontSize: '17px', color: colour,
|
||||||
}));
|
}));
|
||||||
scroller.content.add(scene.add.text(58, cy + 27, sub, {
|
scroller.content.add(scene.add.text(textX, cy + 27, sub, {
|
||||||
fontFamily: FONT, fontSize: '13px', color: '#7f97b3', wordWrap: { width: w - 180 },
|
fontFamily: FONT, fontSize: '13px', color: '#7f97b3',
|
||||||
|
wordWrap: { width: w - textX - 70 },
|
||||||
}));
|
}));
|
||||||
if (onAddFive) {
|
if (onAddFive) {
|
||||||
// The repeat count. Its own button so the row click stays "add one".
|
// The repeat count. Its own button so the row click stays "add one".
|
||||||
|
|
@ -555,7 +607,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
|
||||||
|
|
||||||
for (const hull of rules.hullList) {
|
for (const hull of rules.hullList) {
|
||||||
const d = empireDesign(rules, state, colony.empireIdx, hull.id);
|
const d = empireDesign(rules, state, colony.empireIdx, hull.id);
|
||||||
entry({ key: art.ships, frame: shipFrame(rules, emp.speciesId, hull.id) },
|
entry({ hullId: hull.id },
|
||||||
d.name, `${d.cost} BC · ${turns(d.cost)} · ${hull.desc}`, '#9fd8ff',
|
d.name, `${d.cost} BC · ${turns(d.cost)} · ${hull.desc}`, '#9fd8ff',
|
||||||
() => { enqueue(rules, state, colony, 'ship', hull.id); rebuildFlyout(); },
|
() => { enqueue(rules, state, colony, 'ship', hull.id); rebuildFlyout(); },
|
||||||
() => { enqueueMany(rules, state, colony, 'ship', hull.id, 5); rebuildFlyout(); });
|
() => { enqueueMany(rules, state, colony, 'ship', hull.id, 5); rebuildFlyout(); });
|
||||||
|
|
@ -570,6 +622,12 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
|
||||||
queued ? '#6f8aa3' : '#ffd88a',
|
queued ? '#6f8aa3' : '#ffd88a',
|
||||||
() => { if (enqueue(rules, state, colony, 'building', b.id)) rebuildFlyout(); });
|
() => { if (enqueue(rules, state, colony, 'building', b.id)) rebuildFlyout(); });
|
||||||
}
|
}
|
||||||
|
// Back into the live scroll column, after the rows so the portraits draw
|
||||||
|
// over the row backgrounds, and inside `content` so they scroll and mask
|
||||||
|
// with the list they belong to.
|
||||||
|
scroller.content.add(pool.layer);
|
||||||
|
pool.endFrame();
|
||||||
|
|
||||||
scroller.contentHeight = cy;
|
scroller.contentHeight = cy;
|
||||||
scroller.setOffset(keep[1] ?? 0);
|
scroller.setOffset(keep[1] ?? 0);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1021,8 +1021,12 @@ function moveFleets(rules, state, e) {
|
||||||
f.fromStar = -1;
|
f.fromStar = -1;
|
||||||
f.progress = 0;
|
f.progress = 0;
|
||||||
f.total = 0;
|
f.total = 0;
|
||||||
|
const wasExplored = !!state.empires[e].explored[f.starIdx];
|
||||||
state.empires[e].explored[f.starIdx] = true;
|
state.empires[e].explored[f.starIdx] = true;
|
||||||
pushEvent(state, { type: 'arrive', empire: e, starIdx: f.starIdx, fleetId: f.id, turn: state.turn });
|
pushEvent(state, { type: 'arrive', empire: e, starIdx: f.starIdx, fleetId: f.id, turn: state.turn });
|
||||||
|
if (!wasExplored) {
|
||||||
|
pushEvent(state, { type: 'discovered', empire: e, starIdx: f.starIdx, turn: state.turn });
|
||||||
|
}
|
||||||
makeContactAt(rules, state, e, f.starIdx);
|
makeContactAt(rules, state, e, f.starIdx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,10 @@ import { makeSpeciesPortrait } from './VegaArt.js';
|
||||||
|
|
||||||
export const FONT = '"Julius Sans One"';
|
export const FONT = '"Julius Sans One"';
|
||||||
// `colony` sits above `modal` so the colony screen can stack over the system
|
// `colony` sits above `modal` so the colony screen can stack over the system
|
||||||
// view that opened it without either having to be torn down.
|
// view that opened it without either having to be torn down. `detail` is the
|
||||||
export const D = { map: 1, hud: 30, modal: 60, colony: 70, toast: 80 };
|
// ship pop-over, which opens from the star map's side panel AND from inside the
|
||||||
|
// colony screen, so it has to clear both.
|
||||||
|
export const D = { map: 1, hud: 30, modal: 60, colony: 70, detail: 76, toast: 80 };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Orbit numerals. Worldgen rolls at most five planets, but
|
* Orbit numerals. Worldgen rolls at most five planets, but
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,235 @@
|
||||||
|
// Master of Vega — the ship detail pop-over.
|
||||||
|
//
|
||||||
|
// Clicking any ship row anywhere opens this: the commanding officer at the full
|
||||||
|
// 256px the clips are recorded at, the hull at 192px, and everything the empire
|
||||||
|
// knows about the class — the hull's own description from the rules file, the
|
||||||
|
// stat block the Mark currently resolves to, and the loadout the knapsack put
|
||||||
|
// on it.
|
||||||
|
//
|
||||||
|
// It deliberately does NOT go through MasterOfVegaGame.openModal, which refuses
|
||||||
|
// a second modal while one is up. This window opens from the star map's side
|
||||||
|
// panel (nothing else open) and from inside the colony screen (which is itself
|
||||||
|
// a full-screen layer that must stay up), so it draws its own veil at D.detail
|
||||||
|
// and clears both.
|
||||||
|
//
|
||||||
|
// Chrome and the grow-from-nothing entrance mirror openSpeciesDetail: built at
|
||||||
|
// full size and scaled up, so the type renders crisp at its final size instead
|
||||||
|
// of as a magnified thumbnail.
|
||||||
|
|
||||||
|
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 { empireDesign } from './VegaLogic.js';
|
||||||
|
import { refitCost } from './VegaShips.js';
|
||||||
|
import { makeCommanderPortrait, makeShipIcon } from './VegaShipMedia.js';
|
||||||
|
import { FONT, D } from './VegaScreens.js';
|
||||||
|
|
||||||
|
const ACCENT = 0x6fc4ff;
|
||||||
|
const PANEL = 0x0b1220;
|
||||||
|
const PANEL_W = 1000;
|
||||||
|
const PANEL_H = 700;
|
||||||
|
|
||||||
|
const ROLE_NAMES = {
|
||||||
|
recon: 'Reconnaissance', colony: 'Colonisation', troops: 'Troop carrier',
|
||||||
|
warship: 'Warship', base: 'Orbital fortress',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} opts { empireIdx, hullId, mark } — `mark` is the stack's own
|
||||||
|
* Mark where the caller has one (a fleet row); omit it for a catalogue entry,
|
||||||
|
* which is always quoted at the current Mark.
|
||||||
|
* @param {function} onClose called after the window has finished closing.
|
||||||
|
* @returns {{ close: function }}
|
||||||
|
*/
|
||||||
|
export function openShipDetail(scene, rules, state, art, opts, onClose) {
|
||||||
|
const { empireIdx, hullId, mark = null } = opts;
|
||||||
|
const emp = state.empires[empireIdx];
|
||||||
|
const species = rules.species[emp.speciesId];
|
||||||
|
const design = empireDesign(rules, state, empireIdx, hullId);
|
||||||
|
const hull = design.hull;
|
||||||
|
|
||||||
|
const halfW = PANEL_W / 2;
|
||||||
|
const halfH = PANEL_H / 2;
|
||||||
|
const accent = Phaser.Display.Color.HexStringToColor(species.color).color;
|
||||||
|
|
||||||
|
const layer = scene.add.container(0, 0).setDepth(D.detail);
|
||||||
|
const veil = scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x00060e, 0)
|
||||||
|
.setOrigin(0, 0).setInteractive();
|
||||||
|
layer.add(veil);
|
||||||
|
scene.tweens.add({ targets: veil, fillAlpha: 0.8, duration: 180 });
|
||||||
|
|
||||||
|
const pop = scene.add.container(GAME_WIDTH / 2, GAME_HEIGHT / 2);
|
||||||
|
pop.setScale(0.2).setAlpha(0.3);
|
||||||
|
layer.add(pop);
|
||||||
|
|
||||||
|
const panel = scene.add.rectangle(-halfW, -halfH, PANEL_W, PANEL_H, PANEL, 0.97).setOrigin(0, 0);
|
||||||
|
panel.setStrokeStyle(2, accent, 0.85);
|
||||||
|
pop.add(panel);
|
||||||
|
|
||||||
|
const ticks = scene.add.graphics();
|
||||||
|
ticks.lineStyle(3, accent, 0.9);
|
||||||
|
const t = 30;
|
||||||
|
for (const [tx, ty, dx, dy] of [
|
||||||
|
[-halfW, -halfH, 1, 1], [halfW, -halfH, -1, 1],
|
||||||
|
[-halfW, halfH, 1, -1], [halfW, halfH, -1, -1],
|
||||||
|
]) {
|
||||||
|
ticks.lineBetween(tx, ty, tx + dx * t, ty);
|
||||||
|
ticks.lineBetween(tx, ty, tx, ty + dy * t);
|
||||||
|
}
|
||||||
|
pop.add(ticks);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ left column
|
||||||
|
// The officer at full recording resolution, the hull beneath them. This is
|
||||||
|
// the window's own portrait, not one borrowed from the caller's pool — the
|
||||||
|
// caller pauses its thumbnails while we are up, so nothing decodes twice.
|
||||||
|
const artX = -halfW + 176;
|
||||||
|
pop.add(makeCommanderPortrait(scene, rules, art, emp.speciesId, hullId, artX, -halfH + 190, 256));
|
||||||
|
|
||||||
|
pop.add(scene.add.text(artX, -halfH + 336, 'COMMANDING OFFICER', {
|
||||||
|
fontFamily: FONT, fontSize: '14px', color: '#6f8aa3',
|
||||||
|
}).setOrigin(0.5));
|
||||||
|
|
||||||
|
pop.add(makeShipIcon(scene, rules, art, emp.speciesId, hullId, artX, -halfH + 470, 192));
|
||||||
|
|
||||||
|
pop.add(scene.add.text(artX, halfH - 78, species.name.toUpperCase(), {
|
||||||
|
fontFamily: FONT, fontSize: '18px', color: species.color,
|
||||||
|
}).setOrigin(0.5));
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- right column
|
||||||
|
const colX = -halfW + 340;
|
||||||
|
const colW = PANEL_W - 40 - (colX + halfW);
|
||||||
|
let y = -halfH + 44;
|
||||||
|
|
||||||
|
const text = (str, size, colour, gap = 6, wrap = colW) => {
|
||||||
|
const o = scene.add.text(colX, y, str, {
|
||||||
|
fontFamily: FONT, fontSize: `${size}px`, color: colour, lineSpacing: 3,
|
||||||
|
wordWrap: { width: wrap },
|
||||||
|
});
|
||||||
|
pop.add(o);
|
||||||
|
y += o.height + gap;
|
||||||
|
return o;
|
||||||
|
};
|
||||||
|
|
||||||
|
const heading = (label) => {
|
||||||
|
y += 10;
|
||||||
|
pop.add(scene.add.text(colX, y, label, {
|
||||||
|
fontFamily: FONT, fontSize: '14px', color: '#6f8aa3',
|
||||||
|
}));
|
||||||
|
y += 22;
|
||||||
|
pop.add(scene.add.rectangle(colX, y, colW, 1, ACCENT, 0.22).setOrigin(0, 0));
|
||||||
|
y += 10;
|
||||||
|
};
|
||||||
|
|
||||||
|
text(design.name, 38, '#cfe8ff', 2);
|
||||||
|
text(`${ROLE_NAMES[hull.role] ?? hull.role} · ${design.cost} BC`, 17, '#9fd8ff', 8);
|
||||||
|
text(hull.desc, 18, '#a8c4e0', 4);
|
||||||
|
|
||||||
|
// --- the stat block, two columns of label/value pairs
|
||||||
|
heading('SPECIFICATION');
|
||||||
|
const stats = [
|
||||||
|
['Hull', `${design.hp} HP`],
|
||||||
|
['Armour', design.armorName],
|
||||||
|
['Shields', design.shield > 0 ? `Class ${design.shield}` : 'None'],
|
||||||
|
['Engines', design.engineName],
|
||||||
|
['Speed', design.immobile ? 'Immobile' : `${design.speed}`],
|
||||||
|
['Range', design.immobile ? '—' : `${design.range} parsecs`],
|
||||||
|
['Targeting', `+${design.targeting}`],
|
||||||
|
['Initiative', `${design.initiative}`],
|
||||||
|
];
|
||||||
|
if (design.attack || design.defense) {
|
||||||
|
stats.push(['Attack', `${design.attack >= 0 ? '+' : ''}${design.attack}%`]);
|
||||||
|
stats.push(['Defense', `${design.defense >= 0 ? '+' : ''}${design.defense}%`]);
|
||||||
|
}
|
||||||
|
if (design.repairPerRound > 0) {
|
||||||
|
stats.push(['Repair', `${Math.round(design.repairPerRound * 100)}% / round`]);
|
||||||
|
}
|
||||||
|
if (design.troops > 0) stats.push(['Marines', `${design.troops} divisions`]);
|
||||||
|
|
||||||
|
const statTop = y;
|
||||||
|
const cellW = colW / 2;
|
||||||
|
stats.forEach((pair, i) => {
|
||||||
|
const cx = colX + (i % 2) * cellW;
|
||||||
|
const cy = statTop + Math.floor(i / 2) * 30;
|
||||||
|
pop.add(scene.add.text(cx, cy, pair[0], {
|
||||||
|
fontFamily: FONT, fontSize: '15px', color: '#6f8aa3',
|
||||||
|
}));
|
||||||
|
pop.add(scene.add.text(cx + 120, cy, String(pair[1]), {
|
||||||
|
fontFamily: FONT, fontSize: '16px', color: '#c8dcf0',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
y = statTop + Math.ceil(stats.length / 2) * 30 + 4;
|
||||||
|
|
||||||
|
const flags = [];
|
||||||
|
if (design.cloaked) flags.push('Cloaked');
|
||||||
|
if (design.planetCracker) flags.push('Planet cracker');
|
||||||
|
if (design.immobile) flags.push('Never leaves its system');
|
||||||
|
if (flags.length) text(flags.join(' · '), 15, '#ffd88a', 4);
|
||||||
|
|
||||||
|
// --- the loadout the knapsack settled on
|
||||||
|
heading('LOAD-OUT');
|
||||||
|
if (hull.space <= 0) {
|
||||||
|
text('Unarmed — this hull carries no weapon tonnage.', 16, '#7f97b3', 4);
|
||||||
|
} else if (!design.mounts.length) {
|
||||||
|
text('No weapons researched yet. This hull builds, and flies empty.',
|
||||||
|
16, '#e08a8a', 4);
|
||||||
|
} else {
|
||||||
|
for (const m of design.mounts) {
|
||||||
|
const w = m.weapon;
|
||||||
|
const dmg = w.min === w.max ? `${w.min}` : `${w.min}–${w.max}`;
|
||||||
|
const tail = w.kind === 'missile'
|
||||||
|
? `${w.shots} salvo${w.shots === 1 ? '' : 'es'}`
|
||||||
|
: 'every round';
|
||||||
|
text(`${m.count} × ${w.name} · ${dmg} damage · ${tail}`
|
||||||
|
+ (w.shieldPierce ? ` · pierces ${w.shieldPierce} shield` : ''),
|
||||||
|
17, '#9fd8ff', 3);
|
||||||
|
}
|
||||||
|
y += 4;
|
||||||
|
const summary = [];
|
||||||
|
if (design.beamDamage > 0) summary.push(`Beams ${Math.round(design.beamDamage)} per round`);
|
||||||
|
if (design.missileSalvo > 0) {
|
||||||
|
summary.push(`Missiles ${Math.round(design.missileSalvo)} × ${design.missileSalvos} salvoes`);
|
||||||
|
}
|
||||||
|
summary.push(`${hull.space} tonnage`);
|
||||||
|
text(summary.join(' · '), 15, '#7f97b3', 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stack that has not been refitted yet is flying an older Mark. The older
|
||||||
|
// Mark's LOADOUT cannot be reconstructed — designFor only ever builds at the
|
||||||
|
// empire's current tech — so say plainly that the block above is the new one.
|
||||||
|
if (mark !== null && mark < design.mark) {
|
||||||
|
const cost = refitCost(rules, emp.known, hullId, mark, species.traits);
|
||||||
|
text(`This stack is still Mark ${markNumeral(mark)}. The figures above are the `
|
||||||
|
+ `Mark ${markNumeral(design.mark)} refit, which costs ${cost} BC and happens `
|
||||||
|
+ 'automatically over a friendly colony.', 15, '#ffd88a', 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ close
|
||||||
|
let closing = false;
|
||||||
|
const close = () => {
|
||||||
|
if (closing) return;
|
||||||
|
closing = true;
|
||||||
|
scene.tweens.add({ targets: veil, fillAlpha: 0, duration: 160 });
|
||||||
|
scene.tweens.add({
|
||||||
|
targets: pop,
|
||||||
|
scale: 0.2,
|
||||||
|
alpha: 0.25,
|
||||||
|
duration: 180,
|
||||||
|
ease: 'Cubic.easeIn',
|
||||||
|
onComplete: () => {
|
||||||
|
layer.destroy();
|
||||||
|
onClose?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
pop.add(new Button(scene, halfW - 110, halfH - 52, 'Close', close,
|
||||||
|
{ width: 180, height: 52, fontSize: 22, variant: 'ghost' }));
|
||||||
|
veil.on('pointerup', close);
|
||||||
|
|
||||||
|
scene.tweens.add({
|
||||||
|
targets: pop, scale: 1, alpha: 1, duration: 260, ease: 'Back.easeOut',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { close };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
// Master of Vega — ship pictures and ship-commander videos.
|
||||||
|
//
|
||||||
|
// Two pieces of art travel together everywhere a ship is listed: the hull
|
||||||
|
// itself, cut from the `ships` sheet, and a looping video of the officer who
|
||||||
|
// flies it. This module is the one place that knows how either is addressed.
|
||||||
|
//
|
||||||
|
// The commander clips follow the same drop-in contract as the species
|
||||||
|
// portraits, one tier deeper:
|
||||||
|
//
|
||||||
|
// 1. this species' clip for this hull (shipVideos[species][hull])
|
||||||
|
// 2. the species' own portrait video (portraitVideos[species])
|
||||||
|
// 3. its high-resolution still (portraitStills[species])
|
||||||
|
// 4. a frame on the procedural sheet (always present)
|
||||||
|
//
|
||||||
|
// Tiers 2-4 are exactly makeSpeciesPortrait's ladder, so a species with no ship
|
||||||
|
// clips recorded still shows a moving face on every row — which is why only
|
||||||
|
// `human` is filled in today and the other nine are a single `null` each in the
|
||||||
|
// artwork manifest.
|
||||||
|
//
|
||||||
|
// Ships are drawn facing UP on the sheet. Thumbnails and the detail window turn
|
||||||
|
// them a full 180°, which is a ROTATION and not a mirror: setFlipY would flip
|
||||||
|
// the hull's asymmetries with it.
|
||||||
|
|
||||||
|
import { makeSpeciesPortrait, sizeSpeciesPortrait, shipFrame } from './VegaArt.js';
|
||||||
|
|
||||||
|
/** Source resolution of the commander videos, and of a `ships` sheet frame. */
|
||||||
|
const COMMANDER_VIDEO_PX = 256;
|
||||||
|
const SHIP_FRAME_PX = 192;
|
||||||
|
|
||||||
|
export const shipVideoKey = (speciesId, hullId) => `vega-ship-${speciesId}-${hullId}`;
|
||||||
|
|
||||||
|
export function hasShipVideo(scene, speciesId, hullId) {
|
||||||
|
return !!scene.cache.video?.exists(shipVideoKey(speciesId, hullId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hull itself, `size` px square and turned upside down, centred on (x, y).
|
||||||
|
*
|
||||||
|
* Scales from the texture width rather than calling setDisplaySize so repeated
|
||||||
|
* resizes stay correct — displayWidth changes as it is scaled, width does not.
|
||||||
|
*/
|
||||||
|
export function makeShipIcon(scene, rules, art, speciesId, hullId, x, y, size) {
|
||||||
|
const img = scene.add.image(x, y, art.ships, shipFrame(rules, speciesId, hullId));
|
||||||
|
img.setScale(size / (img.width || SHIP_FRAME_PX));
|
||||||
|
img.setAngle(180);
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The commanding officer, `size` px square and centred on (x, y). Returns a
|
||||||
|
* Phaser Video or Image — the caller adds it to its own container and never has
|
||||||
|
* to know which tier it got.
|
||||||
|
*
|
||||||
|
* `onReplaced` fires if the clip is present but the browser refuses to decode
|
||||||
|
* it and the portrait is swapped underneath; a pool holding a reference needs
|
||||||
|
* to hear about that, or it keeps handing out a destroyed object.
|
||||||
|
*/
|
||||||
|
export function makeCommanderPortrait(
|
||||||
|
scene, rules, art, speciesId, hullId, x, y, size, onReplaced,
|
||||||
|
) {
|
||||||
|
if (!hasShipVideo(scene, speciesId, hullId)) {
|
||||||
|
return makeSpeciesPortrait(scene, rules, art, speciesId, x, y, size);
|
||||||
|
}
|
||||||
|
const v = scene.add.video(x, y, shipVideoKey(speciesId, hullId));
|
||||||
|
v.setMute(true);
|
||||||
|
v.setLoop(true);
|
||||||
|
// Scale from the known source size rather than setDisplaySize: a Video's
|
||||||
|
// texture can still report zero width before its first frame is decoded, and
|
||||||
|
// setDisplaySize would then divide by it and blank the portrait.
|
||||||
|
v.setScale(size / (v.width || COMMANDER_VIDEO_PX));
|
||||||
|
v.play(true);
|
||||||
|
v.once('error', () => {
|
||||||
|
if (!v.scene) return;
|
||||||
|
const fallback = makeSpeciesPortrait(scene, rules, art, speciesId, x, y, size);
|
||||||
|
v.parentContainer?.add(fallback);
|
||||||
|
v.destroy();
|
||||||
|
onReplaced?.(fallback);
|
||||||
|
});
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A scene-lifetime cache of commander portraits, keyed by species and hull.
|
||||||
|
*
|
||||||
|
* Every view that lists ships rebuilds its whole body on each click — the side
|
||||||
|
* panel on every − / +, the colony catalogue on every enqueue. Creating the
|
||||||
|
* portraits inline would tear down and re-create a decoder per row per click,
|
||||||
|
* restarting every loop and burning CPU for no visible gain. So the portraits
|
||||||
|
* live in `layer`, which the caller parents wherever it likes and deliberately
|
||||||
|
* does NOT clear during a rebuild:
|
||||||
|
*
|
||||||
|
* pool.beginFrame();
|
||||||
|
* ... rows call pool.get(...) ...
|
||||||
|
* pool.endFrame();
|
||||||
|
*
|
||||||
|
* Anything not claimed this frame is hidden and paused rather than destroyed,
|
||||||
|
* so dialling a stack to zero and back does not restart its loop.
|
||||||
|
*
|
||||||
|
* One pool per view. Two pools may each hold a portrait on the same cached
|
||||||
|
* video: verified against the Phaser 3.90 source, every Video game object
|
||||||
|
* creates its own HTMLVideoElement and destroys only that one, so this costs
|
||||||
|
* decode time and nothing else.
|
||||||
|
*/
|
||||||
|
export function createShipMediaPool(scene, rules, art) {
|
||||||
|
const entries = new Map();
|
||||||
|
const claimed = new Set();
|
||||||
|
const layer = scene.add.container(0, 0);
|
||||||
|
let paused = false;
|
||||||
|
// A view can be torn down while one of its own close tweens is still
|
||||||
|
// running, so every entry point has to survive being called after destroy().
|
||||||
|
let dead = false;
|
||||||
|
|
||||||
|
const play = (e) => { if (e.isVideo && e.obj.scene) e.obj.resume?.(); };
|
||||||
|
const halt = (e) => { if (e.isVideo && e.obj.scene) e.obj.pause?.(); };
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Parent this where the portraits should draw; never removeAll(true) it. */
|
||||||
|
layer,
|
||||||
|
|
||||||
|
beginFrame() { claimed.clear(); },
|
||||||
|
|
||||||
|
get(speciesId, hullId, x, y, size) {
|
||||||
|
if (dead) return null;
|
||||||
|
const key = `${speciesId}|${hullId}`;
|
||||||
|
let e = entries.get(key);
|
||||||
|
// A destroyed object still answers to its variable. Phaser nulls `scene`
|
||||||
|
// on destroy, which is the only reliable liveness check we have.
|
||||||
|
if (e && !e.obj.scene) { entries.delete(key); e = null; }
|
||||||
|
if (!e) {
|
||||||
|
const obj = makeCommanderPortrait(
|
||||||
|
scene, rules, art, speciesId, hullId, x, y, size,
|
||||||
|
(replacement) => {
|
||||||
|
const cur = entries.get(key);
|
||||||
|
if (cur) entries.set(key, { obj: replacement, isVideo: false });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
e = { obj, isVideo: obj.type === 'Video' };
|
||||||
|
entries.set(key, e);
|
||||||
|
layer.add(obj);
|
||||||
|
}
|
||||||
|
claimed.add(key);
|
||||||
|
e.obj.setPosition(x, y);
|
||||||
|
sizeSpeciesPortrait(e.obj, size);
|
||||||
|
e.obj.setVisible(true);
|
||||||
|
if (!paused) play(e);
|
||||||
|
return e.obj;
|
||||||
|
},
|
||||||
|
|
||||||
|
endFrame() {
|
||||||
|
for (const [key, e] of entries) {
|
||||||
|
if (claimed.has(key)) continue;
|
||||||
|
e.obj.setVisible(false);
|
||||||
|
halt(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Stop decoding entirely — the view is hidden, or something is over it. */
|
||||||
|
setPaused(value) {
|
||||||
|
if (dead) return;
|
||||||
|
paused = !!value;
|
||||||
|
for (const [key, e] of entries) {
|
||||||
|
if (paused || !claimed.has(key)) halt(e);
|
||||||
|
else play(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (dead) return;
|
||||||
|
dead = true;
|
||||||
|
layer.destroy();
|
||||||
|
entries.clear();
|
||||||
|
claimed.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,8 @@ import {
|
||||||
colonyDefenseCap, habitableForEmpire, reachableStars, atWar,
|
colonyDefenseCap, habitableForEmpire, reachableStars, atWar,
|
||||||
} from './VegaLogic.js';
|
} from './VegaLogic.js';
|
||||||
import { FONT, D, ORBIT } from './VegaScreens.js';
|
import { FONT, D, ORBIT } from './VegaScreens.js';
|
||||||
|
import { createShipMediaPool, makeShipIcon } from './VegaShipMedia.js';
|
||||||
|
import { openShipDetail } from './VegaShipDetail.js';
|
||||||
|
|
||||||
const W = 400;
|
const W = 400;
|
||||||
const PAD = 18;
|
const PAD = 18;
|
||||||
|
|
@ -94,8 +96,25 @@ export default class VegaSidePanel {
|
||||||
|
|
||||||
this.body = scene.add.container(0, 0);
|
this.body = scene.add.container(0, 0);
|
||||||
this.root.add(this.body);
|
this.root.add(this.body);
|
||||||
|
|
||||||
|
// The commander videos outlive the body. Every − / + rebuilds `body` from
|
||||||
|
// scratch, and re-creating a decoder per row per click would restart every
|
||||||
|
// loop, so they live in their own layer that the rebuild never touches.
|
||||||
|
// It is added AFTER body because a Container renders its children in
|
||||||
|
// insertion order and ignores their depth.
|
||||||
|
this.pool = createShipMediaPool(scene, rules, art);
|
||||||
|
this.root.add(this.pool.layer);
|
||||||
|
this.detail = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while the ship detail pop-over is up. It veils the whole screen, so
|
||||||
|
* the star map has to be told to keep still everywhere and not just under
|
||||||
|
* the panel's own footprint — the same job `modalOpen` does for the modals
|
||||||
|
* this window deliberately does not go through.
|
||||||
|
*/
|
||||||
|
get detailOpen() { return !!this.detail; }
|
||||||
|
|
||||||
// Used by the star map to keep a drag or a wheel over the panel from panning
|
// Used by the star map to keep a drag or a wheel over the panel from panning
|
||||||
// and zooming the galaxy underneath it.
|
// and zooming the galaxy underneath it.
|
||||||
containsPoint(x, y) {
|
containsPoint(x, y) {
|
||||||
|
|
@ -131,6 +150,8 @@ export default class VegaSidePanel {
|
||||||
|
|
||||||
hide() {
|
hide() {
|
||||||
if (!this.root.visible) return;
|
if (!this.root.visible) return;
|
||||||
|
// Nothing should be decoding while the panel is parked off-screen.
|
||||||
|
this.pool.setPaused(true);
|
||||||
this.mode = null;
|
this.mode = null;
|
||||||
this.fleet = null;
|
this.fleet = null;
|
||||||
this.starIdx = -1;
|
this.starIdx = -1;
|
||||||
|
|
@ -148,6 +169,7 @@ export default class VegaSidePanel {
|
||||||
}
|
}
|
||||||
|
|
||||||
open() {
|
open() {
|
||||||
|
if (!this.detail) this.pool.setPaused(false);
|
||||||
if (this.root.visible && Math.abs(this.root.x - this.x0) < 0.5) return;
|
if (this.root.visible && Math.abs(this.root.x - this.x0) < 0.5) return;
|
||||||
if (!this.root.visible) {
|
if (!this.root.visible) {
|
||||||
this.root.setVisible(true);
|
this.root.setVisible(true);
|
||||||
|
|
@ -179,7 +201,11 @@ export default class VegaSidePanel {
|
||||||
this.rebuild();
|
this.rebuild();
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() { this.root.destroy(); }
|
destroy() {
|
||||||
|
this.detail?.close();
|
||||||
|
this.pool.destroy();
|
||||||
|
this.root.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------- text primitives
|
// -------------------------------------------------------- text primitives
|
||||||
|
|
||||||
|
|
@ -255,10 +281,90 @@ export default class VegaSidePanel {
|
||||||
|
|
||||||
rebuild() {
|
rebuild() {
|
||||||
this.body.removeAll(true);
|
this.body.removeAll(true);
|
||||||
|
// The pool survives the wipe; claim what this pass actually uses and let
|
||||||
|
// endFrame() hide and pause the rest.
|
||||||
|
this.pool.beginFrame();
|
||||||
this.y = 90;
|
this.y = 90;
|
||||||
if (this.mode === 'star') this.buildStar();
|
if (this.mode === 'star') this.buildStar();
|
||||||
else if (this.mode === 'fleet') this.buildFleet();
|
else if (this.mode === 'fleet') this.buildFleet();
|
||||||
else if (this.mode === 'order') this.buildOrder();
|
else if (this.mode === 'order') this.buildOrder();
|
||||||
|
this.pool.endFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ ship media
|
||||||
|
|
||||||
|
/** Species flying a given fleet — the row art is keyed on it. */
|
||||||
|
speciesOf(empireIdx) { return this.state.empires[empireIdx].speciesId; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commander video + upside-down hull, side by side, vertically centred on
|
||||||
|
* `cy`. The video comes from the pool; the hull is a plain Image and is
|
||||||
|
* cheap enough to rebuild with the rest of the body. They land in different
|
||||||
|
* containers but share the panel's local coordinates, so they stay aligned.
|
||||||
|
*/
|
||||||
|
shipThumbs(empireIdx, hullId, x, cy, size) {
|
||||||
|
const speciesId = this.speciesOf(empireIdx);
|
||||||
|
this.pool.get(speciesId, hullId, x + size / 2, cy, size);
|
||||||
|
this.body.add(makeShipIcon(
|
||||||
|
this.scene, this.rules, this.art, speciesId, hullId,
|
||||||
|
x + size * 1.5 + 6, cy, size,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A transparent click target over a row, opening the detail window. */
|
||||||
|
detailHit(x, y, w, h, empireIdx, hullId, mark) {
|
||||||
|
const r = this.scene.add.rectangle(x, y, w, h, 0xffffff, 0.001)
|
||||||
|
.setOrigin(0, 0).setInteractive({ useHandCursor: true });
|
||||||
|
r.on('pointerup', () => this.openDetail(empireIdx, hullId, mark));
|
||||||
|
this.body.add(r);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full-resolution pop-over. The panel's own thumbnails stop decoding
|
||||||
|
* while it is up — the window has its own portrait at 256px and there is no
|
||||||
|
* point running both.
|
||||||
|
*/
|
||||||
|
openDetail(empireIdx, hullId, mark = null) {
|
||||||
|
if (this.detail) return;
|
||||||
|
this.pool.setPaused(true);
|
||||||
|
this.detail = openShipDetail(
|
||||||
|
this.scene, this.rules, this.state, this.art,
|
||||||
|
{ empireIdx, hullId, mark },
|
||||||
|
() => {
|
||||||
|
this.detail = null;
|
||||||
|
if (this.root.visible) this.pool.setPaused(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A read-only `N × Name` line with thumbnails — the in-transit list, the
|
||||||
|
* garrison and the order screen's task force all take this shape. The
|
||||||
|
* task-force rows in fleet mode need the − / + cluster and build their own.
|
||||||
|
*/
|
||||||
|
shipLine(empireIdx, hullId, mark, label, opts = {}) {
|
||||||
|
const { color = '#c8dcf0', size = 40, sub = null } = opts;
|
||||||
|
const rowY = this.y;
|
||||||
|
const textX = PAD + size * 2 + 20;
|
||||||
|
const name = this.scene.add.text(textX, rowY, label, {
|
||||||
|
fontFamily: FONT, fontSize: '15px', color, lineSpacing: 2,
|
||||||
|
wordWrap: { width: PAD + COL - textX },
|
||||||
|
});
|
||||||
|
this.body.add(name);
|
||||||
|
let textH = name.height;
|
||||||
|
if (sub) {
|
||||||
|
const s = this.scene.add.text(textX, rowY + name.height + 2, sub, {
|
||||||
|
fontFamily: FONT, fontSize: '12px', color: '#6f8aa3',
|
||||||
|
wordWrap: { width: PAD + COL - textX },
|
||||||
|
});
|
||||||
|
this.body.add(s);
|
||||||
|
textH += s.height + 2;
|
||||||
|
}
|
||||||
|
const rowH = Math.max(size, textH);
|
||||||
|
this.shipThumbs(empireIdx, hullId, PAD, rowY + rowH / 2, size);
|
||||||
|
this.detailHit(PAD, rowY, COL, rowH, empireIdx, hullId, mark);
|
||||||
|
this.y = rowY + rowH + 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewer() {
|
get viewer() {
|
||||||
|
|
@ -473,8 +579,9 @@ export default class VegaSidePanel {
|
||||||
if (garrison.length) {
|
if (garrison.length) {
|
||||||
this.heading('Garrison');
|
this.heading('Garrison');
|
||||||
for (const s of garrison) {
|
for (const s of garrison) {
|
||||||
this.line(`${s.count} × ${this.designName(s.hullId, s.mark)} — holds this system`,
|
this.shipLine(fleet.empireIdx, s.hullId, s.mark,
|
||||||
{ size: 13, color: '#8fa8c0', gap: 2 });
|
`${s.count} × ${this.designName(s.hullId, s.mark)}`,
|
||||||
|
{ color: '#8fa8c0', sub: 'Holds this system' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -485,31 +592,46 @@ export default class VegaSidePanel {
|
||||||
this.action('Done', () => this.cb.onClose?.(), { variant: 'ghost' });
|
this.action('Done', () => this.cb.onClose?.(), { variant: 'ghost' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A ship stack with a −/+ count selector. */
|
/**
|
||||||
|
* A ship stack with a −/+ count selector. The name sits on the first line and
|
||||||
|
* the counter on the second, so the thumbnails can take the left gutter
|
||||||
|
* without squeezing either into a column too narrow to read.
|
||||||
|
*/
|
||||||
stackRow(s, index) {
|
stackRow(s, index) {
|
||||||
const rowY = this.y;
|
const rowY = this.y;
|
||||||
const name = this.scene.add.text(PAD, rowY, `${this.designName(s.hullId, s.mark)}`, {
|
const MEDIA = 60;
|
||||||
|
const textX = PAD + MEDIA * 2 + 20;
|
||||||
|
const name = this.scene.add.text(textX, rowY, `${this.designName(s.hullId, s.mark)}`, {
|
||||||
fontFamily: FONT, fontSize: '15px', color: s.count > 0 ? '#c8dcf0' : '#5d7085',
|
fontFamily: FONT, fontSize: '15px', color: s.count > 0 ? '#c8dcf0' : '#5d7085',
|
||||||
wordWrap: { width: COL - 130 },
|
wordWrap: { width: PAD + COL - textX },
|
||||||
});
|
});
|
||||||
this.body.add(name);
|
this.body.add(name);
|
||||||
|
|
||||||
const boxSize = 26;
|
const boxSize = 26;
|
||||||
const right = PAD + COL;
|
const right = PAD + COL;
|
||||||
|
const ctrlY = rowY + name.height + 6 + boxSize / 2;
|
||||||
|
const rowH = Math.max(MEDIA, name.height + 6 + boxSize);
|
||||||
|
|
||||||
|
this.shipThumbs(this.fleet.empireIdx, s.hullId, PAD, rowY + rowH / 2, MEDIA);
|
||||||
|
|
||||||
|
// The hit target goes down BEFORE the buttons — it covers the whole row, so
|
||||||
|
// anything added after it stays clickable and anything before it does not.
|
||||||
|
this.detailHit(PAD, rowY, COL, rowH, this.fleet.empireIdx, s.hullId, s.mark);
|
||||||
|
|
||||||
const set = (v) => {
|
const set = (v) => {
|
||||||
this.sel[index].count = Phaser.Math.Clamp(v, 0, s.max);
|
this.sel[index].count = Phaser.Math.Clamp(v, 0, s.max);
|
||||||
this.rebuild();
|
this.rebuild();
|
||||||
};
|
};
|
||||||
this.tinyButton(right - boxSize / 2, rowY + 12, boxSize, '+', () => set(s.count + 1),
|
this.tinyButton(right - boxSize / 2, ctrlY, boxSize, '+', () => set(s.count + 1),
|
||||||
s.count < s.max);
|
s.count < s.max);
|
||||||
this.tinyButton(right - boxSize * 2.9, rowY + 12, boxSize, '–', () => set(s.count - 1),
|
this.tinyButton(right - boxSize * 2.9, ctrlY, boxSize, '–', () => set(s.count - 1),
|
||||||
s.count > 0);
|
s.count > 0);
|
||||||
const count = this.scene.add.text(right - boxSize * 1.7, rowY + 11, `${s.count} / ${s.max}`, {
|
const count = this.scene.add.text(right - boxSize * 1.7, ctrlY - 1, `${s.count} / ${s.max}`, {
|
||||||
fontFamily: FONT, fontSize: '15px', color: s.count > 0 ? '#e8f4ff' : '#5d7085',
|
fontFamily: FONT, fontSize: '15px', color: s.count > 0 ? '#e8f4ff' : '#5d7085',
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
this.body.add(count);
|
this.body.add(count);
|
||||||
|
|
||||||
this.y = rowY + Math.max(name.height, boxSize) + 8;
|
this.y = rowY + rowH + 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectionSummary() {
|
selectionSummary() {
|
||||||
|
|
@ -544,8 +666,8 @@ export default class VegaSidePanel {
|
||||||
this.heading('Ships');
|
this.heading('Ships');
|
||||||
for (const s of this.fleet.ships) {
|
for (const s of this.fleet.ships) {
|
||||||
if (s.count <= 0) continue;
|
if (s.count <= 0) continue;
|
||||||
this.line(`${s.count} × ${this.designName(s.hullId, s.mark)}`,
|
this.shipLine(this.fleet.empireIdx, s.hullId, s.mark,
|
||||||
{ size: 14, color: '#c8dcf0', gap: 2 });
|
`${s.count} × ${this.designName(s.hullId, s.mark)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -579,8 +701,8 @@ export default class VegaSidePanel {
|
||||||
this.line('No ships selected.', { size: 15, color: '#e08a8a' });
|
this.line('No ships selected.', { size: 15, color: '#e08a8a' });
|
||||||
} else {
|
} else {
|
||||||
for (const s of ships) {
|
for (const s of ships) {
|
||||||
this.line(`${s.count} × ${this.designName(s.hullId, s.mark)}`,
|
this.shipLine(fleet.empireIdx, s.hullId, s.mark,
|
||||||
{ size: 14, color: '#c8dcf0', gap: 2 });
|
`${s.count} × ${this.designName(s.hullId, s.mark)}`);
|
||||||
}
|
}
|
||||||
this.y += 4;
|
this.y += 4;
|
||||||
this.line(`Power ${fleetPower(rules, state, { ...fleet, ships })}`,
|
this.line(`Power ${fleetPower(rules, state, { ...fleet, ships })}`,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
// Master of Vega — turn-report classification and detail text. Headless, no
|
||||||
|
// Phaser: this only turns entries from the event bus (`state.events`, pushed
|
||||||
|
// by `pushEvent` in VegaLogic.js) into the headline+lines the "New Turn"
|
||||||
|
// popup renders. Keeping the per-type text formatting out of
|
||||||
|
// MasterOfVegaGame.js/VegaScreens.js keeps those files from ballooning.
|
||||||
|
|
||||||
|
import { habitableForEmpire } from './VegaLogic.js';
|
||||||
|
|
||||||
|
// Events worth interrupting the player for. Everything else (buildingDone,
|
||||||
|
// shipDone, refit, spyCaught, techStolen, invasionFailed, leaderHired,
|
||||||
|
// victory — which already has its own showVictoryOverlay) stays in the
|
||||||
|
// small ticker log only.
|
||||||
|
export const NOTABLE_TYPES = new Set([
|
||||||
|
'discovered', 'techDone', 'contact', 'colonised', 'captured',
|
||||||
|
'colonyDestroyed', 'warDeclared', 'peace', 'alliance', 'council',
|
||||||
|
'councilRefused', 'eliminated',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Exploring, researching and founding a colony are personal — only the
|
||||||
|
// empire that did them cares. Everything else (contact, territory changing
|
||||||
|
// hands, diplomacy, Council results, eliminations) is galaxy news broadcast
|
||||||
|
// to the player regardless of who it happened to, matching the precedent
|
||||||
|
// the old ticker-log code set (MasterOfVegaGame.processTurnEvents(), née
|
||||||
|
// announceEvents()) for captured/colonyDestroyed/warDeclared/contact.
|
||||||
|
const PERSONAL_TYPES = new Set(['discovered', 'techDone', 'colonised']);
|
||||||
|
|
||||||
|
export function isRelevantToHuman(ev, me) {
|
||||||
|
if (PERSONAL_TYPES.has(ev.type)) return ev.empire === me;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TYPE_LABEL = {
|
||||||
|
discovered: 'Discovery',
|
||||||
|
techDone: 'Research',
|
||||||
|
contact: 'First Contact',
|
||||||
|
colonised: 'Colony Founded',
|
||||||
|
captured: 'Colony Captured',
|
||||||
|
colonyDestroyed: 'Colony Destroyed',
|
||||||
|
warDeclared: 'War Declared',
|
||||||
|
peace: 'Peace',
|
||||||
|
alliance: 'Alliance',
|
||||||
|
council: 'Galactic Council',
|
||||||
|
councilRefused: 'Council Refused',
|
||||||
|
eliminated: 'Empire Eliminated',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort weight — diplomacy/territory/council news first (most consequential),
|
||||||
|
// then discoveries, then research, mirroring how the plan orders the list.
|
||||||
|
const CATEGORY_ORDER = {
|
||||||
|
contact: 0, warDeclared: 0, peace: 0, alliance: 0, council: 0, councilRefused: 0, eliminated: 0,
|
||||||
|
colonised: 1, captured: 1, colonyDestroyed: 1,
|
||||||
|
discovered: 2,
|
||||||
|
techDone: 3,
|
||||||
|
};
|
||||||
|
export const categoryWeight = (ev) => CATEGORY_ORDER[ev.type] ?? 9;
|
||||||
|
|
||||||
|
const line = (text, color) => ({ text, color });
|
||||||
|
|
||||||
|
function describeDiscovered(rules, state, ev) {
|
||||||
|
const star = state.galaxy.stars[ev.starIdx];
|
||||||
|
const cls = rules.starClasses[star.classId];
|
||||||
|
const lines = [line(`${cls.name} star — ${cls.desc}`, '#9fb6cc')];
|
||||||
|
if (!star.planets.length) {
|
||||||
|
lines.push(line('No planets in this system.', '#6b7f96'));
|
||||||
|
} else {
|
||||||
|
for (const p of star.planets) {
|
||||||
|
const type = rules.planetTypes[p.typeId];
|
||||||
|
const size = rules.planetSizes[p.sizeId];
|
||||||
|
const rich = rules.richness[p.richId];
|
||||||
|
const grav = rules.gravity[p.gravId];
|
||||||
|
const habitable = habitableForEmpire(rules, state, ev.empire, ev.starIdx, p.orbit);
|
||||||
|
lines.push(line(
|
||||||
|
`Planet ${p.orbit + 1}: ${size.name} ${type.name}, ${rich.name} minerals, ${grav.name}`
|
||||||
|
+ `${habitable ? ' — habitable' : ''}`,
|
||||||
|
habitable ? '#7fd8a0' : '#9fb6cc',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { headline: `Our ships have discovered the ${star.name} system.`, lines };
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeTechDone(rules, state, ev) {
|
||||||
|
const tech = rules.techs[ev.techId];
|
||||||
|
const emp = state.empires[ev.empire];
|
||||||
|
const gate = rules.techGates[ev.techId] ?? { buildings: [], prereqOf: [] };
|
||||||
|
const lines = [line(tech.desc, '#9fb6cc')];
|
||||||
|
const effects = Object.entries(tech.effects ?? {});
|
||||||
|
if (effects.length) {
|
||||||
|
lines.push(line(`Effects: ${effects.map(([k, v]) => `${k} ${v > 0 ? '+' : ''}${v}`).join(', ')}`, '#7fd8a0'));
|
||||||
|
}
|
||||||
|
const newBuildings = gate.buildings.map((id) => rules.buildings[id]?.name).filter(Boolean);
|
||||||
|
const newTechs = gate.prereqOf
|
||||||
|
.filter((id) => emp.available[id] && !emp.known[id])
|
||||||
|
.map((id) => rules.techs[id]?.name)
|
||||||
|
.filter(Boolean);
|
||||||
|
const unlocks = [...newBuildings, ...newTechs];
|
||||||
|
if (unlocks.length) {
|
||||||
|
lines.push(line(`Now available: ${unlocks.join(', ')}`, '#ffd88a'));
|
||||||
|
}
|
||||||
|
return { headline: `Research completed: ${tech.name}.`, lines };
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeContact(rules, state, ev, name) {
|
||||||
|
const me = state.humanIndex;
|
||||||
|
let otherIdx;
|
||||||
|
if (ev.empire === me) otherIdx = ev.other;
|
||||||
|
else if (ev.other === me) otherIdx = ev.empire;
|
||||||
|
|
||||||
|
if (otherIdx === undefined) {
|
||||||
|
return {
|
||||||
|
headline: `The ${name(ev.empire)} and the ${name(ev.other)} have made contact.`,
|
||||||
|
lines: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const other = state.empires[otherIdx];
|
||||||
|
const spec = rules.species[other.speciesId];
|
||||||
|
const lines = [line(spec.desc, '#9fb6cc')];
|
||||||
|
if (spec.strengths?.length) lines.push(line(`Strengths: ${spec.strengths.join(', ')}`, '#7fd8a0'));
|
||||||
|
if (spec.weaknesses?.length) lines.push(line(`Weaknesses: ${spec.weaknesses.join(', ')}`, '#e08a8a'));
|
||||||
|
return { headline: `First contact: the ${spec.name}.`, lines };
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeColonised(rules, state, ev) {
|
||||||
|
const star = state.galaxy.stars[ev.starIdx];
|
||||||
|
const planet = star.planets[ev.orbit];
|
||||||
|
const lines = [];
|
||||||
|
if (planet) {
|
||||||
|
const type = rules.planetTypes[planet.typeId];
|
||||||
|
const size = rules.planetSizes[planet.sizeId];
|
||||||
|
const rich = rules.richness[planet.richId];
|
||||||
|
lines.push(line(`${size.name} ${type.name}, ${rich.name} minerals.`, '#9fb6cc'));
|
||||||
|
}
|
||||||
|
return { headline: `Colony founded at ${star.name}.`, lines };
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeCaptured(rules, state, ev, name) {
|
||||||
|
const me = state.humanIndex;
|
||||||
|
const star = state.galaxy.stars[ev.starIdx];
|
||||||
|
let headline;
|
||||||
|
if (ev.empire === me) headline = `We have captured ${star.name} from the ${name(ev.from)}.`;
|
||||||
|
else if (ev.from === me) headline = `The ${name(ev.empire)} have captured ${star.name} from us.`;
|
||||||
|
else headline = `The ${name(ev.empire)} have captured ${star.name} from the ${name(ev.from)}.`;
|
||||||
|
return { headline, lines: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeColonyDestroyed(rules, state, ev, name) {
|
||||||
|
const me = state.humanIndex;
|
||||||
|
const star = state.galaxy.stars[ev.starIdx];
|
||||||
|
let headline;
|
||||||
|
if (ev.target === me) headline = `The ${name(ev.empire)} have bombed ${star.name} out of existence — our colony is lost.`;
|
||||||
|
else if (ev.empire === me) headline = `We have bombed ${star.name} out of existence, destroying the ${name(ev.target)}'s colony.`;
|
||||||
|
else headline = `${star.name} has been bombed out of existence by the ${name(ev.empire)}.`;
|
||||||
|
return { headline, lines: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeTreaty(rules, state, ev, name, verb) {
|
||||||
|
const me = state.humanIndex;
|
||||||
|
let headline;
|
||||||
|
if (ev.empire === me) headline = `We ${verb} the ${name(ev.other)}.`;
|
||||||
|
else if (ev.other === me) headline = `The ${name(ev.empire)} ${verb} us.`;
|
||||||
|
else headline = `The ${name(ev.empire)} ${verb} the ${name(ev.other)}.`;
|
||||||
|
return { headline, lines: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeCouncil(rules, state, ev, name) {
|
||||||
|
const need = Math.ceil(ev.totalPop * rules.council.winFraction);
|
||||||
|
const lines = (ev.candidates ?? []).map((idx) => line(
|
||||||
|
`${name(idx)}: ${Math.round(ev.votes?.[idx] ?? 0)} votes`, state.empires[idx]?.color,
|
||||||
|
));
|
||||||
|
lines.push(line(`Abstained: ${Math.round(ev.abstained ?? 0)} · ${need} votes of ${Math.round(ev.totalPop)} needed`, '#7f97b3'));
|
||||||
|
const headline = ev.winner >= 0
|
||||||
|
? `${name(ev.winner)} is elected High Guardian of the Galaxy.`
|
||||||
|
: (ev.refused
|
||||||
|
? 'The Council election was refused — war has begun.'
|
||||||
|
: 'The Council failed to elect a High Guardian.');
|
||||||
|
return { headline, lines };
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeCouncilRefused(rules, state, ev, name) {
|
||||||
|
const me = state.humanIndex;
|
||||||
|
let headline;
|
||||||
|
if (ev.empire === me) headline = `We refuse to submit to the ${name(ev.winner)}. The Council election is void.`;
|
||||||
|
else if (ev.winner === me) headline = `The ${name(ev.empire)} refuse to submit to us. The Council election is void — war has begun.`;
|
||||||
|
else headline = `The ${name(ev.empire)} refuse to submit to the ${name(ev.winner)}. The Council election is void.`;
|
||||||
|
return { headline, lines: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeEliminated(rules, state, ev, name) {
|
||||||
|
const me = state.humanIndex;
|
||||||
|
const headline = ev.empire === me
|
||||||
|
? 'We have been eliminated from the galaxy.'
|
||||||
|
: `The ${name(ev.empire)} have been eliminated from the galaxy.`;
|
||||||
|
return { headline, lines: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turns one event into {category, headline, lines} for the turn-report row. */
|
||||||
|
export function describeEvent(rules, state, ev) {
|
||||||
|
const name = (i) => state.empires[i]?.name ?? '?';
|
||||||
|
let out;
|
||||||
|
switch (ev.type) {
|
||||||
|
case 'discovered': out = describeDiscovered(rules, state, ev); break;
|
||||||
|
case 'techDone': out = describeTechDone(rules, state, ev); break;
|
||||||
|
case 'contact': out = describeContact(rules, state, ev, name); break;
|
||||||
|
case 'colonised': out = describeColonised(rules, state, ev); break;
|
||||||
|
case 'captured': out = describeCaptured(rules, state, ev, name); break;
|
||||||
|
case 'colonyDestroyed': out = describeColonyDestroyed(rules, state, ev, name); break;
|
||||||
|
case 'warDeclared': out = describeTreaty(rules, state, ev, name, 'declare war on'); break;
|
||||||
|
case 'peace': out = describeTreaty(rules, state, ev, name, 'make peace with'); break;
|
||||||
|
case 'alliance': out = describeTreaty(rules, state, ev, name, 'form an alliance with'); break;
|
||||||
|
case 'council': out = describeCouncil(rules, state, ev, name); break;
|
||||||
|
case 'councilRefused': out = describeCouncilRefused(rules, state, ev, name); break;
|
||||||
|
case 'eliminated': out = describeEliminated(rules, state, ev, name); break;
|
||||||
|
default: out = { headline: ev.type, lines: [] };
|
||||||
|
}
|
||||||
|
return { category: TYPE_LABEL[ev.type] ?? ev.type, ...out };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
// Master of Vega — the "New Turn" popup. Lists everything notable that
|
||||||
|
// happened since the player's last turn (VegaTurnReport.js classifies and
|
||||||
|
// writes the text); click a row to expand its synopsis in place.
|
||||||
|
//
|
||||||
|
// Lives outside VegaScreens.js because it needs VegaColonyView.js's
|
||||||
|
// scrollColumn — importing that back into VegaScreens.js (which every other
|
||||||
|
// screen file, including VegaColonyView.js itself, imports chrome FROM)
|
||||||
|
// would make the two files import each other. Same one-way-dependency shape
|
||||||
|
// as VegaSystemView.js, which already imports from both.
|
||||||
|
|
||||||
|
import { modalShell, FONT } from './VegaScreens.js';
|
||||||
|
import { scrollColumn } from './VegaColonyView.js';
|
||||||
|
import { turnToYear } from './VegaRules.js';
|
||||||
|
import { Button } from '../../ui/Button.js';
|
||||||
|
import { describeEvent, categoryWeight } from './VegaTurnReport.js';
|
||||||
|
|
||||||
|
const ACCENT = 0x6fc4ff;
|
||||||
|
const ROW_BG = 0x142238;
|
||||||
|
|
||||||
|
export function openTurnReportScreen(scene, rules, state, events, onClose) {
|
||||||
|
const shell = modalShell(scene, `New Turn: ${turnToYear(state.turn)}`, onClose, { width: 1180, height: 760 });
|
||||||
|
|
||||||
|
const ordered = events
|
||||||
|
.map((ev, i) => ({ ev, i, desc: describeEvent(rules, state, ev) }))
|
||||||
|
.sort((a, b) => categoryWeight(a.ev) - categoryWeight(b.ev) || a.i - b.i);
|
||||||
|
|
||||||
|
const listH = shell.body.h - 56;
|
||||||
|
const col = scrollColumn(scene, shell.layer, shell.body.x, shell.body.y, shell.body.w, listH);
|
||||||
|
const w = shell.body.w;
|
||||||
|
// Everything starts expanded — the player came here to read the news, not
|
||||||
|
// to hunt for it — but each row stays individually collapsible.
|
||||||
|
const expanded = new Set(ordered.map((o) => o.i));
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
col.content.removeAll(true);
|
||||||
|
let cy = 0;
|
||||||
|
for (const { i, desc } of ordered) {
|
||||||
|
const rowTop = cy;
|
||||||
|
const isOpen = expanded.has(i);
|
||||||
|
// bg has to be built AFTER the row's text so its final height is known
|
||||||
|
// — Rectangle Shapes in this Phaser build don't tolerate a post-construction
|
||||||
|
// .setSize() (throws inside Phaser's own setSize). So it's built last and
|
||||||
|
// inserted at this index, which puts it behind this row's text instead
|
||||||
|
// of on top of it.
|
||||||
|
const bgIndex = col.content.length;
|
||||||
|
|
||||||
|
const tag = scene.add.text(0, cy, desc.category.toUpperCase(), {
|
||||||
|
fontFamily: FONT, fontSize: '13px', color: '#7fb8e8',
|
||||||
|
});
|
||||||
|
col.content.add(tag);
|
||||||
|
const chevron = scene.add.text(w - 20, cy, isOpen ? '▾' : '▸', {
|
||||||
|
fontFamily: FONT, fontSize: '16px', color: '#7f97b3',
|
||||||
|
});
|
||||||
|
col.content.add(chevron);
|
||||||
|
cy += tag.height + 4;
|
||||||
|
|
||||||
|
const headline = scene.add.text(0, cy, desc.headline, {
|
||||||
|
fontFamily: FONT, fontSize: '19px', color: '#e8f4ff', wordWrap: { width: w - 30 },
|
||||||
|
});
|
||||||
|
col.content.add(headline);
|
||||||
|
cy += headline.height + 10;
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
for (const ln of desc.lines) {
|
||||||
|
const t = scene.add.text(16, cy, ln.text, {
|
||||||
|
fontFamily: FONT, fontSize: '15px', color: ln.color ?? '#9fb6cc', wordWrap: { width: w - 46 },
|
||||||
|
});
|
||||||
|
col.content.add(t);
|
||||||
|
cy += t.height + 6;
|
||||||
|
}
|
||||||
|
cy += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bg = scene.add.rectangle(0, rowTop, w, cy - rowTop, ROW_BG, 0).setOrigin(0, 0)
|
||||||
|
.setInteractive({ useHandCursor: true });
|
||||||
|
col.content.addAt(bg, bgIndex);
|
||||||
|
bg.on('pointerover', () => bg.setFillStyle(ROW_BG, 0.4));
|
||||||
|
bg.on('pointerout', () => bg.setFillStyle(ROW_BG, 0));
|
||||||
|
// A mask is a rendering concern only — a row scrolled out of the visible
|
||||||
|
// band is still hit-testable, so every handler has to ask the column.
|
||||||
|
bg.on('pointerup', (p) => {
|
||||||
|
if (!col.contains(p)) return;
|
||||||
|
if (expanded.has(i)) expanded.delete(i); else expanded.add(i);
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
col.content.add(scene.add.rectangle(0, cy, w, 1, ACCENT, 0.18).setOrigin(0, 0));
|
||||||
|
cy += 16;
|
||||||
|
}
|
||||||
|
col.contentHeight = cy;
|
||||||
|
col.setOffset(col.offset);
|
||||||
|
};
|
||||||
|
render();
|
||||||
|
|
||||||
|
const btn = new Button(scene, shell.x + shell.width / 2, shell.y + shell.height - 34, 'Continue', () => {
|
||||||
|
col.destroy();
|
||||||
|
shell.destroy();
|
||||||
|
onClose?.();
|
||||||
|
}, { width: 220, height: 44 });
|
||||||
|
shell.add(btn);
|
||||||
|
|
||||||
|
return shell;
|
||||||
|
}
|
||||||
|
|
@ -18,12 +18,12 @@ world backdrops (§2b) are opaque 1920 × 1080 scenes for the colony screen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. `ships` — ⬜ `assets/images/vega/ships.png`
|
## 1. `ships` — ✅ `assets/images/vega/ships.png`
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Sheet size | **768 × 960** |
|
| Sheet size | **1536 × 1920** |
|
||||||
| Frame | **96 × 96** |
|
| Frame | **192 × 192** |
|
||||||
| Grid | 8 cols × 10 rows = 80 frames |
|
| Grid | 8 cols × 10 rows = 80 frames |
|
||||||
|
|
||||||
**One row per species, one column per hull.** Frame = `species.shipFrame × 8 + hull.frame`.
|
**One row per species, one column per hull.** Frame = `species.shipFrame × 8 + hull.frame`.
|
||||||
|
|
@ -44,9 +44,61 @@ Columns (hull):
|
||||||
Rows (species): 0 Human, 1 Kestrelli, 2 Ursaal, 3 Umbrix, 4 Kkrix, 5 Mekhan,
|
Rows (species): 0 Human, 1 Kestrelli, 2 Ursaal, 3 Umbrix, 4 Kkrix, 5 Mekhan,
|
||||||
6 Rrashaa, 7 Cerebrai, 8 Ssakar, 9 Lithox.
|
6 Rrashaa, 7 Cerebrai, 8 Ssakar, 9 Lithox.
|
||||||
|
|
||||||
**Ships face UP.** The star map and battle screen rotate them. Each species has
|
**Ships face UP.** The battle screen rotates them ±90° so the two sides face each
|
||||||
a colour in the rules file — art can ignore it (the sprite is used as-is) or
|
other. Each species has a colour in the rules file — art can ignore it (the
|
||||||
lean into it. Silhouette matters more than detail: these draw at ~24–58 px.
|
sprite is used as-is) or lean into it.
|
||||||
|
|
||||||
|
Only the human row is painted so far; the other nine fall back to the procedural
|
||||||
|
stand-in, which is cut at the same 192px geometry.
|
||||||
|
|
||||||
|
> The **thumbnails and the detail window turn the hull a full 180°** — a
|
||||||
|
> deliberate look, not a correction. `makeShipIcon` in `VegaShipMedia.js` is the
|
||||||
|
> one place that does it, with `setAngle(180)` rather than `setFlipY`: a flip
|
||||||
|
> would mirror the hull's asymmetries, a rotation does not.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1b. Ship commander videos — 🟨 `assets/videos/vega/ship-<species>-<hull>.mp4`
|
||||||
|
|
||||||
|
**Not part of the sheet.** One looping **256 × 256** muted clip per species *per
|
||||||
|
hull* — the officer who flies that class — shown beside the hull on every ship
|
||||||
|
row and at full size in the detail window.
|
||||||
|
|
||||||
|
Declared in `shipVideos` in `data/mastervega-artwork.json`, nested
|
||||||
|
`species → hull id → { key, path }`. A species with none recorded is a single
|
||||||
|
`null`. Same drop-in contract as everything else: drop the files, add the lines,
|
||||||
|
no code changes.
|
||||||
|
|
||||||
|
The fallback ladder is one tier deeper than the species portraits:
|
||||||
|
|
||||||
|
1. this species' clip for this hull — `shipVideos[species][hull]`
|
||||||
|
2. the species' own portrait video — `portraitVideos[species]` (§ below)
|
||||||
|
3. its 1024px still — `portraitStills[species]`
|
||||||
|
4. a frame on the procedural `portraits` sheet
|
||||||
|
|
||||||
|
So a gap is never a hole: every row shows a moving face from day one, and each
|
||||||
|
new race lights up one clip at a time. `starbase` has no clip for anyone and is
|
||||||
|
expected to keep falling through.
|
||||||
|
|
||||||
|
| Hull id | File suffix | Human |
|
||||||
|
|---|---|---|
|
||||||
|
| `scout` | `ship-human-scout.mp4` | ✅ |
|
||||||
|
| `colonyship` | ⚠️ `ship-human-**colony**.mp4` | ✅ |
|
||||||
|
| `transport` | `ship-human-transport.mp4` | ✅ |
|
||||||
|
| `frigate` | `ship-human-frigate.mp4` | ✅ |
|
||||||
|
| `destroyer` | `ship-human-destroyer.mp4` | ✅ |
|
||||||
|
| `cruiser` | `ship-human-cruiser.mp4` | ✅ |
|
||||||
|
| `battleship` | `ship-human-battleship.mp4` | ✅ |
|
||||||
|
| `starbase` | — | ⬜ falls back |
|
||||||
|
|
||||||
|
> ⚠️ The colony ship's file is `ship-human-colony.mp4` while its hull id is
|
||||||
|
> `colonyship`. Same class of mismatch as `char-cerebai.mp4`: the manifest points
|
||||||
|
> at the real filename and the verifier confirms both the file and the derived
|
||||||
|
> texture key, so wiring it the wrong way round fails immediately rather than
|
||||||
|
> 404ing in the browser.
|
||||||
|
|
||||||
|
Rows: kestrelli, ursaal, umbrix, kkrix, mekhan, rrashaa, cerebrai, ssakar and
|
||||||
|
lithox are all ⬜ — nine species × seven hulls outstanding.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ import {
|
||||||
ensureSheets, shipFrame, planetFrame, techFrame, buildingFrame,
|
ensureSheets, shipFrame, planetFrame, techFrame, buildingFrame,
|
||||||
speciesVideoKey, speciesStillKey, speciesSpeechClip, UI_SPEECH, worldBgKey,
|
speciesVideoKey, speciesStillKey, speciesSpeechClip, UI_SPEECH, worldBgKey,
|
||||||
} from '../src/games/mastervega/VegaArt.js';
|
} from '../src/games/mastervega/VegaArt.js';
|
||||||
|
// Ship media is addressed here and nowhere else, so the key convention is
|
||||||
|
// checkable without a canvas.
|
||||||
|
import { shipVideoKey } from '../src/games/mastervega/VegaShipMedia.js';
|
||||||
|
|
||||||
const QUICK = process.argv.includes('--quick');
|
const QUICK = process.argv.includes('--quick');
|
||||||
const gamesArg = process.argv.find((a) => a.startsWith('--games='));
|
const gamesArg = process.argv.find((a) => a.startsWith('--games='));
|
||||||
|
|
@ -254,6 +257,56 @@ section('2. Procedural art');
|
||||||
check(`species ${s.id} has a portraitStills entry`, stillIds.includes(s.id));
|
check(`species ${s.id} has a portraitStills entry`, stillIds.includes(s.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ship commander videos, one per species PER HULL. Unlike the three blocks
|
||||||
|
// above, an absent entry here is not a defect — it falls back to that
|
||||||
|
// species' own portrait video, which is why nine of the ten species are a
|
||||||
|
// single `null`. So the count below is reported rather than asserted, and
|
||||||
|
// what IS asserted is that nothing declared is wrong: a key that does not
|
||||||
|
// match shipVideoKey() loads a video nothing will ever ask for, and is
|
||||||
|
// exactly the mistake the colonyship/`ship-human-colony.mp4` filename
|
||||||
|
// mismatch invites.
|
||||||
|
const shipVids = artJson.shipVideos ?? {};
|
||||||
|
const shipVidSpecies = Object.keys(shipVids).filter((k) => !k.startsWith('_'));
|
||||||
|
let shipClips = 0;
|
||||||
|
for (const id of shipVidSpecies) {
|
||||||
|
check(`shipVideos entry ${id} is a known species`, !!RULES.species[id]);
|
||||||
|
const hulls = shipVids[id];
|
||||||
|
if (!hulls) continue;
|
||||||
|
for (const hullId of Object.keys(hulls).filter((k) => !k.startsWith('_'))) {
|
||||||
|
const v = hulls[hullId];
|
||||||
|
check(`shipVideos ${id}.${hullId} is a known hull`, !!RULES.hulls[hullId]);
|
||||||
|
check(`shipVideos ${id}.${hullId} key matches the loader's key`,
|
||||||
|
v?.key === shipVideoKey(id, hullId), `${v?.key} vs ${shipVideoKey(id, hullId)}`);
|
||||||
|
if (v?.path) {
|
||||||
|
shipClips += 1;
|
||||||
|
check(`shipVideos ${id}.${hullId} file exists`,
|
||||||
|
existsSync(join(root, v.path)), v.path);
|
||||||
|
check(`shipVideos ${id}.${hullId} is an mp4`, v.path.endsWith('.mp4'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const s of RULES.speciesList) {
|
||||||
|
// Declared-or-null, so adding a species cannot silently skip the question.
|
||||||
|
check(`species ${s.id} has a shipVideos entry (possibly null)`,
|
||||||
|
shipVidSpecies.includes(s.id));
|
||||||
|
// The fallback the gaps rely on. Without it a missing clip is a hole.
|
||||||
|
check(`species ${s.id} has a shipVideos fallback portrait`, !!vids[s.id]?.path);
|
||||||
|
}
|
||||||
|
console.log(` (${shipClips}/${RULES.speciesList.length * RULES.hullList.length} `
|
||||||
|
+ 'ship commander clips recorded; the rest fall back to the species portrait)');
|
||||||
|
|
||||||
|
// Every species x hull must land inside the declared `ships` grid, or a row
|
||||||
|
// of the fleet panel draws a frame that does not exist.
|
||||||
|
const shipSheet = artJson.sheets?.ships;
|
||||||
|
const shipCells = (shipSheet?.cols ?? 0) * (shipSheet?.rows ?? 0);
|
||||||
|
for (const s of RULES.speciesList) {
|
||||||
|
for (const h of RULES.hullList) {
|
||||||
|
const f = shipFrame(RULES, s.id, h.id);
|
||||||
|
check(`shipFrame ${s.id}/${h.id} is inside the ships sheet`,
|
||||||
|
f >= 0 && f < shipCells, `${f} of ${shipCells}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// World backdrops for the colony screen. These are 1920x1080 opaque images,
|
// World backdrops for the colony screen. These are 1920x1080 opaque images,
|
||||||
// NOT frames on the `planets` sheet — that one holds transparent 192px discs
|
// 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
|
// for the orrery, which is a different picture of the same world. Same
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue