908 lines
39 KiB
JavaScript
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 };
|
|
}
|