feat(mastervega): add research choice prompts, Delaunay-controlled-space corridors, and map UX improvements
- Introduce VegaResearchChoiceScreen for ambiguous tech selections: when a player completes research that opens a 2+ way choice rung, they're prompted before the turn report to pick their next technology. - Add VegaDelaunay.js (Bowyer-Watson Delaunay triangulation) and use it in VegaStarMap to draw "controlled space" corridors/fills between the player's own colonized stars instead of a blanket radius. - Improve star map UX: stagger docked fleet markers per-star, add PAN_SLACK so edge stars can be dragged out from under the side panel, and show colony info bars at mid-zooms with text only at near-zoom. - Add `closable` option to modalShell so forced-choice screens can suppress the ✕ button. - Export buildVideoPanel from VegaResearchScreen for reuse by the new choice screen. - Update artwork JSON: rrashaa and cerebrai audience videos now use species-specific clips; add intro-human.mp3 and new video assets. - Add tests for openResearchChoices and pendingResearchChoices filtering logic in verifyMasterOfVega.js.
This commit is contained in:
parent
582b9acfc7
commit
d8d127f33a
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,98 @@
|
||||||
|
// Master of Vega — Bowyer-Watson Delaunay triangulation.
|
||||||
|
//
|
||||||
|
// Pure and Phaser-free (the VegaZoom.js precedent: the deciding half of a UI
|
||||||
|
// concern is checkable headlessly even though the drawing half is not). The
|
||||||
|
// only consumer today is VegaStarMap.js's range-darkness "controlled space"
|
||||||
|
// mesh between a player's own colonies — it needs to know which colonies are
|
||||||
|
// geometric neighbors (triangulation edges) and which triples bound a filled
|
||||||
|
// face (triangles), without ever connecting colonies that aren't adjacent.
|
||||||
|
|
||||||
|
/** Circumcircle of three points, or null if they're (numerically) collinear. */
|
||||||
|
function circumcircle(a, b, c) {
|
||||||
|
const d = 2 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y));
|
||||||
|
if (Math.abs(d) < 1e-9) return null;
|
||||||
|
const aSq = a.x * a.x + a.y * a.y;
|
||||||
|
const bSq = b.x * b.x + b.y * b.y;
|
||||||
|
const cSq = c.x * c.x + c.y * c.y;
|
||||||
|
const ux = (aSq * (b.y - c.y) + bSq * (c.y - a.y) + cSq * (a.y - b.y)) / d;
|
||||||
|
const uy = (aSq * (c.x - b.x) + bSq * (a.x - c.x) + cSq * (b.x - a.x)) / d;
|
||||||
|
const r2 = (a.x - ux) ** 2 + (a.y - uy) ** 2;
|
||||||
|
return { x: ux, y: uy, r2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const edgeKey = (u, v) => (u < v ? `${u}_${v}` : `${v}_${u}`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delaunay-triangulate `points` ({x,y} array). Returns triangles as index
|
||||||
|
* triples into `points`. Fewer than 3 points has no triangles by
|
||||||
|
* definition — callers that want a single connecting edge for exactly 2
|
||||||
|
* points need to draw that themselves.
|
||||||
|
*/
|
||||||
|
export function delaunayTriangulate(points) {
|
||||||
|
const n = points.length;
|
||||||
|
if (n < 3) return [];
|
||||||
|
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
for (const p of points) {
|
||||||
|
minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
|
||||||
|
maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y);
|
||||||
|
}
|
||||||
|
const spanX = maxX - minX || 1;
|
||||||
|
const spanY = maxY - minY || 1;
|
||||||
|
const midX = (minX + maxX) / 2;
|
||||||
|
const midY = (minY + maxY) / 2;
|
||||||
|
// A super-triangle big enough that no input point can ever sit outside it.
|
||||||
|
const size = Math.max(spanX, spanY) * 20;
|
||||||
|
const pts = [...points,
|
||||||
|
{ x: midX - size, y: midY - size },
|
||||||
|
{ x: midX + size, y: midY - size },
|
||||||
|
{ x: midX, y: midY + size },
|
||||||
|
];
|
||||||
|
const superIdx = [n, n + 1, n + 2];
|
||||||
|
|
||||||
|
let triangles = [superIdx];
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i += 1) {
|
||||||
|
const p = pts[i];
|
||||||
|
const bad = [];
|
||||||
|
const good = [];
|
||||||
|
for (const tri of triangles) {
|
||||||
|
const cc = circumcircle(pts[tri[0]], pts[tri[1]], pts[tri[2]]);
|
||||||
|
const inside = cc && (p.x - cc.x) ** 2 + (p.y - cc.y) ** 2 <= cc.r2;
|
||||||
|
(inside ? bad : good).push(tri);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cavity left by removing the bad triangles is bounded by whichever
|
||||||
|
// edges belong to exactly one of them — shared (interior) edges cancel.
|
||||||
|
const edgeCount = new Map();
|
||||||
|
for (const [a, b, c] of bad) {
|
||||||
|
for (const [u, v] of [[a, b], [b, c], [c, a]]) {
|
||||||
|
const key = edgeKey(u, v);
|
||||||
|
edgeCount.set(key, (edgeCount.get(key) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
triangles = good;
|
||||||
|
for (const [a, b, c] of bad) {
|
||||||
|
for (const [u, v] of [[a, b], [b, c], [c, a]]) {
|
||||||
|
if (edgeCount.get(edgeKey(u, v)) === 1) triangles.push([u, v, i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return triangles.filter((tri) => tri.every((idx) => idx < n));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unique undirected edges (as [i, j] with i < j) covering a triangle set. */
|
||||||
|
export function triangulationEdges(triangles) {
|
||||||
|
const seen = new Map();
|
||||||
|
for (const [a, b, c] of triangles) {
|
||||||
|
for (const [u, v] of [[a, b], [b, c], [c, a]]) {
|
||||||
|
const key = edgeKey(u, v);
|
||||||
|
if (!seen.has(key)) seen.set(key, [Math.min(u, v), Math.max(u, v)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...seen.values()];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,236 @@
|
||||||
|
// Master of Vega — the "choose your next technology" prompt. Opened once per
|
||||||
|
// field, in turn order, right after a human research completion opens up a
|
||||||
|
// genuinely ambiguous rung (2+ live alternatives) — before the New Turn
|
||||||
|
// report. See MasterOfVegaGame.pendingResearchChoices()/runToHumanTurn() for
|
||||||
|
// the detection and sequencing.
|
||||||
|
//
|
||||||
|
// The engine has already auto-picked a default (the veteran tech, same as
|
||||||
|
// always — see VegaLogic.nextResearchTarget) by the time this screen opens;
|
||||||
|
// this screen never shows that pick and immediately discards it
|
||||||
|
// (emp.researching[field] = null) so nothing reads as pre-selected. The
|
||||||
|
// player's click calls the same zero-cost VegaLogic.setResearchTarget the
|
||||||
|
// normal Research screen's tree already uses.
|
||||||
|
//
|
||||||
|
// Same shell geometry as VegaResearchScreen.js's openResearchScreen (this
|
||||||
|
// file duplicates its tree-drawing math rather than sharing it — same
|
||||||
|
// "each screen owns its own copy of intricate rendering logic" convention
|
||||||
|
// already used for the JIT-video plumbing across VegaAudience.js/
|
||||||
|
// VegaColonyIntro.js/VegaResearchScreen.js, to avoid risking a live refactor
|
||||||
|
// of the shipped tree renderer).
|
||||||
|
|
||||||
|
import * as Phaser from 'phaser';
|
||||||
|
import { FONT, D, modalShell } from './VegaScreens.js';
|
||||||
|
import { setResearchTarget, fieldTechLevel } from './VegaLogic.js';
|
||||||
|
import { buildVideoPanel } from './VegaResearchScreen.js';
|
||||||
|
import { Tooltip } from '../../ui/Tooltip.js';
|
||||||
|
import { describeTechTooltip } from './VegaTooltips.js';
|
||||||
|
|
||||||
|
const ACCENT = 0x6fc4ff;
|
||||||
|
const PANEL = 0x0b1220;
|
||||||
|
const GOLD = '#ffd88a';
|
||||||
|
const GOLD_INT = 0xffd88a;
|
||||||
|
|
||||||
|
/** A ring of small dots around (x, y), continuously rotating. */
|
||||||
|
function buildRotatingRing(scene, x, y, radius, colourInt, dotCount = 12, dotRadius = 2.5) {
|
||||||
|
const ring = scene.add.container(x, y);
|
||||||
|
for (let i = 0; i < dotCount; i += 1) {
|
||||||
|
const angle = (i / dotCount) * Math.PI * 2;
|
||||||
|
ring.add(scene.add.circle(Math.cos(angle) * radius, Math.sin(angle) * radius, dotRadius, colourInt));
|
||||||
|
}
|
||||||
|
scene.tweens.add({ targets: ring, angle: 360, duration: 3000, repeat: -1, ease: 'Linear' });
|
||||||
|
return ring;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openResearchChoiceScreen(scene, rules, state, e, art, field, choices, completedTechId, onClose) {
|
||||||
|
const emp = state.empires[e];
|
||||||
|
const fieldDef = rules.techFields[field];
|
||||||
|
const completedTech = rules.techs[completedTechId];
|
||||||
|
|
||||||
|
// Discard the engine's auto-pick — this screen's whole point is that
|
||||||
|
// nothing is chosen until the player clicks one of the ringed options.
|
||||||
|
emp.researching[field] = null;
|
||||||
|
|
||||||
|
const tooltip = new Tooltip(scene, { depth: D.modal + 5 });
|
||||||
|
const shell = modalShell(scene, 'Choose Your Next Research', () => {
|
||||||
|
tooltip.destroy();
|
||||||
|
onClose?.();
|
||||||
|
}, { width: 1500, height: 880, closable: false });
|
||||||
|
|
||||||
|
// --- layout, identical to openResearchScreen's -----------------------
|
||||||
|
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));
|
||||||
|
|
||||||
|
// --- top-right: the completed field's box, read-only -------------------
|
||||||
|
const bx = rightX;
|
||||||
|
const by = gridY;
|
||||||
|
shell.add(scene.add.rectangle(bx + boxW / 2, by + boxH / 2, boxW, boxH, PANEL, 0.6)
|
||||||
|
.setStrokeStyle(1, ACCENT, 0.5));
|
||||||
|
|
||||||
|
const lvl = fieldTechLevel(rules, state, e, field);
|
||||||
|
shell.add(scene.add.text(bx + 12, by + 8, `${fieldDef.name.toUpperCase()} · Lv ${lvl}`, {
|
||||||
|
fontFamily: FONT, fontSize: '18px', color: '#cfe8ff',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// A static, non-interactive stand-in for the allocation slider — the real
|
||||||
|
// slider() widget always wires up its own drag zone, and it's shared by
|
||||||
|
// several other screens that must not be touched, so this is a plain
|
||||||
|
// dimmed track+fill rather than the real thing.
|
||||||
|
shell.add(scene.add.text(bx + 14, by + 42, 'Allocation (locked)', {
|
||||||
|
fontFamily: FONT, fontSize: '17px', color: '#5a708c',
|
||||||
|
}));
|
||||||
|
const trackY = by + 70;
|
||||||
|
shell.add(scene.add.rectangle(bx + 14, trackY, boxW - 28, 8, 0x1b2b42, 0.6).setOrigin(0, 0.5));
|
||||||
|
shell.add(scene.add.rectangle(bx + 14, trackY, (boxW - 28) * (emp.alloc[field] ?? 0), 8, ACCENT, 0.35)
|
||||||
|
.setOrigin(0, 0.5));
|
||||||
|
|
||||||
|
shell.add(scene.add.text(bx + 14, by + 96, 'Select your next technology below.', {
|
||||||
|
fontFamily: FONT, fontSize: '14px', color: '#9fb6cc', wordWrap: { width: boxW - 28 },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// --- remaining top-right space: the notification -----------------------
|
||||||
|
const noteX = bx + boxW + GAP;
|
||||||
|
const noteW = rightX + rightW - noteX;
|
||||||
|
shell.add(scene.add.text(noteX, by + 8, 'RESEARCH COMPLETE', {
|
||||||
|
fontFamily: FONT, fontSize: '22px', color: GOLD,
|
||||||
|
}));
|
||||||
|
shell.add(scene.add.text(noteX, by + 44,
|
||||||
|
`${completedTech.name} is finished. Choose your next ${fieldDef.name} technology `
|
||||||
|
+ 'from the highlighted options below.', {
|
||||||
|
fontFamily: FONT, fontSize: '16px', color: '#cfe8ff', wordWrap: { width: noteW }, lineSpacing: 4,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// --- bottom-right: the tree ---------------------------------------------
|
||||||
|
shell.add(scene.add.text(detailX, headingY, `${fieldDef.name.toUpperCase()} — TECH TREE`, {
|
||||||
|
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);
|
||||||
|
shell.add(bg);
|
||||||
|
|
||||||
|
const tree = scene.add.container(0, 0);
|
||||||
|
shell.add(tree);
|
||||||
|
const maskG = scene.make.graphics({ x: 0, y: 0, add: false });
|
||||||
|
maskG.fillStyle(0xffffff);
|
||||||
|
maskG.fillRect(detailX, detailY, detailW, detailH);
|
||||||
|
tree.setMask(maskG.createGeometryMask());
|
||||||
|
|
||||||
|
const PAD = 28;
|
||||||
|
const LABEL_H = 20;
|
||||||
|
const NODE = 34;
|
||||||
|
const LANE_STEP = 78;
|
||||||
|
const innerX0 = detailX + PAD;
|
||||||
|
const innerY0 = detailY + PAD;
|
||||||
|
const spineY = innerY0 + LABEL_H + (detailH - 2 * PAD - LABEL_H) / 2;
|
||||||
|
const usableX0 = innerX0 + NODE / 2;
|
||||||
|
const usableX1 = detailX + detailW - PAD - NODE / 2;
|
||||||
|
const rungs = rules.techRungsByField[field];
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
|
||||||
|
// No isCurrent/yellow here — nothing is chosen yet in this screen.
|
||||||
|
const statusColour = (tech) => {
|
||||||
|
if (emp.known[tech.id]) return '#7fd8a0';
|
||||||
|
return emp.available[tech.id] ? '#9fb6cc' : '#5a4450';
|
||||||
|
};
|
||||||
|
|
||||||
|
const edges = scene.add.graphics();
|
||||||
|
tree.add(edges);
|
||||||
|
for (const tech of rules.techsByField[field]) {
|
||||||
|
const prereqId = tech.prereqs[0];
|
||||||
|
if (!prereqId) continue;
|
||||||
|
const from = pos.get(prereqId);
|
||||||
|
const to = pos.get(tech.id);
|
||||||
|
const colourInt = Phaser.Display.Color.HexStringToColor(statusColour(tech)).color;
|
||||||
|
edges.lineStyle(2, colourInt, 0.55);
|
||||||
|
edges.lineBetween(from.x, from.y, to.x, to.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
const choiceIds = new Set(choices.map((t) => t.id));
|
||||||
|
const candidateNodes = new Map(); // techId -> { badge, mark, ring }
|
||||||
|
for (const tech of rules.techsByField[field]) {
|
||||||
|
const known = !!emp.known[tech.id];
|
||||||
|
const colour = statusColour(tech);
|
||||||
|
const mark = known ? '■' : (emp.available[tech.id] ? '□' : '✕');
|
||||||
|
const { x, y } = pos.get(tech.id);
|
||||||
|
|
||||||
|
const badge = scene.add.rectangle(x, y, NODE, NODE, 0x0b1220, 0.9)
|
||||||
|
.setStrokeStyle(1.5, Phaser.Display.Color.HexStringToColor(colour).color, 0.7);
|
||||||
|
tree.add(badge);
|
||||||
|
const markText = scene.add.text(x, y, mark, { fontFamily: FONT, fontSize: '20px', color: colour }).setOrigin(0.5);
|
||||||
|
tree.add(markText);
|
||||||
|
tooltip.attachTo(badge.setInteractive(), () => describeTechTooltip(rules, state, emp, tech));
|
||||||
|
|
||||||
|
if (choiceIds.has(tech.id)) {
|
||||||
|
const ring = buildRotatingRing(scene, x, y, NODE / 2 + 10, ACCENT);
|
||||||
|
tree.add(ring);
|
||||||
|
candidateNodes.set(tech.id, { badge, markText, ring });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- selection: shrink every ring, turn the chosen one yellow, persist,
|
||||||
|
// then close once every ring has finished vanishing.
|
||||||
|
let resolved = false;
|
||||||
|
let remaining = candidateNodes.size;
|
||||||
|
const finishAndClose = () => {
|
||||||
|
tooltip.destroy();
|
||||||
|
shell.destroy();
|
||||||
|
onClose?.();
|
||||||
|
};
|
||||||
|
for (const [techId, nodes] of candidateNodes) {
|
||||||
|
nodes.badge.setInteractive({ useHandCursor: true });
|
||||||
|
nodes.badge.on('pointerup', () => {
|
||||||
|
if (resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
setResearchTarget(rules, state, e, field, techId);
|
||||||
|
tooltip.hide();
|
||||||
|
for (const [otherId, otherNodes] of candidateNodes) {
|
||||||
|
if (otherId === techId) {
|
||||||
|
otherNodes.badge.setStrokeStyle(2.5, GOLD_INT, 1);
|
||||||
|
otherNodes.markText.setColor(GOLD);
|
||||||
|
}
|
||||||
|
otherNodes.badge.disableInteractive();
|
||||||
|
scene.tweens.add({
|
||||||
|
targets: otherNodes.ring, scale: 0, alpha: 0, duration: 600, ease: 'Cubic.easeIn',
|
||||||
|
onComplete: () => {
|
||||||
|
otherNodes.ring.destroy();
|
||||||
|
remaining -= 1;
|
||||||
|
if (remaining === 0) scene.time.delayedCall(150, finishAndClose);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return shell;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue