// 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, } 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) { 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) { pushChat('p', pickLine(accept ? chat.acceptOffer : chat.rejectOffer, vars)); respondToOffer(rules, state, me, otherIdx, accept); say(pickLine(accept ? chat.afterAccepted : chat.afterRejected, vars)); 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(); } 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)); } function pickFromList(title, ids, onPick) { 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, rules.techs[id].name, { 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); } 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]); } 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 }; }