Updated videos and tweaks
This commit is contained in:
parent
35cc4d2446
commit
d16e699a8d
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,4 +1,6 @@
|
||||||
{
|
{
|
||||||
|
"fallback": {
|
||||||
|
"volume": 0.6,
|
||||||
"tracks": [
|
"tracks": [
|
||||||
{
|
{
|
||||||
"file": "vega/track-01.mp3",
|
"file": "vega/track-01.mp3",
|
||||||
|
|
@ -10,21 +12,56 @@
|
||||||
"artist": "Klaxon",
|
"artist": "Klaxon",
|
||||||
"title": "High Orbit"
|
"title": "High Orbit"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"file": "vega/track-05.mp3",
|
||||||
|
"artist": "Rrashaa",
|
||||||
|
"title": "Into the Void"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"menu": { "tracks": [
|
||||||
{
|
{
|
||||||
"file": "vega/track-03.mp3",
|
"file": "vega/track-03.mp3",
|
||||||
"artist": "Aiko",
|
"artist": "Aiko",
|
||||||
"title": "Master of Vega Main Theme"
|
"title": "Master of Vega Main Theme"
|
||||||
|
}
|
||||||
|
] },
|
||||||
|
"peace": { "tracks": [
|
||||||
|
{
|
||||||
|
"file": "vega/track-01.mp3",
|
||||||
|
"artist": "DV-8-2303",
|
||||||
|
"title": "Power Down"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "vega/track-04.mp3",
|
"file": "vega/track-02.mp3",
|
||||||
"artist": "Cy-Bro",
|
"artist": "Klaxon",
|
||||||
"title": "Defend the Colony"
|
"title": "High Orbit"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "vega/track-05.mp3",
|
"file": "vega/track-05.mp3",
|
||||||
"artist": "Rrashaa",
|
"artist": "Rrashaa",
|
||||||
"title": "Into the Void"
|
"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": [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,24 @@
|
||||||
"cruiser": { "key": "vega-ship-mekhan-cruiser", "path": "assets/videos/vega/ship-mekhan-cruiser.mp4" },
|
"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" }
|
"battleship": { "key": "vega-ship-mekhan-battleship", "path": "assets/videos/vega/ship-mekhan-battleship.mp4" }
|
||||||
},
|
},
|
||||||
"rrashaa": null,
|
"rrashaa": {
|
||||||
"cerebrai": null,
|
"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,
|
"ssakar": null,
|
||||||
"lithox": 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-<speciesId>.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": {
|
"sheets": {
|
||||||
"ships": {
|
"ships": {
|
||||||
"key": "vega-ships",
|
"key": "vega-ships",
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,34 @@ function musicFrom(scene, jsonKey) {
|
||||||
.map((t, i) => ({ type: 'audio', key: `${jsonKey}-${i}`, path: `assets/music/${t.file}` }));
|
.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 `<name>-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 sheet = (key, path, frameWidth, frameHeight) => ({ type: 'spritesheet', key, path, frameWidth, frameHeight });
|
||||||
const image = (key, path) => ({ type: 'image', key, path });
|
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: '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-1', path: 'assets/fx/ta-rocket-1.mp3' },
|
||||||
{ type: 'audio', key: 'ta-rocket-2', path: 'assets/fx/ta-rocket-2.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: [
|
forbiddenisland: [
|
||||||
// Tiles: 2 cols (dry, flooded) × 24 rows. Row i → dry frame 2i, flooded
|
// Tiles: 2 cols (dry, flooded) × 24 rows. Row i → dry frame 2i, flooded
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,7 @@ import * as Phaser from 'phaser';
|
||||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||||
import { Button } from '../../ui/Button.js';
|
import { Button } from '../../ui/Button.js';
|
||||||
import { TextInput } from '../../ui/TextInput.js';
|
import { TextInput } from '../../ui/TextInput.js';
|
||||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
import { VegaMusic } from './VegaMusic.js';
|
||||||
import { getGameSoundtrack } from '../../services/soundtrack.js';
|
|
||||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||||
import { enqueue as enqueueSpeech, resetQueue as resetSpeechQueue } from '../../ui/SpeechQueue.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 { openSystemView } from './VegaSystemView.js';
|
||||||
import { openCombatView } from './VegaCombatView.js';
|
import { openCombatView } from './VegaCombatView.js';
|
||||||
import {
|
import {
|
||||||
FONT, D, openResearchScreen, openDiplomacyScreen, openCouncilScreen, openLeaderScreen,
|
FONT, D, openDiplomacyScreen, openCouncilScreen, openLeaderScreen,
|
||||||
showVictoryOverlay,
|
showVictoryOverlay,
|
||||||
} from './VegaScreens.js';
|
} from './VegaScreens.js';
|
||||||
|
import { openResearchScreen } from './VegaResearchScreen.js';
|
||||||
import { openTurnReportScreen } from './VegaTurnReportScreen.js';
|
import { openTurnReportScreen } from './VegaTurnReportScreen.js';
|
||||||
import { NOTABLE_TYPES, isRelevantToHuman } from './VegaTurnReport.js';
|
import { NOTABLE_TYPES, isRelevantToHuman } from './VegaTurnReport.js';
|
||||||
import { openAudienceScreen } from './VegaAudience.js';
|
import { openAudienceScreen } from './VegaAudience.js';
|
||||||
|
|
@ -41,7 +41,6 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
|
|
||||||
init(data) {
|
init(data) {
|
||||||
this.roomData = 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.gameDef = data?.game ?? { slug: 'mastervega', name: 'Master of Vega' };
|
||||||
this.modalOpen = false;
|
this.modalOpen = false;
|
||||||
this.busy = false;
|
this.busy = false;
|
||||||
|
|
@ -62,8 +61,8 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { tracks, volume } = getGameSoundtrack(this);
|
this.music = new VegaMusic(this, this.cache.json.get('masterofvega-music'));
|
||||||
if (tracks?.length) this.music = new MusicPlayer(this, tracks, volume);
|
this.music.setCategory('menu');
|
||||||
} catch (err) { /* music is optional */ }
|
} catch (err) { /* music is optional */ }
|
||||||
|
|
||||||
this.events.once('shutdown', () => this.teardown());
|
this.events.once('shutdown', () => this.teardown());
|
||||||
|
|
@ -575,6 +574,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.state.rules = this.rules;
|
this.state.rules = this.rules;
|
||||||
|
this.music?.setCategory('peace');
|
||||||
|
|
||||||
this.fxLayer = this.add.container(0, 0).setDepth(D.hud - 1);
|
this.fxLayer = this.add.container(0, 0).setDepth(D.hud - 1);
|
||||||
this.fx = new VegaFx(this, this.fxLayer);
|
this.fx = new VegaFx(this, this.fxLayer);
|
||||||
|
|
@ -714,8 +714,13 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
if (!this.pendingAudiences.length) { onDone?.(); return; }
|
if (!this.pendingAudiences.length) { onDone?.(); return; }
|
||||||
const otherIdx = this.pendingAudiences.shift();
|
const otherIdx = this.pendingAudiences.shift();
|
||||||
this.modalOpen = true;
|
this.modalOpen = true;
|
||||||
|
this.music?.setDiplomacy(this.state.empires[otherIdx].speciesId);
|
||||||
openAudienceScreen(this, this.rules, this.state, this.state.humanIndex, otherIdx, this.art, () => {
|
openAudienceScreen(this, this.rules, this.state, this.state.humanIndex, otherIdx, this.art, () => {
|
||||||
this.modalOpen = false;
|
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.refreshAll();
|
||||||
this.runAudienceQueue(onDone);
|
this.runAudienceQueue(onDone);
|
||||||
}, () => this.refreshAll());
|
}, () => this.refreshAll());
|
||||||
|
|
@ -850,6 +855,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
if (!prepared) { next(i + 1); return; }
|
if (!prepared) { next(i + 1); return; }
|
||||||
this.map?.panToStar(starIdx, 260);
|
this.map?.panToStar(starIdx, 260);
|
||||||
this.modalOpen = true;
|
this.modalOpen = true;
|
||||||
|
this.music?.setCategory('combat');
|
||||||
openCombatView(this, this.rules, prepared.battle, this.art, {
|
openCombatView(this, this.rules, prepared.battle, this.art, {
|
||||||
attackerSpecies: this.state.empires[prepared.attackerIdx].speciesId,
|
attackerSpecies: this.state.empires[prepared.attackerIdx].speciesId,
|
||||||
defenderSpecies: this.state.empires[prepared.defenderIdx].speciesId,
|
defenderSpecies: this.state.empires[prepared.defenderIdx].speciesId,
|
||||||
|
|
@ -857,6 +863,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
onDone: (result) => {
|
onDone: (result) => {
|
||||||
Logic.applyBattleOutcome(this.rules, this.state, prepared, result);
|
Logic.applyBattleOutcome(this.rules, this.state, prepared, result);
|
||||||
this.modalOpen = false;
|
this.modalOpen = false;
|
||||||
|
this.music?.setCategory('peace');
|
||||||
this.refreshAll();
|
this.refreshAll();
|
||||||
next(i + 1);
|
next(i + 1);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -694,6 +694,16 @@ export function hasAudienceVideo(scene, speciesId, mood) {
|
||||||
return !!scene.cache.video?.exists(audienceVideoKey(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.
|
// Frame helpers — the one place that knows how the sheets are indexed.
|
||||||
export const shipFrame = (rules, speciesId, hullId) => {
|
export const shipFrame = (rules, speciesId, hullId) => {
|
||||||
const s = rules.species[speciesId];
|
const s = rules.species[speciesId];
|
||||||
|
|
|
||||||
|
|
@ -803,7 +803,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
|
||||||
const canSend = !!dest && amount > 0;
|
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', () => {
|
||||||
if (!dest) return;
|
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();
|
closeTransportPicker();
|
||||||
buildPanel();
|
buildPanel();
|
||||||
onChanged?.();
|
onChanged?.();
|
||||||
|
|
|
||||||
|
|
@ -405,6 +405,7 @@ export function createGame(rules, opts) {
|
||||||
knownInField: { computers: 0, construction: 0, forcefields: 0, planetology: 0, propulsion: 0, weapons: 0 },
|
knownInField: { computers: 0, construction: 0, forcefields: 0, planetology: 0, propulsion: 0, weapons: 0 },
|
||||||
researching: {},
|
researching: {},
|
||||||
alloc: {},
|
alloc: {},
|
||||||
|
allocLocked: {},
|
||||||
beakers: {},
|
beakers: {},
|
||||||
bc: rules.economy.startingBC,
|
bc: rules.economy.startingBC,
|
||||||
leaders: [],
|
leaders: [],
|
||||||
|
|
@ -1328,11 +1329,11 @@ export function maxSendablePopulation(colony) {
|
||||||
return Math.max(0, colony.pop - MIN_POP_LEFT_BEHIND);
|
return Math.max(0, colony.pop - MIN_POP_LEFT_BEHIND);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendPopulation(rules, state, e, fromStarIdx, toStarIdx, amount) {
|
export function sendPopulation(rules, state, e, fromColonyId, toColonyId, amount) {
|
||||||
if (fromStarIdx === toStarIdx) return false;
|
if (fromColonyId === toColonyId) return false;
|
||||||
const colony = colonyAt(state, fromStarIdx);
|
const colony = state.colonies.find((c) => c.id === fromColonyId);
|
||||||
if (!colony || colony.empireIdx !== e) return false;
|
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;
|
if (!dest || dest.empireIdx !== e) return false;
|
||||||
const send = Math.min(amount, maxSendablePopulation(colony));
|
const send = Math.min(amount, maxSendablePopulation(colony));
|
||||||
if (send <= 0) return false;
|
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
|
// Built directly in transit — see the module comment above for why this
|
||||||
// does not go through addFleet()/sendFleet() (which would first have to
|
// 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
|
// land in an idle fleet at the source, risking a merge with whatever is
|
||||||
// garrisoned there before it could be split back out).
|
// garrisoned there before it could be split back out). It also can't route
|
||||||
const probe = { empireIdx: e, starIdx: fromStarIdx, toStar: -1, ships: [{ hullId: 'poptransport', mark: 1, count: 1 }] };
|
// through canSendFleet(): that gate rejects a destination equal to the
|
||||||
if (!canSendFleet(rules, state, probe, toStarIdx)) return false;
|
// 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({
|
state.fleets.push({
|
||||||
id: state.nextFleetId += 1,
|
id: state.nextFleetId += 1,
|
||||||
empireIdx: e,
|
empireIdx: e,
|
||||||
starIdx: -1,
|
starIdx: -1,
|
||||||
fromStar: fromStarIdx,
|
fromStar: colony.starIdx,
|
||||||
toStar: toStarIdx,
|
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,
|
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 }],
|
ships: [{ hullId: 'poptransport', mark: 1, count: 1, popPayload: send }],
|
||||||
});
|
});
|
||||||
colony.pop -= send;
|
colony.pop -= send;
|
||||||
pushEvent(state, {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -1369,7 +1378,10 @@ export function sendPopulation(rules, state, e, fromStarIdx, toStarIdx, amount)
|
||||||
function deliverPopulation(rules, state, f) {
|
function deliverPopulation(rules, state, f) {
|
||||||
const stack = f.ships.find((s) => s.hullId === 'poptransport' && s.popPayload > 0);
|
const stack = f.ships.find((s) => s.hullId === 'poptransport' && s.popPayload > 0);
|
||||||
if (!stack) return;
|
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;
|
if (!colony || colony.empireIdx !== f.empireIdx) return;
|
||||||
const maxPop = colonyMaxPop(rules, state, colony);
|
const maxPop = colonyMaxPop(rules, state, colony);
|
||||||
colony.pop = Math.min(maxPop, colony.pop + stack.popPayload);
|
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) {
|
export function setResearchAlloc(rules, state, e, field, value) {
|
||||||
const emp = state.empires[e];
|
const emp = state.empires[e];
|
||||||
|
emp.allocLocked ??= {};
|
||||||
|
if (emp.allocLocked[field]) return;
|
||||||
const fields = Object.keys(rules.techFields);
|
const fields = Object.keys(rules.techFields);
|
||||||
const v = Math.max(0, Math.min(1, value));
|
const v = Math.max(0, Math.min(1, value));
|
||||||
emp.alloc[field] = v;
|
const others = fields.filter((f) => f !== field && !emp.allocLocked[f]);
|
||||||
const others = fields.filter((f) => f !== field);
|
const lockedSum = fields.filter((f) => f !== field && emp.allocLocked[f])
|
||||||
const rest = 1 - v;
|
.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);
|
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;
|
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;
|
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) {
|
for (const emp of state.empires) {
|
||||||
emp._comps = null; emp._compsAt = -1; emp._range = null; emp._rangeAt = -1;
|
emp._comps = null; emp._compsAt = -1; emp._range = null; emp._rangeAt = -1;
|
||||||
emp._designs = null; emp._designsAt = -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;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -7,8 +7,7 @@
|
||||||
import * as Phaser from 'phaser';
|
import * as Phaser from 'phaser';
|
||||||
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
||||||
import { Button } from '../../ui/Button.js';
|
import { Button } from '../../ui/Button.js';
|
||||||
import { techCost } from './VegaRules.js';
|
import { empireColonies, hireLeader } from './VegaLogic.js';
|
||||||
import { empireColonies, setResearchAlloc, nextResearchTarget, hireLeader } from './VegaLogic.js';
|
|
||||||
import {
|
import {
|
||||||
attitudeOf, moodOf, powerOf, canNegotiate,
|
attitudeOf, moodOf, powerOf, canNegotiate,
|
||||||
} from './VegaDiplomacy.js';
|
} from './VegaDiplomacy.js';
|
||||||
|
|
@ -137,86 +136,9 @@ export function slider(scene, x, y, w, label, value, onChange, colour = ACCENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
// Research has its own file, VegaResearchScreen.js — it grew past this
|
||||||
export function openResearchScreen(scene, rules, state, e, art, onClose) {
|
// module's shared factory-function pattern the same way Audience and the
|
||||||
const shell = modalShell(scene, 'Research', onClose, { width: 1500, height: 880 });
|
// colony-founding vignette already had.
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function openDiplomacyScreen(scene, rules, state, e, art, onClose, onChanged) {
|
export function openDiplomacyScreen(scene, rules, state, e, art, onClose, onChanged) {
|
||||||
const shell = modalShell(scene, 'Diplomacy', onClose, { width: 1280, height: 780 });
|
const shell = modalShell(scene, 'Diplomacy', onClose, { width: 1280, height: 780 });
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,10 @@ import { parsecs } from './VegaGalaxyGen.js';
|
||||||
import {
|
import {
|
||||||
coloniesAt, fleetsAt, empireDesign, fleetPower, fleetSpeed, fleetEta, etaTo,
|
coloniesAt, fleetsAt, empireDesign, fleetPower, fleetSpeed, fleetEta, etaTo,
|
||||||
colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories,
|
colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories,
|
||||||
colonyDefenseCap, habitableForEmpire, reachableStars, atWar,
|
colonyDefenseCap, habitableForEmpire, reachableStars, atWar, queueItemEta,
|
||||||
} from './VegaLogic.js';
|
} from './VegaLogic.js';
|
||||||
import { FONT, D, ORBIT } from './VegaScreens.js';
|
import { FONT, D, ORBIT } from './VegaScreens.js';
|
||||||
|
import { etaText } from './VegaColonyView.js';
|
||||||
import { createShipMediaPool, makeShipIcon } from './VegaShipMedia.js';
|
import { createShipMediaPool, makeShipIcon } from './VegaShipMedia.js';
|
||||||
import { openShipDetail } from './VegaShipDetail.js';
|
import { openShipDetail } from './VegaShipDetail.js';
|
||||||
|
|
||||||
|
|
@ -485,7 +486,8 @@ export default class VegaSidePanel {
|
||||||
const name = item.kind === 'building'
|
const name = item.kind === 'building'
|
||||||
? rules.buildings[item.id].name
|
? rules.buildings[item.id].name
|
||||||
: empireDesign(rules, state, colony.empireIdx, 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 {
|
} else {
|
||||||
this.line('Idle — output spills into research.', { size: 13, color: '#6f8aa3' });
|
this.line('Idle — output spills into research.', { size: 13, color: '#6f8aa3' });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import { COLORS } from '../../config.js';
|
import { COLORS } from '../../config.js';
|
||||||
import { coloniesAt, habitableForEmpire } from './VegaLogic.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
|
// 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
|
// 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 };
|
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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
section('7. Diplomacy and the Galactic Council');
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue