diff --git a/assets/fx/vega-build.mp3 b/assets/fx/vega-build.mp3 new file mode 100644 index 0000000..8120760 Binary files /dev/null and b/assets/fx/vega-build.mp3 differ diff --git a/assets/fx/vega-close.mp3 b/assets/fx/vega-close.mp3 new file mode 100644 index 0000000..1a0fc95 Binary files /dev/null and b/assets/fx/vega-close.mp3 differ diff --git a/assets/fx/vega-newturn.mp3 b/assets/fx/vega-newturn.mp3 new file mode 100644 index 0000000..e94b778 Binary files /dev/null and b/assets/fx/vega-newturn.mp3 differ diff --git a/assets/fx/vega-select.mp3 b/assets/fx/vega-select.mp3 new file mode 100644 index 0000000..884e7e4 Binary files /dev/null and b/assets/fx/vega-select.mp3 differ diff --git a/assets/fx/vega-unit.mp3 b/assets/fx/vega-unit.mp3 new file mode 100644 index 0000000..913380f Binary files /dev/null and b/assets/fx/vega-unit.mp3 differ diff --git a/assets/fx/vega-view.mp3 b/assets/fx/vega-view.mp3 new file mode 100644 index 0000000..c7c9a9a Binary files /dev/null and b/assets/fx/vega-view.mp3 differ diff --git a/assets/fx/vega-warp.mp3 b/assets/fx/vega-warp.mp3 new file mode 100644 index 0000000..2a35a5c Binary files /dev/null and b/assets/fx/vega-warp.mp3 differ diff --git a/assets/images/vega/ships.png b/assets/images/vega/ships.png index 460fef9..b05177b 100644 Binary files a/assets/images/vega/ships.png and b/assets/images/vega/ships.png differ diff --git a/assets/images/vega/ships.psd b/assets/images/vega/ships.psd index 44d8a88..fd2a22c 100644 Binary files a/assets/images/vega/ships.psd and b/assets/images/vega/ships.psd differ diff --git a/assets/videos/vega/research-rrashaa.mp4 b/assets/videos/vega/research-rrashaa.mp4 new file mode 100644 index 0000000..6078eee Binary files /dev/null and b/assets/videos/vega/research-rrashaa.mp4 differ diff --git a/assets/videos/vega/research-umbrix.mp4 b/assets/videos/vega/research-umbrix.mp4 new file mode 100644 index 0000000..80c120e Binary files /dev/null and b/assets/videos/vega/research-umbrix.mp4 differ diff --git a/data/mastervega-artwork.json b/data/mastervega-artwork.json index a39bca7..bb98f15 100644 --- a/data/mastervega-artwork.json +++ b/data/mastervega-artwork.json @@ -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" } diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js index 6c3b9d7..921ad54 100644 --- a/src/games/mastervega/MasterOfVegaGame.js +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -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); + } } diff --git a/src/games/mastervega/VegaAudience.js b/src/games/mastervega/VegaAudience.js index 0cc4b68..7491288 100644 --- a/src/games/mastervega/VegaAudience.js +++ b/src/games/mastervega/VegaAudience.js @@ -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, diff --git a/src/games/mastervega/VegaColonyIntro.js b/src/games/mastervega/VegaColonyIntro.js index 077149d..73f88a0 100644 --- a/src/games/mastervega/VegaColonyIntro.js +++ b/src/games/mastervega/VegaColonyIntro.js @@ -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' }); diff --git a/src/games/mastervega/VegaColonyView.js b/src/games/mastervega/VegaColonyView.js index e61d6e3..cd510b9 100644 --- a/src/games/mastervega/VegaColonyView.js +++ b/src/games/mastervega/VegaColonyView.js @@ -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); }; diff --git a/src/games/mastervega/VegaCombatView.js b/src/games/mastervega/VegaCombatView.js index e64dfb3..b7f1795 100644 --- a/src/games/mastervega/VegaCombatView.js +++ b/src/games/mastervega/VegaCombatView.js @@ -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(); diff --git a/src/games/mastervega/VegaScreens.js b/src/games/mastervega/VegaScreens.js index 4e50ecf..d611e69 100644 --- a/src/games/mastervega/VegaScreens.js +++ b/src/games/mastervega/VegaScreens.js @@ -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; } diff --git a/src/games/mastervega/VegaShipDetail.js b/src/games/mastervega/VegaShipDetail.js index a5314c8..8bb9df6 100644 --- a/src/games/mastervega/VegaShipDetail.js +++ b/src/games/mastervega/VegaShipDetail.js @@ -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); diff --git a/src/games/mastervega/VegaSidePanel.js b/src/games/mastervega/VegaSidePanel.js index 4894bff..7209561 100644 --- a/src/games/mastervega/VegaSidePanel.js +++ b/src/games/mastervega/VegaSidePanel.js @@ -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; } } diff --git a/src/games/mastervega/VegaSystemView.js b/src/games/mastervega/VegaSystemView.js index 6b85b7c..ebc3d69 100644 --- a/src/games/mastervega/VegaSystemView.js +++ b/src/games/mastervega/VegaSystemView.js @@ -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) { diff --git a/src/games/mastervega/VegaTurnReportScreen.js b/src/games/mastervega/VegaTurnReportScreen.js index 0e6660f..103aea3 100644 --- a/src/games/mastervega/VegaTurnReportScreen.js +++ b/src/games/mastervega/VegaTurnReportScreen.js @@ -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; diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 2ed1457..7409942 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -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() { diff --git a/src/ui/Sounds.js b/src/ui/Sounds.js index b2e1ee2..fca0813 100644 --- a/src/ui/Sounds.js +++ b/src/ui/Sounds.js @@ -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) {