diff --git a/assets/videos/vega/ship-cerebrai-battleship.mp4 b/assets/videos/vega/ship-cerebrai-battleship.mp4 new file mode 100644 index 0000000..658ad60 Binary files /dev/null and b/assets/videos/vega/ship-cerebrai-battleship.mp4 differ diff --git a/assets/videos/vega/ship-cerebrai-colony.mp4 b/assets/videos/vega/ship-cerebrai-colony.mp4 new file mode 100644 index 0000000..0ceb962 Binary files /dev/null and b/assets/videos/vega/ship-cerebrai-colony.mp4 differ diff --git a/assets/videos/vega/ship-cerebrai-cruiser.mp4 b/assets/videos/vega/ship-cerebrai-cruiser.mp4 new file mode 100644 index 0000000..5f85839 Binary files /dev/null and b/assets/videos/vega/ship-cerebrai-cruiser.mp4 differ diff --git a/assets/videos/vega/ship-cerebrai-destroyer.mp4 b/assets/videos/vega/ship-cerebrai-destroyer.mp4 new file mode 100644 index 0000000..542bf56 Binary files /dev/null and b/assets/videos/vega/ship-cerebrai-destroyer.mp4 differ diff --git a/assets/videos/vega/ship-cerebrai-frigate.mp4 b/assets/videos/vega/ship-cerebrai-frigate.mp4 new file mode 100644 index 0000000..6ea8d3a Binary files /dev/null and b/assets/videos/vega/ship-cerebrai-frigate.mp4 differ diff --git a/assets/videos/vega/ship-cerebrai-scout.mp4 b/assets/videos/vega/ship-cerebrai-scout.mp4 new file mode 100644 index 0000000..85332e0 Binary files /dev/null and b/assets/videos/vega/ship-cerebrai-scout.mp4 differ diff --git a/assets/videos/vega/ship-cerebrai-transport.mp4 b/assets/videos/vega/ship-cerebrai-transport.mp4 new file mode 100644 index 0000000..6c92f68 Binary files /dev/null and b/assets/videos/vega/ship-cerebrai-transport.mp4 differ diff --git a/assets/videos/vega/ship-rrashaa-battleship.mp4 b/assets/videos/vega/ship-rrashaa-battleship.mp4 new file mode 100644 index 0000000..15cb210 Binary files /dev/null and b/assets/videos/vega/ship-rrashaa-battleship.mp4 differ diff --git a/assets/videos/vega/ship-rrashaa-colony.mp4 b/assets/videos/vega/ship-rrashaa-colony.mp4 new file mode 100644 index 0000000..75fea0e Binary files /dev/null and b/assets/videos/vega/ship-rrashaa-colony.mp4 differ diff --git a/assets/videos/vega/ship-rrashaa-cruiser.mp4 b/assets/videos/vega/ship-rrashaa-cruiser.mp4 new file mode 100644 index 0000000..7a4b6d3 Binary files /dev/null and b/assets/videos/vega/ship-rrashaa-cruiser.mp4 differ diff --git a/assets/videos/vega/ship-rrashaa-destroyer.mp4 b/assets/videos/vega/ship-rrashaa-destroyer.mp4 new file mode 100644 index 0000000..763698e Binary files /dev/null and b/assets/videos/vega/ship-rrashaa-destroyer.mp4 differ diff --git a/assets/videos/vega/ship-rrashaa-frigate.mp4 b/assets/videos/vega/ship-rrashaa-frigate.mp4 new file mode 100644 index 0000000..b74723a Binary files /dev/null and b/assets/videos/vega/ship-rrashaa-frigate.mp4 differ diff --git a/assets/videos/vega/ship-rrashaa-scout.mp4 b/assets/videos/vega/ship-rrashaa-scout.mp4 new file mode 100644 index 0000000..2b603c2 Binary files /dev/null and b/assets/videos/vega/ship-rrashaa-scout.mp4 differ diff --git a/assets/videos/vega/ship-rrashaa-transport.mp4 b/assets/videos/vega/ship-rrashaa-transport.mp4 new file mode 100644 index 0000000..ef5b3b8 Binary files /dev/null and b/assets/videos/vega/ship-rrashaa-transport.mp4 differ diff --git a/assets/videos/vega/ships-cerebrai-colony.mp4 b/assets/videos/vega/ships-cerebrai-colony.mp4 new file mode 100644 index 0000000..bfd44e0 Binary files /dev/null and b/assets/videos/vega/ships-cerebrai-colony.mp4 differ diff --git a/data/masterofvega-music.json b/data/masterofvega-music.json index d8d1272..d2e722c 100644 --- a/data/masterofvega-music.json +++ b/data/masterofvega-music.json @@ -1,5 +1,32 @@ { - "tracks": [ + "fallback": { + "volume": 0.6, + "tracks": [ + { + "file": "vega/track-01.mp3", + "artist": "DV-8-2303", + "title": "Power Down" + }, + { + "file": "vega/track-02.mp3", + "artist": "Klaxon", + "title": "High Orbit" + }, + { + "file": "vega/track-05.mp3", + "artist": "Rrashaa", + "title": "Into the Void" + } + ] + }, + "menu": { "tracks": [ + { + "file": "vega/track-03.mp3", + "artist": "Aiko", + "title": "Master of Vega Main Theme" + } + ] }, + "peace": { "tracks": [ { "file": "vega/track-01.mp3", "artist": "DV-8-2303", @@ -10,21 +37,31 @@ "artist": "Klaxon", "title": "High Orbit" }, - { - "file": "vega/track-03.mp3", - "artist": "Aiko", - "title": "Master of Vega Main Theme" - }, - { - "file": "vega/track-04.mp3", - "artist": "Cy-Bro", - "title": "Defend the Colony" - }, { "file": "vega/track-05.mp3", "artist": "Rrashaa", "title": "Into the Void" } - ], - "volume": 0.6 + ] }, + "combat": { "tracks": [ + { + "file": "vega/track-04.mp3", + "artist": "Cy-Bro", + "title": "Defend the Colony" + } + ] }, + "diplomacy": { + "default": { "tracks": [] }, + "bySpecies": { + "kestrelli": { "tracks": [] }, + "ursaal": { "tracks": [] }, + "umbrix": { "tracks": [] }, + "kkrix": { "tracks": [] }, + "mekhan": { "tracks": [] }, + "rrashaa": { "tracks": [] }, + "cerebrai": { "tracks": [] }, + "ssakar": { "tracks": [] }, + "lithox": { "tracks": [] } + } + } } diff --git a/data/mastervega-artwork.json b/data/mastervega-artwork.json index a5d5bb9..8a001a2 100644 --- a/data/mastervega-artwork.json +++ b/data/mastervega-artwork.json @@ -72,8 +72,24 @@ "cruiser": { "key": "vega-ship-mekhan-cruiser", "path": "assets/videos/vega/ship-mekhan-cruiser.mp4" }, "battleship": { "key": "vega-ship-mekhan-battleship", "path": "assets/videos/vega/ship-mekhan-battleship.mp4" } }, - "rrashaa": null, - "cerebrai": null, + "rrashaa": { + "scout": { "key": "vega-ship-rrashaa-scout", "path": "assets/videos/vega/ship-rrashaa-scout.mp4" }, + "colonyship": { "key": "vega-ship-rrashaa-colonyship", "path": "assets/videos/vega/ship-rrashaa-colony.mp4" }, + "transport": { "key": "vega-ship-rrashaa-transport", "path": "assets/videos/vega/ship-rrashaa-transport.mp4" }, + "frigate": { "key": "vega-ship-rrashaa-frigate", "path": "assets/videos/vega/ship-rrashaa-frigate.mp4" }, + "destroyer": { "key": "vega-ship-rrashaa-destroyer", "path": "assets/videos/vega/ship-rrashaa-destroyer.mp4" }, + "cruiser": { "key": "vega-ship-rrashaa-cruiser", "path": "assets/videos/vega/ship-rrashaa-cruiser.mp4" }, + "battleship": { "key": "vega-ship-rrashaa-battleship", "path": "assets/videos/vega/ship-rrashaa-battleship.mp4" } + }, + "cerebrai": { + "scout": { "key": "vega-ship-cerebrai-scout", "path": "assets/videos/vega/ship-cerebrai-scout.mp4" }, + "colonyship": { "key": "vega-ship-cerebrai-colonyship", "path": "assets/videos/vega/ship-cerebrai-colony.mp4" }, + "transport": { "key": "vega-ship-cerebrai-transport", "path": "assets/videos/vega/ship-cerebrai-transport.mp4" }, + "frigate": { "key": "vega-ship-cerebrai-frigate", "path": "assets/videos/vega/ship-cerebrai-frigate.mp4" }, + "destroyer": { "key": "vega-ship-cerebrai-destroyer", "path": "assets/videos/vega/ship-cerebrai-destroyer.mp4" }, + "cruiser": { "key": "vega-ship-cerebrai-cruiser", "path": "assets/videos/vega/ship-cerebrai-cruiser.mp4" }, + "battleship": { "key": "vega-ship-cerebrai-battleship", "path": "assets/videos/vega/ship-cerebrai-battleship.mp4" } + }, "ssakar": null, "lithox": null }, @@ -177,6 +193,20 @@ } }, + "_researchVideosReadme": "Research-screen ambient loops — one per species, showing that species' scientists at work, muted, looping continuously (setLoop(true), unlike the audience screen's play-once mood clips above). Recorded 2:3 (portrait). JIT-loaded like colonyVideos/audienceVideos, NOT eager via assetManifest.js — Research only ever shows the human player's own species, so there is no reason to fetch the other nine at game-room entry. A species with no clip recorded falls back to its portraitVideos clip (256x256, letterboxed to 2:3), then portraitStills — same three-tier fallback convention as makeSpeciesPortrait. Naming: assets/videos/vega/research-.mp4.", + "researchVideos": { + "human": { "key": "vega-research-human", "path": null }, + "kestrelli": { "key": "vega-research-kestrelli", "path": null }, + "ursaal": { "key": "vega-research-ursaal", "path": null }, + "umbrix": { "key": "vega-research-umbrix", "path": null }, + "kkrix": { "key": "vega-research-kkrix", "path": null }, + "mekhan": { "key": "vega-research-mekhan", "path": null }, + "rrashaa": { "key": "vega-research-rrashaa", "path": null }, + "cerebrai": { "key": "vega-research-cerebrai", "path": null }, + "ssakar": { "key": "vega-research-ssakar", "path": null }, + "lithox": { "key": "vega-research-lithox", "path": null } + }, + "sheets": { "ships": { "key": "vega-ships", diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index d26f8f9..8039bc7 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -95,6 +95,34 @@ function musicFrom(scene, jsonKey) { .map((t, i) => ({ type: 'audio', key: `${jsonKey}-${i}`, path: `assets/music/${t.file}` })); } +// Master of Vega's soundtrack is condition-driven (menu/peace/combat/one pool +// per diplomacy race — see VegaMusic.js) rather than one flat shuffled list, +// so its `-music.json` nests tracks under named pools instead of a +// single top-level `tracks` array. Walk every pool and flatten them into the +// same {type:'audio', key, path} shape musicFrom produces. VegaMusic.js reads +// track file paths straight out of the cached JSON at playback time (it plays +// via plain `Audio` elements, not these cache keys) — this is purely so the +// mp3 bytes are pre-fetched when the game room is entered. +function vegaMusicFrom(scene, jsonKey) { + const data = scene.cache.json.get(jsonKey); + if (!data) return []; + const out = []; + const addPool = (pool, keyPrefix) => { + (pool?.tracks ?? []) + .filter((t) => t && t.file) + .forEach((t, i) => out.push({ type: 'audio', key: `${jsonKey}-${keyPrefix}-${i}`, path: `assets/music/${t.file}` })); + }; + addPool(data.fallback, 'fallback'); + addPool(data.menu, 'menu'); + addPool(data.peace, 'peace'); + addPool(data.combat, 'combat'); + addPool(data.diplomacy?.default, 'diplomacy-default'); + for (const [speciesId, pool] of Object.entries(data.diplomacy?.bySpecies ?? {})) { + addPool(pool, `diplomacy-${speciesId}`); + } + return out; +} + const sheet = (key, path, frameWidth, frameHeight) => ({ type: 'spritesheet', key, path, frameWidth, frameHeight }); const image = (key, path) => ({ type: 'image', key, path }); @@ -137,7 +165,7 @@ export const MANIFEST = { { type: 'audio', key: 'scifi-explode', path: 'assets/fx/scifi-explode.mp3' }, { type: 'audio', key: 'ta-rocket-1', path: 'assets/fx/ta-rocket-1.mp3' }, { type: 'audio', key: 'ta-rocket-2', path: 'assets/fx/ta-rocket-2.mp3' }, - (scene) => musicFrom(scene, 'masterofvega-music'), + (scene) => vegaMusicFrom(scene, 'masterofvega-music'), ], forbiddenisland: [ // Tiles: 2 cols (dry, flooded) × 24 rows. Row i → dry frame 2i, flooded diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js index d204ebf..1a48f0c 100644 --- a/src/games/mastervega/MasterOfVegaGame.js +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -8,8 +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 { MusicPlayer } from '../../ui/MusicPlayer.js'; -import { getGameSoundtrack } from '../../services/soundtrack.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,9 +25,10 @@ import VegaFx from './VegaFx.js'; import { openSystemView } from './VegaSystemView.js'; import { openCombatView } from './VegaCombatView.js'; import { - FONT, D, openResearchScreen, openDiplomacyScreen, openCouncilScreen, openLeaderScreen, + FONT, D, openDiplomacyScreen, openCouncilScreen, openLeaderScreen, showVictoryOverlay, } from './VegaScreens.js'; +import { openResearchScreen } from './VegaResearchScreen.js'; import { openTurnReportScreen } from './VegaTurnReportScreen.js'; import { NOTABLE_TYPES, isRelevantToHuman } from './VegaTurnReport.js'; import { openAudienceScreen } from './VegaAudience.js'; @@ -41,7 +41,6 @@ export default class MasterOfVegaGame extends Phaser.Scene { init(data) { this.roomData = data ?? {}; - // getGameSoundtrack() reads scene.gameDef.slug to pick the track list. this.gameDef = data?.game ?? { slug: 'mastervega', name: 'Master of Vega' }; this.modalOpen = false; this.busy = false; @@ -62,8 +61,8 @@ export default class MasterOfVegaGame extends Phaser.Scene { } try { - const { tracks, volume } = getGameSoundtrack(this); - if (tracks?.length) this.music = new MusicPlayer(this, tracks, volume); + this.music = new VegaMusic(this, this.cache.json.get('masterofvega-music')); + this.music.setCategory('menu'); } catch (err) { /* music is optional */ } this.events.once('shutdown', () => this.teardown()); @@ -575,6 +574,7 @@ export default class MasterOfVegaGame extends Phaser.Scene { }); } this.state.rules = this.rules; + this.music?.setCategory('peace'); this.fxLayer = this.add.container(0, 0).setDepth(D.hud - 1); this.fx = new VegaFx(this, this.fxLayer); @@ -714,8 +714,13 @@ export default class MasterOfVegaGame extends Phaser.Scene { if (!this.pendingAudiences.length) { onDone?.(); return; } const otherIdx = this.pendingAudiences.shift(); this.modalOpen = true; + this.music?.setDiplomacy(this.state.empires[otherIdx].speciesId); openAudienceScreen(this, this.rules, this.state, this.state.humanIndex, otherIdx, this.art, () => { this.modalOpen = false; + // Only drop back to peace once the queue is empty — a next entry here + // recurses straight into another setDiplomacy() below, so back-to-back + // audiences never blip through the peacetime pool in between. + if (!this.pendingAudiences.length) this.music?.setDiplomacy(null); this.refreshAll(); this.runAudienceQueue(onDone); }, () => this.refreshAll()); @@ -850,6 +855,7 @@ export default class MasterOfVegaGame extends Phaser.Scene { if (!prepared) { next(i + 1); return; } this.map?.panToStar(starIdx, 260); this.modalOpen = true; + this.music?.setCategory('combat'); openCombatView(this, this.rules, prepared.battle, this.art, { attackerSpecies: this.state.empires[prepared.attackerIdx].speciesId, defenderSpecies: this.state.empires[prepared.defenderIdx].speciesId, @@ -857,6 +863,7 @@ export default class MasterOfVegaGame extends Phaser.Scene { onDone: (result) => { Logic.applyBattleOutcome(this.rules, this.state, prepared, result); this.modalOpen = false; + this.music?.setCategory('peace'); this.refreshAll(); next(i + 1); }, diff --git a/src/games/mastervega/VegaArt.js b/src/games/mastervega/VegaArt.js index cc1120f..d7a0ee6 100644 --- a/src/games/mastervega/VegaArt.js +++ b/src/games/mastervega/VegaArt.js @@ -694,6 +694,16 @@ export function hasAudienceVideo(scene, speciesId, mood) { return !!scene.cache.video?.exists(audienceVideoKey(speciesId, mood)); } +// Research-screen ambient loops (VegaResearchScreen.js) — one per species, +// showing that species' scientists at work, JIT-loaded like the audience +// clips above but with no mood dimension and no play-once contract (these +// loop continuously for as long as the screen is open). +export const speciesResearchVideoKey = (speciesId) => `vega-research-${speciesId}`; + +export function hasSpeciesResearchVideo(scene, speciesId) { + return !!scene.cache.video?.exists(speciesResearchVideoKey(speciesId)); +} + // Frame helpers — the one place that knows how the sheets are indexed. export const shipFrame = (rules, speciesId, hullId) => { const s = rules.species[speciesId]; diff --git a/src/games/mastervega/VegaColonyView.js b/src/games/mastervega/VegaColonyView.js index cce9878..e61d6e3 100644 --- a/src/games/mastervega/VegaColonyView.js +++ b/src/games/mastervega/VegaColonyView.js @@ -803,7 +803,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) { const canSend = !!dest && amount > 0; const sendBtn = new Button(scene, TX + TW / 2, y + 22, 'Send', () => { if (!dest) return; - if (sendPopulation(rules, state, colony.empireIdx, colony.starIdx, dest.starIdx, amount)) { + if (sendPopulation(rules, state, colony.empireIdx, colony.id, dest.id, amount)) { closeTransportPicker(); buildPanel(); onChanged?.(); diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js index 7d0e8c9..ed460fc 100644 --- a/src/games/mastervega/VegaLogic.js +++ b/src/games/mastervega/VegaLogic.js @@ -405,6 +405,7 @@ export function createGame(rules, opts) { knownInField: { computers: 0, construction: 0, forcefields: 0, planetology: 0, propulsion: 0, weapons: 0 }, researching: {}, alloc: {}, + allocLocked: {}, beakers: {}, bc: rules.economy.startingBC, leaders: [], @@ -1328,11 +1329,11 @@ export function maxSendablePopulation(colony) { return Math.max(0, colony.pop - MIN_POP_LEFT_BEHIND); } -export function sendPopulation(rules, state, e, fromStarIdx, toStarIdx, amount) { - if (fromStarIdx === toStarIdx) return false; - const colony = colonyAt(state, fromStarIdx); +export function sendPopulation(rules, state, e, fromColonyId, toColonyId, amount) { + if (fromColonyId === toColonyId) return false; + const colony = state.colonies.find((c) => c.id === fromColonyId); if (!colony || colony.empireIdx !== e) return false; - const dest = colonyAt(state, toStarIdx); + const dest = state.colonies.find((c) => c.id === toColonyId); if (!dest || dest.empireIdx !== e) return false; const send = Math.min(amount, maxSendablePopulation(colony)); if (send <= 0) return false; @@ -1340,23 +1341,31 @@ export function sendPopulation(rules, state, e, fromStarIdx, toStarIdx, amount) // Built directly in transit — see the module comment above for why this // does not go through addFleet()/sendFleet() (which would first have to // land in an idle fleet at the source, risking a merge with whatever is - // garrisoned there before it could be split back out). - const probe = { empireIdx: e, starIdx: fromStarIdx, toStar: -1, ships: [{ hullId: 'poptransport', mark: 1, count: 1 }] }; - if (!canSendFleet(rules, state, probe, toStarIdx)) return false; + // garrisoned there before it could be split back out). It also can't route + // through canSendFleet(): that gate rejects a destination equal to the + // fleet's own starIdx as a no-op order, which would wrongly forbid moving + // population between two colonies that share one system, so reachability + // is checked by hand here instead — same rules, minus that restriction. + const probe = { empireIdx: e, starIdx: colony.starIdx, toStar: -1, ships: [{ hullId: 'poptransport', mark: 1, count: 1 }] }; + if (fleetSpeed(rules, state, probe) <= 0) return false; + if (!reachableStars(rules, state, e)[dest.starIdx]) return false; state.fleets.push({ id: state.nextFleetId += 1, empireIdx: e, starIdx: -1, - fromStar: fromStarIdx, - toStar: toStarIdx, + fromStar: colony.starIdx, + toStar: dest.starIdx, + // Which colony to credit on arrival — starIdx alone can't disambiguate + // two colonies sharing a system (see deliverPopulation). + destColonyId: dest.id, progress: 0, - total: parsecs(state.galaxy, fromStarIdx, toStarIdx), + total: parsecs(state.galaxy, colony.starIdx, dest.starIdx), ships: [{ hullId: 'poptransport', mark: 1, count: 1, popPayload: send }], }); colony.pop -= send; pushEvent(state, { - type: 'populationSent', empire: e, starIdx: fromStarIdx, toStarIdx, amount: send, turn: state.turn, + type: 'populationSent', empire: e, starIdx: colony.starIdx, toStarIdx: dest.starIdx, amount: send, turn: state.turn, }); return true; } @@ -1369,7 +1378,10 @@ export function sendPopulation(rules, state, e, fromStarIdx, toStarIdx, amount) function deliverPopulation(rules, state, f) { const stack = f.ships.find((s) => s.hullId === 'poptransport' && s.popPayload > 0); if (!stack) return; - const colony = colonyAt(state, f.starIdx); + // starIdx alone can't tell two same-system colonies apart, so prefer the + // colony id recorded when the transport was dispatched (see sendPopulation). + const colony = (f.destColonyId != null && state.colonies.find((c) => c.id === f.destColonyId)) + || colonyAt(state, f.starIdx); if (!colony || colony.empireIdx !== f.empireIdx) return; const maxPop = colonyMaxPop(rules, state, colony); colony.pop = Math.min(maxPop, colony.pop + stack.popPayload); @@ -1721,13 +1733,22 @@ export function setSlider(rules, state, colony, channel, value) { } } +// A locked field holds its share while the rest renormalise — same contract +// as setSlider/colony.locked, transposed onto emp.alloc/emp.allocLocked. export function setResearchAlloc(rules, state, e, field, value) { const emp = state.empires[e]; + emp.allocLocked ??= {}; + if (emp.allocLocked[field]) return; const fields = Object.keys(rules.techFields); const v = Math.max(0, Math.min(1, value)); - emp.alloc[field] = v; - const others = fields.filter((f) => f !== field); - const rest = 1 - v; + const others = fields.filter((f) => f !== field && !emp.allocLocked[f]); + const lockedSum = fields.filter((f) => f !== field && emp.allocLocked[f]) + .reduce((t, f) => t + emp.alloc[f], 0); + const room = Math.max(0, 1 - lockedSum); + const target = Math.min(v, room); + emp.alloc[field] = target; + const rest = room - target; + if (others.length === 0) return; const sum = others.reduce((t, f) => t + emp.alloc[f], 0); if (sum <= 0) for (const f of others) emp.alloc[f] = rest / others.length; else for (const f of others) emp.alloc[f] = (emp.alloc[f] / sum) * rest; @@ -1882,6 +1903,10 @@ export function deserialize(json) { for (const emp of state.empires) { emp._comps = null; emp._compsAt = -1; emp._range = null; emp._rangeAt = -1; emp._designs = null; emp._designsAt = -1; + // A save from before the research-lock padlock existed has no allocLocked + // at all — back-fill it the same way a fresh empire gets one, rather than + // bumping the save version over one new always-empty-by-default field. + emp.allocLocked ??= {}; } return state; } diff --git a/src/games/mastervega/VegaMusic.js b/src/games/mastervega/VegaMusic.js new file mode 100644 index 0000000..572aeb1 --- /dev/null +++ b/src/games/mastervega/VegaMusic.js @@ -0,0 +1,140 @@ +// VegaMusic — headless, condition-driven soundtrack for Master of Vega. +// +// Mirrors MusicPlayer's playback mechanics (plain HTML5 Audio elements, not +// Phaser's sound cache) but builds no UI and switches between named track +// pools as the game state changes: menu, peace, combat, and one pool per +// diplomacy race. A pool with no tracks yet (until real mp3s are dropped in) +// silently falls back to the "fallback" pool so the game is never silent. +// pause()/resume()/destroy() keep MusicPlayer's exact no-arg shape so +// VegaColonyIntro.js's scene.music?.pause()/resume() ducking works unchanged. + +const FADE_MS = 600; +const FADE_STEP_MS = 50; + +function shuffle(arr) { + const a = [...arr]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +function sameMode(a, b) { + if (!a || !b) return a === b; + return a.kind === b.kind && a.speciesId === b.speciesId; +} + +export class VegaMusic { + constructor(scene, data, volume = 0.6) { + this.scene = scene; + this.data = data ?? {}; + this.baseVolume = volume; + this.volume = volume; + this.mode = null; + this.pool = []; + this.idx = 0; + this._audio = null; // current/incoming track + this._outgoing = null; // previous track, fading out + this._fadeTimer = null; + + scene.events.once('shutdown', () => this.destroy()); + scene.events.once('destroy', () => this.destroy()); + } + + _poolFor(mode) { + const d = this.data; + if (mode.kind === 'menu') return d.menu?.tracks?.length ? d.menu : d.fallback; + if (mode.kind === 'combat') return d.combat?.tracks?.length ? d.combat : d.fallback; + if (mode.kind === 'diplomacy') { + const bySpecies = d.diplomacy?.bySpecies?.[mode.speciesId]; + if (bySpecies?.tracks?.length) return bySpecies; + if (d.diplomacy?.default?.tracks?.length) return d.diplomacy.default; + return d.fallback; + } + return d.peace?.tracks?.length ? d.peace : d.fallback; + } + + // Menu / peace / combat. + setCategory(kind) { + this._setMode({ kind }); + } + + // Pass a species id to duck into that race's diplomacy pool, or null to + // return to peace (e.g. once an audience queue drains). + setDiplomacy(speciesId) { + this._setMode(speciesId ? { kind: 'diplomacy', speciesId } : { kind: 'peace' }); + } + + _setMode(mode) { + if (sameMode(this.mode, mode)) return; + this.mode = mode; + const pool = this._poolFor(mode); + const tracks = pool?.tracks ?? []; + this.volume = pool?.volume ?? this.baseVolume; + this.pool = shuffle(tracks); + this.idx = 0; + this._crossTo(this.pool.length ? 0 : -1); + } + + _crossTo(idx) { + if (this._fadeTimer) { clearInterval(this._fadeTimer); this._fadeTimer = null; } + if (this._outgoing) { this._outgoing.pause(); this._outgoing.onended = null; } + this._outgoing = this._audio; + this._audio = null; + + const track = idx >= 0 ? this.pool[idx] : null; + let incoming = null; + if (track) { + incoming = new Audio(`assets/music/${track.file}`); + incoming.volume = 0; + incoming.play().catch(() => { }); + incoming.onended = () => this._advance(); + this._audio = incoming; + } + this._fade(this._outgoing, incoming); + } + + _advance() { + if (!this.pool.length) return; + this.idx = (this.idx + 1) % this.pool.length; + this._crossTo(this.idx); + } + + _fade(outgoing, incoming) { + const steps = Math.max(1, Math.round(FADE_MS / FADE_STEP_MS)); + const outStart = outgoing?.volume ?? 0; + const inTarget = this.volume; + let step = 0; + this._fadeTimer = setInterval(() => { + step += 1; + const t = Math.min(1, step / steps); + if (incoming) incoming.volume = inTarget * t; + if (outgoing) outgoing.volume = outStart * (1 - t); + if (t >= 1) { + clearInterval(this._fadeTimer); + this._fadeTimer = null; + if (outgoing) { outgoing.pause(); outgoing.onended = null; } + if (this._outgoing === outgoing) this._outgoing = null; + } + }, FADE_STEP_MS); + } + + // Duck the soundtrack for a one-off cue without losing playback position. + pause() { + this._audio?.pause(); + } + + resume() { + this._audio?.play().catch(() => { }); + } + + destroy() { + if (this._fadeTimer) { clearInterval(this._fadeTimer); this._fadeTimer = null; } + for (const a of [this._audio, this._outgoing]) { + if (a) { a.pause(); a.onended = null; } + } + this._audio = null; + this._outgoing = null; + } +} diff --git a/src/games/mastervega/VegaResearchScreen.js b/src/games/mastervega/VegaResearchScreen.js new file mode 100644 index 0000000..e74206f --- /dev/null +++ b/src/games/mastervega/VegaResearchScreen.js @@ -0,0 +1,371 @@ +// Master of Vega — the Research screen. +// +// Left: a 2:3 ambient loop of the viewing empire's own species at work (or a +// letterboxed portrait fallback until that clip exists). Right, top: a 2x3 +// grid of tech-field boxes — name, a lockable allocation slider, and the +// current target's name/description/progress. Right, bottom: once a box is +// clicked, that field's full tech ladder (researched / available / locked to +// this species) in a much larger, scrollable, tooltip-enabled list than the +// old single-shell version ever had room for. +// +// Split out of VegaScreens.js once this screen grew past the shared +// modalShell+slider factory-function pattern those simpler screens still +// use — the same jump in complexity that pulled VegaAudience.js and +// VegaColonyIntro.js out earlier. FONT/D/modalShell/slider are borrowed back +// from there, same as those two files do. + +import * as Phaser from 'phaser'; +import { FONT, D, modalShell, slider } from './VegaScreens.js'; +import { techCost } from './VegaRules.js'; +import { setResearchAlloc, nextResearchTarget } from './VegaLogic.js'; +import { + makeSpeciesPortrait, sourceWidth, speciesResearchVideoKey, hasSpeciesResearchVideo, +} from './VegaArt.js'; +import { Tooltip } from '../../ui/Tooltip.js'; +import { describeTechTooltip } from './VegaTooltips.js'; + +const ACCENT = 0x6fc4ff; +const PANEL = 0x0b1220; + +/** Source resolution the research clips are recorded at — 2:3 portrait. */ +const RESEARCH_VIDEO_SRC_W = 640; + +function researchVideoPath(scene, speciesId) { + return scene.cache.json.get('mastervega-artwork')?.researchVideos?.[speciesId]?.path ?? null; +} + +// -------------------------------------------------------------------------- +// JIT loading — one-for-one with VegaAudience.js's warmBytes/ +// ensureOneAudienceVideo pair, minus the mood dimension: Research only ever +// shows the VIEWING empire's own species, one clip, so there is nothing to +// warm speculatively and nothing to swap between. + +const warmed = new WeakMap(); +function warmBytes(scene, key, path) { + let store = warmed.get(scene); + if (!store) { store = new Map(); warmed.set(scene, store); } + if (store.has(key)) return; + try { + const el = document.createElement('video'); + el.preload = 'auto'; + el.muted = true; + el.setAttribute('playsinline', 'playsinline'); + el.src = path; + el.load(); + store.set(key, el); + } catch (err) { /* prefetch is an optimisation, never a requirement */ } +} + +const inFlight = new WeakMap(); +function pendingFor(scene) { + let set = inFlight.get(scene); + if (!set) { set = new Set(); inFlight.set(scene, set); } + return set; +} + +function ensureOneResearchVideo(scene, speciesId, onDone) { + const key = speciesResearchVideoKey(speciesId); + const path = researchVideoPath(scene, speciesId); + if (path) warmBytes(scene, key, path); + if (hasSpeciesResearchVideo(scene, speciesId) || !path) { onDone?.(); return; } + + const pending = pendingFor(scene); + const doneEvent = `filecomplete-video-${key}`; + if (onDone) { + let onLoad; + let onError; + const settle = () => { + scene.load.off(doneEvent, onLoad); + scene.load.off(Phaser.Loader.Events.FILE_LOAD_ERROR, onError); + onDone(); + }; + onLoad = () => settle(); + onError = (file) => { if (file?.key === key) settle(); }; + scene.load.once(doneEvent, onLoad); + scene.load.on(Phaser.Loader.Events.FILE_LOAD_ERROR, onError); + } + if (pending.has(key)) return; + pending.add(key); + let clearDone; + let clearError; + const clear = () => { + scene.load.off(doneEvent, clearDone); + scene.load.off(Phaser.Loader.Events.FILE_LOAD_ERROR, clearError); + pending.delete(key); + }; + clearDone = () => clear(); + clearError = (file) => { if (file?.key === key) clear(); }; + scene.load.once(doneEvent, clearDone); + scene.load.on(Phaser.Loader.Events.FILE_LOAD_ERROR, clearError); + scene.load.video(key, path, true); // noAudio: true — ambient loop, silent + if (!scene.load.isLoading()) scene.load.start(); +} + +// -------------------------------------------------------------------------- +// The species video panel. Letterboxed by WIDTH per this game's universal +// video convention (never setDisplaySize, never crop) — a square portrait +// centred inside the taller 2:3 frame until a real research clip loads, then +// the clip itself scaled the same way. + +function buildVideoPanel(scene, rules, art, speciesId, x, y, w, h) { + const container = scene.add.container(x, y); + + function showFallback() { + container.removeAll(true); + const spec = rules.species[speciesId]; + const barColor = Phaser.Display.Color.HexStringToColor(spec.color).color; + container.add(scene.add.rectangle(w / 2, h / 2, w, h, barColor, 0.18)); + container.add(makeSpeciesPortrait(scene, rules, art, speciesId, w / 2, h / 2, w)); + } + + function showVideo() { + container.removeAll(true); + const v = scene.add.video(w / 2, h / 2, speciesResearchVideoKey(speciesId)); + v.setMute(true); + v.setLoop(true); + // A fresh Video reports a placeholder width (not 0) until its first frame + // decodes — sourceWidth() is the documented guard for that trap, so the + // fit is re-applied once the real texture exists rather than only once. + const fit = () => v.setScale(w / sourceWidth(v, RESEARCH_VIDEO_SRC_W)); + fit(); + v.on('created', fit); + v.on('playing', fit); + v.play(true); + v.once('error', () => { if (v.scene) showFallback(); }); + container.add(v); + } + + showFallback(); + ensureOneResearchVideo(scene, speciesId, () => { + if (!container.scene) return; // screen closed before the fetch landed + if (hasSpeciesResearchVideo(scene, speciesId)) showVideo(); + }); + + return container; +} + +// -------------------------------------------------------------------------- + +export function openResearchScreen(scene, rules, state, e, art, onClose) { + const emp = state.empires[e]; + // A game saved before the research-lock padlock existed has no + // allocLocked at all — VegaLogic.deserialize() back-fills this for the + // normal load path, but guard here too rather than trust every caller. + emp.allocLocked ??= {}; + const fields = rules.techFieldList; + + const tooltip = new Tooltip(scene, { depth: D.modal + 5 }); + + const shell = modalShell(scene, 'Research', () => { + tooltip.destroy(); + maskG?.destroy(); + onClose?.(); + }, { width: 1500, height: 880 }); + + // --- layout ------------------------------------------------------------- + const videoH = shell.body.h; + const videoW = Math.round(videoH * (2 / 3)); + const rightX = shell.body.x + videoW + 24; + const rightW = shell.body.w - videoW - 24; + + const GAP = 16; + const boxW = (rightW - 2 * GAP) / 3; + const boxH = 200; + const gridY = shell.body.y; + const gridBottom = gridY + 2 * boxH + GAP; + const headingY = gridBottom + 6; + const detailY = gridBottom + 34; + const detailH = shell.body.h - (detailY - shell.body.y); + const detailX = rightX; + const detailW = rightW; + + shell.add(buildVideoPanel(scene, rules, art, emp.speciesId, shell.body.x, shell.body.y, videoW, videoH)); + + let selectedField = fields[0].id; + let gridLayer = null; + let detailLayer = null; + let maskG = null; + const sliders = []; // [{fieldId, slider}], rebuilt with the grid + + // --- category grid -------------------------------------------------- + function buildCategoryBox(field, i) { + const row = Math.floor(i / 3); + const col = i % 3; + const bx = rightX + col * (boxW + GAP); + const by = gridY + row * (boxH + GAP); + const selected = field.id === selectedField; + + // The background is the field-select click zone, added FIRST so the + // padlock and slider (added after) win Phaser's default topOnly input + // priority over their own smaller areas — same add-order mechanism + // VegaColonyView.js already relies on for its nested lock box + slider. + const bg = scene.add.rectangle(bx + boxW / 2, by + boxH / 2, boxW, boxH, PANEL, selected ? 0.85 : 0.5) + .setStrokeStyle(selected ? 2.5 : 1, ACCENT, selected ? 0.95 : 0.35) + .setInteractive({ useHandCursor: true }); + bg.on('pointerup', () => { + if (selectedField === field.id) return; + selectedField = field.id; + buildGrid(); + buildDetail(); + }); + gridLayer.add(bg); + + gridLayer.add(scene.add.text(bx + 12, by + 8, field.name.toUpperCase(), { + fontFamily: FONT, fontSize: '18px', color: '#cfe8ff', + })); + + // Padlock — VegaColonyView.js:323-349's exact semantics, transposed onto + // emp.allocLocked: can't lock the last unlocked field, since + // setResearchAlloc would have nothing left to renormalise into. + const locked = !!emp.allocLocked[field.id]; + const lockedCount = fields.filter((f) => emp.allocLocked[f.id]).length; + const lockable = locked || lockedCount < fields.length - 1; + const lockBox = scene.add.rectangle(bx + boxW - 22, by + 20, 22, 22, + locked ? 0x2a3550 : 0x16253c).setStrokeStyle(1, ACCENT, lockable ? 0.55 : 0.18); + gridLayer.add(lockBox); + gridLayer.add(scene.add.text(bx + boxW - 22, by + 19, locked ? '■' : '□', { + fontFamily: FONT, fontSize: '14px', color: locked ? '#ffd88a' : (lockable ? '#8fa8c0' : '#3c4c60'), + }).setOrigin(0.5)); + if (lockable) { + lockBox.setInteractive({ useHandCursor: true }); + lockBox.on('pointerup', () => { + if (locked) delete emp.allocLocked[field.id]; + else emp.allocLocked[field.id] = true; + buildGrid(); + }); + } + + const s = slider(scene, bx + 14, by + 42, boxW - 28, 'Alloc', emp.alloc[field.id] ?? 0, (v) => { + setResearchAlloc(rules, state, e, field.id, v); + // Every field's share shifts, so redraw them all — INCLUDING this one, + // which may have been clipped by the room locked fields left (same + // trap VegaColonyView.js's identical callback calls out). Only the + // slider widgets move here; the progress/target text below them is + // deliberately left alone until the next full rebuild (lock toggle or + // box click), matching how the old single-shell screen already + // avoided rebuilding on every drag tick. + sliders.forEach(({ fieldId, slider: other }) => other.setValue(emp.alloc[fieldId] ?? 0)); + }); + gridLayer.add(s.container); + sliders.push({ fieldId: field.id, slider: s }); + + let y = by + 96; + const targetId = emp.researching[field.id] ?? nextResearchTarget(rules, state, e, field.id); + if (targetId) { + const tech = rules.techs[targetId]; + const cost = techCost(rules, tech, emp.knownInField[field.id]); + const have = emp.beakers[field.id] ?? 0; + const nameT = scene.add.text(bx + 14, y, tech.name, { + fontFamily: FONT, fontSize: '15px', color: '#ffd88a', wordWrap: { width: boxW - 28 }, + }); + gridLayer.add(nameT); + y += nameT.height + 3; + const descT = scene.add.text(bx + 14, y, tech.desc, { + fontFamily: FONT, fontSize: '12px', color: '#8fa8c0', wordWrap: { width: boxW - 28 }, + }); + gridLayer.add(descT); + y += descT.height + 6; + const barW = boxW - 28; + gridLayer.add(scene.add.rectangle(bx + 14, y, barW, 6, 0x1b2b42).setOrigin(0, 0)); + gridLayer.add(scene.add.rectangle(bx + 14, y, barW * Phaser.Math.Clamp(have / cost, 0, 1), 6, 0xffd88a) + .setOrigin(0, 0)); + y += 12; + gridLayer.add(scene.add.text(bx + 14, y, `${Math.round(have)} / ${cost} RP`, { + fontFamily: FONT, fontSize: '12px', color: '#7f97b3', + })); + } else { + gridLayer.add(scene.add.text(bx + 14, y, 'Nothing further available', { + fontFamily: FONT, fontSize: '13px', color: '#6b7f96', wordWrap: { width: boxW - 28 }, + })); + } + } + + function buildGrid() { + gridLayer?.destroy(); + gridLayer = scene.add.container(0, 0); + shell.add(gridLayer); + sliders.length = 0; + fields.forEach((field, i) => buildCategoryBox(field, i)); + } + + // --- detail pane: the selected field's full tech ladder, much larger + // font than the old screen's 14px columns, masked+scrollable since ten + // techs at this size can run past the pane's height. Mirrors + // VegaAudience.js's chat-log masked-scroll shape. + function buildDetail() { + detailLayer?.destroy(); + maskG?.destroy(); + detailLayer = scene.add.container(0, 0); + shell.add(detailLayer); + + const field = rules.techFields[selectedField]; + detailLayer.add(scene.add.text(detailX, headingY, `${field.name.toUpperCase()} — RESEARCH LADDER`, { + fontFamily: FONT, fontSize: '18px', color: '#cfe8ff', + })); + + const bg = scene.add.rectangle(detailX + detailW / 2, detailY + detailH / 2, detailW, detailH, 0x000000, 0.35) + .setStrokeStyle(1, ACCENT, 0.3).setInteractive(); + detailLayer.add(bg); + + const ladder = scene.add.container(0, 0); + detailLayer.add(ladder); + maskG = scene.make.graphics({ x: 0, y: 0, add: false }); + maskG.fillStyle(0xffffff); + maskG.fillRect(detailX, detailY, detailW, detailH); + ladder.setMask(maskG.createGeometryMask()); + + let rowY = detailY + 16; + let scrollUp = 0; + let overflow = 0; + const applyScroll = () => { + overflow = Math.max(0, rowY - (detailY + detailH - 16)); + scrollUp = Phaser.Math.Clamp(scrollUp, 0, overflow); + ladder.y = -overflow + scrollUp; + }; + bg.on('wheel', (pointer, dx, dy) => { scrollUp -= dy * 0.5; applyScroll(); }); + + // The field's current research target — same lookup the category box + // uses for its own progress bar — gets the same yellow as that bar, and + // the list opens scrolled to bring it as close to the pane's centre as + // the ladder's length allows. + const targetId = emp.researching[selectedField] ?? nextResearchTarget(rules, state, e, selectedField); + let targetRowTop = null; + let targetRowHeight = 0; + + for (const tech of rules.techsByField[selectedField]) { + const known = !!emp.known[tech.id]; + const avail = !!emp.available[tech.id]; + const isCurrent = tech.id === targetId; + const colour = isCurrent ? '#ffd88a' : (known ? '#7fd8a0' : (avail ? '#9fb6cc' : '#5a4450')); + const mark = known ? '■' : (avail ? '□' : '✕'); + const row = scene.add.text(detailX + 16, rowY, `${mark} ${tech.name}`, { + fontFamily: FONT, fontSize: '26px', color: colour, wordWrap: { width: detailW - 32 }, + }).setInteractive(); + ladder.add(row); + tooltip.attachTo(row, () => describeTechTooltip(rules, state, emp, tech)); + if (isCurrent) { targetRowTop = rowY; targetRowHeight = row.height; } + rowY += row.height + 10; + } + + // First pass establishes `overflow` from the final rowY (and harmlessly + // clamps the still-default scrollUp); the second pass then aims for + // dead-centre on the target row, with applyScroll's own clamp pulling + // that back to the nearest reachable position when the target sits too + // close to either end of the list to actually reach centre. + applyScroll(); + scrollUp = targetRowTop !== null + ? (detailY + detailH / 2 - (targetRowTop + targetRowHeight / 2)) + overflow + : overflow; // nothing in progress in this field — open at the top + applyScroll(); + } + + shell.add(scene.add.text(shell.body.x, shell.y + shell.height - 34, + '■ researched □ available ✕ not available to your species — acquire by trade, espionage or conquest', { + fontFamily: FONT, fontSize: '14px', color: '#6b7f96', + })); + + buildGrid(); + buildDetail(); + + return shell; +} diff --git a/src/games/mastervega/VegaScreens.js b/src/games/mastervega/VegaScreens.js index d895284..ee15dc4 100644 --- a/src/games/mastervega/VegaScreens.js +++ b/src/games/mastervega/VegaScreens.js @@ -7,8 +7,7 @@ import * as Phaser from 'phaser'; import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; import { Button } from '../../ui/Button.js'; -import { techCost } from './VegaRules.js'; -import { empireColonies, setResearchAlloc, nextResearchTarget, hireLeader } from './VegaLogic.js'; +import { empireColonies, hireLeader } from './VegaLogic.js'; import { attitudeOf, moodOf, powerOf, canNegotiate, } from './VegaDiplomacy.js'; @@ -137,86 +136,9 @@ export function slider(scene, x, y, w, label, value, onChange, colour = ACCENT) } // -------------------------------------------------------------------------- - -export function openResearchScreen(scene, rules, state, e, art, onClose) { - const shell = modalShell(scene, 'Research', onClose, { width: 1500, height: 880 }); - const emp = state.empires[e]; - const fields = rules.techFieldList; - const colW = (shell.body.w - 40) / fields.length; - - const sliders = []; - fields.forEach((field, i) => { - const cx = shell.body.x + i * colW; - - const head = scene.add.text(cx, shell.body.y, field.name.toUpperCase(), { - fontFamily: FONT, fontSize: '19px', color: '#cfe8ff', - }); - shell.add(head); - - const s = slider(scene, cx, shell.body.y + 30, colW - 26, 'Allocation', emp.alloc[field.id] ?? 0, (v) => { - setResearchAlloc(rules, state, e, field.id, v); - // Every other field's share shifts, so redraw them all. - sliders.forEach((other, j) => { - if (j !== i) other.setValue(emp.alloc[fields[j].id] ?? 0); - }); - }); - shell.add(s.container); - sliders.push(s); - - // Current target and its progress. - const targetId = emp.researching[field.id] ?? nextResearchTarget(rules, state, e, field.id); - let y = shell.body.y + 84; - if (targetId) { - const tech = rules.techs[targetId]; - const cost = techCost(rules, tech, emp.knownInField[field.id]); - const have = emp.beakers[field.id] ?? 0; - const t = scene.add.text(cx, y, tech.name, { - fontFamily: FONT, fontSize: '17px', color: '#ffd88a', wordWrap: { width: colW - 26 }, - }); - shell.add(t); - y += t.height + 6; - const bar = scene.add.rectangle(cx, y, colW - 26, 6, 0x1b2b42).setOrigin(0, 0); - shell.add(bar); - const prog = scene.add.rectangle(cx, y, (colW - 26) * Phaser.Math.Clamp(have / cost, 0, 1), 6, 0xffd88a).setOrigin(0, 0); - shell.add(prog); - y += 14; - const eta = scene.add.text(cx, y, `${Math.round(have)} / ${cost} RP`, { - fontFamily: FONT, fontSize: '14px', color: '#7f97b3', - }); - shell.add(eta); - y += 26; - } else { - const t = scene.add.text(cx, y, 'Nothing further available', { - fontFamily: FONT, fontSize: '15px', color: '#6b7f96', wordWrap: { width: colW - 26 }, - }); - shell.add(t); - y += 34; - } - - // The field's ladder: known, available, or locked out by the start-of-game - // availability roll (which is the thing that makes tech trading matter). - for (const tech of rules.techsByField[field.id]) { - const known = !!emp.known[tech.id]; - const avail = !!emp.available[tech.id]; - const colour = known ? '#7fd8a0' : (avail ? '#9fb6cc' : '#5a4450'); - const mark = known ? '■' : (avail ? '□' : '✕'); - const row = scene.add.text(cx, y, `${mark} ${tech.name}`, { - fontFamily: FONT, fontSize: '14px', color: colour, wordWrap: { width: colW - 26 }, - }); - shell.add(row); - y += row.height + 3; - } - }); - - const legend = scene.add.text(shell.body.x, shell.y + shell.height - 34, - '■ researched □ available ✕ not available to your species — acquire by trade, espionage or conquest', { - fontFamily: FONT, fontSize: '14px', color: '#6b7f96', - }); - shell.add(legend); - return shell; -} - -// -------------------------------------------------------------------------- +// Research has its own file, VegaResearchScreen.js — it grew past this +// module's shared factory-function pattern the same way Audience and the +// colony-founding vignette already had. export function openDiplomacyScreen(scene, rules, state, e, art, onClose, onChanged) { const shell = modalShell(scene, 'Diplomacy', onClose, { width: 1280, height: 780 }); diff --git a/src/games/mastervega/VegaSidePanel.js b/src/games/mastervega/VegaSidePanel.js index 128d3ed..4894bff 100644 --- a/src/games/mastervega/VegaSidePanel.js +++ b/src/games/mastervega/VegaSidePanel.js @@ -28,9 +28,10 @@ import { parsecs } from './VegaGalaxyGen.js'; import { coloniesAt, fleetsAt, empireDesign, fleetPower, fleetSpeed, fleetEta, etaTo, colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories, - colonyDefenseCap, habitableForEmpire, reachableStars, atWar, + colonyDefenseCap, habitableForEmpire, reachableStars, atWar, queueItemEta, } from './VegaLogic.js'; import { FONT, D, ORBIT } from './VegaScreens.js'; +import { etaText } from './VegaColonyView.js'; import { createShipMediaPool, makeShipIcon } from './VegaShipMedia.js'; import { openShipDetail } from './VegaShipDetail.js'; @@ -485,7 +486,8 @@ export default class VegaSidePanel { const name = item.kind === 'building' ? rules.buildings[item.id].name : empireDesign(rules, state, colony.empireIdx, item.id).name; - this.line(`Building ${name}`, { size: 13, color: '#ffd88a' }); + const eta = queueItemEta(rules, state, colony, item); + this.line(`Building ${name} — ${etaText(eta)}`, { size: 13, color: '#ffd88a' }); } else { this.line('Idle — output spills into research.', { size: 13, color: '#6f8aa3' }); } diff --git a/src/games/mastervega/VegaTooltips.js b/src/games/mastervega/VegaTooltips.js index 8f56d56..e107725 100644 --- a/src/games/mastervega/VegaTooltips.js +++ b/src/games/mastervega/VegaTooltips.js @@ -5,6 +5,7 @@ import { COLORS } from '../../config.js'; import { coloniesAt, habitableForEmpire } from './VegaLogic.js'; +import { techCost } from './VegaRules.js'; // Hover tooltip for a star on the map. `viewerEmp` is the human empire (or // null in observer mode, which sees everything). An unexplored star only @@ -51,3 +52,42 @@ export function describeStarTooltip(rules, state, viewerEmp, starIdx) { return { title: star.name, lines }; } + +// Hover tooltip for a tech row on the Research screen's ladder. `emp` is the +// viewing empire — status and cost are both relative to it (a tech another +// empire already knows may still be mid-tier for this one). +export function describeTechTooltip(rules, state, emp, tech) { + const known = !!emp.known[tech.id]; + const available = !!emp.available[tech.id]; + + if (known) { + return { + title: tech.name, + titleColor: '#7fd8a0', + lines: [ + { text: 'Researched', color: '#7fd8a0' }, + { text: tech.desc }, + ], + }; + } + if (!available) { + return { + title: tech.name, + titleColor: COLORS.dangerHex, + lines: [ + { text: 'Not available to your species', color: COLORS.dangerHex }, + { text: 'Acquire by trade, espionage or conquest.', color: COLORS.mutedHex }, + { text: tech.desc }, + ], + }; + } + const cost = techCost(rules, tech, emp.knownInField[tech.field]); + return { + title: tech.name, + titleColor: COLORS.goldHex, + lines: [ + { text: `Cost ${cost} RP`, color: COLORS.goldHex }, + { text: tech.desc }, + ], + }; +} diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index 51b10e4..796ccbd 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -1386,6 +1386,55 @@ section('6b. Founding vignette and the turn report'); } } +// --------------------------------------------------------------------------- +section('6c. Research allocation locks'); +// --------------------------------------------------------------------------- +{ + // setResearchAlloc has honoured emp.allocLocked since VegaResearchScreen.js + // introduced the padlock UI — this is what proves the behaviour that + // screen assumes, the same way section 6's padlock block does for colony + // sliders. + const st = Logic.createGame(RULES, { + sizeId: 'small', shapeId: 'spiral', seed: 41, difficultyId: 'normal', + speciesIds: ['human', 'kkrix'], humanIndex: 0, + }); + const emp = st.empires[0]; + const fieldIds = Object.keys(RULES.techFields); + + Logic.setResearchAlloc(RULES, st, 0, 'weapons', 0.4); + emp.allocLocked.weapons = true; + const pinned = emp.alloc.weapons; + Logic.setResearchAlloc(RULES, st, 0, 'computers', 0.5); + check('a locked research field holds its share', + Math.abs(emp.alloc.weapons - pinned) < 1e-9, `${emp.alloc.weapons} vs ${pinned}`); + const sum1 = fieldIds.reduce((t, f) => t + emp.alloc[f], 0); + check('locking a research field still normalises the whole allocation to 1', + Math.abs(sum1 - 1) < 1e-6, `${sum1}`); + + // Lock every field but one and confirm the last unlocked field can still + // take the full remaining room without NaN/negative fallout. + for (const f of fieldIds) emp.allocLocked[f] = true; + delete emp.allocLocked.propulsion; + Logic.setResearchAlloc(RULES, st, 0, 'propulsion', 1); + check('the sole unlocked field absorbs whatever room the locks left', + Number.isFinite(emp.alloc.propulsion) && emp.alloc.propulsion >= 0, `${emp.alloc.propulsion}`); + const sum2 = fieldIds.reduce((t, f) => t + emp.alloc[f], 0); + check('allocation still sums to 1 with five of six fields locked', + Math.abs(sum2 - 1) < 1e-6, `${sum2}`); + + // A locked field's own slider is inert — dragging it directly is a no-op + // (the padlock UI disables the control, but setResearchAlloc guards it too + // since AI code shares the same function). + for (const f of fieldIds) delete emp.allocLocked[f]; + emp.allocLocked.forcefields = true; + const before = emp.alloc.forcefields; + Logic.setResearchAlloc(RULES, st, 0, 'forcefields', 0.9); + check('a locked field ignores an attempt to drag its own slider', + emp.alloc.forcefields === before, `${emp.alloc.forcefields} vs ${before}`); + + for (const f of fieldIds) delete emp.allocLocked[f]; +} + // --------------------------------------------------------------------------- section('7. Diplomacy and the Galactic Council'); // ---------------------------------------------------------------------------