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:
parent
8cb6988ded
commit
b061f8eab5
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -51,17 +51,57 @@
|
|||
}
|
||||
] },
|
||||
"diplomacy": {
|
||||
"default": { "tracks": [] },
|
||||
"default": { "tracks": [{
|
||||
"file": "vega/audience-human.mp3",
|
||||
"artist": "Human",
|
||||
"title": "Meet N Greet"
|
||||
}] },
|
||||
"bySpecies": {
|
||||
"kestrelli": { "tracks": [] },
|
||||
"ursaal": { "tracks": [] },
|
||||
"umbrix": { "tracks": [] },
|
||||
"kkrix": { "tracks": [] },
|
||||
"mekhan": { "tracks": [] },
|
||||
"rrashaa": { "tracks": [] },
|
||||
"cerebrai": { "tracks": [] },
|
||||
"ssakar": { "tracks": [] },
|
||||
"lithox": { "tracks": [] }
|
||||
"kestrelli": { "tracks": [{
|
||||
"file": "vega/audience-kestrelli.mp3",
|
||||
"artist": "Kestrelli",
|
||||
"title": "Meet N Greet"
|
||||
}] },
|
||||
"ursaal": { "tracks": [{
|
||||
"file": "vega/audience-ursaal.mp3",
|
||||
"artist": "Ursaal",
|
||||
"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"
|
||||
}] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -835,10 +835,19 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
|||
|
||||
const canSeek = canNegotiate(this.rules, this.state, this.state.humanIndex, otherIdx);
|
||||
const btn = new Button(this, W / 2 - 78, 0, 'Seek Audience', this.uiClick(() => {
|
||||
this.openModal((done) => openAudienceScreen(
|
||||
this, this.rules, this.state, this.state.humanIndex, otherIdx, this.art, done,
|
||||
this.openModal((done) => {
|
||||
// Same per-race ducking runAudienceQueue does for an AI-initiated
|
||||
// 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 });
|
||||
if (!canSeek) btn.setEnabled(false);
|
||||
card.add(btn);
|
||||
|
|
@ -918,6 +927,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
|||
return;
|
||||
}
|
||||
|
||||
playSound(this, SFX.VEGA_STAR);
|
||||
this.map.setSelectedStar(idx);
|
||||
this.selectedFleet = null;
|
||||
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.current === 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
|
||||
// control returns to the human — before the routine turn report, so
|
||||
// "contact made" always reads as the bigger beat.
|
||||
|
|
|
|||
|
|
@ -34,11 +34,13 @@ import * as Phaser from 'phaser';
|
|||
|
||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { Tooltip } from '../../ui/Tooltip.js';
|
||||
import { FONT, D, ORBIT, slider, uiClick } from './VegaScreens.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import { worldBackground, buildingFrame } from './VegaArt.js';
|
||||
import { createShipMediaPool, makeShipIcon } from './VegaShipMedia.js';
|
||||
import { openShipDetail } from './VegaShipDetail.js';
|
||||
import { describeBuildingTooltip } from './VegaTooltips.js';
|
||||
import {
|
||||
CHANNELS, colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories,
|
||||
colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany,
|
||||
|
|
@ -58,11 +60,43 @@ const PAD = 22;
|
|||
const FLY_W = 760;
|
||||
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. */
|
||||
export const CHANNEL_COLOUR = {
|
||||
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)
|
||||
? `${turns} turn${turns === 1 ? '' : 's'}`
|
||||
: 'stalled — no construction budget');
|
||||
|
|
@ -161,6 +195,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
|
|||
const emp = state.empires[colony.empireIdx];
|
||||
|
||||
const root = scene.add.container(0, 0).setDepth(D.colony);
|
||||
const tooltip = new Tooltip(scene, { depth: D.colony + 5 });
|
||||
|
||||
// --- the world itself
|
||||
const bgKey = worldBackground(scene, planet.typeId);
|
||||
|
|
@ -208,15 +243,18 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
|
|||
// --- layers
|
||||
//
|
||||
// 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);
|
||||
root.add(flyLayer);
|
||||
const panelLayer = scene.add.container(0, 0);
|
||||
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
|
||||
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
|
||||
// the catalogue — so both scroll and both have a mask to keep in step.
|
||||
let scrollers = [];
|
||||
|
|
@ -234,7 +272,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
|
|||
pool.setPaused(true);
|
||||
detail = openShipDetail(scene, rules, state, art,
|
||||
{ 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. */
|
||||
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.
|
||||
detachPool();
|
||||
pool.destroy();
|
||||
tooltip.destroy();
|
||||
scene.input.keyboard?.off('keydown-ESC', onEsc);
|
||||
root.destroy();
|
||||
onClose?.();
|
||||
}
|
||||
function onEsc() {
|
||||
if (flyOpen) { toggleFlyout(); return; }
|
||||
if (flyMode) { toggleFlyout(flyMode); return; }
|
||||
close();
|
||||
}
|
||||
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
|
||||
|
||||
function buildPanel() {
|
||||
buildIconRow();
|
||||
panelLayer.removeAll(true);
|
||||
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.',
|
||||
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
|
||||
// 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
|
||||
|
|
@ -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;
|
||||
const sendBtn = add(new Button(scene, PANEL_X + PANEL_W / 2, y + 22,
|
||||
'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;
|
||||
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 }));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- 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() {
|
||||
flyOpen = !flyOpen;
|
||||
function resetFlyLayer() {
|
||||
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);
|
||||
if (flyOpen) {
|
||||
flyLayer.setVisible(true);
|
||||
buildFlyout();
|
||||
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 {
|
||||
if (flyMode === mode) {
|
||||
// The open drawer's own button, clicked again: close it.
|
||||
flyMode = null;
|
||||
scene.tweens.add({
|
||||
targets: flyLayer,
|
||||
x: FLY_HIDDEN,
|
||||
|
|
@ -468,25 +540,42 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
|
|||
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();
|
||||
}
|
||||
|
||||
function rebuildFlyout() {
|
||||
buildFlyout();
|
||||
buildFlyoutContent();
|
||||
buildPanel();
|
||||
onChanged?.();
|
||||
}
|
||||
|
||||
function buildFlyout() {
|
||||
function buildQueueFlyout() {
|
||||
// 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.
|
||||
const keep = scrollers.map((s) => s.offset);
|
||||
dropScrollers();
|
||||
detachPool();
|
||||
resetFlyLayer();
|
||||
pool.beginFrame();
|
||||
if (!detail) pool.setPaused(false);
|
||||
flyLayer.removeAll(true);
|
||||
frame(scene, flyLayer, FLY_X, PANEL_Y, FLY_W, PANEL_H, 0.9);
|
||||
|
||||
const x = FLY_X + PAD;
|
||||
|
|
@ -657,6 +746,86 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
|
|||
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) => {
|
||||
moveQueueRun(rules, state, colony, rows, ri, dir);
|
||||
rebuildFlyout();
|
||||
|
|
|
|||
|
|
@ -547,6 +547,7 @@ export function foundColony(rules, state, e, starIdx, orbit, pop, name = null) {
|
|||
capital: false,
|
||||
sliders: { ships: 0.2, defense: 0.1, industry: 0.4, ecology: 0.1, research: 0.2 },
|
||||
locked: {},
|
||||
focus: 'manual', // 'manual' | 'improvement' | 'research' | 'fleet' | 'growth' | 'trade' | 'defense'
|
||||
founded: state.turn,
|
||||
};
|
||||
state.colonies.push(colony);
|
||||
|
|
@ -1892,6 +1893,130 @@ export function enqueueMany(rules, state, colony, kind, id, n) {
|
|||
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) {
|
||||
if (index < 0 || index >= colony.queue.length) return false;
|
||||
colony.queue.splice(index, 1);
|
||||
|
|
@ -2047,6 +2172,8 @@ export function deserialize(json) {
|
|||
emp.fleetIntrusions ??= {};
|
||||
emp.espionageMission ??= {};
|
||||
}
|
||||
// Same convention for a save from before Colony Focus existed.
|
||||
for (const c of state.colonies) c.focus ??= 'manual';
|
||||
return state;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -201,7 +201,14 @@ export function openDiplomacyScreen(scene, rules, state, e, art, onClose, onChan
|
|||
const canSeek = canNegotiate(rules, state, e, other.idx);
|
||||
const b = new Button(scene, bx, y + rowH / 2 - 20, 'Seek Audience', uiClick(scene, () => {
|
||||
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 });
|
||||
if (!canSeek) b.setEnabled(false);
|
||||
shell.add(b);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||
import { Tooltip } from '../../ui/Tooltip.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import { makeNebula } from './VegaNebula.js';
|
||||
import { PARSEC_PX, parsecs, mulberry32 } from './VegaGalaxyGen.js';
|
||||
import {
|
||||
|
|
@ -840,6 +841,7 @@ export default class VegaStarMap {
|
|||
applyZoom(newIndex, focusX = GAME_WIDTH / 2, focusY = GAME_HEIGHT / 2) {
|
||||
const idx = Phaser.Math.Clamp(newIndex, 0, this.zooms.length - 1);
|
||||
if (idx === this.zoomIndex) return;
|
||||
playSound(this.scene, idx > this.zoomIndex ? SFX.VEGA_ZOOMIN : SFX.VEGA_ZOOMOUT);
|
||||
const old = this.zoom;
|
||||
const next = this.zooms[idx];
|
||||
// Keep whatever is under the cursor under the cursor.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
import * as Phaser from 'phaser';
|
||||
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import { modalShell, FONT, ORBIT, uiClick } from './VegaScreens.js';
|
||||
import { planetFrame, starFrame } from './VegaArt.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
|
||||
// what is keeping the star map behind it inert.
|
||||
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);
|
||||
tick.paused = true;
|
||||
openColonyView(scene, rules, state, colony, art, {
|
||||
|
|
@ -314,7 +318,7 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
|
|||
rebuild();
|
||||
},
|
||||
});
|
||||
}), { width: panelW, height: 50, fontSize: 21 }));
|
||||
}, { width: panelW, height: 50, fontSize: 21 }));
|
||||
}
|
||||
|
||||
function select(i) {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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-view', 'assets/fx/vega-view.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() {
|
||||
|
|
|
|||
|
|
@ -106,6 +106,10 @@ export const SFX = {
|
|||
VEGA_UNIT: 'sfx-vega-unit',
|
||||
VEGA_VIEW: 'sfx-vega-view',
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -1501,6 +1501,50 @@ section('6. Colony economy');
|
|||
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.locked = restore.locked;
|
||||
colony.queue = restore.queue;
|
||||
|
|
@ -2294,6 +2338,9 @@ section('9. Serialisation');
|
|||
for (const emp of oldSave.empires) {
|
||||
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));
|
||||
check('an old save missing the new diplomacy fields deserializes without throwing', !!backOld);
|
||||
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) === '{}'));
|
||||
check('fleetIntrusions is back-filled to {} on an old save',
|
||||
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;
|
||||
check('the freshly-backfilled state tolerates a full galaxy diplomacy pass', (() => {
|
||||
Diplo.runGalaxyDiplomacyPass(RULES, backOld);
|
||||
|
|
|
|||
Loading…
Reference in New Issue