fertig-classic-games/src/games/mastervega/VegaColonyIntro.js

721 lines
30 KiB
JavaScript

// 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 { TextInput } from '../../ui/TextInput.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;
}
const NAME_PANEL_W = 640;
const NAME_PANEL_H = 280;
/**
* Small centred prompt that asks the player to name a colony before it is
* actually founded — the step that runs ahead of openColonyIntro() when a
* "Found colony" click is a human's. Pre-fills with `defaultName` (the next
* unused name off the species' bank, via Logic.peekColonyName) but never
* lets `onConfirm` fire with an empty name: the Found button stays disabled
* and a warning line lights up while the trimmed field is blank.
*
* `onCancel` fires (and nothing is founded) on Cancel, Escape, or a click
* outside the panel — colonising is not something a stray click should
* commit the player to.
*/
export function promptColonyName(scene, { worldName, defaultName }, onConfirm, onCancel = null) {
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const x = cx - NAME_PANEL_W / 2;
const y = cy - NAME_PANEL_H / 2;
const root = scene.add.container(0, 0).setDepth(D.intro);
const veil = scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x00060e, 0.72)
.setOrigin(0, 0).setInteractive();
root.add(veil);
const panel = scene.add.rectangle(x, y, NAME_PANEL_W, NAME_PANEL_H, PANEL, 0.97).setOrigin(0, 0);
panel.setStrokeStyle(1.5, ACCENT, 0.65);
root.add(panel);
root.add(cornerTicks(scene, x, y, NAME_PANEL_W, NAME_PANEL_H, 22));
root.add(scene.add.text(cx, y + 42, 'NAME THIS COLONY', {
fontFamily: FONT, fontSize: '26px', color: '#ffd88a',
}).setOrigin(0.5));
root.add(scene.add.text(cx, y + 78, worldName, {
fontFamily: FONT, fontSize: '17px', color: '#8fa8c0',
}).setOrigin(0.5));
const input = new TextInput(scene, cx, y + 134, {
width: NAME_PANEL_W - 120, height: 48, value: defaultName, maxLength: 28, autocomplete: 'off',
});
input.focus();
input.el.select();
const warn = scene.add.text(cx, y + 172, 'Colony name cannot be empty.', {
fontFamily: FONT, fontSize: '14px', color: '#e0847e',
}).setOrigin(0.5).setAlpha(0);
root.add(warn);
let closed = false;
const destroy = () => {
if (closed) return;
closed = true;
input.destroy();
root.destroy();
};
const refresh = () => {
const ok = input.value.trim().length > 0;
found.setEnabled(ok);
warn.setAlpha(ok ? 0 : 1);
return ok;
};
const confirm = () => {
if (!refresh()) return;
const name = input.value.trim();
destroy();
onConfirm(name);
};
const cancel = () => {
destroy();
onCancel?.();
};
const found = new Button(scene, cx - 110, y + NAME_PANEL_H - 46, 'Found', confirm,
{ width: 200, height: 52 });
root.add(found);
root.add(new Button(scene, cx + 110, y + NAME_PANEL_H - 46, 'Cancel', cancel,
{ width: 200, height: 52, variant: 'ghost' }));
refresh();
input.on('input', refresh);
input.on('keydown', (e) => {
if (e.key === 'Enter') confirm();
else if (e.key === 'Escape') cancel();
});
veil.on('pointerup', cancel);
return { close: destroy };
}
/**
* 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);
// Colonies founded before naming existed have no `name` — worldName is
// exactly what the vignette showed for them, so it is the fallback rather
// than a placeholder.
const colonyName = colony.name ?? worldName;
root.add(shadow(scene.add.text(PANEL_X, 84, colonyName, {
fontFamily: FONT, fontSize: '64px', color: '#ffd88a',
})));
root.add(shadow(scene.add.text(PANEL_X, 168, `COLONY ESTABLISHED · ${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 — ${colonyName} 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 };
}