fertig-classic-games/src/games/mastervega/VegaResearchScreen.js

391 lines
16 KiB
JavaScript

// 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, techCostFactor } from './VegaRules.js';
import {
setResearchAlloc, nextResearchTarget, setResearchTarget, canResearch,
} 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 spec = rules.species[emp.speciesId];
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], techCostFactor(spec, 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;
// A branched rung can have more than one open (□) tech at once — this
// is what makes it clickable: canResearch is the same gate
// setResearchTarget itself checks, so "clickable" and "would actually
// succeed" never disagree.
const selectable = !known && canResearch(rules, state, e, tech);
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({ useHandCursor: selectable });
ladder.add(row);
tooltip.attachTo(row, () => describeTechTooltip(rules, state, emp, tech));
if (selectable) {
row.on('pointerup', () => {
if (!setResearchTarget(rules, state, e, selectedField, tech.id)) return;
// The row this listener is on is about to be destroyed by the
// rebuild below — Tooltip only clears itself on pointerout, which
// never fires for a destroyed object, so hide it explicitly first.
tooltip.hide();
buildGrid();
buildDetail();
});
}
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;
}