diff --git a/assets/videos/vega/research-cerebrai.mp4 b/assets/videos/vega/research-cerebrai.mp4 new file mode 100644 index 0000000..afd2c16 Binary files /dev/null and b/assets/videos/vega/research-cerebrai.mp4 differ diff --git a/data/mastervega-artwork.json b/data/mastervega-artwork.json index 1be5a76..18b9cb9 100644 --- a/data/mastervega-artwork.json +++ b/data/mastervega-artwork.json @@ -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 } }, diff --git a/src/games/mastervega/VegaResearchScreen.js b/src/games/mastervega/VegaResearchScreen.js index 849d012..93e12c1 100644 --- a/src/games/mastervega/VegaResearchScreen.js +++ b/src/games/mastervega/VegaResearchScreen.js @@ -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(); } diff --git a/src/games/mastervega/VegaTurnReportScreen.js b/src/games/mastervega/VegaTurnReportScreen.js index b65760d..0e6660f 100644 --- a/src/games/mastervega/VegaTurnReportScreen.js +++ b/src/games/mastervega/VegaTurnReportScreen.js @@ -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)