feat(mastervega): add full-screen colony-founding vignette with on-demand video clips

Replace the turn-report "New Turn" popup row with a cinematic vignette that
plays the moment the player plants a colony: the soundtrack ducks, the world's
1920×1080 backdrop fills the screen, a 960×544 clip of this world being settled
plays in a framed window, and the founding numbers are read out underneath.

Key changes:
- Add `colonyVideos` block (15 clips, one per colonisable planet type) to
  artwork JSON; clips are fetched just-in-time via ensureColonyVideo() when a
  colony ship reaches a settleable world, keeping the set (~19 MB) out of the
  eager manifest
- Add `vega-colony-cue` audio to the eager manifest for the founding soundtrack
- Remove `colonised` from NOTABLE_TYPES and TYPE_LABEL so the event is no longer
  announced twice (once in the vignette, once in the turn report)
- Add `sourceWidth()` guard in VegaArt.js to fix a Phaser Video placeholder
  width bug exposed by the non-square 960×544 colony clips (2.5× scale issue)
- Apply `sourceWidth()` to portrait and commander video scaling paths as well
- Add depth entry `intro: 78` to VegaScreens.js for the vignette overlay
- Add section 6b to the verifier: checks manifest exclusion, key conventions,
  sourceWidth() behavior, turn-report classification, and colony stats

See build-plan trap #23 for the full sourceWidth() explanation.
This commit is contained in:
Brian Fertig 2026-08-03 23:31:28 -06:00
parent ee7a3214fd
commit f9df69b09f
25 changed files with 1058 additions and 38 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -79,6 +79,23 @@
"asteroids": { "key": "vega-world-asteroids", "path": null }
},
"_colonyVideosReadme": "Colony-founding VIGNETTE clips — 960x544, muted, ~10s, one per COLONISABLE planetType id. VegaColonyIntro.js plays one full-screen the moment the player plants a colony, over assets/music/vega/colony.mp3. Only colonisable types are listed: canColonize() rejects gasgiant and asteroids before any species trait is consulted, so a clip for those could never play. A null path — or a colonisable type missing from this block — falls back to that type's worldBackgrounds still, and then to the gradient, exactly like the colony screen, so a newly colonisable world is presentable the day it is added.",
"colonyVideos": {
"terran": { "key": "vega-colony-terran", "path": "assets/videos/vega/colony-terran.mp4" },
"ocean": { "key": "vega-colony-ocean", "path": "assets/videos/vega/colony-ocean.mp4" },
"jungle": { "key": "vega-colony-jungle", "path": "assets/videos/vega/colony-jungle.mp4" },
"steppe": { "key": "vega-colony-steppe", "path": "assets/videos/vega/colony-steppe.mp4" },
"arid": { "key": "vega-colony-arid", "path": "assets/videos/vega/colony-arid.mp4" },
"desert": { "key": "vega-colony-desert", "path": "assets/videos/vega/colony-desert.mp4" },
"tundra": { "key": "vega-colony-tundra", "path": "assets/videos/vega/colony-tundra.mp4" },
"minimal": { "key": "vega-colony-minimal", "path": "assets/videos/vega/colony-minimal.mp4" },
"barren": { "key": "vega-colony-barren", "path": "assets/videos/vega/colony-barren.mp4" },
"dead": { "key": "vega-colony-dead", "path": "assets/videos/vega/colony-dead.mp4" },
"inferno": { "key": "vega-colony-inferno", "path": "assets/videos/vega/colony-inferno.mp4" },
"toxic": { "key": "vega-colony-toxic", "path": "assets/videos/vega/colony-toxic.mp4" },
"radiated": { "key": "vega-colony-radiated", "path": "assets/videos/vega/colony-radiated.mp4" }
},
"sheets": {
"ships": {
"key": "vega-ships",

View File

@ -263,6 +263,25 @@ 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
flyout tween drags it along by hand in `onUpdate`.
23. **A Phaser `Video` does not report a width of zero before it decodes.**
Every video in this game is sized by scaling *from* its source width rather
than calling `setDisplaySize`, guarded as `obj.width || SRC`. That guard
never fires: a freshly created `Video` carries a **placeholder** size until
its first frame decodes, at which point `updateTexture()` builds the real
texture and re-sizes the object to it. So the scale is set from the
placeholder and stays wrong by `placeholder / realWidth` once the real
texture lands. It hid for as long as it did because every clip in the game
**was** 256 px square — placeholder and fallback were the same number, so
both branches agreed. The 960×544 colony clips exposed it: the vignette
opened 2.5× too large (`640 / 256`, which is how the placeholder is known to
be 256 — phaser is not vendored in this repo to read it off), and corrected
itself only on the *second* lap of the loop, when the re-fit ran against a
texture that was real by then. Removing the loop is what made it permanent
and visible. `VegaArt.sourceWidth()` is now the single guard: it gates on
`videoTexture`, null until that moment and the only reliable way to tell the
two states apart, so the placeholder's actual value never matters. Section 2
asserts both branches.
### 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,
@ -304,6 +323,108 @@ 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.
### Founding a colony is a cutscene, not a report row
Planting a colony is the most consequential thing a player does on the map, and
it used to be announced the *next* turn as one row in the "New Turn" popup —
the same weight the game gives a finished refit. `VegaColonyIntro.js` replaces
that with a full-screen vignette the moment the Found Colony button is pressed:
the soundtrack ducks, the world's own 1920×1080 backdrop fills the screen, a clip
of *this world* being settled plays in a framed window over it alongside
`assets/music/vega/colony.mp3`, and the founding numbers are read out underneath.
`colonised` is **out of `NOTABLE_TYPES`** as a result — put it back and the same
event is announced twice, the second time smaller.
Seven things about it.
**The clips are fetched just in time, not by the asset manifest — and Phaser's
video loader does not fetch anything at all.** `VideoFile.load()` records the
URL, marks itself complete and returns; its own comment reads *"we don't
actually load anything (the Video Game Object does that)"*. The bytes arrive
when a `Video` sets `el.src`. So putting 13 clips in the manifest never cost
19 MB at game-room entry — it cost 13 cache entries — and a "warm-up" that only
went through `scene.load` would have warmed nothing. What keeping the block out
of the manifest actually buys is **ownership of the timing**:
`ensureColonyVideo()` registers the URL *and* pulls the bytes through a detached
`<video preload="auto">` on the same URL, so the game object's own request hits
the browser cache. The system view calls it the moment the Found Colony button
appears — a colony ship in orbit over a settleable world, the strongest signal
available that the vignette is about to be needed — which is a better moment
than "entered the room" and a far better one than "the vignette is already on
screen". If the clip still has not landed when the vignette opens, the window
says `ACQUIRING COLONY FEED…` and the picture fades into it on arrival. Three
details it is built around: `filecomplete-video-<key>` is emitted with the key
as a *string* while `FILE_LOAD_ERROR` is emitted with the *File* and fires for
every failing file in the queue, so only the error handlers filter by key; the
in-flight set is a `WeakMap` keyed by scene, because the loader is per-scene and
leaving mid-fetch must not wedge a key against a loader that no longer exists;
and the panel re-asks on every rebuild, so a warm-up with no callback registers
no listeners at all. Section 2 asserts the block stays out of the manifest — the
resolver runs headlessly against a cache stub — because putting it back would
quietly restore the 19 MB and nothing else would notice.
**Two pictures of the world are on screen at once, and the clip is the optional
one.** The backdrop is always the opaque 1920×1080 still (`worldBackgrounds`,
gradient-painted from `type.color` when that art does not exist). The 960×544
clip (`colonyVideos`) plays in a window between the masthead and the
congratulation line, right-aligned so its edge lands on the centre line of the
orrery disc in the top corner — the third picture of the same world, next to the
disc that is the first. A type with no clip simply gets **no window**: the
vignette reads as a still-backed report card rather than a broken frame, which
is why the verifier only *reports* the clip count while asserting that a type
without one still has a backdrop to stand on.
**Only colonisable types are declared.** `canColonize()` rejects `gasgiant` and
`asteroids` on the static flag *before* any species trait is consulted, so a
clip for either could never play. Section 2 fails a `colonyVideos` entry naming
a type that cannot be settled, the same way it fails a key that does not equal
`colonyVideoKey(typeId)`.
**The window is cut to the clip, and the clip is scaled by width alone.** The
frame is 640×363 — 960×544 exactly — so one axis settles the other and nothing
has to reason about two. The scale goes through `VegaArt.sourceWidth()`, which
is load-bearing rather than a nicety: see trap 23, which this clip is what
finally exposed.
**It plays once, holds the last frame, and offers a replay.** `setLoop(false)`
and `play(false)` — an `HTMLVideoElement` stops on its final frame and the
texture keeps it, so ending *is* the still. On Phaser's `complete` a badge fades
in at the picture's bottom-right and a hit zone over the *whole* picture is
armed; clicking anywhere replays it. Both go in **after** the video (a Container
renders in insertion order), the hit zone is armed and disarmed with
`setVisible`, which is also what gates Phaser's input (`inputCandidate`), and
`complete` is bound with `on`, not `once`, so it re-arms after every replay.
`play()` restarts cleanly because `completeHandler` clears Phaser's
`_playCalled` and an ended element seeks back to 0 by itself.
**Keeping the sound on took three separate guards.** This is **the only video in
the game with audio** — `setVolume(0.9)` over the founding cue, loaded with
`noAudio: false` while `services/assetLoader.js` passes `true` for every
portrait and ship clip — and three things in Phaser 3.90 will silence it: (1)
`loadHandler()` sets `el.muted` **and `el.defaultMuted`** from the cache entry's
`noAudio` flag, so a key ever registered with `noAudio: true` stays muted for
the session; (2) `playSuccess()` calls `setMute(true)` if the Sound Manager is
muted; (3) the first-frame handler copies `el.muted` back into `_codeMuted`.
`applyAudio()` re-asserts all of it on creation, on `created` and on `playing`,
and clears `defaultMuted` on the element directly — Phaser never does, and
`el.load()` re-applies it. The autoplay-refusal fallback hangs off **`locked`**
(`VIDEO_LOCKED`, emitted on a `NotAllowedError`); `playfailed` and `playerror`,
used here first, **do not exist in Phaser** and were dead code. Muting is the
whole fallback: Phaser retries from `preUpdate` on its own, and calling `play()`
again would no-op against `_playCalled`.
**Only the human ever sees it.** `VegaAI.js` plants colonies through the same
`Logic.colonize()`; the vignette is opened by the *view* that called it, never
by the engine. The event still fires for both, because the ticker log and the
turn-report classifier still walk it.
Section 6b covers what is checkable headlessly: that `colonised` is not notable
and has no label, that colonising still pushes its event, that the new colony is
findable by orbit the way the view finds it, and that every number the vignette
prints — max pop, output, construction, factory cap — is finite and sane on a
colony *one tick old*, for all 13 colonisable world types. That last state is
one no other screen ever sees.
## Balance reference (27-game AI soak)
```
@ -324,17 +445,19 @@ wins spread across 8 of 10 species
## Verification
```bash
node tools/verifyMasterOfVega.js # ~1221 checks, ~60s
node tools/verifyMasterOfVega.js --quick # 1220 checks, ~15s
node tools/verifyMasterOfVega.js # ~1421 checks, ~60s
node tools/verifyMasterOfVega.js --quick # 1420 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 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.
Thirteen sections; section 2 runs the real procedural painters against a Proxy
fake canvas and cross-checks every declared artwork path against the filesystem
(portraits, stills, world backdrops, colony clips), 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,
section 6b is the founding vignette and what the turn report will no longer
interrupt for, 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
@ -352,3 +475,17 @@ starting fleet plus mine" and its counts mean nothing. Clear `st.fleets` first.
All sheets are optional and start `path: null`; `VegaArt.js` paints stand-ins at
the identical frame geometry. See `src/games/mastervega/sprites.md` for the
frame maps. Frame indexes are append-only.
The video and full-screen blocks in `data/mastervega-artwork.json` follow the
same drop-in contract — declare it, drop the file, no code changes:
| block | shape | size | fallback |
| --- | --- | --- | --- |
| `portraitVideos` | species → clip | 256×256 | `portraitStills`, then the sheet |
| `portraitStills` | species → image | — | the painted sheet |
| `shipVideos` | species → hull → clip | 256×256 | that species' portrait |
| `worldBackgrounds` | planet type → image | 1920×1080 | gradient from `type.color` |
| `colonyVideos` | *colonisable* planet type → clip | 960×544 | `worldBackgrounds`, then gradient |
`colonyVideos` is the one block **not** in `src/data/assetManifest.js` — see the
vignette section above; it is fetched a clip at a time by `ensureColonyVideo()`.

View File

@ -119,8 +119,20 @@ export const MANIFEST = {
(scene) => nestedVideosFrom(scene, 'mastervega-artwork', 'shipVideos'),
(scene) => imagesFromMap(scene, 'mastervega-artwork', 'portraitStills'),
(scene) => imagesFromMap(scene, 'mastervega-artwork', 'worldBackgrounds'),
// The artwork JSON's `colonyVideos` block is deliberately NOT resolved here.
// Note what that does and does not buy: Phaser's video loader downloads
// nothing (VideoFile.load() just records the URL), so this was never about
// 19 MB of bytes at game-room entry — it is about WHERE the fetch is
// decided. VegaColonyIntro.ensureColonyVideo() registers a clip and warms
// its bytes when a colony ship reaches a settleable world, which is a
// better moment than "entered the room" and a far better one than "the
// vignette is already on screen".
image('vega-menu-bg', 'assets/images/vega/background-menu.png'),
image('vega-menu-title', 'assets/images/vega/menu-title.png'),
// The colony-founding cue. It is not part of the shuffled soundtrack —
// VegaColonyIntro.js ducks that and plays this over the vignette instead.
// Small, and it has to be ready the instant the vignette opens.
{ type: 'audio', key: 'vega-colony-cue', path: 'assets/music/vega/colony.mp3' },
{ type: 'audio', key: 'laser-zap', path: 'assets/fx/laser-zap.mp3' },
{ type: 'audio', key: 'scifi-explode', path: 'assets/fx/scifi-explode.mp3' },
{ type: 'audio', key: 'ta-rocket-1', path: 'assets/fx/ta-rocket-1.mp3' },

View File

@ -588,10 +588,9 @@ export function makeSpeciesPortrait(scene, rules, art, speciesId, x, y, size) {
const v = scene.add.video(x, y, speciesVideoKey(speciesId));
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 || PORTRAIT_VIDEO_PX));
// Scale from the known source size rather than setDisplaySize — see
// sourceWidth() for why a Video's own width cannot be trusted this early.
v.setScale(size / sourceWidth(v, PORTRAIT_VIDEO_PX));
v.play(true);
// If the file is present but the browser refuses to decode it, drop to the
// still rather than leaving a hole where the portrait should be.
@ -614,6 +613,31 @@ function makeSpeciesStillOrFrame(scene, rules, art, speciesId, x, y, size) {
return img;
}
/**
* The width a game object should be scaled FROM.
*
* THE TRAP THIS EXISTS FOR: a freshly created Phaser `Video` does **not**
* report a width of zero. It carries a placeholder size until its first frame
* decodes, at which point `updateTexture()` builds the real texture and
* re-sizes the object to it. So the obvious `obj.width || SRC` guard never
* fires it divides by the placeholder and the scale is wrong by
* `placeholder / realWidth` from then on. `videoTexture` is null until that
* moment and is the only reliable way to tell the two states apart, which is
* what this gates on: the exact placeholder value does not matter, and phaser
* is not vendored here to read it off (the arithmetic below says 256).
*
* This went unnoticed for as long as it did because every video in this game
* WAS 256 px square: the placeholder and the fallback were the same number, so
* the bad branch and the good branch agreed. The 960x544 colony clips are what
* finally showed it they opened at 2.5x and only snapped to the right size
* on the second time round the loop, when the re-fit ran against a texture
* that by then was real.
*/
export function sourceWidth(obj, fallback) {
if (obj.type === 'Video' && !obj.videoTexture) return fallback;
return obj.width || fallback;
}
/**
* Resize a portrait made by makeSpeciesPortrait, whichever tier it came from.
*
@ -622,7 +646,7 @@ function makeSpeciesStillOrFrame(scene, rules, art, speciesId, x, y, size) {
* not) and survives a Video whose first frame has not decoded yet.
*/
export function sizeSpeciesPortrait(obj, size) {
obj.setScale(size / (obj.width || PORTRAIT_VIDEO_PX));
obj.setScale(size / sourceWidth(obj, PORTRAIT_VIDEO_PX));
return obj;
}
@ -646,6 +670,19 @@ export function worldBackground(scene, typeId) {
return scene.textures.exists(key) ? key : null;
}
// Colony-founding vignettes
//
// A 960x544 clip per COLONISABLE planet type (`colonyVideos` in the manifest),
// played full-screen by VegaColonyIntro.js. Third picture of the same world
// after the orrery disc and the backdrop still, and it falls back to that still
// — and then to the gradient — when a type has no clip.
export const colonyVideoKey = (typeId) => `vega-colony-${typeId}`;
export function hasColonyVideo(scene, typeId) {
return !!scene.cache.video?.exists(colonyVideoKey(typeId));
}
// Frame helpers — the one place that knows how the sheets are indexed.
export const shipFrame = (rules, speciesId, hullId) => {
const s = rules.species[speciesId];

View File

@ -0,0 +1,622 @@
// Master of Vega — the colony-founding vignette.
//
// Planting a colony used to be a line in the next turn's "New Turn" report,
// which is the same weight the game gives a finished refit. It is the single
// most consequential thing a player does on the map, so it now stops the game
// instead: the soundtrack ducks, the world fills the screen, a clip of THIS
// world being settled plays in a window over it, and the founding numbers are
// read out underneath.
//
// Two pictures of the same world are on screen at once. The BACKDROP is always
// the 1920x1080 still (`worldBackgrounds[typeId]`, gradient-painted from the
// type's colour when that art does not exist), and the CLIP
// (`colonyVideos[typeId]`, 960x544) plays in a framed window between the
// masthead and the congratulation line, right-aligned so its edge lands on the
// centre line of the planet disc in the top corner. The clip is optional; the
// window is simply not built for a type that has none, and the vignette reads
// as a still-backed report card instead of looking broken.
//
// The clip is scaled by WIDTH alone, never `setDisplaySize` — 960x544 is
// 1.765:1 and the window is cut to match, so anything that fits the two
// independently would skew every horizon in the set.
//
// It plays ONCE, with its own audio at 0.9 over the founding cue, and holds its
// last frame — then a replay badge lights in the corner of the window and a
// click anywhere on the picture runs it again. That is why
// `ensureColonyVideo()` loads it with `noAudio: false`, unlike every other
// video in this game, and why `applyAudio()` exists.
//
// THE CLIPS ARE NOT IN THE ASSET MANIFEST — but NOT for the reason it looks
// like. Phaser's video loader downloads nothing: `VideoFile.load()` records the
// URL, marks itself complete and returns ("we don't actually load anything, the
// Video Game Object does that"). So the manifest was never pulling 19 MB on
// entering the game room; those bytes arrive when a `Video` sets `el.src`,
// which is the moment the vignette opens. Keeping the block out of the manifest
// is what lets THIS file own that timing instead: `ensureColonyVideo()`
// registers the URL *and* warms the bytes through a detached element, and the
// system view calls it the moment a colony ship is in orbit over a settleable
// world — the strongest signal we ever get that the vignette is about to be
// needed. If the clip has not landed by the time it opens, the window says so
// and the picture fades into it on arrival, so the network never holds the
// moment up.
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { FONT, D, ORBIT } from './VegaScreens.js';
import { turnToYear } from './VegaRules.js';
import {
colonyVideoKey, hasColonyVideo, worldBackground, planetFrame, sourceWidth,
} from './VegaArt.js';
import {
colonyMaxPop, colonyProduction, colonyFactoryCap, colonyBuildRate, empireColonies,
} from './VegaLogic.js';
/** Source resolution of every colony clip. The window below is cut to match. */
const SRC_W = 960;
const SRC_H = 544;
/** The founding cue (assets/music/vega/colony.mp3), loaded by the manifest. */
const CUE_KEY = 'vega-colony-cue';
const CUE_VOLUME = 0.7;
/** The clip's own soundtrack, played over the cue. */
const CLIP_VOLUME = 0.9;
const ACCENT = 0x6fc4ff;
const PANEL = 0x0b1220;
const PANEL_X = 64;
const PANEL_Y = 838;
const PANEL_W = 1180;
const PANEL_H = 176;
/** The orrery disc in the top corner. Its centre line is also the clip's right edge. */
const DISC_CX = GAME_WIDTH - 150;
const DISC_CY = 158;
const DISC_SIZE = 150;
// The clip window: right edge on the disc's centre line, centred in the band
// between the masthead (which ends around y=283, under the empire/year line)
// and the congratulation line at y=700, leaving ~19px of air at each end. Cut
// to the clip's own 960x544 so the picture is never distorted to fit it.
const VIDEO_W = 640;
const VIDEO_H = Math.round((VIDEO_W * SRC_H) / SRC_W);
const VIDEO_CX = DISC_CX - VIDEO_W / 2;
const VIDEO_CY = 491;
/** How far the frame stands off the picture on every side. */
const VIDEO_INSET = 8;
/** The replay badge, inset from the picture's bottom-right corner. */
const REPLAY_R = 26;
const REPLAY_PAD = 18;
// ---------------------------------------------------------------------------
// Just-in-time clip loading
/**
* Clips handed to a scene's loader but not yet arrived, per scene.
*
* A WeakMap rather than one module-level Set because the loader is per-scene:
* leaving Master of Vega mid-fetch and coming back must not leave a key stuck
* in a set that the NEW scene's loader knows nothing about, which would wedge
* that clip on the still fallback forever.
*/
const inFlight = new WeakMap();
function pendingFor(scene) {
let set = inFlight.get(scene);
if (!set) { set = new Set(); inFlight.set(scene, set); }
return set;
}
/** The path is declared in the artwork manifest, which is eager-preloaded. */
function colonyVideoPath(scene, typeId) {
return scene.cache.json.get('mastervega-artwork')?.colonyVideos?.[typeId]?.path ?? null;
}
/**
* Detached elements holding a warmed clip, per scene. See warmBytes().
*/
const warmed = new WeakMap();
/**
* Actually pull the bytes down.
*
* `scene.load.video()` does NOT do this. Phaser's `VideoFile.load()` records
* the URL, marks itself complete and returns its own comment reads "we don't
* actually load anything (the Video Game Object does that)". The download
* starts when a `Video` sets `el.src`, which for this file is the moment the
* vignette opens. So the loader alone buys nothing but a cache entry, and a
* "warm-up" through it would leave the first megabyte arriving over the
* player's shoulder.
*
* A detached `<video preload="auto">` is what makes the warm-up real: it
* fetches on the same URL the game object will use, so that one hits the
* browser's cache. The element is kept until the scene dies it is the thing
* holding the buffered data, and dropping it invites a re-fetch.
*/
function warmBytes(scene, key, path) {
let store = warmed.get(scene);
if (!store) { store = new Map(); warmed.set(scene, store); }
if (store.has(key)) return;
try {
const el = document.createElement('video');
el.preload = 'auto';
// A detached element must never be audible; the game object that plays for
// real is a different element and carries its own volume.
el.muted = true;
el.setAttribute('playsinline', 'playsinline');
el.src = path;
el.load();
store.set(key, el);
} catch (err) { /* prefetch is an optimisation, never a requirement */ }
}
/**
* Make sure this planet type's founding clip is ready to play: registered in
* the video cache, and its bytes on their way. `onReady(ok)` fires once
* SYNCHRONOUSLY if the clip is already cached or is never coming (no entry in
* the manifest), asynchronously otherwise. Callers must therefore be safe to
* run before their own locals exist, or open with the fallback and swap.
*
* Called with no callback to warm the clip speculatively, which is what the
* system view does.
*/
export function ensureColonyVideo(scene, typeId, onReady = null) {
const key = colonyVideoKey(typeId);
const path = colonyVideoPath(scene, typeId);
// The bytes are worth warming even when the cache entry already exists: the
// entry is only a URL, and this is the half that actually downloads.
if (path) warmBytes(scene, key, path);
if (hasColonyVideo(scene, typeId)) { onReady?.(true); return; }
if (!path) { onReady?.(false); return; }
const pending = pendingFor(scene);
// Note the two event shapes: `filecomplete-<type>-<key>` is emitted with the
// KEY as a string, while FILE_LOAD_ERROR is emitted with the File and fires
// for every failing file in the queue — so only the error handlers filter.
const doneEvent = `filecomplete-video-${key}`;
// The caller's own pair, registered only when somebody is actually waiting.
// The system view warms the same clip on every rebuild of its panel; without
// this guard those would pile up listeners nobody reads.
if (onReady) {
let onDone;
let onError;
const settle = (ok) => {
scene.load.off(doneEvent, onDone);
scene.load.off(Phaser.Loader.Events.FILE_LOAD_ERROR, onError);
onReady(ok);
};
onDone = () => settle(hasColonyVideo(scene, typeId));
onError = (file) => { if (file?.key === key) settle(false); };
scene.load.once(doneEvent, onDone);
scene.load.on(Phaser.Loader.Events.FILE_LOAD_ERROR, onError);
}
// A second ask for a clip already in flight — the warm-up followed by the
// vignette itself, which is the normal case — rides the listeners above
// rather than handing the loader a duplicate key.
if (pending.has(key)) return;
pending.add(key);
// Bookkeeping, registered once per actual fetch: however it ends, the key
// stops counting as in flight.
let clearDone;
let clearError;
const clear = () => {
scene.load.off(doneEvent, clearDone);
scene.load.off(Phaser.Loader.Events.FILE_LOAD_ERROR, clearError);
pending.delete(key);
};
clearDone = () => clear();
clearError = (file) => { if (file?.key === key) clear(); };
scene.load.once(doneEvent, clearDone);
scene.load.on(Phaser.Loader.Events.FILE_LOAD_ERROR, clearError);
// noAudio: FALSE, unlike every other video in this game (compare
// services/assetLoader.js, which passes true for the portrait and ship
// clips). These carry a soundtrack the vignette plays. Browsers refuse to
// autoplay audio without user activation, which is fine here and only here:
// the vignette always follows a click on Found Colony, and the Video object
// falls back to muted playback if a browser disagrees.
scene.load.video(key, path, false);
// Adding to a loader that is already running just joins the current batch;
// start() is only needed when it is idle, and is ignored when it is not.
if (!scene.load.isLoading()) scene.load.start();
}
// ---------------------------------------------------------------------------
const ORDINAL_SUFFIX = ['th', 'st', 'nd', 'rd'];
/** 1st, 2nd, 3rd, 4th… — the teens are the reason this is not a lookup on n%10. */
function ordinal(n) {
const tens = n % 100;
const suffix = (tens >= 11 && tens <= 13) ? 'th' : (ORDINAL_SUFFIX[n % 10] ?? 'th');
return `${n}${suffix}`;
}
/**
* What the landing was actually like, chosen by how much the world hates us.
* `hostility` is the same number canColonize() tests against the empire's
* planetology, so the line always matches the tech that made the landing legal.
*/
function foundingLine(type) {
if (type.hostility <= 0) {
return 'The first landers are down, the surveys are in, and there is air worth breathing. '
+ 'Settlers are already walking out past the perimeter.';
}
if (type.hostility <= 2) {
return 'The habitat domes are sealed and holding pressure. It is no garden — but every '
+ 'seam is ours, and the reactors are lit.';
}
return 'The shielding went up before the crews came down. Nothing on this world wants us '
+ 'here, and we have planted a city on it regardless.';
}
/**
* Corner ticks on a top-left rectangle, the same detail `modalShell` uses
* they are what make a rectangle read as a HUD read-out rather than a dialog
* box. Both panels on this screen wear them.
*/
function cornerTicks(scene, x, y, w, h, len = 22) {
const g = scene.add.graphics();
g.lineStyle(2.5, ACCENT, 0.9);
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],
]) {
g.lineBetween(cx, cy, cx + dx * len, cy);
g.lineBetween(cx, cy, cx, cy + dy * len);
}
return g;
}
/**
* Full-screen founding vignette for a colony that was just planted. Opens over
* whatever is on screen (the system view, in practice), ducks the soundtrack
* for the founding cue, and hands both back on close.
*
* Player-only by design: VegaAI.js plants colonies through the same
* Logic.colonize() and must never be interrupted by this.
*/
export function openColonyIntro(scene, rules, state, colony, art, opts = {}) {
const { 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 worldName = `${star.name} ${ORBIT[colony.orbit] ?? colony.orbit + 1}`;
const root = scene.add.container(0, 0).setDepth(D.intro).setAlpha(0);
// Declared up here rather than beside close(): the clip can land after the
// player has already dismissed the vignette, and the swap-in has to see it.
let closed = false;
// --- the world itself, full screen behind everything
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 its own colour rather
// than dropping the player onto black, same contract as the colony screen.
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);
}
// Knocks the backdrop back so the type and the panels read over it, and is
// interactive so nothing falls through to the system view underneath. Not a
// click-to-dismiss: the Continue button is the only way out, or a fast
// double-click on "Found colony" would blow straight through the vignette.
root.add(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x00060e, 0.32)
.setOrigin(0, 0).setInteractive());
// --- scrims, so type stays legible wherever the world art is brightest
const scrim = scene.add.graphics();
scrim.fillGradientStyle(0x00060e, 0x00060e, 0x00060e, 0x00060e, 0.86, 0.86, 0, 0);
scrim.fillRect(0, 0, GAME_WIDTH, 340);
scrim.fillGradientStyle(0x00060e, 0x00060e, 0x00060e, 0x00060e, 0, 0, 0.92, 0.92);
scrim.fillRect(0, 620, GAME_WIDTH, GAME_HEIGHT - 620);
root.add(scrim);
// --- masthead
const shadow = (t) => t.setShadow(0, 3, '#000814', 10, false, true);
root.add(shadow(scene.add.text(PANEL_X, 84, 'COLONY ESTABLISHED', {
fontFamily: FONT, fontSize: '64px', color: '#ffd88a',
})));
root.add(shadow(scene.add.text(PANEL_X, 168, worldName, {
fontFamily: FONT, fontSize: '40px', color: '#e8f4ff',
})));
root.add(shadow(scene.add.text(PANEL_X, 222,
`${type.name} · ${rules.planetSizes[planet.sizeId]?.name ?? ''} · `
+ `${rules.richness[planet.richId]?.name ?? ''} minerals · `
+ `${rules.gravity[planet.gravId]?.name ?? ''} gravity`, {
fontFamily: FONT, fontSize: '21px', color: '#c8dcf0',
})));
root.add(shadow(scene.add.text(PANEL_X, 256, `${emp.name} · Year ${turnToYear(state.turn)}`, {
fontFamily: FONT, fontSize: '21px', color: emp.color,
})));
// The orrery's own disc of this world, opposite the masthead — the same
// picture the player was just clicking on, so the vignette is anchored to it.
const disc = scene.add.image(DISC_CX, DISC_CY, art.planets, planetFrame(rules, planet.typeId));
disc.setScale(DISC_SIZE / (disc.width || 192));
root.add(disc);
// --- the clip window
//
// Built only when there is a clip to put in it — cached already, or declared
// in the manifest and therefore on its way. A type with neither gets no empty
// frame; the vignette is simply a still-backed report card.
const clipComing = hasColonyVideo(scene, planet.typeId) || !!colonyVideoPath(scene, planet.typeId);
let video = null;
if (clipComing) {
const fx = VIDEO_CX - VIDEO_W / 2 - VIDEO_INSET;
const fy = VIDEO_CY - VIDEO_H / 2 - VIDEO_INSET;
const fw = VIDEO_W + VIDEO_INSET * 2;
const fh = VIDEO_H + VIDEO_INSET * 2;
const backing = scene.add.rectangle(fx, fy, fw, fh, PANEL, 0.72).setOrigin(0, 0);
backing.setStrokeStyle(1.5, ACCENT, 0.55);
root.add(backing);
root.add(cornerTicks(scene, fx, fy, fw, fh, 18));
// Says what an empty frame is waiting for, or why it stayed empty. Null
// takes it away. It can come BACK — a clip can fail after it has started —
// so this creates on demand rather than hiding one made up front.
let status = null;
const setStatus = (text) => {
if (!text) { status?.destroy(); status = null; return; }
if (status) { status.setText(text); return; }
status = scene.add.text(VIDEO_CX, VIDEO_CY, text, {
fontFamily: FONT, fontSize: '19px', color: '#6f8aa3',
}).setOrigin(0.5);
root.add(status);
};
/**
* Re-assert the clip's audio. Checked against the Phaser 3.90 source,
* THREE things can silence it out from under us, which is why this is a
* function called at every point one of them could have run:
*
* 1. `loadHandler()` sets `el.muted` AND `el.defaultMuted` from the cache
* entry's `noAudio` flag so a key that was ever registered with
* `noAudio: true` (as every other video in this game is, via
* services/assetLoader.js) stays muted for the whole session, even
* after our own loader re-registers it.
* 2. `playSuccess()` calls `setMute(true)` if the Sound Manager is muted.
* 3. the first-frame handler copies `el.muted` back into `_codeMuted`.
*
* `setMute(false)` is Phaser's own path and correctly leaves a
* system-muted game alone; `defaultMuted` is its blind spot nothing in
* Phaser ever clears it, and `el.load()` re-applies it on every source
* change so that one is set on the element directly.
*/
const applyAudio = () => {
if (!video?.scene) return;
video.setMute(false);
video.setVolume(CLIP_VOLUME);
if (video.video) video.video.defaultMuted = false;
};
/**
* `late` means the empty frame has been on screen, so the picture fades
* into it rather than snapping in.
*/
const startVideo = (late) => {
video = scene.add.video(VIDEO_CX, VIDEO_CY, colonyVideoKey(planet.typeId));
// Once through, then hold the last frame: an HTMLVideoElement stops on
// its final frame and the texture keeps it, so ending IS the still.
video.setLoop(false);
applyAudio();
// Scale by WIDTH alone — the window is cut to the clip's aspect, so one
// axis settles the other.
//
// `sourceWidth` is load-bearing, not a nicety: a Phaser Video reports a
// placeholder 256x256 until its first frame decodes, so dividing by
// `video.width` here opened the clip at 640/256 = 2.5x — a 2400px picture
// on a 1920px screen — and only corrected itself on the second lap of the
// loop, when this ran again against a texture that was real by then. Not
// looping any more, so there is no second lap to hide it. The scale set
// now stays correct when the real texture arrives, because scale is
// applied to whatever the texture turns out to be.
const fit = () => video.setScale(VIDEO_W / sourceWidth(video, SRC_W));
fit();
// 'created' is the moment the real texture exists — the only re-fit that
// can change anything, and only for a clip that is not 960 wide. Both
// events also land after Phaser's two chances to mute us.
video.on('created', fit);
video.on('playing', fit);
video.on('created', applyAudio);
video.on('playing', applyAudio);
video.once('error', () => {
if (!video.scene) return;
video.destroy();
video = null;
setStatus('COLONY FEED UNAVAILABLE');
});
// 'locked' is what Phaser emits when the browser refuses to start a clip
// that has sound (a NotAllowedError out of `el.play()`). It keeps
// retrying on its own from `preUpdate`, so muting is all that is needed
// to let one of those retries through: a silent picture beats a stalled
// one. Calling play() again here would be a no-op — Phaser has already
// set `_playCalled`.
video.once('locked', () => {
if (!video?.scene) return;
video.setMute(true);
});
video.play(false);
root.add(video);
setStatus(null);
// --- replay, armed only once the clip has ended
//
// The hit zone is the whole picture ("clicking anywhere on the video"),
// and both it and the badge go in AFTER the video: a Container renders
// its children in insertion order and ignores depth.
const hit = scene.add.rectangle(VIDEO_CX, VIDEO_CY, VIDEO_W, VIDEO_H, 0xffffff, 0.001)
.setInteractive({ useHandCursor: true })
.setVisible(false);
root.add(hit);
const badge = scene.add.container(
VIDEO_CX + VIDEO_W / 2 - REPLAY_PAD - REPLAY_R,
VIDEO_CY + VIDEO_H / 2 - REPLAY_PAD - REPLAY_R,
).setVisible(false);
badge.add(scene.add.circle(0, 0, REPLAY_R, PANEL, 0.82).setStrokeStyle(2, ACCENT, 0.95));
// The three points are in Phaser's own 0-to-width / 0-to-height space,
// NOT pre-centred about (0, 0) — a centred triangle gets shifted by its
// display origin a second time and lands half its width off. (Same trap
// as the colony markers in VegaSystemView.) Nudged right by 3px so the
// glyph's optical centre, not its bounding box, sits in the disc.
badge.add(scene.add.triangle(3, 0, 0, 0, 0, 22, 19, 11, 0xe8f4ff));
root.add(badge);
const arm = (on) => {
hit.setVisible(on);
badge.setVisible(on).setScale(1);
if (!on) return;
badge.setAlpha(0);
scene.tweens.add({ targets: badge, alpha: 1, duration: 260, ease: 'Cubic.easeOut' });
};
hit.on('pointerover', () => badge.setScale(1.1));
hit.on('pointerout', () => badge.setScale(1));
hit.on('pointerup', () => {
if (!video?.scene) return;
arm(false);
applyAudio();
// A genuine replay: `completeHandler` cleared Phaser's `_playCalled`
// when the clip ended, and an ended element seeks back to 0 of its own
// accord when play() is called on it.
video.play(false);
});
// Not `once` — it has to re-arm after every replay.
video.on('complete', () => { if (video?.scene) arm(true); });
if (!late) return;
video.setAlpha(0);
scene.tweens.add({ targets: video, alpha: 1, duration: 500, ease: 'Sine.easeInOut' });
};
if (hasColonyVideo(scene, planet.typeId)) {
startVideo(false);
} else {
// Warmed by the system view when the colony ship arrived, so this is the
// slow-network case rather than the normal one. `closed` matters: the
// player can dismiss the whole vignette before the fetch finishes.
setStatus('ACQUIRING COLONY FEED…');
ensureColonyVideo(scene, planet.typeId, (ok) => {
if (closed) return;
if (ok) startVideo(true);
else setStatus('COLONY FEED UNAVAILABLE');
});
}
}
// --- the lower block: congratulation, founding numbers, and the way out.
// Slid up as one piece after the backdrop has faded in.
const lower = scene.add.container(0, 40).setAlpha(0);
root.add(lower);
const colonyCount = empireColonies(state, colony.empireIdx).length;
lower.add(shadow(scene.add.text(PANEL_X, 700,
`Congratulations — ${worldName} is the ${ordinal(colonyCount)} world of our empire.`, {
fontFamily: FONT, fontSize: '32px', color: '#ffd88a',
})));
lower.add(shadow(scene.add.text(PANEL_X, 748, foundingLine(type), {
fontFamily: FONT, fontSize: '20px', color: '#c8dcf0',
wordWrap: { width: 1150 }, lineSpacing: 4,
})));
const panel = scene.add.rectangle(PANEL_X, PANEL_Y, PANEL_W, PANEL_H, PANEL, 0.78).setOrigin(0, 0);
panel.setStrokeStyle(1.5, ACCENT, 0.55);
lower.add(panel);
lower.add(cornerTicks(scene, PANEL_X, PANEL_Y, PANEL_W, PANEL_H));
// The numbers the colony opens with. Every one of these is read live off the
// engine rather than hard-coded from foundColony(), so a species trait or a
// tech that changes what a new colony is worth shows up here for free.
const cells = [
['POPULATION', colony.pop.toFixed(1)],
['MAX POPULATION', `${colonyMaxPop(rules, state, colony)}`],
['OUTPUT', `${colonyProduction(rules, state, colony).toFixed(1)} BC`],
['CONSTRUCTION', `${colonyBuildRate(rules, state, colony).toFixed(1)} BC`],
['FACTORY LIMIT', `${colonyFactoryCap(rules, state, colony)}`],
];
const cellW = (PANEL_W - 56) / cells.length;
cells.forEach(([label, value], i) => {
const cx = PANEL_X + 28 + i * cellW;
lower.add(scene.add.text(cx, PANEL_Y + 36, label, {
fontFamily: FONT, fontSize: '16px', color: '#7f97b3',
}));
lower.add(scene.add.text(cx, PANEL_Y + 68, value, {
fontFamily: FONT, fontSize: '38px', color: '#e8f4ff',
}));
});
lower.add(scene.add.text(PANEL_X + 28, PANEL_Y + 130,
'Allocation opens on a builder\'s split — set it on the colony screen.', {
fontFamily: FONT, fontSize: '16px', color: '#8fa8c0',
}));
// --- sound: duck the soundtrack, play the founding cue over it
scene.music?.pause();
let musicResumed = false;
const resumeMusic = () => {
if (musicResumed) return;
musicResumed = true;
scene.music?.resume();
};
let cue = null;
if (scene.cache.audio?.exists(CUE_KEY)) {
cue = scene.sound.add(CUE_KEY, { volume: CUE_VOLUME });
// The cue is shorter than a player can linger here, so the soundtrack comes
// back on its own rather than leaving the game silent.
cue.once(Phaser.Sound.Events.COMPLETE, resumeMusic);
cue.play();
} else {
resumeMusic();
}
// --- close
const close = () => {
if (closed) return;
closed = true;
scene.tweens.add({
targets: root,
alpha: 0,
duration: 260,
ease: 'Cubic.easeIn',
onComplete: () => {
// Destroys the video with it, which is what stops its decoder.
root.destroy();
onClose?.();
},
});
if (cue) {
cue.off(Phaser.Sound.Events.COMPLETE, resumeMusic);
cue.stop();
cue.destroy();
}
resumeMusic();
};
lower.add(new Button(scene, 1620, PANEL_Y + PANEL_H / 2, 'Continue', close,
{ width: 320, height: 70, fontSize: 28 }));
scene.tweens.add({ targets: root, alpha: 1, duration: 340, ease: 'Cubic.easeOut' });
scene.tweens.add({
targets: lower, y: 0, alpha: 1, duration: 420, delay: 200, ease: 'Cubic.easeOut',
});
return { close };
}

View File

@ -20,8 +20,12 @@ export const FONT = '"Julius Sans One"';
// `colony` sits above `modal` so the colony screen can stack over the system
// view that opened it without either having to be torn down. `detail` is the
// 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 };
// colony screen, so it has to clear both. `intro` is the colony-founding
// vignette, which opens over the system view it was triggered from and must
// cover everything except the end-of-game overlay.
export const D = {
map: 1, hud: 30, modal: 60, colony: 70, detail: 76, intro: 78, toast: 80,
};
/**
* Orbit numerals. Worldgen rolls at most five planets, but

View File

@ -21,7 +21,7 @@
// 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';
import { makeSpeciesPortrait, sizeSpeciesPortrait, shipFrame, sourceWidth } from './VegaArt.js';
/** Source resolution of the commander videos, and of a `ships` sheet frame. */
const COMMANDER_VIDEO_PX = 256;
@ -64,10 +64,11 @@ export function makeCommanderPortrait(
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));
// Scale from the known source size rather than setDisplaySize — see
// VegaArt.sourceWidth() for why a Video's own width lies before its first
// frame decodes. A no-op here today (these clips ARE 256 px, the same as the
// placeholder it would otherwise divide by) and correct if one ever is not.
v.setScale(size / sourceWidth(v, COMMANDER_VIDEO_PX));
v.play(true);
v.once('error', () => {
if (!v.scene) return;

View File

@ -18,6 +18,7 @@ import { Button } from '../../ui/Button.js';
import { modalShell, FONT, ORBIT } from './VegaScreens.js';
import { planetFrame, starFrame } from './VegaArt.js';
import { openColonyView, CHANNEL_COLOUR, itemName, etaText } from './VegaColonyView.js';
import { openColonyIntro, ensureColonyVideo } from './VegaColonyIntro.js';
import {
CHANNELS, coloniesAt, canColonize, colonize, colonyMaxPop, colonyProduction,
colonyFactoryCap, effectiveFactories, colonyDefenseCap, colonyBuildRate,
@ -150,8 +151,26 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
}));
y += 40;
if (settleable && hasShip) {
// Warm this world's founding clip while the player is deciding. The
// clips are not in the asset manifest (~19 MB for a set a game barely
// touches), and a colony ship parked over a settleable world is the
// strongest signal there is that one is about to be watched. Cheap to
// repeat: every rebuild of this panel asks again, and ensureColonyVideo
// returns immediately once the clip is cached or already in flight.
ensureColonyVideo(scene, planet.typeId);
console_.add(new Button(scene, panelX + 130, y + 20, 'Found colony', () => {
if (colonize(rules, state, viewerIdx, starIdx, selected)) { onChanged?.(); rebuild(); }
const orbit = selected;
if (!colonize(rules, state, viewerIdx, starIdx, orbit)) return;
onChanged?.();
// The founding vignette, not a rebuilt panel — it covers this whole
// modal, so the orrery behind it stops turning until it is dismissed.
// Only the human ever gets here: the AI colonises through the same
// Logic.colonize() with no view attached.
const founded = coloniesAt(state, starIdx).find((c) => c.orbit === orbit);
tick.paused = true;
openColonyIntro(scene, rules, state, founded, art, {
onClose: () => { tick.paused = false; rebuild(); },
});
}, { width: 260, height: 44 }));
}
return;

View File

@ -10,8 +10,14 @@ import { habitableForEmpire } from './VegaLogic.js';
// shipDone, refit, spyCaught, techStolen, invasionFailed, leaderHired,
// victory — which already has its own showVictoryOverlay) stays in the
// small ticker log only.
//
// `colonised` is deliberately NOT here. Founding a colony gets its own
// full-screen vignette the instant it happens (VegaColonyIntro.js), and a row
// in the next turn's report on top of that would announce it twice — the
// second time with less weight than a finished refit. Do not add it back
// without taking the vignette out.
export const NOTABLE_TYPES = new Set([
'discovered', 'techDone', 'contact', 'colonised', 'captured',
'discovered', 'techDone', 'contact', 'captured',
'colonyDestroyed', 'warDeclared', 'peace', 'alliance', 'council',
'councilRefused', 'eliminated',
]);
@ -22,6 +28,9 @@ export const NOTABLE_TYPES = new Set([
// 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.
//
// `colonised` stays classified here even though it is no longer notable: the
// ticker log runs every event past isRelevantToHuman() too.
const PERSONAL_TYPES = new Set(['discovered', 'techDone', 'colonised']);
export function isRelevantToHuman(ev, me) {
@ -33,7 +42,6 @@ export const TYPE_LABEL = {
discovered: 'Discovery',
techDone: 'Research',
contact: 'First Contact',
colonised: 'Colony Founded',
captured: 'Colony Captured',
colonyDestroyed: 'Colony Destroyed',
warDeclared: 'War Declared',
@ -48,7 +56,7 @@ export const TYPE_LABEL = {
// 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,
captured: 1, colonyDestroyed: 1,
discovered: 2,
techDone: 3,
};
@ -120,19 +128,6 @@ function describeContact(rules, state, ev, name) {
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];
@ -201,7 +196,6 @@ export function describeEvent(rules, state, ev) {
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;

View File

@ -38,10 +38,16 @@ import { buildZoomLadder, minZoomFor, MAX_ZOOM, DEFAULT_ZOOM_INDEX } from '../sr
import {
ensureSheets, shipFrame, planetFrame, techFrame, buildingFrame,
speciesVideoKey, speciesStillKey, speciesSpeechClip, UI_SPEECH, worldBgKey,
colonyVideoKey, sourceWidth,
} from '../src/games/mastervega/VegaArt.js';
// Turn-report classification is Phaser-free, so what the "New Turn" popup will
// and will not interrupt the player for is checkable here.
import { NOTABLE_TYPES, describeEvent, TYPE_LABEL } from '../src/games/mastervega/VegaTurnReport.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';
// Dependency-free, so what the game room eagerly pulls is checkable here.
import { resolveGameAssets } from '../src/data/assetManifest.js';
const QUICK = process.argv.includes('--quick');
const gamesArg = process.argv.find((a) => a.startsWith('--games='));
@ -334,6 +340,97 @@ section('2. Procedural art');
console.log(` (${worldsPainted}/${worldIds.length} world backdrops painted; `
+ `${colonisablePainted}/${colonisableTotal} of the colonisable types)`);
// Colony-founding vignettes, one per COLONISABLE planet type. A type with no
// clip falls back to the backdrop still, so a gap is legal — but a clip for a
// world that can never be settled is dead weight nothing will ever play, and
// a key that does not match colonyVideoKey() loads a video the vignette will
// never find. Both are asserted; the roster count is only reported.
const colVids = artJson.colonyVideos ?? {};
const colVidIds = Object.keys(colVids).filter((k) => !k.startsWith('_'));
for (const id of colVidIds) {
check(`colonyVideos entry ${id} is a known planet type`, !!RULES.planetTypes[id]);
check(`colonyVideos ${id} is a colonisable type`, !!RULES.planetTypes[id]?.colonizable);
check(`colonyVideos ${id} key matches the loader's key`,
colVids[id]?.key === colonyVideoKey(id), `${colVids[id]?.key} vs ${colonyVideoKey(id)}`);
if (colVids[id]?.path) {
check(`colonyVideos ${id} file exists`, existsSync(join(root, colVids[id].path)),
colVids[id].path);
check(`colonyVideos ${id} is an mp4`, colVids[id].path.endsWith('.mp4'));
}
}
for (const p of RULES.planetTypeList.filter((t) => t.colonizable)) {
// Not a hard requirement — but every gap must still have the still-image
// fallback the vignette drops to, or founding a colony there is a gradient.
if (!colVidIds.includes(p.id)) {
check(`colonisable type ${p.id} without a colony clip has a backdrop to fall back on`,
!!worlds[p.id]?.path, p.id);
}
}
const colClips = colVidIds.filter((id) => colVids[id]?.path).length;
console.log(` (${colClips}/${colonisableTotal} colony-founding clips recorded; `
+ 'the rest fall back to the world backdrop)');
// …and they must stay OUT of the eager manifest. The whole set is ~19 MB for
// clips a game barely touches, so VegaColonyIntro.ensureColonyVideo() fetches
// one on demand. Resolving the block here again would quietly put all of it
// back on the game-room load, which nothing else would notice.
// assetManifest.js is dependency-free, so the real resolver runs headlessly
// against a cache stub.
{
const stub = { cache: { json: { get: (k) => (k === 'mastervega-artwork' ? artJson : null) } } };
const eager = resolveGameAssets(stub, 'mastervega');
const eagerColony = eager.filter((d) => d.type === 'video' && d.key.startsWith('vega-colony-'));
check('colony-founding clips are not eager-loaded', eagerColony.length === 0,
eagerColony.map((d) => d.key).join(' '));
// The cue is the opposite case: small, and it has to be ready the instant
// the vignette opens.
check('the colony-founding cue IS eager-loaded',
eager.some((d) => d.type === 'audio' && d.key === 'vega-colony-cue'));
// Nothing else lost its ride while that was being arranged.
check('species portraits are still eager-loaded',
eager.some((d) => d.type === 'video' && d.key === speciesVideoKey('human')));
check('ship commander clips are still eager-loaded',
eager.some((d) => d.type === 'video' && d.key === shipVideoKey('human', 'scout')));
}
// sourceWidth() — the guard every video in this game is scaled through.
//
// A Phaser Video carries a placeholder 256x256 size until its first frame
// decodes, so the obvious `obj.width || SRC` never fires and divides by 256.
// That is invisible while every clip IS 256 px (placeholder and fallback
// agree) and is exactly what opened the 960x544 colony clips at 2.5x. Pure
// function, so the distinction is assertable here rather than in a browser.
{
const undecoded = { type: 'Video', videoTexture: null, width: 256 };
const decoded = { type: 'Video', videoTexture: {}, width: 960 };
check('sourceWidth ignores an undecoded Video\'s placeholder width',
sourceWidth(undecoded, 960) === 960, `${sourceWidth(undecoded, 960)}`);
check('sourceWidth trusts a decoded Video',
sourceWidth(decoded, 256) === 960, `${sourceWidth(decoded, 256)}`);
check('sourceWidth trusts an Image outright',
sourceWidth({ type: 'Image', width: 192 }, 256) === 192);
check('sourceWidth still falls back on a zero width',
sourceWidth({ type: 'Image', width: 0 }, 256) === 256);
// The scale the vignette actually sets, both ways round: a 640px window on
// a 960px clip is 0.667, and never the 2.5 the placeholder would give.
check('a 640px clip window scales a 960px clip by 2/3',
Math.abs(640 / sourceWidth(undecoded, 960) - 0.6667) < 0.001);
}
// The just-in-time loader reads its path straight out of the artwork JSON
// rather than a descriptor, so the two must agree on where a clip lives.
for (const id of colVidIds) {
if (!colVids[id]?.path) continue;
check(`colonyVideos ${id} path is under the vega video folder`,
colVids[id].path.startsWith('assets/videos/vega/'), colVids[id].path);
}
// The founding cue the vignette ducks the soundtrack for. Declared in the
// asset manifest rather than the artwork JSON, so this is the only place the
// path gets checked.
check('colony-founding cue exists',
existsSync(join(root, 'assets/music/vega/colony.mp3')), 'assets/music/vega/colony.mp3');
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, `
@ -1003,6 +1100,86 @@ section('6. Colony economy');
check('a colony recovers from a waste backlog', dirty.waste < 5, `${dirty.waste.toFixed(1)}`);
}
// ---------------------------------------------------------------------------
section('6b. Founding vignette and the turn report');
// ---------------------------------------------------------------------------
{
// Founding a colony is announced ONCE, by VegaColonyIntro.js the moment it
// happens. Putting it back in the "New Turn" popup would announce it twice.
check('founding a colony is not a turn-report row', !NOTABLE_TYPES.has('colonised'));
check('colonised has no turn-report label either', !TYPE_LABEL.colonised);
for (const type of NOTABLE_TYPES) {
check(`notable event ${type} has a turn-report label`, !!TYPE_LABEL[type]);
}
// The event itself must survive: the ticker log still classifies it, and the
// engine's own bookkeeping (announced/trimmed) runs over it.
{
const st = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'spiral', difficultyId: 'normal', seed: 6161,
speciesIds: ['human', 'kkrix'], humanIndex: 0,
});
const home = st.empires[0].homeStar;
// Settleable means habitable AT THIS TECH, not merely a colonisable type —
// planetology is what gates hostile worlds, and picking on the static flag
// alone lands on a barren world colonize() will refuse on turn one.
let target = -1;
let orbit = -1;
st.galaxy.stars.forEach((s, i) => {
if (target >= 0 || i === home) return;
const o = s.planets.findIndex((p, oo) => Logic.canColonize(RULES, st, 0, i, oo));
if (o >= 0) { target = i; orbit = o; }
});
check('the opening galaxy offers somewhere to settle', target >= 0);
if (target >= 0) {
st.empires[0].explored[target] = true;
Logic.addFleet(RULES, st, 0, target, [{ hullId: 'colonyship', mark: 1, count: 1 }]);
const ok = Logic.colonize(RULES, st, 0, target, orbit);
const ev = st.events.find((e) => e.type === 'colonised' && e.starIdx === target);
check('colonising still pushes a colonised event', ok && !!ev);
// The vignette looks the new colony up by orbit, exactly like this.
check('the founded colony is findable by its orbit',
!!Logic.coloniesAt(st, target).find((c) => c.orbit === orbit));
// describeEvent must not throw on an event that is no longer notable —
// the ticker log walks every event, notable or not.
if (ev) check('describeEvent survives a colonised event', !!describeEvent(RULES, st, ev));
}
}
// Every number the vignette reads out, on a colony one tick old, for every
// colonisable world type any species could land on. These are called before
// the colony has ever been processed, which is a state no other screen sees.
{
const st = Logic.createGame(RULES, {
sizeId: 'large', shapeId: 'cluster', difficultyId: 'normal', seed: 2727,
speciesIds: RULES.speciesList.slice(0, 6).map((s) => s.id), humanIndex: 0,
});
const seen = new Set();
st.galaxy.stars.forEach((star, starIdx) => {
star.planets.forEach((planet, orbit) => {
if (!RULES.planetTypes[planet.typeId].colonizable) return;
if (seen.has(planet.typeId)) return;
if (Logic.coloniesAt(st, starIdx).some((c) => c.orbit === orbit)) return;
seen.add(planet.typeId);
const c = Logic.foundColony(RULES, st, 0, starIdx, orbit, 5);
const stats = {
maxPop: Logic.colonyMaxPop(RULES, st, c),
output: Logic.colonyProduction(RULES, st, c),
build: Logic.colonyBuildRate(RULES, st, c),
factoryCap: Logic.colonyFactoryCap(RULES, st, c),
};
for (const [name, v] of Object.entries(stats)) {
check(`vignette ${name} on a fresh ${planet.typeId} colony is a finite number`,
Number.isFinite(v) && v >= 0, `${v}`);
}
check(`a fresh ${planet.typeId} colony can hold the settlers it landed with`,
stats.maxPop >= c.pop, `${stats.maxPop} < ${c.pop}`);
st.colonies.pop();
});
});
console.log(` (${seen.size} colonisable world types exercised)`);
}
}
// ---------------------------------------------------------------------------
section('7. Diplomacy and the Galactic Council');
// ---------------------------------------------------------------------------