feat: add population transport and audience video replay
- Add Colony Transport ('poptransport') hull, dispatched directly in transit
from a Send Population order rather than built from the queue. Shares the
Troop Transport's hull art and uses the colony ship's commander clip.
- Implement VegaLogic sendPopulation/deliverPopulation flow: clamped to
maxSendablePopulation (leaves 0.5 floor), creates poptransport fleet
immediately, auto-delivers on arrival capped at destination max pop.
- Add Send Population button and overlay picker in colony view with
destination list, ETA display, amount dial, and MAX button.
- Log populationSent/populationDelivered events in the turn report.
- Refactor VegaAudience mood visuals to play-once-and-hold-last-frame with
a clickable replay badge, mirroring VegaColonyIntro's video contract.
- Seed human audience mood clips (angry/neutral/happy) into artwork config
as placeholders for all diplomacy-capable species.
- Add verification tests for poptransport mechanics and video aliasing.
This commit is contained in:
parent
1251da186c
commit
7e7956d86d
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -128,17 +128,53 @@
|
|||
"radiated": { "key": "vega-colony-radiated", "path": "assets/videos/vega/colony-radiated.mp4" }
|
||||
},
|
||||
|
||||
"_audienceVideosReadme": "Audience-screen MOOD clips for the full-screen diplomacy interface — one per diplomacy-capable species x {angry, neutral, happy}, 3:2 (e.g. 1200x800 or 1080x720), muted, looping. Lithox has no entry at all: canNegotiate() is permanently false for them (traits.diplomacy <= -100), so the Audience screen never opens for that species and a clip would never play. A species entirely absent (null) or a mood missing inside a recorded species falls back to that species' portraitVideos clip (256x256, letterboxed to 3:2), then to portraitStills — same three-tier fallback convention as makeSpeciesPortrait, so the roster fills one species (or one mood) at a time with no code change. Naming: assets/videos/vega/audience-<speciesId>-<mood>.mp4.",
|
||||
"_audienceVideosReadme": "Audience-screen MOOD clips for the full-screen diplomacy interface — one per diplomacy-capable species x {angry, neutral, happy}, muted, looping. Recorded at 896x576 (1.5556:1, close to but not exactly the 3:2 the VegaAudience.js frame is cut to — the clip is scaled by WIDTH only, never setDisplaySize, so it sits slightly short of the frame's full height rather than being stretched). Lithox has no entry at all: canNegotiate() is permanently false for them (traits.diplomacy <= -100), so the Audience screen never opens for that species and a clip would never play. A species entirely absent (null) or a mood missing inside a recorded species falls back to that species' portraitVideos clip (256x256, letterboxed to 3:2), then to portraitStills — same three-tier fallback convention as makeSpeciesPortrait, so the roster fills one species (or one mood) at a time with no code change. Naming: assets/videos/vega/audience-<speciesId>-<mood>.mp4.",
|
||||
"audienceVideos": {
|
||||
"human": null,
|
||||
"kestrelli": null,
|
||||
"ursaal": null,
|
||||
"umbrix": null,
|
||||
"kkrix": null,
|
||||
"mekhan": null,
|
||||
"rrashaa": null,
|
||||
"cerebrai": null,
|
||||
"ssakar": null
|
||||
"human": {
|
||||
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
|
||||
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
|
||||
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" }
|
||||
},
|
||||
"kestrelli": {
|
||||
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
|
||||
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
|
||||
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" }
|
||||
},
|
||||
"ursaal": {
|
||||
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
|
||||
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
|
||||
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" }
|
||||
},
|
||||
"umbrix": {
|
||||
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
|
||||
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
|
||||
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" }
|
||||
},
|
||||
"kkrix": {
|
||||
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
|
||||
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
|
||||
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" }
|
||||
},
|
||||
"mekhan": {
|
||||
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
|
||||
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
|
||||
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" }
|
||||
},
|
||||
"rrashaa": {
|
||||
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
|
||||
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
|
||||
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" }
|
||||
},
|
||||
"cerebrai": {
|
||||
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
|
||||
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
|
||||
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" }
|
||||
},
|
||||
"ssakar": {
|
||||
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
|
||||
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
|
||||
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" }
|
||||
}
|
||||
},
|
||||
|
||||
"sheets": {
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@
|
|||
{ "id": "scout", "name": "Scout", "role": "recon", "baseCost": 18, "baseHp": 10, "space": 0, "speedBonus": 2, "rangeBonus": 3, "frame": 0, "desc": "Unarmed, fast and long-legged. Peels back the dark." },
|
||||
{ "id": "colonyship", "name": "Colony Ship", "role": "colony", "baseCost": 100, "baseHp": 18, "space": 0, "speedBonus": 0, "rangeBonus": 0, "frame": 1, "desc": "Carries a founding population. Consumed on arrival." },
|
||||
{ "id": "transport", "name": "Troop Transport","role": "troops","baseCost": 40, "baseHp": 20, "space": 0, "speedBonus": 0, "rangeBonus": 0, "frame": 2, "troops": 4, "desc": "Four divisions of marines for boarding and invasion." },
|
||||
{ "id": "poptransport","name": "Colony Transport","role": "colony","baseCost": 30,"baseHp": 20, "space": 0, "speedBonus": 0, "rangeBonus": 0, "frame": 2, "desc": "Ferries colonists between worlds you already hold. Dispatched directly from a colony's Send Population order, never built from the queue — shares the Troop Transport's hull picture (frame 2, the ships sheet is full at 8 columns)." },
|
||||
{ "id": "frigate", "name": "Frigate", "role": "warship", "baseCost": 35, "baseHp": 18, "space": 6, "speedBonus": 1, "rangeBonus": 1, "frame": 3, "desc": "Cheap escort. Screens the line and hunts scouts." },
|
||||
{ "id": "destroyer", "name": "Destroyer", "role": "warship", "baseCost": 90, "baseHp": 40, "space": 14, "speedBonus": 1, "rangeBonus": 0, "frame": 4, "desc": "The workhorse combatant of any mid-game fleet." },
|
||||
{ "id": "cruiser", "name": "Cruiser", "role": "warship", "baseCost": 240, "baseHp": 100, "space": 32, "speedBonus": 0, "rangeBonus": 0, "frame": 5, "desc": "Heavy line ship. Expensive enough to hurt when it dies." },
|
||||
|
|
|
|||
|
|
@ -890,6 +890,8 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
|||
// A held peace/alliance offer — not urgent enough to interrupt with a
|
||||
// popup, but discoverable next time Diplomacy is opened.
|
||||
this.log(`The ${name(ev.empire)} seek an audience.`);
|
||||
} else if (ev.type === 'populationDelivered') {
|
||||
this.log(`${ev.amount.toFixed(1)} population arrived at ${star?.name}.`);
|
||||
}
|
||||
}
|
||||
return notable;
|
||||
|
|
|
|||
|
|
@ -147,52 +147,89 @@ function buildLetterboxedFallback(scene, rules, art, speciesId, x, y, w, h) {
|
|||
return container;
|
||||
}
|
||||
|
||||
function buildMoodVisual(scene, rules, art, speciesId, mood, x, y, w, h, parent) {
|
||||
let obj;
|
||||
if (hasAudienceVideo(scene, speciesId, mood)) {
|
||||
obj = scene.add.video(x, y, audienceVideoKey(speciesId, mood));
|
||||
obj.setMute(true);
|
||||
obj.setLoop(true);
|
||||
const fit = () => obj.setScale(w / sourceWidth(obj, w));
|
||||
fit();
|
||||
obj.on('created', fit);
|
||||
obj.on('playing', fit);
|
||||
obj.play(true);
|
||||
} else {
|
||||
obj = buildLetterboxedFallback(scene, rules, art, speciesId, x, y, w, h);
|
||||
}
|
||||
parent.add(obj);
|
||||
return obj;
|
||||
}
|
||||
|
||||
// `parent` is the container these objects are added to (so they inherit the
|
||||
// screen's depth tier and are torn down for free when it is destroyed —
|
||||
// Container.destroy() recurses into its children, same as VegaColonyIntro's
|
||||
// video: "destroys the video with it, which is what stops its decoder").
|
||||
function createMoodVisual(scene, rules, art, speciesId, x, y, w, h, parent) {
|
||||
//
|
||||
// Unlike the colony-founding clip, these carry NO audio at all — muted for
|
||||
// their whole life, never unmuted — so there is no applyAudio()-style
|
||||
// re-assertion to worry about. They DO share the colony clip's "plays once,
|
||||
// holds the last frame, replay badge" contract: `onEndedChanged(ended)`
|
||||
// fires whenever the CURRENTLY shown video's ended state should be reflected
|
||||
// by the caller's replay badge — on first build, on every show() (so
|
||||
// swapping back to an already-finished mood re-arms the badge immediately),
|
||||
// on the video's own 'complete' event, and on an explicit replay() call.
|
||||
function createMoodVisual(scene, rules, art, speciesId, x, y, w, h, parent, onEndedChanged) {
|
||||
const anyClip = MOODS.some((m) => hasAudienceVideo(scene, speciesId, m));
|
||||
if (!anyClip) {
|
||||
// Nothing to visually swap, but `current` still has to track the real
|
||||
// mood — otherwise afterAction()'s "did the tier change" check compares
|
||||
// against a value that never updates and fires the glow pulse on every
|
||||
// single action regardless of whether the mood actually moved.
|
||||
// single action regardless of whether the mood actually moved. The
|
||||
// fallback (a species portrait, video or still) is not a "clip" in the
|
||||
// play-once sense, so there is nothing to replay.
|
||||
const shared = buildLetterboxedFallback(scene, rules, art, speciesId, x, y, w, h);
|
||||
parent.add(shared);
|
||||
let current = null;
|
||||
return { show(mood) { current = mood; }, get current() { return current; } };
|
||||
return {
|
||||
show(mood) { current = mood; }, get current() { return current; }, replay() {},
|
||||
};
|
||||
}
|
||||
const built = {};
|
||||
const built = {}; // mood -> { obj, ended }
|
||||
let current = null;
|
||||
|
||||
function build(mood) {
|
||||
const obj = scene.add.video(x, y, audienceVideoKey(speciesId, mood));
|
||||
obj.setMute(true);
|
||||
// Plays once and holds the last frame — an ended HTMLVideoElement stops
|
||||
// on its final frame and the texture keeps it, so ending IS the still.
|
||||
// Same contract as VegaColonyIntro's clip, minus the audio.
|
||||
obj.setLoop(false);
|
||||
const fit = () => obj.setScale(w / sourceWidth(obj, w));
|
||||
fit();
|
||||
obj.on('created', fit);
|
||||
obj.on('playing', fit);
|
||||
const entry = { obj, ended: false };
|
||||
// Not `once` — a replayed clip fires 'complete' again every time it ends.
|
||||
obj.on('complete', () => {
|
||||
entry.ended = true;
|
||||
if (current === mood) onEndedChanged?.(true);
|
||||
});
|
||||
obj.play(false);
|
||||
parent.add(obj);
|
||||
built[mood] = entry;
|
||||
return entry;
|
||||
}
|
||||
|
||||
return {
|
||||
show(mood) {
|
||||
if (current === mood) return;
|
||||
if (built[current]) { built[current].setVisible(false); built[current].pause?.(); }
|
||||
if (!built[mood]) built[mood] = buildMoodVisual(scene, rules, art, speciesId, mood, x, y, w, h, parent);
|
||||
built[mood].setVisible(true);
|
||||
built[mood].play?.(true);
|
||||
if (built[current]) { built[current].obj.setVisible(false); built[current].obj.pause(); }
|
||||
let entry = built[mood];
|
||||
if (!entry) entry = build(mood);
|
||||
else {
|
||||
entry.obj.setVisible(true);
|
||||
// Only resume playback if it never finished — an ended video's
|
||||
// `.play()`/`.resume()` seeks back to 0 on some browsers, which
|
||||
// would silently replay it just for being swapped back to.
|
||||
if (!entry.ended) entry.obj.resume();
|
||||
}
|
||||
current = mood;
|
||||
onEndedChanged?.(entry.ended);
|
||||
},
|
||||
get current() { return current; },
|
||||
replay() {
|
||||
const entry = built[current];
|
||||
if (!entry) return;
|
||||
entry.ended = false;
|
||||
onEndedChanged?.(false);
|
||||
// A genuine replay: Phaser's completeHandler cleared `_playCalled` when
|
||||
// the clip ended, and an ended element seeks back to 0 of its own
|
||||
// accord when play() is called on it (verified against VegaColonyIntro's
|
||||
// identical replay mechanism).
|
||||
entry.obj.play(false);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -216,6 +253,10 @@ const VIDEO_Y = 190;
|
|||
const VIDEO_W = 1080;
|
||||
const VIDEO_H = 720; // 3:2
|
||||
|
||||
/** The replay badge, inset from the video's bottom-right corner. */
|
||||
const REPLAY_R = 26;
|
||||
const REPLAY_PAD = 18;
|
||||
|
||||
const CHAT_X = VIDEO_X + VIDEO_W + 60;
|
||||
const CHAT_W = GAME_WIDTH - CHAT_X - 70;
|
||||
const CHAT_Y = VIDEO_Y;
|
||||
|
|
@ -283,7 +324,36 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
|
|||
frame.add(scene.add.rectangle(VIDEO_X - 8, VIDEO_Y - 8, VIDEO_W + 16, VIDEO_H + 16, PANEL, 0.55)
|
||||
.setOrigin(0, 0).setStrokeStyle(1.5, ACCENT, 0.5));
|
||||
frame.add(cornerTicks(scene, VIDEO_X - 8, VIDEO_Y - 8, VIDEO_W + 16, VIDEO_H + 16, speciesColor, 26));
|
||||
const moodVisual = createMoodVisual(scene, rules, art, speciesId, videoCx, videoCy, VIDEO_W, VIDEO_H, frame);
|
||||
|
||||
// --- replay badge: armed whenever the currently-shown mood clip has
|
||||
// played through once. Built once, over the whole picture (clicking
|
||||
// anywhere on it replays), same shape as VegaColonyIntro's — minus audio,
|
||||
// since these clips are muted for their whole life.
|
||||
const replayHit = scene.add.rectangle(videoCx, videoCy, VIDEO_W, VIDEO_H, 0xffffff, 0.001)
|
||||
.setInteractive({ useHandCursor: true }).setVisible(false);
|
||||
const replayBadge = scene.add.container(
|
||||
videoCx + VIDEO_W / 2 - REPLAY_PAD - REPLAY_R,
|
||||
videoCy + VIDEO_H / 2 - REPLAY_PAD - REPLAY_R,
|
||||
).setVisible(false);
|
||||
replayBadge.add(scene.add.circle(0, 0, REPLAY_R, PANEL, 0.82).setStrokeStyle(2, ACCENT, 0.95));
|
||||
replayBadge.add(scene.add.triangle(3, 0, 0, 0, 0, 22, 19, 11, 0xe8f4ff));
|
||||
function armReplay(on) {
|
||||
replayHit.setVisible(on);
|
||||
replayBadge.setVisible(on).setScale(1);
|
||||
if (!on) return;
|
||||
replayBadge.setAlpha(0);
|
||||
scene.tweens.add({ targets: replayBadge, alpha: 1, duration: 260, ease: 'Cubic.easeOut' });
|
||||
}
|
||||
replayHit.on('pointerover', () => replayBadge.setScale(1.1));
|
||||
replayHit.on('pointerout', () => replayBadge.setScale(1));
|
||||
replayHit.on('pointerup', () => moodVisual.replay());
|
||||
|
||||
const moodVisual = createMoodVisual(scene, rules, art, speciesId, videoCx, videoCy, VIDEO_W, VIDEO_H, frame,
|
||||
armReplay);
|
||||
// Added after the video(s) so the hit zone and badge sit on top of them —
|
||||
// a Container renders its children in insertion order and ignores depth.
|
||||
frame.add(replayHit);
|
||||
frame.add(replayBadge);
|
||||
const glow = scene.add.graphics().setAlpha(0);
|
||||
frame.add(glow);
|
||||
scene.tweens.add({ targets: frame, alpha: 1, scale: 1, duration: 220, ease: 'Cubic.easeOut' });
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import {
|
|||
CHANNELS, colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories,
|
||||
colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany,
|
||||
dequeue, collapseQueue, moveQueueRun, queueItemCost, queueEtas, empireDesign,
|
||||
empireColonies, maxSendablePopulation, sendPopulation, etaTo,
|
||||
} from './VegaLogic.js';
|
||||
|
||||
const ACCENT = 0x6fc4ff;
|
||||
|
|
@ -240,6 +241,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
|
|||
if (closed) return;
|
||||
closed = true;
|
||||
detail?.close();
|
||||
closeTransportPicker();
|
||||
dropScrollers();
|
||||
// Out of the tree first, or root.destroy() takes it and pool.destroy()
|
||||
// then runs against a corpse.
|
||||
|
|
@ -365,7 +367,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
|
|||
// So the two lists below give way rather than spilling past the frame, the
|
||||
// same way VegaSidePanel's `room()` works. The status block and the sliders
|
||||
// are never allowed to be the thing that gets dropped.
|
||||
const buttonY = PANEL_Y + PANEL_H - 76;
|
||||
const buttonY = PANEL_Y + PANEL_H - 76 - 56;
|
||||
const room = (px) => y + px < buttonY - 12;
|
||||
|
||||
// --- what is being built
|
||||
|
|
@ -415,6 +417,12 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
|
|||
}
|
||||
|
||||
y = buttonY;
|
||||
const sendBtn = add(new Button(scene, PANEL_X + PANEL_W / 2, y + 22,
|
||||
'Send Population', openTransportPicker, { width: w, height: 44, fontSize: 18 }));
|
||||
if (maxSendablePopulation(colony) <= 0 || empireColonies(state, colony.empireIdx).length < 2) {
|
||||
sendBtn.setEnabled(false);
|
||||
}
|
||||
y += 56;
|
||||
add(new Button(scene, PANEL_X + PANEL_W / 2, y + 24,
|
||||
flyOpen ? 'Close Build Queue' : 'Build Queue', toggleFlyout,
|
||||
{ width: w, height: 48, fontSize: 20 }));
|
||||
|
|
@ -614,6 +622,8 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
|
|||
const turns = (cost) => (rate > 0 ? `${Math.max(1, Math.ceil(cost / rate))} turns` : 'no budget');
|
||||
|
||||
for (const hull of rules.hullList) {
|
||||
// Dispatched directly by the Send Population order, never queued.
|
||||
if (hull.id === 'poptransport') continue;
|
||||
const d = empireDesign(rules, state, colony.empireIdx, hull.id);
|
||||
entry({ hullId: hull.id },
|
||||
d.name, `${d.cost} BC · ${turns(d.cost)} · ${hull.desc}`, '#9fd8ff',
|
||||
|
|
@ -665,6 +675,136 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
|
|||
r.on('pointerup', (p) => { if (!guard || guard.contains(p)) fn(); });
|
||||
}
|
||||
|
||||
// ------------------------------------------------- population transport
|
||||
//
|
||||
// MOO1-style: a self-contained overlay opened from the panel — pick one of
|
||||
// your other colonies, dial an amount up to what the source can spare, and
|
||||
// send it. Dispatches through Logic.sendPopulation(), which builds the
|
||||
// 'poptransport' fleet already in transit; delivery on arrival is automatic
|
||||
// (VegaLogic.js's moveFleets), so there is no separate "unload" step here.
|
||||
|
||||
const TW = 640;
|
||||
const TH = 520;
|
||||
const TX = (GAME_WIDTH - TW) / 2;
|
||||
const TY = (GAME_HEIGHT - TH) / 2;
|
||||
|
||||
let transportLayer = null;
|
||||
let listCol = null; // tracked so its wheel listener is unhooked on every rebuild, not just on close
|
||||
|
||||
function closeTransportPicker() {
|
||||
listCol?.destroy();
|
||||
listCol = null;
|
||||
transportLayer?.destroy();
|
||||
transportLayer = null;
|
||||
}
|
||||
|
||||
function openTransportPicker() {
|
||||
if (transportLayer) return;
|
||||
const others = empireColonies(state, colony.empireIdx).filter((c) => c.id !== colony.id);
|
||||
let dest = others[0] ?? null;
|
||||
let amount = 1;
|
||||
|
||||
transportLayer = scene.add.container(0, 0);
|
||||
root.add(transportLayer);
|
||||
|
||||
const renderTransport = () => {
|
||||
listCol?.destroy();
|
||||
listCol = null;
|
||||
transportLayer.removeAll(true);
|
||||
transportLayer.add(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x00060e, 0.6)
|
||||
.setOrigin(0, 0).setInteractive());
|
||||
frame(scene, transportLayer, TX, TY, TW, TH, 0.97);
|
||||
|
||||
const x = TX + PAD;
|
||||
const w = TW - PAD * 2;
|
||||
let y = TY + 24;
|
||||
|
||||
transportLayer.add(scene.add.text(x, y, 'SEND POPULATION', {
|
||||
fontFamily: FONT, fontSize: '24px', color: '#cfe8ff',
|
||||
}));
|
||||
transportLayer.add(new Button(scene, TX + TW - 38, y + 14, '✕', closeTransportPicker,
|
||||
{ width: 40, height: 36, fontSize: 18, variant: 'ghost' }));
|
||||
y += 46;
|
||||
|
||||
transportLayer.add(scene.add.text(x, y,
|
||||
`From ${star.name} ${ORBIT[colony.orbit] ?? colony.orbit + 1} — ${colony.pop.toFixed(1)} population`, {
|
||||
fontFamily: FONT, fontSize: '15px', color: '#9fb6cc',
|
||||
}));
|
||||
y += 34;
|
||||
|
||||
if (!others.length) {
|
||||
transportLayer.add(scene.add.text(x, y, 'No other colonies to send population to.', {
|
||||
fontFamily: FONT, fontSize: '15px', color: '#6f8aa3',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
transportLayer.add(scene.add.text(x, y, 'DESTINATION', {
|
||||
fontFamily: FONT, fontSize: '13px', color: '#6f8aa3',
|
||||
}));
|
||||
y += 20;
|
||||
|
||||
const listH = 220;
|
||||
listCol = scrollColumn(scene, transportLayer, x, y, w, listH);
|
||||
let ly = 0;
|
||||
others.forEach((c) => {
|
||||
const dStar = state.galaxy.stars[c.starIdx];
|
||||
const selected = dest?.id === c.id;
|
||||
const row = scene.add.rectangle(0, ly, w, 42, selected ? 0x22405f : 0x0f1a2c, selected ? 0.9 : 0.5)
|
||||
.setOrigin(0, 0).setStrokeStyle(1, ACCENT, selected ? 0.7 : 0.2)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
row.on('pointerup', (p) => { if (listCol.contains(p)) { dest = c; renderTransport(); } });
|
||||
listCol.content.add(row);
|
||||
const eta = etaTo(rules, state, { starIdx: colony.starIdx, empireIdx: colony.empireIdx },
|
||||
c.starIdx, [{ hullId: 'poptransport', mark: 1, count: 1 }]);
|
||||
listCol.content.add(scene.add.text(12, ly + 6, `${dStar.name} ${ORBIT[c.orbit] ?? c.orbit + 1}`, {
|
||||
fontFamily: FONT, fontSize: '16px', color: '#e8f4ff',
|
||||
}));
|
||||
listCol.content.add(scene.add.text(12, ly + 25,
|
||||
`${c.pop.toFixed(1)} population · ${Number.isFinite(eta) ? `${eta} turn${eta === 1 ? '' : 's'}` : 'unreachable'}`, {
|
||||
fontFamily: FONT, fontSize: '13px', color: '#8fa8c0',
|
||||
}));
|
||||
ly += 48;
|
||||
});
|
||||
listCol.contentHeight = ly;
|
||||
y += listH + 20;
|
||||
|
||||
const cap = maxSendablePopulation(colony);
|
||||
amount = Phaser.Math.Clamp(amount, 0, cap);
|
||||
|
||||
transportLayer.add(scene.add.text(x, y, 'AMOUNT TO SEND', {
|
||||
fontFamily: FONT, fontSize: '13px', color: '#6f8aa3',
|
||||
}));
|
||||
y += 24;
|
||||
transportLayer.add(scene.add.text(x, y, amount.toFixed(1), {
|
||||
fontFamily: FONT, fontSize: '30px', color: '#ffd88a',
|
||||
}));
|
||||
const setAmount = (v) => { amount = Phaser.Math.Clamp(v, 0, cap); renderTransport(); };
|
||||
pusher(transportLayer, x + 110, y + 15, '−', () => setAmount(amount - 1), amount > 0);
|
||||
pusher(transportLayer, x + 142, y + 15, '+', () => setAmount(amount + 1), amount < cap);
|
||||
transportLayer.add(new Button(scene, x + 200, y + 15, 'MAX', () => setAmount(cap),
|
||||
{ width: 66, height: 32, fontSize: 14, variant: 'ghost' }));
|
||||
transportLayer.add(scene.add.text(x, y + 40, `${cap.toFixed(1)} available to send`, {
|
||||
fontFamily: FONT, fontSize: '13px', color: '#6f8aa3',
|
||||
}));
|
||||
y += 76;
|
||||
|
||||
const canSend = !!dest && amount > 0;
|
||||
const sendBtn = new Button(scene, TX + TW / 2, y + 22, 'Send', () => {
|
||||
if (!dest) return;
|
||||
if (sendPopulation(rules, state, colony.empireIdx, colony.starIdx, dest.starIdx, amount)) {
|
||||
closeTransportPicker();
|
||||
buildPanel();
|
||||
onChanged?.();
|
||||
}
|
||||
}, { width: w, height: 44, fontSize: 18 });
|
||||
if (!canSend) sendBtn.setEnabled(false);
|
||||
transportLayer.add(sendBtn);
|
||||
};
|
||||
|
||||
renderTransport();
|
||||
}
|
||||
|
||||
buildPanel();
|
||||
return { root, close };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1029,8 +1029,10 @@ function moveFleets(rules, state, e) {
|
|||
pushEvent(state, { type: 'discovered', empire: e, starIdx: f.starIdx, turn: state.turn });
|
||||
}
|
||||
checkContactAt(rules, state, f.starIdx);
|
||||
deliverPopulation(rules, state, f);
|
||||
}
|
||||
}
|
||||
cleanFleets(state);
|
||||
}
|
||||
|
||||
// Empires meet by sharing a system — a ship or colony of one sitting where a
|
||||
|
|
@ -1254,6 +1256,77 @@ export function colonize(rules, state, e, starIdx, orbit) {
|
|||
return true;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Population transport — MOO1's "send N population from one of your colonies
|
||||
// to another." Unlike colonize()'s colony ship, this never sits in a build
|
||||
// queue: the 'poptransport' hull (data/mastervega-rules.json) is dispatched
|
||||
// directly, in transit already, the instant the order is given, carrying
|
||||
// whatever amount was chosen as a payload on its own ship stack rather than a
|
||||
// fixed per-hull capacity. It rides the same movement/fuel-range/combat
|
||||
// machinery as any other fleet, so an unescorted transport crossing hostile
|
||||
// space is exactly as vulnerable as a colony ship would be.
|
||||
|
||||
// A colony can never be fully emptied this way — same floor colony growth
|
||||
// itself never goes below (see the `Math.max(0.5, ...)` in processColony).
|
||||
const MIN_POP_LEFT_BEHIND = 0.5;
|
||||
|
||||
export function maxSendablePopulation(colony) {
|
||||
return Math.max(0, colony.pop - MIN_POP_LEFT_BEHIND);
|
||||
}
|
||||
|
||||
export function sendPopulation(rules, state, e, fromStarIdx, toStarIdx, amount) {
|
||||
if (fromStarIdx === toStarIdx) return false;
|
||||
const colony = colonyAt(state, fromStarIdx);
|
||||
if (!colony || colony.empireIdx !== e) return false;
|
||||
const dest = colonyAt(state, toStarIdx);
|
||||
if (!dest || dest.empireIdx !== e) return false;
|
||||
const send = Math.min(amount, maxSendablePopulation(colony));
|
||||
if (send <= 0) return false;
|
||||
|
||||
// Built directly in transit — see the module comment above for why this
|
||||
// does not go through addFleet()/sendFleet() (which would first have to
|
||||
// land in an idle fleet at the source, risking a merge with whatever is
|
||||
// garrisoned there before it could be split back out).
|
||||
const probe = { empireIdx: e, starIdx: fromStarIdx, toStar: -1, ships: [{ hullId: 'poptransport', mark: 1, count: 1 }] };
|
||||
if (!canSendFleet(rules, state, probe, toStarIdx)) return false;
|
||||
|
||||
state.fleets.push({
|
||||
id: state.nextFleetId += 1,
|
||||
empireIdx: e,
|
||||
starIdx: -1,
|
||||
fromStar: fromStarIdx,
|
||||
toStar: toStarIdx,
|
||||
progress: 0,
|
||||
total: parsecs(state.galaxy, fromStarIdx, toStarIdx),
|
||||
ships: [{ hullId: 'poptransport', mark: 1, count: 1, popPayload: send }],
|
||||
});
|
||||
colony.pop -= send;
|
||||
pushEvent(state, {
|
||||
type: 'populationSent', empire: e, starIdx: fromStarIdx, toStarIdx, amount: send, turn: state.turn,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Called from moveFleets the instant a poptransport fleet arrives. Delivers
|
||||
// to a still-friendly colony there and consumes the stack; if the star no
|
||||
// longer has a colony of ours (captured, or the fleet was mis-sent), the
|
||||
// payload just sits inert in orbit rather than being silently discarded, in
|
||||
// case the player wants to redirect it by hand later.
|
||||
function deliverPopulation(rules, state, f) {
|
||||
const stack = f.ships.find((s) => s.hullId === 'poptransport' && s.popPayload > 0);
|
||||
if (!stack) return;
|
||||
const colony = colonyAt(state, f.starIdx);
|
||||
if (!colony || colony.empireIdx !== f.empireIdx) return;
|
||||
const maxPop = colonyMaxPop(rules, state, colony);
|
||||
colony.pop = Math.min(maxPop, colony.pop + stack.popPayload);
|
||||
pushEvent(state, {
|
||||
type: 'populationDelivered', empire: f.empireIdx, starIdx: f.starIdx,
|
||||
colonyId: colony.id, amount: stack.popPayload, turn: state.turn,
|
||||
});
|
||||
stack.count = 0;
|
||||
stack.popPayload = 0;
|
||||
}
|
||||
|
||||
// Orbital superiority at a system: our combat power there exceeds theirs.
|
||||
// Bombardment and invasion both require it.
|
||||
export function holdsOrbit(rules, state, e, starIdx, defenderIdx) {
|
||||
|
|
|
|||
|
|
@ -29,8 +29,17 @@ const SHIP_FRAME_PX = 192;
|
|||
|
||||
export const shipVideoKey = (speciesId, hullId) => `vega-ship-${speciesId}-${hullId}`;
|
||||
|
||||
// The Colony Transport ('poptransport') shares the Troop Transport's static
|
||||
// picture (same sheet frame — see data/mastervega-rules.json, the ships sheet
|
||||
// is full at 8 columns) but is deliberately given the COLONY SHIP's commander
|
||||
// clip instead: thematically the same kind of settlement run, and it means
|
||||
// every species with a colony-ship video already has one for this hull too,
|
||||
// with zero new video files. `shipVideoKey` itself stays a pure naming
|
||||
// function — the alias is applied only where a video is actually looked up.
|
||||
const VIDEO_HULL_ALIAS = { poptransport: 'colonyship' };
|
||||
|
||||
export function hasShipVideo(scene, speciesId, hullId) {
|
||||
return !!scene.cache.video?.exists(shipVideoKey(speciesId, hullId));
|
||||
return !!scene.cache.video?.exists(shipVideoKey(speciesId, VIDEO_HULL_ALIAS[hullId] ?? hullId));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -61,7 +70,7 @@ export function makeCommanderPortrait(
|
|||
if (!hasShipVideo(scene, speciesId, hullId)) {
|
||||
return makeSpeciesPortrait(scene, rules, art, speciesId, x, y, size);
|
||||
}
|
||||
const v = scene.add.video(x, y, shipVideoKey(speciesId, hullId));
|
||||
const v = scene.add.video(x, y, shipVideoKey(speciesId, VIDEO_HULL_ALIAS[hullId] ?? hullId));
|
||||
v.setMute(true);
|
||||
v.setLoop(true);
|
||||
// Scale from the known source size rather than setDisplaySize — see
|
||||
|
|
|
|||
|
|
@ -31,7 +31,10 @@ export const NOTABLE_TYPES = new Set([
|
|||
//
|
||||
// `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', 'buildingDone', 'shipDone']);
|
||||
const PERSONAL_TYPES = new Set([
|
||||
'discovered', 'techDone', 'colonised', 'buildingDone', 'shipDone',
|
||||
'populationSent', 'populationDelivered',
|
||||
]);
|
||||
|
||||
export function isRelevantToHuman(ev, me) {
|
||||
if (PERSONAL_TYPES.has(ev.type)) return ev.empire === me;
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ import { CHAT } from '../src/games/mastervega/VegaChat.js';
|
|||
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';
|
||||
import { shipVideoKey, hasShipVideo } 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';
|
||||
|
||||
|
|
@ -311,6 +311,27 @@ section('2. Procedural art');
|
|||
console.log(` (${shipClips}/${RULES.speciesList.length * RULES.hullList.length} `
|
||||
+ 'ship commander clips recorded; the rest fall back to the species portrait)');
|
||||
|
||||
// poptransport's commander clip is deliberately aliased to the colony
|
||||
// ship's (VegaShipMedia.js) — same picture as the Troop Transport, but the
|
||||
// "settlement run" video. hasShipVideo() takes a scene as a plain
|
||||
// duck-typed parameter (no Phaser import in that module), so the alias is
|
||||
// checkable here with a stub rather than needing a browser.
|
||||
{
|
||||
const stubScene = (cachedKeys) => ({ cache: { video: { exists: (k) => cachedKeys.has(k) } } });
|
||||
const withColonyClip = stubScene(new Set([shipVideoKey('human', 'colonyship')]));
|
||||
check('poptransport resolves to a video when the species has a colony-ship clip',
|
||||
hasShipVideo(withColonyClip, 'human', 'poptransport'));
|
||||
const withoutAnyClip = stubScene(new Set());
|
||||
check('poptransport reports no video when the colony-ship clip is absent too',
|
||||
!hasShipVideo(withoutAnyClip, 'human', 'poptransport'));
|
||||
// A clip filed under poptransport's OWN key must NOT be what satisfies
|
||||
// this — the alias means only the colony ship's key is ever consulted,
|
||||
// and one under poptransport's own name is never expected to exist.
|
||||
const onlyOwnKey = stubScene(new Set([shipVideoKey('human', 'poptransport')]));
|
||||
check('the alias looks at the colony ship\'s key, not poptransport\'s own',
|
||||
!hasShipVideo(onlyOwnKey, 'human', 'poptransport'));
|
||||
}
|
||||
|
||||
// Audience-screen mood clips: species -> {angry, neutral, happy}. Lithox
|
||||
// (diplomacy-incapable) must have NO entry at all — canNegotiate() is
|
||||
// permanently false for them, so the Audience screen never opens for that
|
||||
|
|
@ -883,6 +904,138 @@ section('4b. Fleet orders and detachments');
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('4c. Population transport');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
// MOO1's "send N population from one colony to another" — dispatched
|
||||
// directly as an already-in-transit 'poptransport' fleet (VegaLogic.js:
|
||||
// sendPopulation), never built from a colony's queue.
|
||||
const hull = RULES.hulls.poptransport;
|
||||
check('the poptransport hull exists', !!hull);
|
||||
check('poptransport shares the Troop Transport\'s sheet frame (no new art)',
|
||||
hull?.frame === RULES.hulls.transport.frame);
|
||||
check('poptransport is unarmed (space 0, matching its role)', hull?.space === 0);
|
||||
check('poptransport carries no troops', !(hull?.troops > 0));
|
||||
|
||||
// 'large' + only two species maximises the odds of an unclaimed, in-range
|
||||
// star existing near the human's homeworld to found a second colony on —
|
||||
// same defensive style as section 6b's colonize() setup, which checks
|
||||
// rather than assumes a settleable target exists.
|
||||
function mkTwoColonyGame(seed) {
|
||||
const st = Logic.createGame(RULES, {
|
||||
sizeId: 'large', shapeId: 'cluster', seed, difficultyId: 'normal',
|
||||
speciesIds: ['human', 'lithox'], humanIndex: 0,
|
||||
});
|
||||
st.rules = RULES;
|
||||
const home = st.galaxy.homeIdx[0];
|
||||
const target = Object.keys(Logic.reachableStars(RULES, st, 0)).map(Number)
|
||||
.find((i) => i !== home && i !== st.galaxy.homeIdx[1]);
|
||||
if (target === undefined) return { st, home, target: -1, second: null };
|
||||
const second = Logic.foundColony(RULES, st, 0, target, 0, 5);
|
||||
return { st, home, target, second };
|
||||
}
|
||||
|
||||
{
|
||||
const { st, home, target, second } = mkTwoColonyGame(4141);
|
||||
check('the test galaxy offers a second site to found on', target >= 0);
|
||||
if (target >= 0) {
|
||||
const source = Logic.colonyAt(st, home);
|
||||
const before = source.pop;
|
||||
const cap = Logic.maxSendablePopulation(source);
|
||||
check('maxSendablePopulation leaves the floor behind', cap === before - 0.5);
|
||||
|
||||
const ok = Logic.sendPopulation(RULES, st, 0, home, target, cap);
|
||||
check('sendPopulation succeeds for a reachable, owned destination', ok);
|
||||
check('the source colony loses exactly what was sent', Math.abs(source.pop - 0.5) < 1e-9,
|
||||
`${source.pop}`);
|
||||
|
||||
// Already in transit (starIdx -1), so it is found on state.fleets
|
||||
// directly rather than fleetsAt(home) — fleetsAt filters by starIdx,
|
||||
// which this fleet no longer has.
|
||||
const fleet = st.fleets.find((f) => f.empireIdx === 0
|
||||
&& f.ships.some((s) => s.hullId === 'poptransport'));
|
||||
check('a poptransport fleet departs immediately (already in transit)',
|
||||
!!fleet && fleet.toStar === target && fleet.starIdx < 0);
|
||||
check('the payload rides on the ship stack, not a fixed per-hull capacity',
|
||||
Math.abs(fleet.ships[0].popPayload - cap) < 1e-9);
|
||||
|
||||
// Run it to arrival.
|
||||
const destBefore = second.pop;
|
||||
let turns = 0;
|
||||
while (fleet.toStar >= 0 && turns < 200) { Logic.moveFleetsFor(RULES, st, 0); turns += 1; }
|
||||
check('the transport arrives within a sane number of turns', turns < 200, `${turns}`);
|
||||
// A near-total transfer from a mature homeworld can comfortably exceed
|
||||
// a freshly founded colony's max population — delivery caps at that
|
||||
// max rather than overflowing it, discarding the excess silently
|
||||
// (a deliberate simplification: the sender's responsibility to check
|
||||
// the destination has room, exactly like a colony ship arriving at a
|
||||
// world already at capacity).
|
||||
const maxPopSecond = Logic.colonyMaxPop(RULES, st, second);
|
||||
check('arrival delivers the payload, capped at the destination\'s max population',
|
||||
Math.abs(second.pop - Math.min(maxPopSecond, destBefore + cap)) < 1e-9,
|
||||
`${second.pop} vs min(${maxPopSecond}, ${destBefore + cap})`);
|
||||
check('the delivered payload never exceeds the destination\'s max population',
|
||||
second.pop <= maxPopSecond + 1e-9);
|
||||
check('the spent transport is cleaned up, not left idle forever',
|
||||
!Logic.fleetsAt(st, target).some((f) => f.empireIdx === 0
|
||||
&& f.ships.some((s) => s.hullId === 'poptransport')));
|
||||
check('a populationSent event was recorded',
|
||||
st.events.some((e) => e.type === 'populationSent' && e.empire === 0 && e.starIdx === home));
|
||||
check('a populationDelivered event was recorded',
|
||||
st.events.some((e) => e.type === 'populationDelivered' && e.empire === 0 && e.starIdx === target));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// Guardrails: a colony can never be fully emptied, and a request beyond
|
||||
// what is available is clamped rather than refused outright.
|
||||
const { st, home, target } = mkTwoColonyGame(4242);
|
||||
if (target >= 0) {
|
||||
const source = Logic.colonyAt(st, home);
|
||||
const cap = Logic.maxSendablePopulation(source);
|
||||
const ok = Logic.sendPopulation(RULES, st, 0, home, target, cap + 1000);
|
||||
check('an oversized request is clamped to what the colony can spare', ok);
|
||||
check('the source is left at exactly the floor', Math.abs(source.pop - 0.5) < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// Refusals: not your colony, same star, not your destination, nothing to spare.
|
||||
const { st, home, target } = mkTwoColonyGame(4343);
|
||||
if (target >= 0) {
|
||||
const enemyHome = st.galaxy.homeIdx[1];
|
||||
check('sending from a colony you do not own is refused',
|
||||
Logic.sendPopulation(RULES, st, 0, enemyHome, target, 1) === false);
|
||||
check('sending to a star with no colony of yours is refused',
|
||||
Logic.sendPopulation(RULES, st, 0, home, st.galaxy.homeIdx[1], 1) === false);
|
||||
check('sending to the same star is refused',
|
||||
Logic.sendPopulation(RULES, st, 0, home, home, 1) === false);
|
||||
const source = Logic.colonyAt(st, home);
|
||||
source.pop = 0.5;
|
||||
check('a colony already at the floor has nothing left to send',
|
||||
Logic.sendPopulation(RULES, st, 0, home, target, 1) === false);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// A poptransport in transit must never be swept up as invasion troops or
|
||||
// combat power — it shares no hullId with 'transport' and has no weapons.
|
||||
const { st, home, target } = mkTwoColonyGame(4444);
|
||||
if (target >= 0) {
|
||||
Logic.sendPopulation(RULES, st, 0, home, target, 1);
|
||||
// Already in transit — see the note above about why this is
|
||||
// state.fleets directly and not fleetsAt(home).
|
||||
const fleet = st.fleets.find((f) => f.ships.some((s) => s.hullId === 'poptransport'));
|
||||
check('invasionForecast ignores poptransport stacks',
|
||||
Logic.invasionForecast(RULES, st, 1, home)?.troops === 0
|
||||
|| Logic.invasionForecast(RULES, st, 1, home) === null);
|
||||
check('poptransport contributes no combat power',
|
||||
Logic.fleetPower(RULES, st, fleet) === 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('5. Combat');
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue