feat(mastervega): add 10-slot save/load system, game menu, and sound effects

- Implement 10 manual save slots with metadata (turn, year, empire, species, date)
- Add ☰ game menu button with Save, Load, Return to Main Menu, and Quit to Arcade
- Replace generic sounds with 7 custom Vega sound effects (select, build, close,
  newturn, unit, view, warp)
- Introduce uiClick() helper to standardize click sound across all modal screens
- Update research video paths for umbrix and rrashaa species
- Refactor button handlers in all Vega screens to use uiClick() pattern
- Save/load uses scene restart via pendingSavedState for clean HUD/map teardown
This commit is contained in:
Brian Fertig 2026-08-08 17:32:11 -06:00
parent 223c1fd191
commit 8cb6988ded
24 changed files with 386 additions and 75 deletions

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -214,10 +214,10 @@
"human": { "key": "vega-research-human", "path": "assets/videos/vega/research-human.mp4" },
"kestrelli": { "key": "vega-research-kestrelli", "path": "assets/videos/vega/research-kestrelli.mp4" },
"ursaal": { "key": "vega-research-ursaal", "path": "assets/videos/vega/research-ursaal.mp4" },
"umbrix": { "key": "vega-research-umbrix", "path": null },
"umbrix": { "key": "vega-research-umbrix", "path": "assets/videos/vega/research-umbrix.mp4" },
"kkrix": { "key": "vega-research-kkrix", "path": "assets/videos/vega/research-kkrix.mp4" },
"mekhan": { "key": "vega-research-mekhan", "path": "assets/videos/vega/research-mekhan.mp4" },
"rrashaa": { "key": "vega-research-rrashaa", "path": null },
"rrashaa": { "key": "vega-research-rrashaa", "path": "assets/videos/vega/research-rrashaa.mp4" },
"cerebrai": { "key": "vega-research-cerebrai", "path": "assets/videos/vega/research-cerebrai.mp4" },
"ssakar": { "key": "vega-research-ssakar", "path": "assets/videos/vega/research-ssakar.mp4" },
"lithox": { "key": "vega-research-lithox", "path": "assets/videos/vega/research-lithox.mp4" }

View File

@ -8,6 +8,7 @@ import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { TextInput } from '../../ui/TextInput.js';
import { Tooltip } from '../../ui/Tooltip.js';
import { VegaMusic } from './VegaMusic.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { enqueue as enqueueSpeech, resetQueue as resetSpeechQueue } from '../../ui/SpeechQueue.js';
@ -26,7 +27,7 @@ import { openSystemView } from './VegaSystemView.js';
import { openCombatView } from './VegaCombatView.js';
import {
FONT, D, openDiplomacyScreen, openCouncilScreen, openLeaderScreen,
showVictoryOverlay,
openSaveScreen, openLoadScreen, showVictoryOverlay,
} from './VegaScreens.js';
import { openResearchScreen } from './VegaResearchScreen.js';
import { openResearchChoiceScreen } from './VegaResearchChoiceScreen.js';
@ -36,6 +37,13 @@ import { openAudienceScreen } from './VegaAudience.js';
import { claimAudienceContacts, claimFleetComplaints, canNegotiate } from './VegaDiplomacy.js';
const SAVE_KEY = 'mastervega-save';
// 10 manual slots, independent of the single SAVE_KEY auto-save above (which
// exists purely to power the landing screen's "Resume Game" button). Each
// slot is one localStorage entry: { meta: {...for display...}, raw: the
// engine's own serialize() string }, so loading a slot is just a
// deserialize() call away from the exact same state shape "Resume Game" uses.
const SAVE_SLOT_COUNT = 10;
const saveSlotKey = (i) => `mastervega-save-slot-${i}`;
export default class MasterOfVegaGame extends Phaser.Scene {
constructor() { super('MasterOfVegaGame'); }
@ -43,6 +51,12 @@ export default class MasterOfVegaGame extends Phaser.Scene {
init(data) {
this.roomData = data ?? {};
this.gameDef = data?.game ?? { slug: 'mastervega', name: 'Master of Vega' };
// Set by the game menu's Load screen: a state deserialized from a save
// slot, handed across a full scene restart (see openLoadScreen below)
// rather than swapped into a still-running scene, so the map/HUD/fx from
// the game being left behind get torn down the same proven way Quit to
// Arcade already relies on instead of a bespoke in-place teardown.
this.pendingSavedState = data?.savedState ?? null;
this.modalOpen = false;
this.busy = false;
// Empire indices with a freshly-claimed contact waiting for their
@ -68,7 +82,8 @@ export default class MasterOfVegaGame extends Phaser.Scene {
this.events.once('shutdown', () => this.teardown());
this.showLanding();
if (this.pendingSavedState) this.beginGame(null, this.pendingSavedState);
else this.showLanding();
}
// Wrap a menu handler so it clicks. Every button and card on the front-end
@ -76,7 +91,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
// is actually clickable.
uiClick(fn) {
return (...args) => {
playSound(this, SFX.EIGHTBIT_ACTIVATE);
playSound(this, SFX.VEGA_SELECT);
fn?.(...args);
};
}
@ -476,6 +491,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
});
// --- option rows
const optTooltip = new Tooltip(this, { depth: D.detail });
const optY = 600;
const mkRow = (label, y, options, key, format = (o) => o.name) => {
layer.add(this.add.text(GAME_WIDTH / 2 - 620, y, label, {
@ -489,6 +505,9 @@ export default class MasterOfVegaGame extends Phaser.Scene {
b.setActive(true);
}), { width: 195, height: 46, fontSize: 19 });
if ((opt.id ?? opt) === choice[key]) b.setActive(true);
if (opt.desc) {
optTooltip.attachTo(b, () => ({ title: opt.name, lines: [{ text: opt.desc }] }));
}
buttons.push(b);
layer.add(b);
});
@ -531,6 +550,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
if (!choice.speciesId || !choice.homeColonyName.trim()) { refreshStart(); return; }
choice.homeColonyName = choice.homeColonyName.trim();
nameInput.destroy();
optTooltip.destroy();
layer.destroy();
this.beginGame(choice);
}), { width: 260, height: 64, fontSize: 30 });
@ -540,6 +560,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
// Resuming lives on the landing screen now, so this only steps back to it.
const back = new Button(this, 120, 60, '← Back', this.uiClick(() => {
nameInput.destroy();
optTooltip.destroy();
layer.destroy();
this.showLanding();
}), { width: 170, height: 48, fontSize: 20, variant: 'ghost' });
@ -626,7 +647,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
hud.add(this.hudText);
const mk = (label, x, fn) => {
const b = new Button(this, x, 31, label, fn, { width: 150, height: 42, fontSize: 18 });
const b = new Button(this, x, 31, label, this.uiClick(fn), { width: 150, height: 42, fontSize: 18 });
hud.add(b);
return b;
};
@ -641,7 +662,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
openLeaderScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done,
() => this.refreshHud())));
this.endTurnBtn = new Button(this, GAME_WIDTH - 130, 31, 'End turn', () => this.onEndTurn(),
this.endTurnBtn = new Button(this, GAME_WIDTH - 130, 31, 'End turn', this.uiClick(() => this.onEndTurn()),
{ width: 190, height: 46, fontSize: 20 });
hud.add(this.endTurnBtn);
@ -661,11 +682,103 @@ export default class MasterOfVegaGame extends Phaser.Scene {
this.offerCardLayer = this.add.container(0, 0);
hud.add(this.offerCardLayer);
const back = new Button(this, 96, GAME_HEIGHT - 40, '← Menu', () => {
this.writeSave();
this.scene.start('GameMenu');
}, { width: 150, height: 40, fontSize: 17, variant: 'ghost' });
hud.add(back);
const menuBtn = new Button(this, 56, GAME_HEIGHT - 40, '☰', this.uiClick(() => this.toggleGameMenu()),
{ width: 56, height: 44, fontSize: 24, variant: 'ghost' });
hud.add(menuBtn);
}
// -------------------------------------------------------------- game menu
/** toggles a small popover directly above it: Return to Main Menu, Save,
* Load (lit only once a slot exists), Quit to Arcade. Gated by modalOpen
* like every other HUD button so it can't stack on top of Research/
* Diplomacy/Council/Leaders. */
toggleGameMenu() {
if (this.modalOpen) return;
if (this.gameMenuLayer) { this.closeGameMenu(); return; }
this.openGameMenu();
}
openGameMenu() {
const layer = this.add.container(0, 0).setDepth(D.modal);
this.gameMenuLayer = layer;
// Click-anywhere-else-to-dismiss, same mechanism VegaScreens.js's list
// pickers use for their own veil.
const catcher = this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.001)
.setOrigin(0, 0).setInteractive();
catcher.on('pointerup', () => this.closeGameMenu());
layer.add(catcher);
const items = [
['Return to Main Menu', () => this.returnToMainMenu()],
['Save', () => this.openSaveMenu()],
['Load', () => this.openLoadMenu(), !this.hasAnySaveSlot()],
['Quit to Arcade', () => this.quitToArcade()],
];
const BTN_W = 260;
const BTN_H = 46;
const GAP = 10;
const PAD = 16;
const panelH = PAD * 2 + items.length * BTN_H + (items.length - 1) * GAP;
const panelCx = 56 + BTN_W / 2 - 12;
const panelBottomY = (GAME_HEIGHT - 40) - 22 - 12; // just above the ☰ button
const panelCy = panelBottomY - panelH / 2;
layer.add(this.add.rectangle(panelCx, panelCy, BTN_W + PAD * 2, panelH, 0x0b1220, 0.97)
.setStrokeStyle(1.5, 0x6fc4ff, 0.55));
let by = panelCy - panelH / 2 + PAD + BTN_H / 2;
for (const [label, fn, disabled] of items) {
const btn = new Button(this, panelCx, by, label, this.uiClick(() => { this.closeGameMenu(); fn(); }),
{ width: BTN_W, height: BTN_H, fontSize: 16, variant: 'ghost' });
if (disabled) btn.setEnabled(false);
layer.add(btn);
by += BTN_H + GAP;
}
}
closeGameMenu() {
this.gameMenuLayer?.destroy();
this.gameMenuLayer = null;
}
// Both destinations auto-save to the single "Resume Game" slot on the way
// out, same safety net the old ← Menu button always had — independent of,
// and in addition to, the 10 manual slots below.
returnToMainMenu() {
this.writeSave();
this.scene.start('MasterOfVegaGame', { game: this.gameDef });
}
quitToArcade() {
this.writeSave();
this.scene.start('GameMenu');
}
openSaveMenu() {
this.openModal((done) => openSaveScreen(this, this.rules, this.state, {
getSlots: () => this.allSaveSlotMeta(),
onSave: (i) => this.writeSaveSlot(i),
}, done));
}
openLoadMenu() {
this.openModal((done) => openLoadScreen(this, this.rules, this.state, {
getSlots: () => this.allSaveSlotMeta(),
// A successful load restarts the whole scene (see init()'s
// pendingSavedState) rather than swapping this.state under the
// running map/HUD — the same proven teardown path Quit to Arcade
// already relies on, so `done` (the modal-close callback) is
// intentionally left uncalled: the scene it would resume is gone.
onLoad: (i) => {
const loaded = this.loadSaveSlot(i);
if (!loaded) return;
this.scene.start('MasterOfVegaGame', { game: this.gameDef, savedState: loaded });
},
onDelete: (i) => this.deleteSaveSlot(i),
}, done));
}
refreshHud() {
@ -721,12 +834,12 @@ export default class MasterOfVegaGame extends Phaser.Scene {
}).setOrigin(0, 0.5));
const canSeek = canNegotiate(this.rules, this.state, this.state.humanIndex, otherIdx);
const btn = new Button(this, W / 2 - 78, 0, 'Seek Audience', () => {
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(),
));
}, { width: 140, height: 40, fontSize: 14 });
}), { width: 140, height: 40, fontSize: 14 });
if (!canSeek) btn.setEnabled(false);
card.add(btn);
@ -799,6 +912,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
// instead of ringing the destination star.
if (this.selectedFleet && this.state.fleets.includes(this.selectedFleet)
&& this.selectedFleet.starIdx >= 0 && this.selectedFleet.starIdx !== idx) {
playSound(this, SFX.VEGA_VIEW);
this.map.setRoutePreview(this.selectedFleet.starIdx, idx);
this.panel.showOrder(idx);
return;
@ -818,6 +932,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
if (fleet.starIdx >= 0) this.onStarClick(fleet.starIdx);
return;
}
playSound(this, SFX.VEGA_UNIT);
this.selectedFleet = fleet;
this.map.setSelectedFleet(fleet);
this.panel.showFleet(fleet);
@ -865,7 +980,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
}
const eta = Logic.fleetEta(this.rules, this.state, sent);
this.log(`Fleet away — ${dest.name} in ${eta} turn${eta === 1 ? '' : 's'}.`);
playSound(this, 'ta-rocket-1');
playSound(this, SFX.VEGA_WARP);
this.selectedFleet = null;
this.panel.hide();
this.map.setSelectedStar(toStar);
@ -1089,4 +1204,50 @@ export default class MasterOfVegaGame extends Phaser.Scene {
clearSave() {
try { window.localStorage.removeItem(SAVE_KEY); } catch (err) { /* ignore */ }
}
// ------------------------------------------------------- manual save slots
writeSaveSlot(i) {
try {
const emp = this.state.empires[this.state.humanIndex];
const meta = {
savedAt: Date.now(),
turn: this.state.turn,
year: turnToYear(this.state.turn),
empireName: emp?.name ?? 'Unknown',
speciesName: this.rules.species[emp?.speciesId]?.name ?? '?',
};
window.localStorage.setItem(saveSlotKey(i), JSON.stringify({ meta, raw: Logic.serialize(this.state) }));
return true;
} catch (err) { return false; }
}
readSaveSlotMeta(i) {
try {
const raw = window.localStorage.getItem(saveSlotKey(i));
return raw ? (JSON.parse(raw).meta ?? null) : null;
} catch (err) { return null; }
}
loadSaveSlot(i) {
try {
const raw = window.localStorage.getItem(saveSlotKey(i));
if (!raw) return null;
return Logic.deserialize(JSON.parse(raw).raw);
} catch (err) { return null; }
}
deleteSaveSlot(i) {
try { window.localStorage.removeItem(saveSlotKey(i)); } catch (err) { /* ignore */ }
}
allSaveSlotMeta() {
const out = [];
for (let i = 0; i < SAVE_SLOT_COUNT; i += 1) out.push(this.readSaveSlotMeta(i));
return out;
}
hasAnySaveSlot() {
return this.allSaveSlotMeta().some(Boolean);
}
}

View File

@ -37,7 +37,8 @@ import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js';
import { FONT, D } from './VegaScreens.js';
import { FONT, D, uiClick } from './VegaScreens.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import {
sourceWidth, makeSpeciesPortrait, speciesSpeechClip, audienceVideoKey, hasAudienceVideo,
} from './VegaArt.js';
@ -343,7 +344,10 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
fontFamily: FONT, fontSize: '18px', color: '#9fb6cc',
});
root.add(statusTxt);
const closeBtn = new Button(scene, GAME_WIDTH - 80, 60, '✕', () => close(), { width: 56, height: 48, variant: 'ghost' });
const closeBtn = new Button(scene, GAME_WIDTH - 80, 60, '✕', () => {
playSound(scene, SFX.VEGA_CLOSE);
close();
}, { width: 56, height: 48, variant: 'ghost' });
root.add(closeBtn);
function statusLine() {
@ -682,7 +686,7 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
t.on('pointerdown', () => { box.destroy(true); onPick(id); });
box.add(t);
});
const cancel = new Button(scene, bx, by + h / 2 - 34, 'Cancel', () => box.destroy(true),
const cancel = new Button(scene, bx, by + h / 2 - 34, 'Cancel', uiClick(scene, () => box.destroy(true)),
{ width: 160, height: 40, fontSize: 16, variant: 'ghost' });
box.add(cancel);
}
@ -717,7 +721,7 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
t.on('pointerdown', () => { box.destroy(true); doGift(tier.id); });
box.add(t);
});
const cancel = new Button(scene, bx, by + h / 2 - 34, 'Cancel', () => box.destroy(true),
const cancel = new Button(scene, bx, by + h / 2 - 34, 'Cancel', uiClick(scene, () => box.destroy(true)),
{ width: 160, height: 40, fontSize: 16, variant: 'ghost' });
box.add(cancel);
}
@ -730,7 +734,7 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
const row = Math.floor(i / perRow);
const x = CHAT_X + col * (BTN_W + BTN_GAP) + BTN_W / 2;
const yy = y + row * (BTN_H + BTN_GAP) + BTN_H / 2;
buttonRow.add(new Button(scene, x, yy, label, fn, { width: BTN_W, height: BTN_H, fontSize: 16, ...extraOpts }));
buttonRow.add(new Button(scene, x, yy, label, uiClick(scene, fn), { width: BTN_W, height: BTN_H, fontSize: 16, ...extraOpts }));
});
return Math.ceil(list.length / perRow) || 0;
}
@ -786,7 +790,7 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
[['off', 'Off'], ['steal', 'Steal Tech'], ['sabotage', 'Sabotage']].forEach(([id, label], i) => {
const active = current === id;
const x = CHAT_X + i * (BTN_W + BTN_GAP) + BTN_W / 2;
buttonRow.add(new Button(scene, x, rowY, label, () => doSetMission(id), {
buttonRow.add(new Button(scene, x, rowY, label, uiClick(scene, () => doSetMission(id)), {
width: BTN_W, height: BTN_H, fontSize: 16,
variant: active ? 'solid' : 'ghost',
bg: active ? ACCENT : undefined,

View File

@ -45,7 +45,7 @@ import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { TextInput } from '../../ui/TextInput.js';
import { FONT, D, ORBIT } from './VegaScreens.js';
import { FONT, D, ORBIT, uiClick } from './VegaScreens.js';
import { turnToYear } from './VegaRules.js';
import {
colonyVideoKey, hasColonyVideo, worldBackground, planetFrame, sourceWidth,
@ -349,10 +349,10 @@ export function promptColonyName(scene, { worldName, defaultName }, onConfirm, o
onCancel?.();
};
const found = new Button(scene, cx - 110, y + NAME_PANEL_H - 46, 'Found', confirm,
const found = new Button(scene, cx - 110, y + NAME_PANEL_H - 46, 'Found', uiClick(scene, confirm),
{ width: 200, height: 52 });
root.add(found);
root.add(new Button(scene, cx + 110, y + NAME_PANEL_H - 46, 'Cancel', cancel,
root.add(new Button(scene, cx + 110, y + NAME_PANEL_H - 46, 'Cancel', uiClick(scene, cancel),
{ width: 200, height: 52, variant: 'ghost' }));
refresh();
@ -708,7 +708,7 @@ export function openColonyIntro(scene, rules, state, colony, art, opts = {}) {
resumeMusic();
};
lower.add(new Button(scene, 1620, PANEL_Y + PANEL_H / 2, 'Continue', close,
lower.add(new Button(scene, 1620, PANEL_Y + PANEL_H / 2, 'Continue', uiClick(scene, close),
{ width: 320, height: 70, fontSize: 28 }));
scene.tweens.add({ targets: root, alpha: 1, duration: 340, ease: 'Cubic.easeOut' });

View File

@ -34,7 +34,8 @@ import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { FONT, D, ORBIT, slider } from './VegaScreens.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';
@ -290,8 +291,10 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
add(scene.add.text(x, y, 'COLONY', {
fontFamily: FONT, fontSize: '26px', color: '#cfe8ff',
}));
add(new Button(scene, PANEL_X + PANEL_W - 38, y + 16, '✕', close,
{ width: 40, height: 36, fontSize: 18, variant: 'ghost' }));
add(new Button(scene, PANEL_X + PANEL_W - 38, y + 16, '✕', () => {
playSound(scene, SFX.VEGA_CLOSE);
close();
}, { width: 40, height: 36, fontSize: 18, variant: 'ghost' }));
y += 48;
// --- summary
@ -422,13 +425,13 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
y = buttonY;
const sendBtn = add(new Button(scene, PANEL_X + PANEL_W / 2, y + 22,
'Send Population', openTransportPicker, { width: w, height: 44, fontSize: 18 }));
'Send Population', uiClick(scene, openTransportPicker), { width: w, height: 44, fontSize: 18 }));
if (maxSendablePopulation(colony) <= 0 || empireColonies(state, colony.empireIdx).length < 2) {
sendBtn.setEnabled(false);
}
y += 56;
add(new Button(scene, PANEL_X + PANEL_W / 2, y + 24,
flyOpen ? 'Close Build Queue' : 'Build Queue', toggleFlyout,
flyOpen ? 'Close Build Queue' : 'Build Queue', uiClick(scene, toggleFlyout),
{ width: w, height: 48, fontSize: 20 }));
}
@ -573,7 +576,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
bg.on('pointerout', () => bg.setFillStyle(0x0f1a2c, 0.0));
// Scrolled out of sight is still hit-testable — a mask is a rendering
// concern only, so the visible band has to be checked explicitly.
bg.on('pointerup', (p) => { if (scroller.contains(p)) onAdd(); });
bg.on('pointerup', (p) => { if (scroller.contains(p)) { playSound(scene, SFX.VEGA_BUILD); onAdd(); } });
scroller.content.add(bg);
// A ship gets its commanding officer beside the hull; a building keeps
// the single icon it always had. Both sheets are optional and procedural
@ -614,7 +617,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
.setStrokeStyle(1, ACCENT, 0.55).setInteractive({ useHandCursor: true });
r.on('pointerover', () => r.setFillStyle(0x22405f));
r.on('pointerout', () => r.setFillStyle(0x16253c));
r.on('pointerup', (p) => { if (scroller.contains(p)) onAddFive(); });
r.on('pointerup', (p) => { if (scroller.contains(p)) { playSound(scene, SFX.VEGA_BUILD); onAddFive(); } });
scroller.content.add(r);
scroller.content.add(scene.add.text(w - 40, cy + 25, '+5', {
fontFamily: FONT, fontSize: '15px', color: '#cfe8ff',
@ -727,8 +730,10 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
transportLayer.add(scene.add.text(x, y, 'SEND POPULATION', {
fontFamily: FONT, fontSize: '24px', color: '#cfe8ff',
}));
transportLayer.add(new Button(scene, TX + TW - 38, y + 14, '✕', closeTransportPicker,
{ width: 40, height: 36, fontSize: 18, variant: 'ghost' }));
transportLayer.add(new Button(scene, TX + TW - 38, y + 14, '✕', () => {
playSound(scene, SFX.VEGA_CLOSE);
closeTransportPicker();
}, { width: 40, height: 36, fontSize: 18, variant: 'ghost' }));
y += 46;
transportLayer.add(scene.add.text(x, y,
@ -793,7 +798,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
pusher(transportLayer, x + 235, y + 15, '+', () => setAmount(amount + 1), amount < cap);
pusher(transportLayer, x + 271, y + 15, '+5', () => setAmount(amount + 5), amount < cap,
null, { w: 34, fontSize: 13 });
transportLayer.add(new Button(scene, x + 331, y + 15, 'MAX', () => setAmount(cap),
transportLayer.add(new Button(scene, x + 331, y + 15, 'MAX', uiClick(scene, () => setAmount(cap)),
{ width: 66, height: 32, fontSize: 14, variant: 'ghost' }));
transportLayer.add(scene.add.text(x, y + 40, `${cap.toFixed(1)} available to send`, {
fontFamily: FONT, fontSize: '13px', color: '#6f8aa3',
@ -801,14 +806,14 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
y += 76;
const canSend = !!dest && amount > 0;
const sendBtn = new Button(scene, TX + TW / 2, y + 22, 'Send', () => {
const sendBtn = new Button(scene, TX + TW / 2, y + 22, 'Send', uiClick(scene, () => {
if (!dest) return;
if (sendPopulation(rules, state, colony.empireIdx, colony.id, dest.id, amount)) {
closeTransportPicker();
buildPanel();
onChanged?.();
}
}, { width: w, height: 44, fontSize: 18 });
}), { width: w, height: 44, fontSize: 18 });
if (!canSend) sendBtn.setEnabled(false);
transportLayer.add(sendBtn);
};

View File

@ -8,7 +8,7 @@
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { FONT, D } from './VegaScreens.js';
import { FONT, D, uiClick } from './VegaScreens.js';
import VegaFx from './VegaFx.js';
import { stepRound, runBattle, battleResult } from './VegaCombat.js';
import { shipFrame } from './VegaArt.js';
@ -138,27 +138,27 @@ export function openCombatView(scene, rules, battle, art, opts = {}) {
}
// --- controls
const next = new Button(scene, GAME_WIDTH / 2 - 230, GAME_HEIGHT - 70, 'Next round', () => {
const next = new Button(scene, GAME_WIDTH / 2 - 230, GAME_HEIGHT - 70, 'Next round', uiClick(scene, () => {
if (battle.done) return;
animate(stepRound(battle, {}));
if (battle.done) scene.time.delayedCall(700, finish);
}, { width: 220, height: 52 });
}), { width: 220, height: 52 });
layer.add(next);
const auto = new Button(scene, GAME_WIDTH / 2, GAME_HEIGHT - 70, 'Auto-resolve', () => {
const auto = new Button(scene, GAME_WIDTH / 2, GAME_HEIGHT - 70, 'Auto-resolve', uiClick(scene, () => {
runBattle(battle);
syncMarkers();
finish();
}, { width: 220, height: 52 });
}), { width: 220, height: 52 });
layer.add(auto);
const retreat = new Button(scene, GAME_WIDTH / 2 + 230, GAME_HEIGHT - 70, 'Withdraw', () => {
const retreat = new Button(scene, GAME_WIDTH / 2 + 230, GAME_HEIGHT - 70, 'Withdraw', uiClick(scene, () => {
if (battle.done || !playerSide) return;
const orders = {};
for (const s of battle.stacks) if (s.side === playerSide) orders[s.uid] = 'retreat';
animate(stepRound(battle, orders));
if (battle.done) scene.time.delayedCall(700, finish);
}, { width: 220, height: 52, variant: playerSide ? 'solid' : 'ghost' });
}), { width: 220, height: 52, variant: playerSide ? 'solid' : 'ghost' });
layer.add(retreat);
buildMarkers();

View File

@ -7,6 +7,7 @@
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import {
empireColonies, empireFleets, hireLeader, assignLeader, unassignLeader, leaderHireCost,
} from './VegaLogic.js';
@ -39,6 +40,16 @@ export const ORBIT = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII'];
const ACCENT = 0x6fc4ff;
const PANEL = 0x0b1220;
// Wrap a Button handler so every ordinary click across the game's screens
// shares one cue. Anything that closes a window via the ✕ glyph or has its
// own dedicated sound (build queueing, ship detail, new-turn) bypasses this.
export function uiClick(scene, fn) {
return (...args) => {
playSound(scene, SFX.VEGA_SELECT);
fn?.(...args);
};
}
/** The holographic frame every modal is built inside. */
export function modalShell(scene, title, onClose, { width = 1220, height = 800, closable = true } = {}) {
const layer = scene.add.container(0, 0).setDepth(D.modal);
@ -78,6 +89,7 @@ export function modalShell(scene, title, onClose, { width = 1220, height = 800,
// closable defaults true so every existing caller is unaffected.
if (closable) {
const close = new Button(scene, x + width - 70, y + 40, '✕', () => {
playSound(scene, SFX.VEGA_CLOSE);
layer.destroy();
onClose?.();
}, { width: 46, height: 40, variant: 'ghost' });
@ -187,10 +199,10 @@ export function openDiplomacyScreen(scene, rules, state, e, art, onClose, onChan
const bx = shell.body.x + shell.body.w - 150;
const canSeek = canNegotiate(rules, state, e, other.idx);
const b = new Button(scene, bx, y + rowH / 2 - 20, 'Seek Audience', () => {
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);
}, { width: 200, height: 40 });
}), { width: 200, height: 40 });
if (!canSeek) b.setEnabled(false);
shell.add(b);
});
@ -290,7 +302,7 @@ function openListPicker(scene, shell, title, items) {
t.on('pointerdown', () => { box.destroy(true); fn(); });
box.add(t);
});
const cancel = new Button(scene, bx, by + h / 2 - 34, 'Cancel', () => box.destroy(true),
const cancel = new Button(scene, bx, by + h / 2 - 34, 'Cancel', uiClick(scene, () => box.destroy(true)),
{ width: 160, height: 40, fontSize: 16, variant: 'ghost' });
box.add(cancel);
}
@ -319,13 +331,13 @@ export function openLeaderScreen(scene, rules, state, e, art, onClose, onChanged
fontFamily: FONT, fontSize: '13px', color: '#7fd8a0',
}));
const b = new Button(scene, shell.body.x + shell.body.w - 130, y + 30,
`Hire ${cost} BC`, () => {
`Hire ${cost} BC`, uiClick(scene, () => {
if (hireLeader(rules, state, e, offer.id)) {
shell.destroy();
openLeaderScreen(scene, rules, state, e, art, onClose, onChanged);
onChanged?.();
}
}, { width: 190, height: 38, variant: affordable ? 'solid' : 'ghost' });
}), { width: 190, height: 38, variant: affordable ? 'solid' : 'ghost' });
shell.add(b);
y += 84;
}
@ -379,12 +391,12 @@ export function openLeaderScreen(scene, rules, state, e, art, onClose, onChanged
openListPicker(scene, shell, def.kind === 'admin' ? 'POST TO WHICH COLONY?' : 'POST TO WHICH FLEET?', targets);
};
shell.add(new Button(scene, shell.body.x + shell.body.w - 110, y + 20,
l.assignKind ? 'Reassign' : 'Assign', openPicker, { width: 118, height: 32, fontSize: 14 }));
l.assignKind ? 'Reassign' : 'Assign', uiClick(scene, openPicker), { width: 118, height: 32, fontSize: 14 }));
if (l.assignKind) {
shell.add(new Button(scene, shell.body.x + shell.body.w - 250, y + 20, 'Unassign', () => {
shell.add(new Button(scene, shell.body.x + shell.body.w - 250, y + 20, 'Unassign', uiClick(scene, () => {
unassignLeader(rules, state, e, l.leaderId);
refresh();
}, { width: 118, height: 32, fontSize: 14, variant: 'ghost' }));
}), { width: 118, height: 32, fontSize: 14, variant: 'ghost' }));
}
y += 44;
}
@ -393,6 +405,109 @@ export function openLeaderScreen(scene, rules, state, e, art, onClose, onChanged
// --------------------------------------------------------------------------
/** "Slot 3 — Ursaal Empire (Ursaal) — Year 2431, Turn 131 — 8/8/2026 3:45 PM". */
function saveSlotLabel(i, meta) {
if (!meta) return `Slot ${i + 1} — empty`;
const d = new Date(meta.savedAt);
const when = `${d.toLocaleDateString()} ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
return `Slot ${i + 1}${meta.empireName} (${meta.speciesName}) — Year ${meta.year}, Turn ${meta.turn}${when}`;
}
/**
* Write the current game to one of 10 slots. `getSlots()` returns a
* length-10 array of meta objects (or null for an empty slot), re-read on
* every (re)render so a just-written slot shows up immediately. Overwriting
* an occupied slot needs a confirm click first `confirmIdx` carries that
* transient state across the destroy+rebuild refresh, same pattern
* openLeaderScreen uses for its own picker flows.
*/
export function openSaveScreen(scene, rules, state, { getSlots, onSave }, onClose, confirmIdx = -1) {
const shell = modalShell(scene, 'Save Game', onClose, { width: 1000, height: 800 });
shell.add(scene.add.text(shell.body.x, shell.body.y,
'Choose a slot to save your current game to.', {
fontFamily: FONT, fontSize: '15px', color: '#7f97b3',
}));
const refresh = (nextConfirmIdx = -1) => {
shell.destroy();
openSaveScreen(scene, rules, state, { getSlots, onSave }, onClose, nextConfirmIdx);
};
const ROW_H = 66;
// Load's row (Load + Delete buttons) leaves less room than Save's (one
// button) — sized to the tighter of the two so a long empire/species name
// never runs under either screen's buttons.
const LABEL_W = shell.body.w - 340;
let y = shell.body.y + 34;
getSlots().forEach((meta, i) => {
shell.add(scene.add.text(shell.body.x, y + ROW_H / 2, saveSlotLabel(i, meta), {
fontFamily: FONT, fontSize: '15px', color: meta ? '#c8dcf0' : '#5f7890',
wordWrap: { width: LABEL_W }, lineSpacing: 2,
}).setOrigin(0, 0.5));
if (confirmIdx === i) {
shell.add(new Button(scene, shell.body.x + shell.body.w - 320, y + ROW_H / 2, 'Confirm Overwrite',
uiClick(scene, () => { onSave(i); refresh(); }), { width: 200, height: 40, fontSize: 14 }));
shell.add(new Button(scene, shell.body.x + shell.body.w - 100, y + ROW_H / 2, 'Cancel',
uiClick(scene, () => refresh()), { width: 180, height: 40, fontSize: 14, variant: 'ghost' }));
} else {
shell.add(new Button(scene, shell.body.x + shell.body.w - 100, y + ROW_H / 2,
meta ? 'Overwrite' : 'Save',
uiClick(scene, () => { if (meta) refresh(i); else { onSave(i); refresh(); } }),
{ width: 180, height: 40, fontSize: 15, variant: meta ? 'ghost' : 'solid' }));
}
y += ROW_H;
});
return shell;
}
/**
* Load or delete one of the 10 slots. Deleting needs the same confirm-click
* as an overwrite in openSaveScreen; loading doesn't (it's not destructive
* to anything but the current in-progress session, and the caller is about
* to tear that down regardless see MasterOfVegaGame.openLoadMenu).
*/
export function openLoadScreen(scene, rules, state, { getSlots, onLoad, onDelete }, onClose, confirmDeleteIdx = -1) {
const shell = modalShell(scene, 'Load Game', onClose, { width: 1000, height: 800 });
shell.add(scene.add.text(shell.body.x, shell.body.y,
'Choose a saved game to load or delete.', {
fontFamily: FONT, fontSize: '15px', color: '#7f97b3',
}));
const refresh = (nextConfirmDeleteIdx = -1) => {
shell.destroy();
openLoadScreen(scene, rules, state, { getSlots, onLoad, onDelete }, onClose, nextConfirmDeleteIdx);
};
const ROW_H = 66;
const LABEL_W = shell.body.w - 340;
let y = shell.body.y + 34;
getSlots().forEach((meta, i) => {
shell.add(scene.add.text(shell.body.x, y + ROW_H / 2, saveSlotLabel(i, meta), {
fontFamily: FONT, fontSize: '15px', color: meta ? '#c8dcf0' : '#5f7890',
wordWrap: { width: LABEL_W }, lineSpacing: 2,
}).setOrigin(0, 0.5));
if (!meta) { y += ROW_H; return; }
if (confirmDeleteIdx === i) {
shell.add(new Button(scene, shell.body.x + shell.body.w - 320, y + ROW_H / 2, 'Confirm Delete',
uiClick(scene, () => { onDelete(i); refresh(); }), { width: 200, height: 40, fontSize: 14 }));
shell.add(new Button(scene, shell.body.x + shell.body.w - 100, y + ROW_H / 2, 'Cancel',
uiClick(scene, () => refresh()), { width: 180, height: 40, fontSize: 14, variant: 'ghost' }));
} else {
shell.add(new Button(scene, shell.body.x + shell.body.w - 320, y + ROW_H / 2, 'Load',
uiClick(scene, () => onLoad(i)), { width: 180, height: 40, fontSize: 15 }));
shell.add(new Button(scene, shell.body.x + shell.body.w - 100, y + ROW_H / 2, 'Delete',
uiClick(scene, () => refresh(i)), { width: 180, height: 40, fontSize: 15, variant: 'ghost' }));
}
y += ROW_H;
});
return shell;
}
// --------------------------------------------------------------------------
export function showVictoryOverlay(scene, rules, state, onClose) {
const layer = scene.add.container(0, 0).setDepth(D.toast);
layer.add(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x00060e, 0.9)
@ -424,10 +539,10 @@ export function showVictoryOverlay(scene, rules, state, onClose) {
}).setOrigin(0.5));
// Buttons in this repo draw centred, so they are positioned by their centre.
const b = new Button(scene, GAME_WIDTH / 2, 560, 'Return to menu', () => {
const b = new Button(scene, GAME_WIDTH / 2, 560, 'Return to menu', uiClick(scene, () => {
layer.destroy();
onClose?.();
}, { width: 220, height: 52 });
}), { width: 220, height: 52 });
layer.add(b);
return layer;
}

View File

@ -23,7 +23,8 @@ import { markNumeral } from './VegaRules.js';
import { empireDesign } from './VegaLogic.js';
import { refitCost } from './VegaShips.js';
import { makeCommanderPortrait, makeShipIcon } from './VegaShipMedia.js';
import { FONT, D } from './VegaScreens.js';
import { FONT, D, uiClick } from './VegaScreens.js';
import { playSound, SFX } from '../../ui/Sounds.js';
const ACCENT = 0x6fc4ff;
const PANEL = 0x0b1220;
@ -44,6 +45,7 @@ const ROLE_NAMES = {
*/
export function openShipDetail(scene, rules, state, art, opts, onClose) {
const { empireIdx, hullId, mark = null } = opts;
playSound(scene, SFX.VEGA_UNIT);
const emp = state.empires[empireIdx];
const species = rules.species[emp.speciesId];
const design = empireDesign(rules, state, empireIdx, hullId);
@ -223,7 +225,7 @@ export function openShipDetail(scene, rules, state, art, opts, onClose) {
});
};
pop.add(new Button(scene, halfW - 110, halfH - 52, 'Close', close,
pop.add(new Button(scene, halfW - 110, halfH - 52, 'Close', uiClick(scene, close),
{ width: 180, height: 52, fontSize: 22, variant: 'ghost' }));
veil.on('pointerup', close);

View File

@ -30,7 +30,8 @@ import {
colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories,
colonyDefenseCap, habitableForEmpire, reachableStars, atWar, queueItemEta,
} from './VegaLogic.js';
import { FONT, D, ORBIT } from './VegaScreens.js';
import { FONT, D, ORBIT, uiClick } from './VegaScreens.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { etaText } from './VegaColonyView.js';
import { createShipMediaPool, makeShipIcon } from './VegaShipMedia.js';
import { openShipDetail } from './VegaShipDetail.js';
@ -92,6 +93,7 @@ export default class VegaSidePanel {
this.root.add(scene.add.rectangle(PAD, 72, COL, 1, ACCENT, 0.4).setOrigin(0, 0));
this.root.add(new Button(scene, W - 32, 30, '✕', () => {
playSound(scene, SFX.VEGA_CLOSE);
this.cb.onClose?.();
}, { width: 38, height: 34, fontSize: 18, variant: 'ghost' }));
@ -273,7 +275,7 @@ export default class VegaSidePanel {
/** Full-width action button parked at the bottom of the column. */
action(label, fn, opts = {}) {
const { enabled = true, width = COL, x = PAD + COL / 2, variant = 'solid' } = opts;
const b = new Button(this.scene, x, this.y + 24, label, enabled ? fn : null,
const b = new Button(this.scene, x, this.y + 24, label, enabled ? uiClick(this.scene, fn) : null,
{ width, height: 48, fontSize: 20, variant });
if (!enabled) b.setEnabled?.(false);
this.body.add(b);
@ -752,13 +754,16 @@ export default class VegaSidePanel {
const half = (COL - 12) / 2;
const btnY = this.y;
// Not uiClick — accepting an order plays its own vega-warp cue once the
// fleet is actually sent (MasterOfVegaGame.confirmOrder), not a generic
// click, and a fuel-range refusal should stay silent either way.
const accept = new Button(this.scene, PAD + half / 2, btnY + 24, 'Accept',
refusal ? null : () => this.cb.onAcceptOrder?.(this.fleet, this.orderStar, this.selectedShips()),
{ width: half, height: 48, fontSize: 20 });
if (refusal) accept.setEnabled?.(false);
this.body.add(accept);
this.body.add(new Button(this.scene, PAD + half * 1.5 + 12, btnY + 24, 'Cancel',
() => this.cb.onCancelOrder?.(), { width: half, height: 48, fontSize: 20, variant: 'ghost' }));
uiClick(this.scene, () => this.cb.onCancelOrder?.()), { width: half, height: 48, fontSize: 20, variant: 'ghost' }));
this.y = btnY + 60;
}
}

View File

@ -15,7 +15,7 @@
import * as Phaser from 'phaser';
import { Button } from '../../ui/Button.js';
import { modalShell, FONT, ORBIT } from './VegaScreens.js';
import { modalShell, FONT, ORBIT, uiClick } from './VegaScreens.js';
import { planetFrame, starFrame } from './VegaArt.js';
import { openColonyView, CHANNEL_COLOUR, itemName, etaText } from './VegaColonyView.js';
import { openColonyIntro, ensureColonyVideo, promptColonyName } from './VegaColonyIntro.js';
@ -167,7 +167,7 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
// repeat: every rebuild of this panel asks again, and ensureColonyVideo
// returns immediately once the clip is cached or already in flight.
ensureColonyVideo(scene, planet.typeId);
console_.add(new Button(scene, panelX + 130, y + 20, 'Found colony', () => {
console_.add(new Button(scene, panelX + 130, y + 20, 'Found colony', uiClick(scene, () => {
const orbit = selected;
// Naming happens before the colony exists — canColonize/colonize
// read no state this pauses, so the orrery is frozen for the whole
@ -188,7 +188,7 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
onClose: () => { tick.paused = false; rebuild(); },
});
}, () => { tick.paused = false; });
}, { width: 260, height: 44 }));
}), { width: 260, height: 44 }));
}
return;
}
@ -223,13 +223,13 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
wordWrap: { width: panelW },
}));
y += 44;
console_.add(new Button(scene, panelX + 120, y + 20, 'Bombard', () => {
console_.add(new Button(scene, panelX + 120, y + 20, 'Bombard', uiClick(scene, () => {
bombard(rules, state, viewerIdx, starIdx); onChanged?.(); rebuild();
}, { width: 220, height: 42, bg: 0x6b2230 }));
}), { width: 220, height: 42, bg: 0x6b2230 }));
if (forecast && forecast.troops > 0) {
console_.add(new Button(scene, panelX + 370, y + 20, 'Invade', () => {
console_.add(new Button(scene, panelX + 370, y + 20, 'Invade', uiClick(scene, () => {
invade(rules, state, viewerIdx, starIdx); onChanged?.(); rebuild();
}, { width: 220, height: 42 }));
}), { width: 220, height: 42 }));
}
}
return;
@ -303,7 +303,7 @@ 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', () => {
console_.add(new Button(scene, panelX + panelW / 2, y + 24, 'View Colony', uiClick(scene, () => {
shell.layer.setVisible(false);
tick.paused = true;
openColonyView(scene, rules, state, colony, art, {
@ -314,7 +314,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) {

View File

@ -8,7 +8,8 @@
// would make the two files import each other. Same one-way-dependency shape
// as VegaSystemView.js, which already imports from both.
import { modalShell, FONT } from './VegaScreens.js';
import { modalShell, FONT, uiClick } from './VegaScreens.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { scrollColumn } from './VegaColonyView.js';
import { openSystemView } from './VegaSystemView.js';
import { openResearchScreen } from './VegaResearchScreen.js';
@ -45,6 +46,7 @@ const BUILDING_MEDIA_W = 90;
const VIEW_SYSTEM_TYPES = new Set(['discovered', 'buildingDone', 'shipDone']);
export function openTurnReportScreen(scene, rules, state, events, onClose) {
playSound(scene, SFX.VEGA_NEWTURN);
const shell = modalShell(scene, `New Turn: ${turnToYear(state.turn)}`, onClose, { width: 1180, height: 760 });
const ordered = events
@ -142,11 +144,13 @@ export function openTurnReportScreen(scene, rules, state, events, onClose) {
};
const allBtn = new Button(scene, 16 + 84, cy + 14, 'All Colonies', () => {
playSound(scene, SFX.VEGA_BUILD);
queueAt(empireColonies(state, ev.empire));
}, { width: 168, height: 28, fontSize: 13 });
col.content.add(allBtn);
const bcBtn = new Button(scene, 16 + 168 + 16 + 105, cy + 14, 'Colonies with positive BC', () => {
playSound(scene, SFX.VEGA_BUILD);
queueAt(empireColonies(state, ev.empire).filter((c) => colonyNetIncome(rules, state, c) > 0));
}, { width: 210, height: 28, fontSize: 13 });
col.content.add(bcBtn);
@ -224,7 +228,7 @@ export function openTurnReportScreen(scene, rules, state, events, onClose) {
// the bottom-right of the expanded synopsis, not the collapsed
// header, so it only appears once the player has read the row.
if (VIEW_SYSTEM_TYPES.has(ev.type)) {
const viewBtn = new Button(scene, w - 108, cy + 14, 'View Star System', () => {
const viewBtn = new Button(scene, w - 108, cy + 14, 'View Star System', uiClick(scene, () => {
col.destroy();
shell.destroy();
openSystemView(scene, rules, state, ev.starIdx, scene.art, {
@ -232,7 +236,7 @@ export function openTurnReportScreen(scene, rules, state, events, onClose) {
onChanged: () => scene.refreshAll?.(),
onClose,
});
}, { width: 168, height: 28, fontSize: 13 });
}), { width: 168, height: 28, fontSize: 13 });
col.content.add(viewBtn);
cy += 36;
}
@ -244,13 +248,13 @@ export function openTurnReportScreen(scene, rules, state, events, onClose) {
// through the same onClose so the turn-transition still completes
// exactly once, whichever button the player used to leave the report.
if (ev.type === 'techDone') {
const viewBtn = new Button(scene, w - 108, cy + 14, 'View Research', () => {
const viewBtn = new Button(scene, w - 108, cy + 14, 'View Research', uiClick(scene, () => {
col.destroy();
shell.destroy();
openResearchScreen(scene, rules, state, ev.empire, scene.art, onClose, {
initialField: rules.techs[ev.techId].field,
});
}, { width: 168, height: 28, fontSize: 13 });
}), { width: 168, height: 28, fontSize: 13 });
col.content.add(viewBtn);
cy += 36;
}
@ -277,11 +281,11 @@ export function openTurnReportScreen(scene, rules, state, events, onClose) {
};
render();
const btn = new Button(scene, shell.x + shell.width / 2, shell.y + shell.height - 34, 'Continue', () => {
const btn = new Button(scene, shell.x + shell.width / 2, shell.y + shell.height - 34, 'Continue', uiClick(scene, () => {
col.destroy();
shell.destroy();
onClose?.();
}, { width: 220, height: 44 });
}), { width: 220, height: 44 });
shell.add(btn);
return shell;

View File

@ -182,6 +182,14 @@ export default class PreloadScene extends Phaser.Scene {
this.load.audio('sfx-monopoly-expense', 'assets/fx/monopoly-expense.mp3');
this.load.audio('sfx-monopoly-pay', 'assets/fx/monopoly-pay.mp3');
this.load.audio('sfx-monopoly-paid', 'assets/fx/monopoly-paid.mp3');
this.load.audio('sfx-vega-select', 'assets/fx/vega-select.mp3');
this.load.audio('sfx-vega-build', 'assets/fx/vega-build.mp3');
this.load.audio('sfx-vega-close', 'assets/fx/vega-close.mp3');
this.load.audio('sfx-vega-newturn', 'assets/fx/vega-newturn.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-warp', 'assets/fx/vega-warp.mp3');
}
async create() {

View File

@ -99,6 +99,13 @@ export const SFX = {
KART_SHELL: 'sfx-kart-shell',
KART_STAR: 'sfx-kart-star',
KART_SPINNER: 'sfx-kart-spinner',
VEGA_SELECT: 'sfx-vega-select',
VEGA_BUILD: 'sfx-vega-build',
VEGA_CLOSE: 'sfx-vega-close',
VEGA_NEWTURN: 'sfx-vega-newturn',
VEGA_UNIT: 'sfx-vega-unit',
VEGA_VIEW: 'sfx-vega-view',
VEGA_WARP: 'sfx-vega-warp',
};
export function playSound(scene, key) {