feat(masterofvega): add Colony Focus autopilot and diplomacy audio

- Introduce Colony Focus: a per-colony autopilot that auto-queues one
  building/ship per turn when the queue is empty. Supports seven modes
  (manual, improvement, research, fleet, growth, trade, defense) with
  cost-aware affordability checks matching AI behaviour.
- Add Allocation Focus: one-time slider presets (default, research,
  growth, production, military) that bulk-overwrite channel allocations.
- Refactor colony view flyout into a shared drawer supporting queue,
  colony focus, and allocation focus modes with in-place swapping.
- Replace "Built here" text with hoverable building icon row and
  tooltips on the colony screen.
- Add per-species audience music tracks and diplomacy music ducking when
  seeking/planning an audience.
- Add sound effects for star selection, zoom in/out, and colony view
  opening.
- Back-fill `focus: 'manual'` on old saves for forwards compatibility.
- Add verification tests exercising each focus mode in isolation.
This commit is contained in:
Brian Fertig 2026-08-08 19:34:12 -06:00
parent 8cb6988ded
commit b061f8eab5
26 changed files with 495 additions and 59 deletions

BIN
assets/fx/vega-star.mp3 Normal file

Binary file not shown.

Binary file not shown.

BIN
assets/fx/vega-zoomin.mp3 Normal file

Binary file not shown.

BIN
assets/fx/vega-zoomout.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -51,17 +51,57 @@
} }
] }, ] },
"diplomacy": { "diplomacy": {
"default": { "tracks": [] }, "default": { "tracks": [{
"file": "vega/audience-human.mp3",
"artist": "Human",
"title": "Meet N Greet"
}] },
"bySpecies": { "bySpecies": {
"kestrelli": { "tracks": [] }, "kestrelli": { "tracks": [{
"ursaal": { "tracks": [] }, "file": "vega/audience-kestrelli.mp3",
"umbrix": { "tracks": [] }, "artist": "Kestrelli",
"kkrix": { "tracks": [] }, "title": "Meet N Greet"
"mekhan": { "tracks": [] }, }] },
"rrashaa": { "tracks": [] }, "ursaal": { "tracks": [{
"cerebrai": { "tracks": [] }, "file": "vega/audience-ursaal.mp3",
"ssakar": { "tracks": [] }, "artist": "Ursaal",
"lithox": { "tracks": [] } "title": "Meet N Greet"
}] },
"umbrix": { "tracks": [{
"file": "vega/audience-umbrix.mp3",
"artist": "Umbrix",
"title": "Meet N Greet"
}] },
"kkrix": { "tracks": [{
"file": "vega/audience-kkrix.mp3",
"artist": "Kkrix",
"title": "Meet N Greet"
}] },
"mekhan": { "tracks": [{
"file": "vega/audience-mekhan.mp3",
"artist": "Mekhan",
"title": "Meet N Greet"
}] },
"rrashaa": { "tracks": [{
"file": "vega/audience-rrasha.mp3",
"artist": "Ursaal",
"title": "Meet N Greet"
}] },
"cerebrai": { "tracks": [{
"file": "vega/audience-cerebrai.mp3",
"artist": "Cerebrai",
"title": "Meet N Greet"
}] },
"ssakar": { "tracks": [{
"file": "vega/audience-ssakar.mp3",
"artist": "Ssakar",
"title": "Meet N Greet"
}] },
"lithox": { "tracks": [{
"file": "vega/audience-lithox.mp3",
"artist": "Lithox",
"title": "Meet N Greet"
}] }
} }
} }
} }

View File

@ -835,10 +835,19 @@ export default class MasterOfVegaGame extends Phaser.Scene {
const canSeek = canNegotiate(this.rules, this.state, this.state.humanIndex, otherIdx); const canSeek = canNegotiate(this.rules, this.state, this.state.humanIndex, otherIdx);
const btn = new Button(this, W / 2 - 78, 0, 'Seek Audience', this.uiClick(() => { const btn = new Button(this, W / 2 - 78, 0, 'Seek Audience', this.uiClick(() => {
this.openModal((done) => openAudienceScreen( this.openModal((done) => {
this, this.rules, this.state, this.state.humanIndex, otherIdx, this.art, done, // Same per-race ducking runAudienceQueue does for an AI-initiated
() => this.refreshAll(), // audience — only drop back to peace on close if nothing else is
)); // queued up behind this one.
this.music?.setDiplomacy(other.speciesId);
openAudienceScreen(
this, this.rules, this.state, this.state.humanIndex, otherIdx, this.art, () => {
if (!this.pendingAudiences.length) this.music?.setDiplomacy(null);
done();
},
() => this.refreshAll(),
);
});
}), { width: 140, height: 40, fontSize: 14 }); }), { width: 140, height: 40, fontSize: 14 });
if (!canSeek) btn.setEnabled(false); if (!canSeek) btn.setEnabled(false);
card.add(btn); card.add(btn);
@ -918,6 +927,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
return; return;
} }
playSound(this, SFX.VEGA_STAR);
this.map.setSelectedStar(idx); this.map.setSelectedStar(idx);
this.selectedFleet = null; this.selectedFleet = null;
this.panel.showStar(idx); this.panel.showStar(idx);
@ -1054,6 +1064,11 @@ export default class MasterOfVegaGame extends Phaser.Scene {
if (this.state.over) { this.finishGame(); return; } if (this.state.over) { this.finishGame(); return; }
if (this.state.current === this.state.humanIndex) { if (this.state.current === this.state.humanIndex) {
Logic.beginEmpireTurn(this.rules, this.state, this.state.humanIndex); Logic.beginEmpireTurn(this.rules, this.state, this.state.humanIndex);
// Colony Focus autopilot: same relative placement as runAITurn is for
// an AI empire below — right after this turn's production has been
// spent, so whatever it queues here is what NEXT turn's
// beginEmpireTurn will consume.
Logic.autoQueueColonies(this.rules, this.state, this.state.humanIndex);
// Contact discovered during an AI empire's turn surfaces the moment // Contact discovered during an AI empire's turn surfaces the moment
// control returns to the human — before the routine turn report, so // control returns to the human — before the routine turn report, so
// "contact made" always reads as the bigger beat. // "contact made" always reads as the bigger beat.

View File

@ -34,11 +34,13 @@ import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js'; import { Button } from '../../ui/Button.js';
import { Tooltip } from '../../ui/Tooltip.js';
import { FONT, D, ORBIT, slider, uiClick } from './VegaScreens.js'; import { FONT, D, ORBIT, slider, uiClick } from './VegaScreens.js';
import { playSound, SFX } from '../../ui/Sounds.js'; import { playSound, SFX } from '../../ui/Sounds.js';
import { worldBackground, buildingFrame } from './VegaArt.js'; import { worldBackground, buildingFrame } from './VegaArt.js';
import { createShipMediaPool, makeShipIcon } from './VegaShipMedia.js'; import { createShipMediaPool, makeShipIcon } from './VegaShipMedia.js';
import { openShipDetail } from './VegaShipDetail.js'; import { openShipDetail } from './VegaShipDetail.js';
import { describeBuildingTooltip } from './VegaTooltips.js';
import { import {
CHANNELS, colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories, CHANNELS, colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories,
colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany, colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany,
@ -58,11 +60,43 @@ const PAD = 22;
const FLY_W = 760; const FLY_W = 760;
const FLY_X = PANEL_X - FLY_W - 16; const FLY_X = PANEL_X - FLY_W - 16;
// Built-building icon row, bottom-left over the world art. Kept inside
// roughly x=48..600 so it never reaches the flyout's column (FLY_X=640) even
// with every building in the game built here, wrapping upward into a second
// row rather than running off the right edge.
const ICON = 52;
const ICON_GAP = 10;
const ICON_ROW_X = 48;
const ICON_ROW_MAX_W = 552;
const ICON_ROW_BOTTOM = 50;
/** Matches the read-only allocation bars in the system view. */ /** Matches the read-only allocation bars in the system view. */
export const CHANNEL_COLOUR = { export const CHANNEL_COLOUR = {
ships: 0x6fc4ff, defense: 0xe08a8a, industry: 0xffd88a, ecology: 0x7fd8a0, research: 0xb89cff, ships: 0x6fc4ff, defense: 0xe08a8a, industry: 0xffd88a, ecology: 0x7fd8a0, research: 0xb89cff,
}; };
// Colony Focus — see autoQueueColonies (VegaLogic.js) for what each value
// actually does; this is just the picker's label/description copy.
const COLONY_FOCUS_OPTIONS = [
{ value: 'manual', label: 'Manual', desc: 'No automation — you queue everything yourself.' },
{ value: 'improvement', label: 'Colony Improvement', desc: 'Industry buildings first, then any other building not yet built.' },
{ value: 'research', label: 'Research Focus', desc: 'Only queues research buildings. Once none are left, the queue stays empty and construction spills into research.' },
{ value: 'fleet', label: 'Fleet Production', desc: 'Builds a diversified warship fleet, favouring whichever hull you have the fewest of at this system.' },
{ value: 'growth', label: 'Population Growth', desc: 'Queues buildings that raise your population ceiling or growth rate.' },
{ value: 'trade', label: 'Trade & Commerce', desc: 'Queues buildings that raise trade income.' },
{ value: 'defense', label: 'Homeworld Defense', desc: 'Queues planetary defense buildings.' },
];
// Allocation Focus — one-time slider presets (see buildAllocationFocusFlyout).
// Every row sums to 1.0.
const ALLOCATION_FOCUS_OPTIONS = [
{ key: 'default', label: 'Default', sliders: { ships: 0.20, defense: 0.10, industry: 0.40, ecology: 0.10, research: 0.20 } },
{ key: 'research', label: 'Research Focus', sliders: { ships: 0.10, defense: 0.05, industry: 0.20, ecology: 0.15, research: 0.50 } },
{ key: 'growth', label: 'Population Growth', sliders: { ships: 0.15, defense: 0.10, industry: 0.30, ecology: 0.35, research: 0.10 } },
{ key: 'production', label: 'Production', sliders: { ships: 0.20, defense: 0.05, industry: 0.55, ecology: 0.10, research: 0.10 } },
{ key: 'military', label: 'Military Buildup', sliders: { ships: 0.45, defense: 0.25, industry: 0.15, ecology: 0.10, research: 0.05 } },
];
export const etaText = (turns) => (Number.isFinite(turns) export const etaText = (turns) => (Number.isFinite(turns)
? `${turns} turn${turns === 1 ? '' : 's'}` ? `${turns} turn${turns === 1 ? '' : 's'}`
: 'stalled — no construction budget'); : 'stalled — no construction budget');
@ -161,6 +195,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
const emp = state.empires[colony.empireIdx]; const emp = state.empires[colony.empireIdx];
const root = scene.add.container(0, 0).setDepth(D.colony); const root = scene.add.container(0, 0).setDepth(D.colony);
const tooltip = new Tooltip(scene, { depth: D.colony + 5 });
// --- the world itself // --- the world itself
const bgKey = worldBackground(scene, planet.typeId); const bgKey = worldBackground(scene, planet.typeId);
@ -208,15 +243,18 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
// --- layers // --- layers
// //
// panelLayer sits at the origin FOREVER (see the header note on slider()). // panelLayer sits at the origin FOREVER (see the header note on slider()).
// flyLayer is the one that moves, and it holds no sliders. // flyLayer is the one that moves, and it holds no sliders. iconLayer sits
// over the world art at the bottom-left, well clear of both.
const flyLayer = scene.add.container(0, 0).setVisible(false); const flyLayer = scene.add.container(0, 0).setVisible(false);
root.add(flyLayer); root.add(flyLayer);
const panelLayer = scene.add.container(0, 0); const panelLayer = scene.add.container(0, 0);
root.add(panelLayer); root.add(panelLayer);
const iconLayer = scene.add.container(0, 0);
root.add(iconLayer);
const FLY_HIDDEN = PANEL_X - FLY_X; // parked exactly behind the panel const FLY_HIDDEN = PANEL_X - FLY_X; // parked exactly behind the panel
flyLayer.x = FLY_HIDDEN; flyLayer.x = FLY_HIDDEN;
let flyOpen = false; let flyMode = null; // null | 'queue' | 'colonyFocus' | 'allocFocus'
// Both of the flyout's lists can grow without limit — the queue as well as // Both of the flyout's lists can grow without limit — the queue as well as
// the catalogue — so both scroll and both have a mask to keep in step. // the catalogue — so both scroll and both have a mask to keep in step.
let scrollers = []; let scrollers = [];
@ -234,7 +272,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
pool.setPaused(true); pool.setPaused(true);
detail = openShipDetail(scene, rules, state, art, detail = openShipDetail(scene, rules, state, art,
{ empireIdx: colony.empireIdx, hullId }, { empireIdx: colony.empireIdx, hullId },
() => { detail = null; if (flyOpen) pool.setPaused(false); }); () => { detail = null; if (flyMode === 'queue') pool.setPaused(false); });
}; };
/** Take the pool out of whatever is about to be destroyed. */ /** Take the pool out of whatever is about to be destroyed. */
const detachPool = () => pool.layer.parentContainer?.remove(pool.layer); const detachPool = () => pool.layer.parentContainer?.remove(pool.layer);
@ -252,19 +290,48 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
// then runs against a corpse. // then runs against a corpse.
detachPool(); detachPool();
pool.destroy(); pool.destroy();
tooltip.destroy();
scene.input.keyboard?.off('keydown-ESC', onEsc); scene.input.keyboard?.off('keydown-ESC', onEsc);
root.destroy(); root.destroy();
onClose?.(); onClose?.();
} }
function onEsc() { function onEsc() {
if (flyOpen) { toggleFlyout(); return; } if (flyMode) { toggleFlyout(flyMode); return; }
close(); close();
} }
scene.input.keyboard?.on('keydown-ESC', onEsc); scene.input.keyboard?.on('keydown-ESC', onEsc);
// -------------------------------------------------------- built buildings
//
// A hoverable icon row over the world art, bottom-left — replaces the old
// "Built here" text line in the panel, which had no room to say what each
// building actually does. Rebuilt every time buildPanel() is, so it never
// drifts out of sync with what just finished.
function buildIconRow() {
iconLayer.removeAll(true);
tooltip.hide(); // the object under the cursor may be about to be destroyed
// colony.buildings also carries the synthetic 'starbase' completion-flag
// id (see VegaLogic.js's completeQueueItem) — it has no rules.buildings
// entry, so it is filtered out here the same way the old text line did.
const realBuildings = colony.buildings.filter((b) => rules.buildings[b]);
const perRow = Math.max(1, Math.floor((ICON_ROW_MAX_W + ICON_GAP) / (ICON + ICON_GAP)));
realBuildings.forEach((id, i) => {
const row = Math.floor(i / perRow);
const col = i % perRow;
const cx = ICON_ROW_X + col * (ICON + ICON_GAP) + ICON / 2;
const cy = GAME_HEIGHT - ICON_ROW_BOTTOM - ICON / 2 - row * (ICON + ICON_GAP);
const icon = scene.add.image(cx, cy, art.buildings, buildingFrame(rules, id))
.setDisplaySize(ICON, ICON)
.setInteractive({ useHandCursor: true });
iconLayer.add(icon);
tooltip.attachTo(icon, () => describeBuildingTooltip(rules, id));
});
}
// ------------------------------------------------------------- the panel // ------------------------------------------------------------- the panel
function buildPanel() { function buildPanel() {
buildIconRow();
panelLayer.removeAll(true); panelLayer.removeAll(true);
frame(scene, panelLayer, PANEL_X, PANEL_Y, PANEL_W, PANEL_H, 0.82); frame(scene, panelLayer, PANEL_X, PANEL_Y, PANEL_W, PANEL_H, 0.82);
@ -369,6 +436,18 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
+ 'A locked channel holds its share while the rest renormalise.', + 'A locked channel holds its share while the rest renormalise.',
13, '#6f8aa3', 4); 13, '#6f8aa3', 4);
// --- focus pickers, in normal vertical flow (not pinned like the pair below)
{
const half = (w - 12) / 2;
add(new Button(scene, x + half / 2, y + 24,
flyMode === 'colonyFocus' ? 'Close Colony Focus' : 'Colony Focus',
uiClick(scene, () => toggleFlyout('colonyFocus')), { width: half, height: 44, fontSize: 16 }));
add(new Button(scene, x + half * 1.5 + 12, y + 24,
flyMode === 'allocFocus' ? 'Close Allocation Focus' : 'Allocation Focus',
uiClick(scene, () => toggleFlyout('allocFocus')), { width: half, height: 44, fontSize: 16 }));
y += 60;
}
// The button is PINNED to the bottom of the column, and everything below // The button is PINNED to the bottom of the column, and everything below
// here is length-variable — a long queue, a colony with a dozen buildings. // here is length-variable — a long queue, a colony with a dozen buildings.
// So the two lists below give way rather than spilling past the frame, the // So the two lists below give way rather than spilling past the frame, the
@ -407,22 +486,6 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
} }
} }
// Lowest priority: it is reference, and the flyout's catalogue already
// hides anything already built here.
//
// `colony.buildings` also doubles as a completion flag for a finished Star
// Base (VegaLogic.js's completeQueueItem pushes the HULL id 'starbase' in
// there so the fuel-range and AI checks have a cheap `.includes()` test) —
// that id has no entry in `rules.buildings`, so it is filtered out here
// rather than crashing on `rules.buildings[b].name`. The Star Base itself
// still shows up in orbit like any other ship.
const realBuildings = colony.buildings.filter((b) => rules.buildings[b]);
if (realBuildings.length && room(60)) {
heading('Built here');
text(realBuildings.map((b) => rules.buildings[b].name).join(' · '),
13, '#ffd88a', 2, w);
}
y = buttonY; y = buttonY;
const sendBtn = add(new Button(scene, PANEL_X + PANEL_W / 2, y + 22, const sendBtn = add(new Button(scene, PANEL_X + PANEL_W / 2, y + 22,
'Send Population', uiClick(scene, openTransportPicker), { width: w, height: 44, fontSize: 18 })); 'Send Population', uiClick(scene, openTransportPicker), { width: w, height: 44, fontSize: 18 }));
@ -431,29 +494,38 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
} }
y += 56; y += 56;
add(new Button(scene, PANEL_X + PANEL_W / 2, y + 24, add(new Button(scene, PANEL_X + PANEL_W / 2, y + 24,
flyOpen ? 'Close Build Queue' : 'Build Queue', uiClick(scene, toggleFlyout), flyMode === 'queue' ? 'Close Build Queue' : 'Build Queue', uiClick(scene, () => toggleFlyout('queue')),
{ width: w, height: 48, fontSize: 20 })); { width: w, height: 48, fontSize: 20 }));
} }
// ------------------------------------------------------------- the flyout // ------------------------------------------------------------- the flyout
//
// A single sliding drawer shared by three modes (Build Queue / Colony Focus
// / Allocation Focus) rather than three independent panels — clicking a
// different mode's button while the drawer is already open swaps its
// content in place with no re-tween (flyLayer.x is already 0); clicking the
// OPEN mode's own button tweens it closed; clicking from closed tweens it
// open. Every content builder starts with resetFlyLayer(), since switching
// modes mid-open still has to tear down whatever the PREVIOUS mode left
// behind (scroll masks, the ship-media pool) before building fresh content.
function toggleFlyout() { function resetFlyLayer() {
flyOpen = !flyOpen; dropScrollers();
detachPool();
flyLayer.removeAll(true);
}
function buildFlyoutContent() {
if (flyMode === 'queue') buildQueueFlyout();
else if (flyMode === 'colonyFocus') buildColonyFocusFlyout();
else if (flyMode === 'allocFocus') buildAllocationFocusFlyout();
}
function toggleFlyout(mode) {
scene.tweens.killTweensOf(flyLayer); scene.tweens.killTweensOf(flyLayer);
if (flyOpen) { if (flyMode === mode) {
flyLayer.setVisible(true); // The open drawer's own button, clicked again: close it.
buildFlyout(); flyMode = null;
scene.tweens.add({
targets: flyLayer,
x: 0,
duration: 220,
ease: 'Cubic.easeOut',
// A scroll mask is a Graphics with its own world transform and does
// not follow a moving parent, so it has to be dragged along by hand.
onUpdate: () => syncMasks(flyLayer.x),
onComplete: () => syncMasks(0),
});
} else {
scene.tweens.add({ scene.tweens.add({
targets: flyLayer, targets: flyLayer,
x: FLY_HIDDEN, x: FLY_HIDDEN,
@ -468,25 +540,42 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
flyLayer.removeAll(true); flyLayer.removeAll(true);
}, },
}); });
} else if (flyMode === null) {
// Closed -> open with `mode`, tweening in.
flyMode = mode;
flyLayer.setVisible(true);
buildFlyoutContent();
scene.tweens.add({
targets: flyLayer,
x: 0,
duration: 220,
ease: 'Cubic.easeOut',
// A scroll mask is a Graphics with its own world transform and does
// not follow a moving parent, so it has to be dragged along by hand.
onUpdate: () => syncMasks(flyLayer.x),
onComplete: () => syncMasks(0),
});
} else {
// Already open on a DIFFERENT mode: swap content in place, no tween.
flyMode = mode;
buildFlyoutContent();
} }
buildPanel(); buildPanel();
} }
function rebuildFlyout() { function rebuildFlyout() {
buildFlyout(); buildFlyoutContent();
buildPanel(); buildPanel();
onChanged?.(); onChanged?.();
} }
function buildFlyout() { function buildQueueFlyout() {
// Queueing something rebuilds the whole flyout, so both lists would jump // Queueing something rebuilds the whole flyout, so both lists would jump
// back to the top under the player's cursor. Put them back where they were. // back to the top under the player's cursor. Put them back where they were.
const keep = scrollers.map((s) => s.offset); const keep = scrollers.map((s) => s.offset);
dropScrollers(); resetFlyLayer();
detachPool();
pool.beginFrame(); pool.beginFrame();
if (!detail) pool.setPaused(false); if (!detail) pool.setPaused(false);
flyLayer.removeAll(true);
frame(scene, flyLayer, FLY_X, PANEL_Y, FLY_W, PANEL_H, 0.9); frame(scene, flyLayer, FLY_X, PANEL_Y, FLY_W, PANEL_H, 0.9);
const x = FLY_X + PAD; const x = FLY_X + PAD;
@ -657,6 +746,86 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
scroller.setOffset(keep[1] ?? 0); scroller.setOffset(keep[1] ?? 0);
} }
// Autopilot picker: what VegaLogic.js's autoQueueColonies will build here
// once a turn, when this colony's queue is empty. Short enough (7 rows) to
// fit without a scrollColumn.
function buildColonyFocusFlyout() {
resetFlyLayer();
pool.setPaused(true); // no ship media in this mode
frame(scene, flyLayer, FLY_X, PANEL_Y, FLY_W, PANEL_H, 0.9);
const x = FLY_X + PAD;
const w = FLY_W - PAD * 2;
flyLayer.add(scene.add.text(x, PANEL_Y + 24, 'COLONY FOCUS', {
fontFamily: FONT, fontSize: '26px', color: '#cfe8ff',
}));
flyLayer.add(scene.add.text(x, PANEL_Y + 60,
'Automatically queues one thing per turn when the queue is empty. Never touches the allocation sliders.', {
fontFamily: FONT, fontSize: '15px', color: '#9fd8ff', wordWrap: { width: w },
}));
let y = PANEL_Y + 110;
const rowH = 78;
for (const opt of COLONY_FOCUS_OPTIONS) {
const selected = (colony.focus ?? 'manual') === opt.value;
const row = scene.add.rectangle(x, y, w, rowH - 8, selected ? 0x22405f : 0x0f1a2c, selected ? 0.9 : 0.5)
.setOrigin(0, 0).setStrokeStyle(1, ACCENT, selected ? 0.7 : 0.2).setInteractive({ useHandCursor: true });
row.on('pointerup', () => { colony.focus = opt.value; buildColonyFocusFlyout(); });
flyLayer.add(row);
flyLayer.add(scene.add.text(x + 14, y + 8, opt.label, {
fontFamily: FONT, fontSize: '18px', color: selected ? '#ffd88a' : '#e8f4ff',
}));
flyLayer.add(scene.add.text(x + 14, y + 32, opt.desc, {
fontFamily: FONT, fontSize: '13px', color: '#8fa8c0', wordWrap: { width: w - 28 },
}));
y += rowH;
}
}
// One-time slider presets. Picking one overwrites colony.sliders directly —
// a deliberate bulk overwrite that BYPASSES colony.locked (unlike
// setSlider, which honours it): this is an explicit "reset everything"
// action, not a nudge to one channel, and the sliders stay freely
// adjustable afterward exactly as if the player had dragged them there.
function buildAllocationFocusFlyout() {
resetFlyLayer();
pool.setPaused(true);
frame(scene, flyLayer, FLY_X, PANEL_Y, FLY_W, PANEL_H, 0.9);
const x = FLY_X + PAD;
const w = FLY_W - PAD * 2;
flyLayer.add(scene.add.text(x, PANEL_Y + 24, 'ALLOCATION FOCUS', {
fontFamily: FONT, fontSize: '26px', color: '#cfe8ff',
}));
flyLayer.add(scene.add.text(x, PANEL_Y + 60,
'A one-time preset for the five sliders above — picking one does not lock the allocation.', {
fontFamily: FONT, fontSize: '15px', color: '#9fd8ff', wordWrap: { width: w },
}));
let y = PANEL_Y + 110;
const rowH = 84;
for (const opt of ALLOCATION_FOCUS_OPTIONS) {
const matches = CHANNELS.every((ch) => Math.abs((colony.sliders[ch] ?? 0) - opt.sliders[ch]) < 1e-6);
const row = scene.add.rectangle(x, y, w, rowH - 8, matches ? 0x22405f : 0x0f1a2c, matches ? 0.9 : 0.5)
.setOrigin(0, 0).setStrokeStyle(1, ACCENT, matches ? 0.7 : 0.2).setInteractive({ useHandCursor: true });
row.on('pointerup', () => {
colony.sliders = { ...opt.sliders };
onChanged?.();
buildPanel(); // redraws the five sliders with their new values
buildAllocationFocusFlyout(); // refreshes which row is highlighted
});
flyLayer.add(row);
flyLayer.add(scene.add.text(x + 14, y + 8, opt.label, {
fontFamily: FONT, fontSize: '18px', color: matches ? '#ffd88a' : '#e8f4ff',
}));
const breakdown = CHANNELS.map((ch) => `${rules.economy.channelNames[ch] ?? ch} ${Math.round(opt.sliders[ch] * 100)}%`).join(' · ');
flyLayer.add(scene.add.text(x + 14, y + 34, breakdown, {
fontFamily: FONT, fontSize: '13px', color: '#8fa8c0', wordWrap: { width: w - 28 },
}));
y += rowH;
}
}
const moveRun = (rows, ri, dir) => { const moveRun = (rows, ri, dir) => {
moveQueueRun(rules, state, colony, rows, ri, dir); moveQueueRun(rules, state, colony, rows, ri, dir);
rebuildFlyout(); rebuildFlyout();

View File

@ -547,6 +547,7 @@ export function foundColony(rules, state, e, starIdx, orbit, pop, name = null) {
capital: false, capital: false,
sliders: { ships: 0.2, defense: 0.1, industry: 0.4, ecology: 0.1, research: 0.2 }, sliders: { ships: 0.2, defense: 0.1, industry: 0.4, ecology: 0.1, research: 0.2 },
locked: {}, locked: {},
focus: 'manual', // 'manual' | 'improvement' | 'research' | 'fleet' | 'growth' | 'trade' | 'defense'
founded: state.turn, founded: state.turn,
}; };
state.colonies.push(colony); state.colonies.push(colony);
@ -1892,6 +1893,130 @@ export function enqueueMany(rules, state, colony, kind, id, n) {
return added; return added;
} }
// --------------------------------------------------------------------------
// Colony Focus — the colony screen's per-colony autopilot (VegaColonyView.js).
// Runs once per human turn, right after that empire's own beginEmpireTurn
// (MasterOfVegaGame.js's runToHumanTurn), and mirrors the shape of VegaAI.js's
// manageColony building ladder / preferredWarship. Deliberately reimplemented
// rather than imported: VegaAI.js already imports FROM this file, so pulling
// AI helpers in here would cycle, and duplicating a dozen lines of scoring
// logic is cheaper than restructuring either file around a shared surface.
/**
* First candidate in `ids` that clears enqueue's own guards (already-built,
* already-queued, prereq) plus an affordability guard mirroring VegaAI.js's
* `if (b.cost > prod * 25) continue;` so a small colony is never saddled
* with upkeep it cannot carry. Returns whether something was queued.
*/
function enqueueFirstAffordableBuilding(rules, state, colony, ids) {
const prod = colonyProduction(rules, state, colony);
const emp = state.empires[colony.empireIdx];
for (const id of ids) {
const b = rules.buildings[id];
if (!b) continue;
if (colony.buildings.includes(id)) continue;
if (colony.queue.some((q) => q.kind === 'building' && q.id === id)) continue;
if (b.prereq && !emp.known[b.prereq]) continue;
if (b.cost > prod * 25) continue;
if (enqueue(rules, state, colony, 'building', id)) return true;
}
return false;
}
const byCostAsc = (a, b) => a.cost - b.cost;
function pickImprovement(rules, state, colony) {
const industry = rules.buildingList.filter((b) => b.channel === 'industry').sort(byCostAsc).map((b) => b.id);
const rest = rules.buildingList.filter((b) => b.channel !== 'industry').sort(byCostAsc).map((b) => b.id);
enqueueFirstAffordableBuilding(rules, state, colony, [...industry, ...rest]);
}
// No fallback if nothing qualifies — an empty queue is correct here, since
// processColony already spills unspent construction BC into research.
function pickResearchOnly(rules, state, colony) {
const ids = rules.buildingList.filter((b) => b.channel === 'research').sort(byCostAsc).map((b) => b.id);
enqueueFirstAffordableBuilding(rules, state, colony, ids);
}
function pickGrowth(rules, state, colony) {
const ids = rules.buildingList
.filter((b) => b.effects?.maxPopBonus || b.effects?.growthMult)
.sort(byCostAsc).map((b) => b.id);
enqueueFirstAffordableBuilding(rules, state, colony, ids);
}
function pickTrade(rules, state, colony) {
const ids = rules.buildingList
.filter((b) => b.effects?.tradeBonus || b.effects?.tradeMult)
.sort(byCostAsc).map((b) => b.id);
enqueueFirstAffordableBuilding(rules, state, colony, ids);
}
function pickDefense(rules, state, colony) {
const ids = rules.buildingList.filter((b) => b.channel === 'defense').sort(byCostAsc).map((b) => b.id);
enqueueFirstAffordableBuilding(rules, state, colony, ids);
}
// Independently-implemented twin of VegaAI.js's preferredWarship (same
// (hp + damage*4) / cost scoring, same ~15-turns-of-budget affordability
// cutoff — see the header note above for why this is duplicated rather than
// shared), plus a diversity pass: rather than always maxing out the single
// best-value hull, it counts what's already docked at this colony's star and
// favours whichever warship role is least represented, ties broken by score.
function pickFleet(rules, state, colony) {
const e = colony.empireIdx;
const budget = colonyBuildRate(rules, state, colony);
const hullIds = rules.hullList.filter((h) => h.role === 'warship').map((h) => h.id);
const present = {};
for (const f of fleetsAt(state, colony.starIdx)) {
if (f.empireIdx !== e) continue;
for (const s of f.ships) {
if (hullIds.includes(s.hullId)) present[s.hullId] = (present[s.hullId] ?? 0) + s.count;
}
}
let best = null;
let bestCount = Infinity;
let bestScore = -Infinity;
for (const hullId of hullIds) {
const d = empireDesign(rules, state, e, hullId);
if (d.damage <= 0) continue;
if (d.cost > budget * 15) continue;
const count = present[hullId] ?? 0;
const score = (d.hp + d.damage * 4) / d.cost;
if (count < bestCount || (count === bestCount && score > bestScore)) {
best = hullId; bestCount = count; bestScore = score;
}
}
// Before any weapon tech is known every hull scores damage=0 and `best`
// stays null — VegaAI.js's preferredWarship falls back to a bare frigate
// in that same situation rather than building nothing, and Fleet
// Production mirrors it: an empty queue every turn until the first weapon
// is researched would read as the focus doing nothing at all.
enqueue(rules, state, colony, 'ship', best ?? 'frigate');
}
const FOCUS_PICKERS = {
improvement: pickImprovement,
research: pickResearchOnly,
fleet: pickFleet,
growth: pickGrowth,
trade: pickTrade,
defense: pickDefense,
};
/**
* For every colony of empire `e` with a non-manual focus and an empty queue,
* enqueue at most one thing. Called once per human turn see
* MasterOfVegaGame.js's runToHumanTurn for the exact hook point.
*/
export function autoQueueColonies(rules, state, e) {
for (const colony of empireColonies(state, e)) {
if (!colony.focus || colony.focus === 'manual') continue;
if (colony.queue.length > 0) continue;
FOCUS_PICKERS[colony.focus]?.(rules, state, colony);
}
}
export function dequeue(rules, state, colony, index) { export function dequeue(rules, state, colony, index) {
if (index < 0 || index >= colony.queue.length) return false; if (index < 0 || index >= colony.queue.length) return false;
colony.queue.splice(index, 1); colony.queue.splice(index, 1);
@ -2047,6 +2172,8 @@ export function deserialize(json) {
emp.fleetIntrusions ??= {}; emp.fleetIntrusions ??= {};
emp.espionageMission ??= {}; emp.espionageMission ??= {};
} }
// Same convention for a save from before Colony Focus existed.
for (const c of state.colonies) c.focus ??= 'manual';
return state; return state;
} }

View File

@ -201,7 +201,14 @@ export function openDiplomacyScreen(scene, rules, state, e, art, onClose, onChan
const canSeek = canNegotiate(rules, state, e, other.idx); const canSeek = canNegotiate(rules, state, e, other.idx);
const b = new Button(scene, bx, y + rowH / 2 - 20, 'Seek Audience', uiClick(scene, () => { const b = new Button(scene, bx, y + rowH / 2 - 20, 'Seek Audience', uiClick(scene, () => {
shell.destroy(); shell.destroy();
openAudienceScreen(scene, rules, state, e, other.idx, art, onClose, onChanged); // Same per-race ducking runAudienceQueue does for an AI-initiated
// audience (MasterOfVegaGame.js) — only drop back to peace on close if
// nothing else is queued up behind this one.
scene.music?.setDiplomacy(other.speciesId);
openAudienceScreen(scene, rules, state, e, other.idx, art, () => {
if (!scene.pendingAudiences?.length) scene.music?.setDiplomacy(null);
onClose?.();
}, onChanged);
}), { width: 200, height: 40 }); }), { width: 200, height: 40 });
if (!canSeek) b.setEnabled(false); if (!canSeek) b.setEnabled(false);
shell.add(b); shell.add(b);

View File

@ -14,6 +14,7 @@
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Tooltip } from '../../ui/Tooltip.js'; import { Tooltip } from '../../ui/Tooltip.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { makeNebula } from './VegaNebula.js'; import { makeNebula } from './VegaNebula.js';
import { PARSEC_PX, parsecs, mulberry32 } from './VegaGalaxyGen.js'; import { PARSEC_PX, parsecs, mulberry32 } from './VegaGalaxyGen.js';
import { import {
@ -840,6 +841,7 @@ export default class VegaStarMap {
applyZoom(newIndex, focusX = GAME_WIDTH / 2, focusY = GAME_HEIGHT / 2) { applyZoom(newIndex, focusX = GAME_WIDTH / 2, focusY = GAME_HEIGHT / 2) {
const idx = Phaser.Math.Clamp(newIndex, 0, this.zooms.length - 1); const idx = Phaser.Math.Clamp(newIndex, 0, this.zooms.length - 1);
if (idx === this.zoomIndex) return; if (idx === this.zoomIndex) return;
playSound(this.scene, idx > this.zoomIndex ? SFX.VEGA_ZOOMIN : SFX.VEGA_ZOOMOUT);
const old = this.zoom; const old = this.zoom;
const next = this.zooms[idx]; const next = this.zooms[idx];
// Keep whatever is under the cursor under the cursor. // Keep whatever is under the cursor under the cursor.

View File

@ -15,6 +15,7 @@
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import { Button } from '../../ui/Button.js'; import { Button } from '../../ui/Button.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { modalShell, FONT, ORBIT, uiClick } from './VegaScreens.js'; import { modalShell, FONT, ORBIT, uiClick } from './VegaScreens.js';
import { planetFrame, starFrame } from './VegaArt.js'; import { planetFrame, starFrame } from './VegaArt.js';
import { openColonyView, CHANNEL_COLOUR, itemName, etaText } from './VegaColonyView.js'; import { openColonyView, CHANNEL_COLOUR, itemName, etaText } from './VegaColonyView.js';
@ -303,7 +304,10 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
// second modal while one is up — and this one has to stay up, because it is // second modal while one is up — and this one has to stay up, because it is
// what is keeping the star map behind it inert. // what is keeping the star map behind it inert.
y = Math.max(y + 20, shell.y + shell.height - 96); y = Math.max(y + 20, shell.y + shell.height - 96);
console_.add(new Button(scene, panelX + panelW / 2, y + 24, 'View Colony', uiClick(scene, () => { console_.add(new Button(scene, panelX + panelW / 2, y + 24, 'View Colony', () => {
// Not uiClick — this gets its own dedicated cue instead of the generic
// button click.
playSound(scene, SFX.VEGA_VIEWCOLONY);
shell.layer.setVisible(false); shell.layer.setVisible(false);
tick.paused = true; tick.paused = true;
openColonyView(scene, rules, state, colony, art, { openColonyView(scene, rules, state, colony, art, {
@ -314,7 +318,7 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
rebuild(); rebuild();
}, },
}); });
}), { width: panelW, height: 50, fontSize: 21 })); }, { width: panelW, height: 50, fontSize: 21 }));
} }
function select(i) { function select(i) {

View File

@ -92,3 +92,18 @@ export function describeTechTooltip(rules, state, emp, tech) {
], ],
}; };
} }
// Hover tooltip for a building icon on the colony screen's icon row. `desc`
// already reads as prose for the building's effect, so the body reuses it
// verbatim instead of re-deriving the same numbers from the `effects` bag.
export function describeBuildingTooltip(rules, buildingId) {
const b = rules.buildings[buildingId];
return {
title: b.name,
titleColor: COLORS.goldHex,
lines: [
{ text: `${b.upkeep} BC upkeep`, color: COLORS.goldHex },
{ text: b.desc },
],
};
}

View File

@ -190,6 +190,10 @@ export default class PreloadScene extends Phaser.Scene {
this.load.audio('sfx-vega-unit', 'assets/fx/vega-unit.mp3'); this.load.audio('sfx-vega-unit', 'assets/fx/vega-unit.mp3');
this.load.audio('sfx-vega-view', 'assets/fx/vega-view.mp3'); this.load.audio('sfx-vega-view', 'assets/fx/vega-view.mp3');
this.load.audio('sfx-vega-warp', 'assets/fx/vega-warp.mp3'); this.load.audio('sfx-vega-warp', 'assets/fx/vega-warp.mp3');
this.load.audio('sfx-vega-star', 'assets/fx/vega-star.mp3');
this.load.audio('sfx-vega-zoomin', 'assets/fx/vega-zoomin.mp3');
this.load.audio('sfx-vega-zoomout', 'assets/fx/vega-zoomout.mp3');
this.load.audio('sfx-vega-viewcolony', 'assets/fx/vega-viewcolony.mp3');
} }
async create() { async create() {

View File

@ -106,6 +106,10 @@ export const SFX = {
VEGA_UNIT: 'sfx-vega-unit', VEGA_UNIT: 'sfx-vega-unit',
VEGA_VIEW: 'sfx-vega-view', VEGA_VIEW: 'sfx-vega-view',
VEGA_WARP: 'sfx-vega-warp', VEGA_WARP: 'sfx-vega-warp',
VEGA_STAR: 'sfx-vega-star',
VEGA_ZOOMIN: 'sfx-vega-zoomin',
VEGA_ZOOMOUT: 'sfx-vega-zoomout',
VEGA_VIEWCOLONY: 'sfx-vega-viewcolony',
}; };
export function playSound(scene, key) { export function playSound(scene, key) {

View File

@ -1501,6 +1501,50 @@ section('6. Colony economy');
Logic.queueItemEta(RULES, st, idle, c.queue[0]) === Infinity); Logic.queueItemEta(RULES, st, idle, c.queue[0]) === Infinity);
} }
// --- Colony Focus autopilot: exercised directly, each focus in isolation
// on an empty queue. Runs before the restore below, so nothing here needs
// to worry about leaving the colony as it was found.
{
const c = st.colonies[0];
check('a freshly-founded colony defaults to manual focus',
st.colonies.every((col) => col.focus === 'manual'));
c.queue.length = 0;
c.focus = 'manual';
Logic.autoQueueColonies(RULES, st, c.empireIdx);
check('manual focus never auto-queues anything', c.queue.length === 0);
c.queue.length = 0;
c.focus = 'improvement';
Logic.autoQueueColonies(RULES, st, c.empireIdx);
check('colony improvement queues a building when the queue is empty',
c.queue.length === 1 && c.queue[0].kind === 'building', JSON.stringify(c.queue[0]));
c.queue.length = 0;
c.focus = 'fleet';
Logic.autoQueueColonies(RULES, st, c.empireIdx);
check('fleet production queues a warship hull',
c.queue.length === 1 && c.queue[0].kind === 'ship' && RULES.hulls[c.queue[0].id]?.role === 'warship',
c.queue[0]?.id);
// With every research building's prereq stripped, Research Focus has
// nothing it is allowed to queue — the queue must stay empty rather than
// queuing something else or throwing.
c.queue.length = 0;
c.focus = 'research';
const emp = st.empires[c.empireIdx];
const savedKnown = { ...emp.known };
for (const b of RULES.buildingList.filter((bb) => bb.channel === 'research')) {
if (b.prereq) delete emp.known[b.prereq];
}
Logic.autoQueueColonies(RULES, st, c.empireIdx);
check('research focus leaves the queue empty when no research buildings are available',
c.queue.length === 0);
emp.known = savedKnown;
c.focus = 'manual';
}
colony.sliders = restore.sliders; colony.sliders = restore.sliders;
colony.locked = restore.locked; colony.locked = restore.locked;
colony.queue = restore.queue; colony.queue = restore.queue;
@ -2294,6 +2338,9 @@ section('9. Serialisation');
for (const emp of oldSave.empires) { for (const emp of oldSave.empires) {
delete emp.tradeAgreements; delete emp.lastGiftTurn; delete emp.fleetIntrusions; delete emp.tradeAgreements; delete emp.lastGiftTurn; delete emp.fleetIntrusions;
} }
// Colony Focus follows the same convention: an old save has no `focus` at
// all on any colony.
for (const c of oldSave.colonies) delete c.focus;
const backOld = Logic.deserialize(JSON.stringify(oldSave)); const backOld = Logic.deserialize(JSON.stringify(oldSave));
check('an old save missing the new diplomacy fields deserializes without throwing', !!backOld); check('an old save missing the new diplomacy fields deserializes without throwing', !!backOld);
check('tradeAgreements is back-filled to {} on an old save', check('tradeAgreements is back-filled to {} on an old save',
@ -2302,6 +2349,8 @@ section('9. Serialisation');
backOld.empires.every((e) => JSON.stringify(e.lastGiftTurn) === '{}')); backOld.empires.every((e) => JSON.stringify(e.lastGiftTurn) === '{}'));
check('fleetIntrusions is back-filled to {} on an old save', check('fleetIntrusions is back-filled to {} on an old save',
backOld.empires.every((e) => JSON.stringify(e.fleetIntrusions) === '{}')); backOld.empires.every((e) => JSON.stringify(e.fleetIntrusions) === '{}'));
check('focus is back-filled to manual on an old save',
backOld.colonies.every((c) => c.focus === 'manual'));
backOld.rules = RULES; backOld.rules = RULES;
check('the freshly-backfilled state tolerates a full galaxy diplomacy pass', (() => { check('the freshly-backfilled state tolerates a full galaxy diplomacy pass', (() => {
Diplo.runGalaxyDiplomacyPass(RULES, backOld); Diplo.runGalaxyDiplomacyPass(RULES, backOld);