721 lines
30 KiB
JavaScript
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, uiClick } 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', uiClick(scene, confirm),
|
|
{ width: 200, height: 52 });
|
|
root.add(found);
|
|
root.add(new Button(scene, cx + 110, y + NAME_PANEL_H - 46, 'Cancel', uiClick(scene, 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', uiClick(scene, 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 };
|
|
}
|