// 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, fieldTechLevel, } 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. export 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; } // -------------------------------------------------------------------------- // `initialField`: which category the screen opens on, e.g. the "View // Research" button on a turn report's Research row jumps straight to the // field that just completed instead of always landing on the first one. export function openResearchScreen(scene, rules, state, e, art, onClose, { initialField = null } = {}) { 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 = (initialField && rules.techFields[initialField]) ? initialField : 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); const lvl = fieldTechLevel(rules, state, e, field.id); const fieldIconSize = 26; gridLayer.add(scene.add.image(bx + 12 + fieldIconSize / 2, by + 8 + 10, art.techfields, field.iconFrame) .setDisplaySize(fieldIconSize, fieldIconSize)); gridLayer.add(scene.add.text(bx + 12 + fieldIconSize + 8, by + 8, `${field.name.toUpperCase()} · Lv ${lvl}`, { 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 tech TREE — a ten-tier spine with // up to two short branch spurs (every branch tech added this session is a // dead end, never reconverging — confirmed against the data), rendered as // small status badges connected by prereq lines rather than a text list, // so the shape of the field is visible at a glance. Full name/cost/desc // stays in the hover tooltip, unchanged. Masked+scrollable defensively // (mirrors VegaAudience.js's chat-log masked-scroll shape) for a future // field that outgrows 10 tiers or a 3+-way branch — today's data always // fits the pane with no scrolling needed. function buildDetail() { detailLayer?.destroy(); maskG?.destroy(); detailLayer = scene.add.container(0, 0); shell.add(detailLayer); const field = rules.techFields[selectedField]; const detailLvl = fieldTechLevel(rules, state, e, selectedField); const detailIconSize = 22; detailLayer.add(scene.add.image(detailX + detailIconSize / 2, headingY + 9, art.techfields, field.iconFrame) .setDisplaySize(detailIconSize, detailIconSize)); detailLayer.add(scene.add.text(detailX + detailIconSize + 8, headingY, `${field.name.toUpperCase()} — TECH TREE (Level ${detailLvl})`, { 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 tree = scene.add.container(0, 0); detailLayer.add(tree); maskG = scene.make.graphics({ x: 0, y: 0, add: false }); maskG.fillStyle(0xffffff); maskG.fillRect(detailX, detailY, detailW, detailH); tree.setMask(maskG.createGeometryMask()); // --- layout: rung index -> column x, lane within a rung -> row y. const PAD = 28; const LABEL_H = 20; const NODE = 34; const LANE_STEP = 78; const innerX0 = detailX + PAD; const innerX1 = detailX + detailW - PAD; const innerY0 = detailY + PAD; const innerY1 = detailY + detailH - PAD; const spineY = innerY0 + LABEL_H + (detailH - 2 * PAD - LABEL_H) / 2; // Node CENTRES are placed NODE/2 in from innerX0/innerX1, so the node's // own edge lands on the padding boundary instead of overshooting past it // — colStep spans the gap between node edges, not between the raw pane // edges. const usableX0 = innerX0 + NODE / 2; const usableX1 = innerX1 - NODE / 2; const rungs = rules.techRungsByField[selectedField]; const colStep = (usableX1 - usableX0) / Math.max(1, rungs.length - 1); const nodeX = (i) => usableX0 + i * colStep; const nodeY = (lane) => { if (lane === 0) return spineY; const sign = lane % 2 === 1 ? -1 : 1; return spineY + sign * Math.ceil(lane / 2) * LANE_STEP; }; const pos = new Map(); // techId -> {x, y} rungs.forEach((rung, i) => { rung.techs.forEach((tech, lane) => pos.set(tech.id, { x: nodeX(i), y: nodeY(lane) })); }); rungs.forEach((rung, i) => { tree.add(scene.add.text(nodeX(i), innerY0, `T${rung.tier}`, { fontFamily: FONT, fontSize: '12px', color: '#5a708c', }).setOrigin(0.5, 0)); }); // The field's current research target — same lookup the category box // uses for its own progress bar — gets the same yellow as that bar. const targetId = emp.researching[selectedField] ?? nextResearchTarget(rules, state, e, selectedField); const statusColour = (tech) => { if (tech.id === targetId) return '#ffd88a'; if (emp.known[tech.id]) return '#7fd8a0'; return emp.available[tech.id] ? '#9fb6cc' : '#5a4450'; }; // Edges first, so the badge nodes render on top of the lines feeding them. const edges = scene.add.graphics(); tree.add(edges); for (const tech of rules.techsByField[selectedField]) { const prereqId = tech.prereqs[0]; if (!prereqId) continue; const from = pos.get(prereqId); const to = pos.get(tech.id); const isCurrent = tech.id === targetId; const colourInt = Phaser.Display.Color.HexStringToColor(statusColour(tech)).color; edges.lineStyle(isCurrent ? 3 : 2, colourInt, isCurrent ? 1 : 0.55); edges.lineBetween(from.x, from.y, to.x, to.y); } let maxRight = innerX0; let maxBottom = innerY0; let minTop = innerY0; for (const tech of rules.techsByField[selectedField]) { const known = !!emp.known[tech.id]; // 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 = statusColour(tech); const mark = known ? '■' : (emp.available[tech.id] ? '□' : '✕'); const { x, y } = pos.get(tech.id); const isCurrent = tech.id === targetId; const badge = scene.add.rectangle(x, y, NODE, NODE, 0x0b1220, 0.9) .setStrokeStyle(isCurrent ? 2.5 : 1.5, Phaser.Display.Color.HexStringToColor(colour).color, isCurrent ? 1 : 0.7) .setInteractive({ useHandCursor: selectable }); tree.add(badge); tree.add(scene.add.text(x, y, mark, { fontFamily: FONT, fontSize: '20px', color: colour }).setOrigin(0.5)); tooltip.attachTo(badge, () => describeTechTooltip(rules, state, emp, tech)); if (selectable) { badge.on('pointerup', () => { if (!setResearchTarget(rules, state, e, selectedField, tech.id)) return; // The badge 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(); }); } maxRight = Math.max(maxRight, x + NODE / 2); maxBottom = Math.max(maxBottom, y + NODE / 2); minTop = Math.min(minTop, y - NODE / 2); } // Defensive two-axis scroll fallback — a no-op against today's data // (everything fits, confirmed by the pixel math above), kept so a field // that later grows past 10 tiers or a 3+-way branch doesn't silently // render off-pane. Mirrors this file's own overflow/clamp pattern. let scrollX = 0; let scrollY = 0; let overflowX = 0; let overflowY = 0; const applyScroll = () => { overflowX = Math.max(0, maxRight - innerX1); overflowY = Math.max(0, innerY0 - minTop) + Math.max(0, maxBottom - innerY1); scrollX = Phaser.Math.Clamp(scrollX, 0, overflowX); scrollY = Phaser.Math.Clamp(scrollY, 0, overflowY); tree.x = -scrollX; tree.y = -scrollY; }; bg.on('wheel', (pointer, dx, dy) => { scrollX += dx * 0.5; scrollY += dy * 0.5; applyScroll(); }); applyScroll(); if (targetId && pos.has(targetId)) { const t = pos.get(targetId); scrollX = Phaser.Math.Clamp(t.x - (usableX0 + usableX1) / 2, 0, overflowX); scrollY = Phaser.Math.Clamp(t.y - spineY, 0, overflowY); } 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; }