feat(mastervega): redesign research screen tech tree and add direct navigation

- Replace vertical "research ladder" list with a visual tech tree rendered as
  connected node badges showing prerequisites and branch structure
- Add `initialField` parameter to `openResearchScreen` to open on a specific
  category instead of always defaulting to the first one
- Add "View Research" shortcut button on turn report when a tech completes,
  jumping directly to that field
- Add Cerebrai species research video (research-cerebrai.mp4)
This commit is contained in:
Brian Fertig 2026-08-07 21:49:03 -06:00
parent 97dbe18dd2
commit 27aff33429
4 changed files with 137 additions and 45 deletions

Binary file not shown.

View File

@ -202,7 +202,7 @@
"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 },
"cerebrai": { "key": "vega-research-cerebrai", "path": "assets/videos/vega/research-cerebrai.mp4" },
"ssakar": { "key": "vega-research-ssakar", "path": null },
"lithox": { "key": "vega-research-lithox", "path": null }
},

View File

@ -148,7 +148,10 @@ function buildVideoPanel(scene, rules, art, speciesId, x, y, w, h) {
// --------------------------------------------------------------------------
export function openResearchScreen(scene, rules, state, e, art, onClose) {
// `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
@ -184,7 +187,7 @@ export function openResearchScreen(scene, rules, state, e, art, onClose) {
shell.add(buildVideoPanel(scene, rules, art, emp.speciesId, shell.body.x, shell.body.y, videoW, videoH));
let selectedField = fields[0].id;
let selectedField = (initialField && rules.techFields[initialField]) ? initialField : fields[0].id;
let gridLayer = null;
let detailLayer = null;
let maskG = null;
@ -291,10 +294,15 @@ export function openResearchScreen(scene, rules, state, e, art, onClose) {
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.
// --- 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();
@ -302,7 +310,7 @@ export function openResearchScreen(scene, rules, state, e, art, onClose) {
shell.add(detailLayer);
const field = rules.techFields[selectedField];
detailLayer.add(scene.add.text(detailX, headingY, `${field.name.toUpperCase()}RESEARCH LADDER`, {
detailLayer.add(scene.add.text(detailX, headingY, `${field.name.toUpperCase()}TECH TREE`, {
fontFamily: FONT, fontSize: '18px', color: '#cfe8ff',
}));
@ -310,51 +318,99 @@ export function openResearchScreen(scene, rules, state, e, art, onClose) {
.setStrokeStyle(1, ACCENT, 0.3).setInteractive();
detailLayer.add(bg);
const ladder = scene.add.container(0, 0);
detailLayer.add(ladder);
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);
ladder.setMask(maskG.createGeometryMask());
tree.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;
// --- 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;
};
bg.on('wheel', (pointer, dx, dy) => { scrollUp -= dy * 0.5; applyScroll(); });
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, and
// the list opens scrolled to bring it as close to the pane's centre as
// the ladder's length allows.
// uses for its own progress bar — gets the same yellow as that bar.
const targetId = emp.researching[selectedField] ?? nextResearchTarget(rules, state, e, selectedField);
let targetRowTop = null;
let targetRowHeight = 0;
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];
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));
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) {
row.on('pointerup', () => {
badge.on('pointerup', () => {
if (!setResearchTarget(rules, state, e, selectedField, tech.id)) return;
// The row this listener is on is about to be destroyed by the
// 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();
@ -362,19 +418,36 @@ export function openResearchScreen(scene, rules, state, e, art, onClose) {
buildDetail();
});
}
if (isCurrent) { targetRowTop = rowY; targetRowHeight = row.height; }
rowY += row.height + 10;
maxRight = Math.max(maxRight, x + NODE / 2);
maxBottom = Math.max(maxBottom, y + NODE / 2);
minTop = Math.min(minTop, y - NODE / 2);
}
// 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.
// 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();
scrollUp = targetRowTop !== null
? (detailY + detailH / 2 - (targetRowTop + targetRowHeight / 2)) + overflow
: overflow; // nothing in progress in this field — open at the top
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();
}

View File

@ -11,6 +11,7 @@
import { modalShell, FONT } from './VegaScreens.js';
import { scrollColumn } from './VegaColonyView.js';
import { openSystemView } from './VegaSystemView.js';
import { openResearchScreen } from './VegaResearchScreen.js';
import { makeShipIcon, makeCommanderPortrait } from './VegaShipMedia.js';
import { openShipDetail } from './VegaShipDetail.js';
import { buildingFrame } from './VegaArt.js';
@ -235,6 +236,24 @@ export function openTurnReportScreen(scene, rules, state, events, onClose) {
col.content.add(viewBtn);
cy += 36;
}
// Same shortcut shape as View Star System above, for the one type it
// doesn't cover: a finished tech jumps straight to its own field on
// the Research screen. Also not via scene.openModal (same reasoning
// as the star-system button — modalOpen is already true), and hands
// through the same onClose so the turn-transition still completes
// exactly once, whichever button the player used to leave the report.
if (ev.type === 'techDone') {
const viewBtn = new Button(scene, w - 108, cy + 14, 'View Research', () => {
col.destroy();
shell.destroy();
openResearchScreen(scene, rules, state, ev.empire, scene.art, onClose, {
initialField: rules.techs[ev.techId].field,
});
}, { width: 168, height: 28, fontSize: 13 });
col.content.add(viewBtn);
cy += 36;
}
}
const bg = scene.add.rectangle(0, rowTop, w, cy - rowTop, ROW_BG, 0).setOrigin(0, 0)