diff --git a/assets/fx/vega-star.mp3 b/assets/fx/vega-star.mp3 new file mode 100644 index 0000000..b376135 Binary files /dev/null and b/assets/fx/vega-star.mp3 differ diff --git a/assets/fx/vega-viewcolony.mp3 b/assets/fx/vega-viewcolony.mp3 new file mode 100644 index 0000000..078117b Binary files /dev/null and b/assets/fx/vega-viewcolony.mp3 differ diff --git a/assets/fx/vega-zoomin.mp3 b/assets/fx/vega-zoomin.mp3 new file mode 100644 index 0000000..22d1f59 Binary files /dev/null and b/assets/fx/vega-zoomin.mp3 differ diff --git a/assets/fx/vega-zoomout.mp3 b/assets/fx/vega-zoomout.mp3 new file mode 100644 index 0000000..a96750e Binary files /dev/null and b/assets/fx/vega-zoomout.mp3 differ diff --git a/assets/music/vega/audience-cerebrai.mp3 b/assets/music/vega/audience-cerebrai.mp3 new file mode 100644 index 0000000..9e12392 Binary files /dev/null and b/assets/music/vega/audience-cerebrai.mp3 differ diff --git a/assets/music/vega/audience-human.mp3 b/assets/music/vega/audience-human.mp3 new file mode 100644 index 0000000..28b1608 Binary files /dev/null and b/assets/music/vega/audience-human.mp3 differ diff --git a/assets/music/vega/audience-kestrelli.mp3 b/assets/music/vega/audience-kestrelli.mp3 new file mode 100644 index 0000000..d0f3303 Binary files /dev/null and b/assets/music/vega/audience-kestrelli.mp3 differ diff --git a/assets/music/vega/audience-kkrix.mp3 b/assets/music/vega/audience-kkrix.mp3 new file mode 100644 index 0000000..645d511 Binary files /dev/null and b/assets/music/vega/audience-kkrix.mp3 differ diff --git a/assets/music/vega/audience-mekhan.mp3 b/assets/music/vega/audience-mekhan.mp3 new file mode 100644 index 0000000..b74285f Binary files /dev/null and b/assets/music/vega/audience-mekhan.mp3 differ diff --git a/assets/music/vega/audience-rrashaa.mp3 b/assets/music/vega/audience-rrashaa.mp3 new file mode 100644 index 0000000..e0d053e Binary files /dev/null and b/assets/music/vega/audience-rrashaa.mp3 differ diff --git a/assets/music/vega/audience-ssakar.mp3 b/assets/music/vega/audience-ssakar.mp3 new file mode 100644 index 0000000..777206b Binary files /dev/null and b/assets/music/vega/audience-ssakar.mp3 differ diff --git a/assets/music/vega/audience-umbrix.mp3 b/assets/music/vega/audience-umbrix.mp3 new file mode 100644 index 0000000..a6cec0f Binary files /dev/null and b/assets/music/vega/audience-umbrix.mp3 differ diff --git a/assets/music/vega/audience-ursaal.mp3 b/assets/music/vega/audience-ursaal.mp3 new file mode 100644 index 0000000..2c067b9 Binary files /dev/null and b/assets/music/vega/audience-ursaal.mp3 differ diff --git a/assets/music/vega/peace-track-04.mp3 b/assets/music/vega/peace-track-04.mp3 new file mode 100644 index 0000000..2be8c09 Binary files /dev/null and b/assets/music/vega/peace-track-04.mp3 differ diff --git a/assets/videos/vega/audience-mekhan-neutral.mp4 b/assets/videos/vega/audience-mekhan-neutral.mp4 index d49611f..55453a3 100644 Binary files a/assets/videos/vega/audience-mekhan-neutral.mp4 and b/assets/videos/vega/audience-mekhan-neutral.mp4 differ diff --git a/data/masterofvega-music.json b/data/masterofvega-music.json index d2e722c..94dd43c 100644 --- a/data/masterofvega-music.json +++ b/data/masterofvega-music.json @@ -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" + }] } } } } diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js index 921ad54..ddd0df5 100644 --- a/src/games/mastervega/MasterOfVegaGame.js +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -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.refreshAll(), - )); + 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. diff --git a/src/games/mastervega/VegaColonyView.js b/src/games/mastervega/VegaColonyView.js index cd510b9..ee13b3a 100644 --- a/src/games/mastervega/VegaColonyView.js +++ b/src/games/mastervega/VegaColonyView.js @@ -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(); diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js index 5f2c5c9..ea508ed 100644 --- a/src/games/mastervega/VegaLogic.js +++ b/src/games/mastervega/VegaLogic.js @@ -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; } diff --git a/src/games/mastervega/VegaScreens.js b/src/games/mastervega/VegaScreens.js index d611e69..fef36d7 100644 --- a/src/games/mastervega/VegaScreens.js +++ b/src/games/mastervega/VegaScreens.js @@ -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); diff --git a/src/games/mastervega/VegaStarMap.js b/src/games/mastervega/VegaStarMap.js index 65fd516..5f06112 100644 --- a/src/games/mastervega/VegaStarMap.js +++ b/src/games/mastervega/VegaStarMap.js @@ -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. diff --git a/src/games/mastervega/VegaSystemView.js b/src/games/mastervega/VegaSystemView.js index ebc3d69..b918016 100644 --- a/src/games/mastervega/VegaSystemView.js +++ b/src/games/mastervega/VegaSystemView.js @@ -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) { diff --git a/src/games/mastervega/VegaTooltips.js b/src/games/mastervega/VegaTooltips.js index 082ce7d..4996c5d 100644 --- a/src/games/mastervega/VegaTooltips.js +++ b/src/games/mastervega/VegaTooltips.js @@ -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 }, + ], + }; +} diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 7409942..bb31f67 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -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() { diff --git a/src/ui/Sounds.js b/src/ui/Sounds.js index fca0813..314402b 100644 --- a/src/ui/Sounds.js +++ b/src/ui/Sounds.js @@ -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) { diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index 94a8913..9a5206e 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -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);