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

908 lines
39 KiB
JavaScript

// Master of Vega — the full-screen Audience interface: a large mood video of
// the species you're negotiating with on the left, and a running chat
// conversation with contextual action buttons on the right. This is the ONE
// place diplomacy actually happens now — openDiplomacyScreen (VegaScreens.js)
// is just a directory of "Seek Audience" buttons.
//
// Opened two ways: manually, from openDiplomacyScreen's Seek Audience button
// (in-place replace, same as that screen already does to itself), or
// automatically by the turn loop the moment first contact is made
// (MasterOfVegaGame.js's runAudienceQueue). Both paths call
// openAudienceScreen() directly.
//
// Chat log mechanism (masked scroll container, typewriter reveal) is adapted
// from Civilization's openDiplomacyScreen — the two games' diplomacy screens
// converged on the same idea independently, so this borrows the mechanism
// wholesale rather than re-deriving it. The content is NOT shared: every line
// here comes from VegaChat.js, keyed by species id, in that species' voice.
//
// Mood video: moodOf() (VegaDiplomacy.js) has five attitude tiers; this
// collapses them to the three the player actually authors clips for
// (angry/neutral/happy). Clips are 3:2 (VIDEO_W x VIDEO_H below) and, per
// VegaArt.sourceWidth's "trap 23" (docs/mastervega-build-plan.md), a fresh
// Phaser Video reports a 256px placeholder width — never 0 — until it decodes,
// so scale is always derived from sourceWidth(), re-applied on 'created' and
// 'playing'. Each mood clip is JIT-loaded (like colonyVideos, not the eager
// shipVideos/portraitVideos) one at a time, only the moment it is actually
// about to be shown — never all three moods at once, since a species' full
// set runs several megabytes and most conversations never even change mood.
// Whatever is already on screen keeps playing while a newly-needed mood loads
// behind it; the very first clip a species ever shows gets a letterboxed
// placeholder in its place until that first fetch lands. A species with no
// audience clips at all falls back to that same placeholder permanently,
// letterboxed into the 3:2 frame with that species' own colour as the bar —
// so an art-less species is still fully playable.
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from './VegaButton.js';
import { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js';
import { FONT, D, uiClick } from './VegaScreens.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import {
sourceWidth, makeSpeciesPortrait, speciesSpeechClip, audienceVideoKey, hasAudienceVideo,
} from './VegaArt.js';
import { atWar, grantTech } from './VegaLogic.js';
import {
attitudeOf, moodOf, videoMood, canNegotiate, declareWar, proposeTreaty, respondToOffer, tradeTech, giveGift,
requestPeace,
} from './VegaDiplomacy.js';
import { CHAT, pickLine } from './VegaChat.js';
const ACCENT = 0x6fc4ff;
const PANEL = 0x0b1220;
const MOODS = ['angry', 'neutral', 'happy'];
function audienceVideoPath(scene, speciesId, mood) {
return scene.cache.json.get('mastervega-artwork')?.audienceVideos?.[speciesId]?.[mood]?.path ?? null;
}
// --------------------------------------------------------------------------
// JIT loading — one-for-one with VegaColonyIntro.js's warmBytes/
// ensureColonyVideo pair, just muted (these clips carry no audio) and warming
// all three moods per species rather than one clip per planet type.
const warmed = new WeakMap();
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';
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 */ }
}
const inFlight = new WeakMap();
function pendingFor(scene) {
let set = inFlight.get(scene);
if (!set) { set = new Set(); inFlight.set(scene, set); }
return set;
}
function ensureOneAudienceVideo(scene, speciesId, mood, onDone) {
const key = audienceVideoKey(speciesId, mood);
const path = audienceVideoPath(scene, speciesId, mood);
if (path) warmBytes(scene, key, path);
if (hasAudienceVideo(scene, speciesId, mood) || !path) { onDone?.(); return; }
const pending = pendingFor(scene);
const doneEvent = `filecomplete-video-${key}`;
if (onDone) {
let onLoad;
let onError;
const settle = () => {
scene.load.off(doneEvent, onLoad);
scene.load.off(Phaser.Loader.Events.FILE_LOAD_ERROR, onError);
onDone();
};
onLoad = () => settle();
onError = (file) => { if (file?.key === key) settle(); };
scene.load.once(doneEvent, onLoad);
scene.load.on(Phaser.Loader.Events.FILE_LOAD_ERROR, onError);
}
if (pending.has(key)) return;
pending.add(key);
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);
scene.load.video(key, path, true); // noAudio: true — these clips are silent
if (!scene.load.isLoading()) scene.load.start();
}
// --------------------------------------------------------------------------
// The mood visual: three pre-built Video objects (built lazily on first use,
// per mood), hide+pause on swap, never destroy+recreate — safe and cheap per
// the verified Phaser 3.90 fact this game already relies on elsewhere (every
// Video owns one HTMLVideoElement; duplicates cost only decode time). A
// species with no dedicated clips at all gets one shared, non-swapping
// fallback instead of three redundant copies of the same portrait.
function buildLetterboxedFallback(scene, rules, art, speciesId, x, y, w, h) {
const container = scene.add.container(x, y);
const spec = rules.species[speciesId];
const barColor = Phaser.Display.Color.HexStringToColor(spec.color).color;
container.add(scene.add.rectangle(0, 0, w, h, barColor, 0.22));
container.add(makeSpeciesPortrait(scene, rules, art, speciesId, 0, 0, h));
return container;
}
// `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").
//
// 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) {
// Whether this species HAS clips at all is a property of the artwork
// manifest, known synchronously — it must not be read off `hasAudienceVideo`
// (whether bytes happen to be loaded already), or the very first audience
// this session would always read as clip-less and get stuck on the
// placeholder forever, having never asked the loader for anything.
const anyClip = MOODS.some((m) => !!audienceVideoPath(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. 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; }, replay() {}, destroy() {},
};
}
const built = {}; // mood -> { obj, ended }
let current = null; // mood actually on screen right now
let wanted = null; // mood show() last asked for — may still be loading
let placeholder = null; // letterboxed stand-in, only ever needed before the FIRST clip lands
let destroyed = false;
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;
}
/** Swap the visible clip to `mood`, which must already be in the video cache. */
function showLoaded(mood) {
placeholder?.setVisible(false);
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);
}
return {
show(mood) {
if (wanted === mood) return;
wanted = mood;
if (hasAudienceVideo(scene, speciesId, mood)) { showLoaded(mood); return; }
// Not fetched yet. Whatever is already on screen keeps playing (that's
// the whole point of loading one mood at a time instead of all three up
// front) — except the very first clip this species ever shows, which
// has nothing to keep on screen, so it gets a placeholder instead of a
// blank frame.
if (!current) {
if (!placeholder) {
placeholder = buildLetterboxedFallback(scene, rules, art, speciesId, x, y, w, h);
parent.add(placeholder);
}
placeholder.setVisible(true);
}
ensureOneAudienceVideo(scene, speciesId, mood, () => {
// Superseded by a later show() call, or the screen is gone — either
// way this fetch's result is no longer wanted.
if (destroyed || wanted !== mood) return;
if (hasAudienceVideo(scene, speciesId, mood)) showLoaded(mood);
});
},
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);
},
// Called from openAudienceScreen's close(), before root.destroy() tears
// down whatever WAS built — stops a fetch that lands after the screen is
// gone from reaching into a destroyed container.
destroy() { destroyed = true; },
};
}
/** Corner ticks — matches modalShell's/VegaColonyIntro's HUD-read-out look. */
function cornerTicks(scene, x, y, w, h, colour, len = 20) {
const g = scene.add.graphics();
g.lineStyle(2.5, colour, 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 VIDEO_X = 70;
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;
// Shortened from 620 — the button area below was already tight (2 treaty
// rows + the CONVERSATION lore row, which does happen at happy mood, ran
// past the 1080-tall canvas) and the new INTELLIGENCE row made it worse.
// The chat log is a scrollable masked container regardless of height, so
// this only means slightly more scrolling on long conversations, not lost
// history.
const CHAT_H = 460;
const BTN_Y = CHAT_Y + CHAT_H + 55;
// Widened from 190 — as close to CHAT_W's 3-per-row ceiling as BTN_GAP allows
// (3*200 + 2*16 = 632, just inside CHAT_W's 640) so longer labels (the
// CONVERSATION topics especially) need less help from Button's own
// shrink-to-fit before they read comfortably.
const BTN_W = 200;
const BTN_H = 60;
const BTN_GAP = 16;
/**
* Full-screen Audience interface with `otherIdx`, played from `me`'s
* (always the human's) point of view.
*/
export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClose, onChanged) {
const other = state.empires[otherIdx];
const spec = rules.species[other.speciesId];
const speciesId = other.speciesId;
const chat = CHAT[speciesId];
const vars = { you: state.empires[me].name, me: other.name };
const speciesColor = Phaser.Display.Color.HexStringToColor(spec.color).color;
// Declared up front — pushChat()/say() both guard on it, and pushChat() is
// called synchronously below (the opening dialogue) well before close()'s
// own declaration used to sit.
let closed = false;
const root = scene.add.container(0, 0).setDepth(D.modal).setAlpha(0);
root.add(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x00060e, 0.94)
.setOrigin(0, 0).setInteractive());
// Ambient background: a soft gradient tinted to the species' own colour.
const bgTop = Phaser.Display.Color.HexStringToColor(spec.color).darken(80).color;
const bgBottom = Phaser.Display.Color.HexStringToColor(spec.color).darken(60).color;
const bgG = scene.add.graphics();
bgG.fillGradientStyle(bgTop, bgTop, bgBottom, bgBottom, 1);
bgG.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
root.add(bgG);
// --- header
root.add(scene.add.text(VIDEO_X, 44, spec.name.toUpperCase(), {
fontFamily: FONT, fontSize: '46px', color: spec.color,
}));
const statusTxt = scene.add.text(VIDEO_X, 100, '', {
fontFamily: FONT, fontSize: '18px', color: '#9fb6cc',
});
root.add(statusTxt);
const closeBtn = new Button(scene, GAME_WIDTH - 80, 60, '✕', () => {
playSound(scene, SFX.VEGA_CLOSE);
close();
}, { width: 56, height: 48, variant: 'ghost' });
root.add(closeBtn);
function statusLine() {
const treaty = state.empires[me].treaties[otherIdx] ?? 'none';
const att = attitudeOf(state, otherIdx, me);
const tradeSuffix = state.empires[me].tradeAgreements[otherIdx] ? ' · Trade Agreement' : '';
statusTxt.setText(
`${treaty === 'war' ? 'AT WAR' : treaty.toUpperCase()} · attitude ${att} (${moodOf(att)})${tradeSuffix}`,
);
}
statusLine();
// --- video frame
const videoCx = VIDEO_X + VIDEO_W / 2;
const videoCy = VIDEO_Y + VIDEO_H / 2;
const frame = scene.add.container(0, 0).setAlpha(0).setScale(0.95);
root.add(frame);
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));
// --- 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' });
function pulseGlow(improved) {
const colour = improved ? 0x6fe08a : 0xe0616f;
glow.clear();
glow.lineStyle(6, colour, 1);
glow.strokeRect(VIDEO_X - 8, VIDEO_Y - 8, VIDEO_W + 16, VIDEO_H + 16);
glow.setAlpha(1);
scene.tweens.add({ targets: glow, alpha: 0, duration: 900, ease: 'Cubic.easeIn' });
}
// --- chat log (adapted from Civilization's openDiplomacyScreen)
const chatBg = scene.add.rectangle(CHAT_X + CHAT_W / 2, CHAT_Y + CHAT_H / 2, CHAT_W, CHAT_H, 0x000000, 0.55)
.setStrokeStyle(2, ACCENT, 0.4);
root.add(chatBg);
const msgContainer = scene.add.container(0, 0);
root.add(msgContainer);
const chatMaskG = scene.make.graphics({ x: 0, y: 0, add: false });
chatMaskG.fillStyle(0xffffff);
chatMaskG.fillRect(CHAT_X, CHAT_Y, CHAT_W, CHAT_H);
msgContainer.setMask(chatMaskG.createGeometryMask());
let msgY = CHAT_Y + 16;
let scrollUp = 0;
let overflow = 0;
const typeQueue = [];
let typing = null;
let indicator = null;
function historyFor() {
state.chatLog ??= {};
return (state.chatLog[otherIdx] ??= []);
}
function applyScroll() {
overflow = Math.max(0, msgY - (CHAT_Y + CHAT_H - 16));
scrollUp = Math.min(Math.max(scrollUp, 0), overflow);
msgContainer.y = -overflow + scrollUp;
}
chatBg.setInteractive();
chatBg.on('wheel', (pointer, dx, dy) => { scrollUp -= dy * 0.5; applyScroll(); });
function addChatText(who, text) {
const isPlayer = who === 'p';
const txt = scene.add.text(
isPlayer ? CHAT_X + CHAT_W - 16 : CHAT_X + 16, msgY, text, {
fontFamily: FONT, fontSize: '18px',
color: isPlayer ? state.empires[me].color : spec.color,
align: isPlayer ? 'right' : 'left',
wordWrap: { width: CHAT_W - 60 }, lineSpacing: 4,
},
).setOrigin(isPlayer ? 1 : 0, 0);
msgY += txt.height + 16;
msgContainer.add(txt);
applyScroll();
return txt;
}
function pumpTypeQueue() {
if (typing || !typeQueue.length) return;
// Capture this entry locally rather than reading the shared `typing`
// variable inside the callback: closing the screen (or a race between
// this timer's own completion and stopTyping() elsewhere) can null
// `typing` out from under an already-scheduled callback, which crashed
// on `typing.full` when the stale callback still fired.
const entry = typeQueue.shift();
typing = entry;
entry.timer = scene.time.addEvent({
delay: 16,
loop: true,
callback: () => {
if (entry.done) return;
entry.i = Math.min(entry.full.length, entry.i + 2);
entry.txt.setText(entry.full.slice(0, entry.i));
if (entry.i >= entry.full.length) {
entry.done = true;
entry.timer.remove();
if (typing === entry) typing = null;
pumpTypeQueue();
}
},
});
}
function stopTyping() {
if (typing) { typing.done = true; typing.timer?.remove(); }
typing = null;
typeQueue.length = 0;
}
function pushChat(who, text) {
if (!text || closed) return;
const hist = historyFor();
hist.push({ who, text });
if (hist.length > 50) hist.splice(0, hist.length - 50);
scrollUp = 0;
const txt = addChatText(who, text);
txt.setText('');
typeQueue.push({ txt, full: text, i: 0 });
pumpTypeQueue();
}
/** A brief "···" beat before a species line lands — never for the player's own. */
function say(text) {
if (!text || closed) return;
indicator?.destroy();
indicator = scene.add.text(CHAT_X + 16, msgY, '···', { fontFamily: FONT, fontSize: '20px', color: spec.color });
msgContainer.add(indicator);
scene.tweens.add({ targets: indicator, alpha: 0.25, duration: 260, yoyo: true, repeat: -1 });
scene.time.delayedCall(380, () => {
indicator?.destroy();
indicator = null;
pushChat('o', text);
});
}
function renderChatHistory() {
stopTyping();
msgContainer.removeAll(true);
msgY = CHAT_Y + 16;
scrollUp = 0;
for (const m of historyFor()) addChatText(m.who, m.text);
}
// --- opening dialogue
const history = historyFor();
const isFirstEver = history.length === 0;
if (isFirstEver) {
pushChat('o', pickLine(chat.firstContact, vars));
enqueueSpeech(speciesSpeechClip(speciesId), null, { force: true });
} else {
renderChatHistory();
pushChat('o', pickLine(chat.opener[videoMood(attitudeOf(state, otherIdx, me))], vars));
}
const openingOffer = state.empires[me].pendingOffers[otherIdx];
if (openingOffer) {
if (openingOffer.kind === 'peaceRequest') {
pushChat('o', pickLine(chat.offerPeaceRequestOpener, { ...vars, third: state.empires[openingOffer.thirdParty]?.name }));
} else {
const key = openingOffer.kind === 'peace' ? chat.offerPeaceOpener
: openingOffer.kind === 'alliance' ? chat.offerAllianceOpener
: chat.offerTradeAgreementOpener;
pushChat('o', pickLine(key, vars));
}
}
// A fresh (this-turn-or-last) fleet complaint gets its own opener line,
// same slot as the pending-offer injection above — this is what makes the
// force-opened Audience screen actually voice the complaint that triggered it.
const recentComplaint = state.events.some((ev) => ev.type === 'fleetComplaint'
&& ev.empire === otherIdx && ev.other === me && ev.first && ev.turn >= state.turn - 1);
if (recentComplaint) pushChat('o', pickLine(chat.fleetComplaintOpener, vars));
moodVisual.show(videoMood(attitudeOf(state, otherIdx, me)));
// --- action buttons
const buttonRow = scene.add.container(0, 0);
root.add(buttonRow);
function afterAction() {
statusLine();
const att = attitudeOf(state, otherIdx, me);
const newMood = videoMood(att);
if (newMood !== moodVisual.current) {
const improved = MOODS.indexOf(newMood) > MOODS.indexOf(moodVisual.current ?? 'neutral');
moodVisual.show(newMood);
pulseGlow(improved);
}
renderButtons();
onChanged?.();
}
function doPending(accept) {
const offer = state.empires[me].pendingOffers[otherIdx];
const isPeaceRequest = offer?.kind === 'peaceRequest';
const thirdVars = isPeaceRequest ? { ...vars, third: state.empires[offer.thirdParty]?.name } : vars;
pushChat('p', pickLine(
isPeaceRequest ? (accept ? chat.acceptPeaceRequest : chat.rejectPeaceRequest)
: (accept ? chat.acceptOffer : chat.rejectOffer),
thirdVars,
));
respondToOffer(rules, state, me, otherIdx, accept);
if (isPeaceRequest) {
// respondToOffer's own true/false means "was there an offer to
// answer," not "did the treaty succeed" (existing callers depend on
// that contract) — the actual outcome is read back off the treaty
// state instead, only when accepted.
const madePeace = accept && !atWar(state, me, offer.thirdParty);
say(pickLine(
!accept ? chat.afterPeaceRequestRejected
: madePeace ? chat.afterPeaceRequestAcceptedSuccess : chat.afterPeaceRequestAcceptedTried,
thirdVars,
));
} else {
say(pickLine(accept ? chat.afterAccepted : chat.afterRejected, thirdVars));
}
afterAction();
}
function doPropose(kind) {
const proposeKey = kind === 'peace' ? chat.proposePeace
: kind === 'alliance' ? chat.proposeAlliance
: chat.proposeTradeAgreement;
pushChat('p', pickLine(proposeKey, vars));
const accepted = proposeTreaty(rules, state, me, otherIdx, kind);
const key = kind === 'peace'
? (accepted ? chat.replyPeaceAccept : chat.replyPeaceReject)
: kind === 'alliance'
? (accepted ? chat.replyAllianceAccept : chat.replyAllianceReject)
: (accepted ? chat.replyTradeAgreementAccept : chat.replyTradeAgreementReject);
say(pickLine(key, vars));
afterAction();
}
function doGift(tierId) {
pushChat('p', pickLine(chat.giftOffer, vars));
const beforeMood = videoMood(attitudeOf(state, otherIdx, me));
giveGift(rules, state, me, otherIdx, tierId);
const afterMood = videoMood(attitudeOf(state, otherIdx, me));
const moodImproved = MOODS.indexOf(afterMood) > MOODS.indexOf(beforeMood);
say(pickLine(moodImproved ? chat.replyGiftWarm : chat.replyGiftNeutral, vars));
afterAction();
}
// The two responses to a fresh fleet complaint (see the opener injection
// above). Both are conversational only in v1 — the Audience screen has no
// reach into fleet selection/pathing to actually issue a recall order, so
// "withdraw" is a promise the player has to follow through on manually by
// moving the fleet; "defy" applies its own small extra sting on top of the
// ongoing per-turn escalation checkFleetIntrusions already handles.
let complaintPending = recentComplaint;
function doComplaintWithdraw() {
pushChat('p', pickLine(chat.acknowledgeComplaintWithdraw, vars));
complaintPending = false;
say(pickLine(chat.afterComplaintWithdrawPromise, vars));
afterAction();
}
function doComplaintDefy() {
pushChat('p', pickLine(chat.acknowledgeComplaintDefy, vars));
complaintPending = false;
state.empires[otherIdx].attitude[me] = Math.max(-100, (state.empires[otherIdx].attitude[me] ?? 0) - 5);
say(pickLine(chat.afterComplaintDefy, vars));
afterAction();
}
function doDeclareWar() {
pushChat('p', pickLine(chat.declareWar, vars));
declareWar(rules, state, me, otherIdx);
say(pickLine(chat.replyWarDeclared, vars));
afterAction();
}
// Asking `otherIdx` to end a war with a THIRD empire — see
// VegaDiplomacy.js's requestPeace for why this is kept separate from
// proposeTreaty/wouldAccept (it's a favor about someone else's war, not a
// treaty between the two people actually talking).
function doRequestPeace(thirdPartyIdx) {
const reqVars = { ...vars, third: state.empires[thirdPartyIdx].name };
pushChat('p', pickLine(chat.requestPeaceThird, reqVars));
const accepted = requestPeace(rules, state, me, otherIdx, thirdPartyIdx);
say(pickLine(accepted ? chat.replyRequestPeaceAccept : chat.replyRequestPeaceReject, reqVars));
afterAction();
}
function doTechTrade(giveId, wantId) {
pushChat('p', pickLine(chat.tradeTechOffer, { ...vars, tech: rules.techs[wantId].name }));
const ok = tradeTech(rules, state, me, otherIdx, giveId, wantId);
if (ok) {
grantTech(rules, state, otherIdx, giveId, 'trade');
grantTech(rules, state, me, wantId, 'trade');
}
say(pickLine(ok ? chat.replyTechAccept : chat.replyTechReject, vars));
afterAction();
}
// A standing policy, not a negotiated exchange — no chat line, no
// attitude change, no mood pulse. Just re-render so the active toggle
// highlights immediately. 'off' pulls this empire out of runEspionage's
// target pool entirely (VegaLogic.js); 'steal'/'sabotage' only change
// what a successful hit against them does.
function doSetMission(mission) {
if (mission === 'steal') delete state.empires[me].espionageMission[otherIdx];
else state.empires[me].espionageMission[otherIdx] = mission;
renderButtons();
}
// --- pure-flavor conversation topics: no treaty/attitude effect, so no
// afterAction() — pushChat()/say() already do everything the chat log
// needs, and nothing about the mood, status line or button set changes.
function doAskPeople() {
pushChat('p', pickLine(chat.askPeople, vars));
say(pickLine(chat.peopleLore, vars));
}
function doAskHomeworld() {
pushChat('p', pickLine(chat.askHomeworld, vars));
say(pickLine(chat.homeworldLore, vars));
}
function doAskStory() {
pushChat('p', pickLine(chat.askStory, vars));
say(pickLine(chat.storyLore, vars));
}
// `labelFn` defaults to the tech-name lookup every existing caller wants;
// doRequestWhichWar (below) is the one caller that isn't picking a tech,
// so it passes an empire-name lookup instead.
function pickFromList(title, ids, onPick, labelFn = (id) => rules.techs[id].name) {
const box = scene.add.container(0, 0);
root.add(box);
const bx = GAME_WIDTH / 2;
const by = GAME_HEIGHT / 2;
const h = Math.min(520, 100 + ids.length * 34);
box.add(scene.add.rectangle(bx, by, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.001).setOrigin(0.5).setInteractive());
box.add(scene.add.rectangle(bx, by, 420, h, PANEL, 0.98).setStrokeStyle(2, ACCENT));
box.add(scene.add.text(bx, by - h / 2 + 26, title, { fontFamily: FONT, fontSize: '19px', color: '#ffd88a' }).setOrigin(0.5));
ids.slice(0, 12).forEach((id, i) => {
const t = scene.add.text(bx, by - h / 2 + 66 + i * 34, labelFn(id), {
fontFamily: FONT, fontSize: '16px', color: '#e8f4ff',
}).setOrigin(0.5).setInteractive({ useHandCursor: true });
t.on('pointerover', () => t.setColor('#ffd88a'));
t.on('pointerout', () => t.setColor('#e8f4ff'));
t.on('pointerdown', () => { box.destroy(true); onPick(id); });
box.add(t);
});
const cancel = new Button(scene, bx, by + h / 2 - 34, 'Cancel', uiClick(scene, () => box.destroy(true)),
{ width: 160, height: 40, fontSize: 16, variant: 'ghost' });
box.add(cancel);
}
/** Opens the "which war?" picker for Demand Peace, listing every empire
* `otherIdx` is at war with (that a request could ever succeed against). */
function openWarPicker(warTargets) {
pickFromList('END WHICH WAR?', warTargets, doRequestPeace, (idx) => state.empires[idx].name);
}
function openTechPicker(giveOptions, wantOptions) {
pickFromList('OFFER WHICH TECHNOLOGY?', giveOptions, (giveId) => {
pickFromList('ASK FOR WHICH TECHNOLOGY?', wantOptions, (wantId) => doTechTrade(giveId, wantId));
});
}
/** A small fixed 3-tier picker — distinct from pickFromList since that one
* assumes rules.techs[id].name labels, not a tier table. */
function openGiftPicker() {
const tiers = rules.diplomacy.gift.tiers.filter((t) => t.bc <= state.empires[me].bc);
if (!tiers.length) return;
const box = scene.add.container(0, 0);
root.add(box);
const bx = GAME_WIDTH / 2;
const by = GAME_HEIGHT / 2;
const h = Math.min(420, 120 + tiers.length * 46);
box.add(scene.add.rectangle(bx, by, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.001).setOrigin(0.5).setInteractive());
box.add(scene.add.rectangle(bx, by, 420, h, PANEL, 0.98).setStrokeStyle(2, ACCENT));
box.add(scene.add.text(bx, by - h / 2 + 26, 'SEND WHICH GIFT?', {
fontFamily: FONT, fontSize: '19px', color: '#ffd88a',
}).setOrigin(0.5));
tiers.forEach((tier, i) => {
const t = scene.add.text(bx, by - h / 2 + 66 + i * 46, `${tier.name}${tier.bc} BC`, {
fontFamily: FONT, fontSize: '16px', color: '#e8f4ff',
}).setOrigin(0.5).setInteractive({ useHandCursor: true });
t.on('pointerover', () => t.setColor('#ffd88a'));
t.on('pointerout', () => t.setColor('#e8f4ff'));
t.on('pointerdown', () => { box.destroy(true); doGift(tier.id); });
box.add(t);
});
const cancel = new Button(scene, bx, by + h / 2 - 34, 'Cancel', uiClick(scene, () => box.destroy(true)),
{ width: 160, height: 40, fontSize: 16, variant: 'ghost' });
box.add(cancel);
}
/** Lays out one row (or wrapped rows) of [label, fn] pairs at `y`, 3 per row. Returns the row count. */
function layoutRow(list, y, extraOpts = {}) {
const perRow = 3;
list.forEach(([label, fn, itemOpts], i) => {
const col = i % perRow;
const row = Math.floor(i / perRow);
const x = CHAT_X + col * (BTN_W + BTN_GAP) + BTN_W / 2;
const yy = y + row * (BTN_H + BTN_GAP) + BTN_H / 2;
buttonRow.add(new Button(scene, x, yy, label, uiClick(scene, fn), { width: BTN_W, height: BTN_H, fontSize: 16, ...extraOpts, ...itemOpts }));
});
return Math.ceil(list.length / perRow) || 0;
}
function renderButtons() {
buttonRow.removeAll(true);
const offer = state.empires[me].pendingOffers[otherIdx];
const giftAffordable = state.empires[me].bc >= rules.diplomacy.gift.tiers[0].bc;
const items = [];
if (complaintPending) {
items.push(['We\'ll Withdraw', doComplaintWithdraw]);
items.push(['We\'re Staying', doComplaintDefy]);
}
if (offer) {
items.push(['Accept', () => doPending(true), { scheme: 'green' }]);
items.push(['Reject', () => doPending(false), { scheme: 'red' }]);
} else if (!canNegotiate(rules, state, me, otherIdx)) {
if (!atWar(state, me, otherIdx)) items.push(['Declare War', doDeclareWar, { scheme: 'red' }]);
} else if (atWar(state, me, otherIdx)) {
items.push(['Sue for Peace', () => doPropose('peace')]);
if (giftAffordable) items.push(['Send Gift', openGiftPicker]);
} else {
items.push(['Declare War', doDeclareWar, { scheme: 'red' }]);
if (state.empires[me].treaties[otherIdx] !== 'alliance') {
items.push(['Propose Alliance', () => doPropose('alliance')]);
}
if (!state.empires[me].tradeAgreements[otherIdx]) {
items.push(['Propose Trade Agreement', () => doPropose('tradeAgreement')]);
}
const give = Object.keys(state.empires[me].known).filter((t) => !state.empires[otherIdx].known[t]);
const want = Object.keys(state.empires[otherIdx].known).filter((t) => !state.empires[me].known[t]);
if (give.length && want.length) items.push(['Trade Tech', () => openTechPicker(give, want)]);
if (giftAffordable) items.push(['Send Gift', openGiftPicker]);
// Wars otherIdx is fighting against a THIRD empire — one the request
// could ever actually reach (canNegotiate both ways), never our own
// war with them (that's Sue for Peace, above).
const theirWars = state.empires
.filter((o) => o.alive && o.idx !== me && o.idx !== otherIdx
&& atWar(state, otherIdx, o.idx) && canNegotiate(rules, state, otherIdx, o.idx))
.map((o) => o.idx);
if (theirWars.length) items.push(['Demand Peace', () => openWarPicker(theirWars)]);
}
const treatyRows = layoutRow(items, BTN_Y);
let nextY = BTN_Y + treatyRows * (BTN_H + BTN_GAP);
// INTELLIGENCE — a standing mission choice, not a one-shot proposal, so
// it renders as a highlighted 3-way toggle rather than action buttons.
// Available whenever you could negotiate with them at all and they're
// not an ally (mirroring runEspionage's own targeting exclusions);
// unlike the treaty items above it stays available even at war.
if (canNegotiate(rules, state, me, otherIdx) && state.empires[me].treaties[otherIdx] !== 'alliance') {
const current = state.empires[me].espionageMission[otherIdx] ?? 'steal';
const labelY = nextY + 4;
buttonRow.add(scene.add.text(CHAT_X, labelY, 'INTELLIGENCE', {
fontFamily: FONT, fontSize: '13px', color: '#6f8aa8', letterSpacing: 2,
}));
// Not layoutRow: the active option needs its own styling (solid
// accent vs. ghost) rather than the one shared extraOpts every other
// row applies to its whole list.
const rowY = labelY + 22 + BTN_H / 2;
[['off', 'Off'], ['steal', 'Steal Tech'], ['sabotage', 'Sabotage']].forEach(([id, label], i) => {
const active = current === id;
const x = CHAT_X + i * (BTN_W + BTN_GAP) + BTN_W / 2;
buttonRow.add(new Button(scene, x, rowY, label, uiClick(scene, () => doSetMission(id)), {
width: BTN_W, height: BTN_H, fontSize: 16,
variant: active ? 'solid' : 'ghost',
bg: active ? ACCENT : undefined,
textColor: active ? '#04121e' : undefined,
}));
});
nextY = labelY + 22 + (BTN_H + BTN_GAP);
}
// Lore/conversation topics — pure flavor, appended UNCONDITIONALLY after
// the treaty-branch logic above, gated purely on mood tier (never on
// offer/atWar/canNegotiate state).
const mood = videoMood(attitudeOf(state, otherIdx, me));
const loreItems = [];
// Kept short on purpose — sitting under the CONVERSATION label already
// frames these as topics, so "Their People" reads the same as "Tell Me
// About Your People" would, without fighting BTN_W for room.
if (mood !== 'angry') loreItems.push(['Their People', doAskPeople]);
if (mood === 'happy') {
loreItems.push(['Their Homeworld', doAskHomeworld]);
loreItems.push(['A Story', doAskStory]);
}
if (loreItems.length) {
const labelY = nextY + 4;
buttonRow.add(scene.add.text(CHAT_X, labelY, 'CONVERSATION', {
fontFamily: FONT, fontSize: '13px', color: '#6f8aa8', letterSpacing: 2,
}));
layoutRow(loreItems, labelY + 22, { variant: 'ghost' });
}
}
renderButtons();
// --- close
function close() {
if (closed) return;
closed = true;
scene.tweens.add({
targets: root,
alpha: 0,
duration: 200,
ease: 'Cubic.easeIn',
onComplete: () => {
stopTyping();
indicator?.destroy();
chatMaskG.destroy();
// Stops a still-in-flight fetch from swapping a clip into a container
// that's about to be destroyed out from under it.
moodVisual.destroy();
// Destroys the mood video(s) with it, which is what stops their
// decoders — same contract as VegaColonyIntro's root.destroy().
root.destroy();
onClose?.();
},
});
}
scene.tweens.add({ targets: root, alpha: 1, duration: 220, ease: 'Cubic.easeOut' });
return { close };
}