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:
parent
97dbe18dd2
commit
27aff33429
Binary file not shown.
|
|
@ -202,7 +202,7 @@
|
||||||
"kkrix": { "key": "vega-research-kkrix", "path": null },
|
"kkrix": { "key": "vega-research-kkrix", "path": null },
|
||||||
"mekhan": { "key": "vega-research-mekhan", "path": null },
|
"mekhan": { "key": "vega-research-mekhan", "path": null },
|
||||||
"rrashaa": { "key": "vega-research-rrashaa", "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 },
|
"ssakar": { "key": "vega-research-ssakar", "path": null },
|
||||||
"lithox": { "key": "vega-research-lithox", "path": null }
|
"lithox": { "key": "vega-research-lithox", "path": null }
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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];
|
const emp = state.empires[e];
|
||||||
// A game saved before the research-lock padlock existed has no
|
// A game saved before the research-lock padlock existed has no
|
||||||
// allocLocked at all — VegaLogic.deserialize() back-fills this for the
|
// 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));
|
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 gridLayer = null;
|
||||||
let detailLayer = null;
|
let detailLayer = null;
|
||||||
let maskG = null;
|
let maskG = null;
|
||||||
|
|
@ -291,10 +294,15 @@ export function openResearchScreen(scene, rules, state, e, art, onClose) {
|
||||||
fields.forEach((field, i) => buildCategoryBox(field, i));
|
fields.forEach((field, i) => buildCategoryBox(field, i));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- detail pane: the selected field's full tech ladder, much larger
|
// --- detail pane: the selected field's tech TREE — a ten-tier spine with
|
||||||
// font than the old screen's 14px columns, masked+scrollable since ten
|
// up to two short branch spurs (every branch tech added this session is a
|
||||||
// techs at this size can run past the pane's height. Mirrors
|
// dead end, never reconverging — confirmed against the data), rendered as
|
||||||
// VegaAudience.js's chat-log masked-scroll shape.
|
// 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() {
|
function buildDetail() {
|
||||||
detailLayer?.destroy();
|
detailLayer?.destroy();
|
||||||
maskG?.destroy();
|
maskG?.destroy();
|
||||||
|
|
@ -302,7 +310,7 @@ export function openResearchScreen(scene, rules, state, e, art, onClose) {
|
||||||
shell.add(detailLayer);
|
shell.add(detailLayer);
|
||||||
|
|
||||||
const field = rules.techFields[selectedField];
|
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',
|
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();
|
.setStrokeStyle(1, ACCENT, 0.3).setInteractive();
|
||||||
detailLayer.add(bg);
|
detailLayer.add(bg);
|
||||||
|
|
||||||
const ladder = scene.add.container(0, 0);
|
const tree = scene.add.container(0, 0);
|
||||||
detailLayer.add(ladder);
|
detailLayer.add(tree);
|
||||||
maskG = scene.make.graphics({ x: 0, y: 0, add: false });
|
maskG = scene.make.graphics({ x: 0, y: 0, add: false });
|
||||||
maskG.fillStyle(0xffffff);
|
maskG.fillStyle(0xffffff);
|
||||||
maskG.fillRect(detailX, detailY, detailW, detailH);
|
maskG.fillRect(detailX, detailY, detailW, detailH);
|
||||||
ladder.setMask(maskG.createGeometryMask());
|
tree.setMask(maskG.createGeometryMask());
|
||||||
|
|
||||||
let rowY = detailY + 16;
|
// --- layout: rung index -> column x, lane within a rung -> row y.
|
||||||
let scrollUp = 0;
|
const PAD = 28;
|
||||||
let overflow = 0;
|
const LABEL_H = 20;
|
||||||
const applyScroll = () => {
|
const NODE = 34;
|
||||||
overflow = Math.max(0, rowY - (detailY + detailH - 16));
|
const LANE_STEP = 78;
|
||||||
scrollUp = Phaser.Math.Clamp(scrollUp, 0, overflow);
|
const innerX0 = detailX + PAD;
|
||||||
ladder.y = -overflow + scrollUp;
|
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
|
// 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
|
// uses for its own progress bar — gets the same yellow as that bar.
|
||||||
// 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);
|
const targetId = emp.researching[selectedField] ?? nextResearchTarget(rules, state, e, selectedField);
|
||||||
let targetRowTop = null;
|
const statusColour = (tech) => {
|
||||||
let targetRowHeight = 0;
|
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]) {
|
for (const tech of rules.techsByField[selectedField]) {
|
||||||
const known = !!emp.known[tech.id];
|
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
|
// A branched rung can have more than one open (□) tech at once — this
|
||||||
// is what makes it clickable: canResearch is the same gate
|
// is what makes it clickable: canResearch is the same gate
|
||||||
// setResearchTarget itself checks, so "clickable" and "would actually
|
// setResearchTarget itself checks, so "clickable" and "would actually
|
||||||
// succeed" never disagree.
|
// succeed" never disagree.
|
||||||
const selectable = !known && canResearch(rules, state, e, tech);
|
const selectable = !known && canResearch(rules, state, e, tech);
|
||||||
const colour = isCurrent ? '#ffd88a' : (known ? '#7fd8a0' : (avail ? '#9fb6cc' : '#5a4450'));
|
const colour = statusColour(tech);
|
||||||
const mark = known ? '■' : (avail ? '□' : '✕');
|
const mark = known ? '■' : (emp.available[tech.id] ? '□' : '✕');
|
||||||
const row = scene.add.text(detailX + 16, rowY, `${mark} ${tech.name}`, {
|
const { x, y } = pos.get(tech.id);
|
||||||
fontFamily: FONT, fontSize: '26px', color: colour, wordWrap: { width: detailW - 32 },
|
const isCurrent = tech.id === targetId;
|
||||||
}).setInteractive({ useHandCursor: selectable });
|
|
||||||
ladder.add(row);
|
const badge = scene.add.rectangle(x, y, NODE, NODE, 0x0b1220, 0.9)
|
||||||
tooltip.attachTo(row, () => describeTechTooltip(rules, state, emp, tech));
|
.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) {
|
if (selectable) {
|
||||||
row.on('pointerup', () => {
|
badge.on('pointerup', () => {
|
||||||
if (!setResearchTarget(rules, state, e, selectedField, tech.id)) return;
|
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
|
// rebuild below — Tooltip only clears itself on pointerout, which
|
||||||
// never fires for a destroyed object, so hide it explicitly first.
|
// never fires for a destroyed object, so hide it explicitly first.
|
||||||
tooltip.hide();
|
tooltip.hide();
|
||||||
|
|
@ -362,19 +418,36 @@ export function openResearchScreen(scene, rules, state, e, art, onClose) {
|
||||||
buildDetail();
|
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
|
// Defensive two-axis scroll fallback — a no-op against today's data
|
||||||
// clamps the still-default scrollUp); the second pass then aims for
|
// (everything fits, confirmed by the pixel math above), kept so a field
|
||||||
// dead-centre on the target row, with applyScroll's own clamp pulling
|
// that later grows past 10 tiers or a 3+-way branch doesn't silently
|
||||||
// that back to the nearest reachable position when the target sits too
|
// render off-pane. Mirrors this file's own overflow/clamp pattern.
|
||||||
// close to either end of the list to actually reach centre.
|
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();
|
applyScroll();
|
||||||
scrollUp = targetRowTop !== null
|
if (targetId && pos.has(targetId)) {
|
||||||
? (detailY + detailH / 2 - (targetRowTop + targetRowHeight / 2)) + overflow
|
const t = pos.get(targetId);
|
||||||
: overflow; // nothing in progress in this field — open at the top
|
scrollX = Phaser.Math.Clamp(t.x - (usableX0 + usableX1) / 2, 0, overflowX);
|
||||||
|
scrollY = Phaser.Math.Clamp(t.y - spineY, 0, overflowY);
|
||||||
|
}
|
||||||
applyScroll();
|
applyScroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
import { modalShell, FONT } from './VegaScreens.js';
|
import { modalShell, FONT } from './VegaScreens.js';
|
||||||
import { scrollColumn } from './VegaColonyView.js';
|
import { scrollColumn } from './VegaColonyView.js';
|
||||||
import { openSystemView } from './VegaSystemView.js';
|
import { openSystemView } from './VegaSystemView.js';
|
||||||
|
import { openResearchScreen } from './VegaResearchScreen.js';
|
||||||
import { makeShipIcon, makeCommanderPortrait } from './VegaShipMedia.js';
|
import { makeShipIcon, makeCommanderPortrait } from './VegaShipMedia.js';
|
||||||
import { openShipDetail } from './VegaShipDetail.js';
|
import { openShipDetail } from './VegaShipDetail.js';
|
||||||
import { buildingFrame } from './VegaArt.js';
|
import { buildingFrame } from './VegaArt.js';
|
||||||
|
|
@ -235,6 +236,24 @@ export function openTurnReportScreen(scene, rules, state, events, onClose) {
|
||||||
col.content.add(viewBtn);
|
col.content.add(viewBtn);
|
||||||
cy += 36;
|
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)
|
const bg = scene.add.rectangle(0, rowTop, w, cy - rowTop, ROW_BG, 0).setOrigin(0, 0)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue