diff --git a/assets/speech/vega/intro-human.mp3 b/assets/speech/vega/intro-human.mp3 new file mode 100644 index 0000000..80928b8 Binary files /dev/null and b/assets/speech/vega/intro-human.mp3 differ diff --git a/assets/videos/vega/audience-cerebrai-angry.mp4 b/assets/videos/vega/audience-cerebrai-angry.mp4 new file mode 100644 index 0000000..86bfc04 Binary files /dev/null and b/assets/videos/vega/audience-cerebrai-angry.mp4 differ diff --git a/assets/videos/vega/audience-cerebrai-happy.mp4 b/assets/videos/vega/audience-cerebrai-happy.mp4 new file mode 100644 index 0000000..2a5dd55 Binary files /dev/null and b/assets/videos/vega/audience-cerebrai-happy.mp4 differ diff --git a/assets/videos/vega/audience-cerebrai-neutral.mp4 b/assets/videos/vega/audience-cerebrai-neutral.mp4 new file mode 100644 index 0000000..d073fa2 Binary files /dev/null and b/assets/videos/vega/audience-cerebrai-neutral.mp4 differ diff --git a/assets/videos/vega/audience-rrashaa-angry.mp4 b/assets/videos/vega/audience-rrashaa-angry.mp4 new file mode 100644 index 0000000..3c0f6fe Binary files /dev/null and b/assets/videos/vega/audience-rrashaa-angry.mp4 differ diff --git a/assets/videos/vega/audience-rrashaa-happy.mp4 b/assets/videos/vega/audience-rrashaa-happy.mp4 new file mode 100644 index 0000000..7538ac5 Binary files /dev/null and b/assets/videos/vega/audience-rrashaa-happy.mp4 differ diff --git a/assets/videos/vega/audience-rrashaa-neutral.mp4 b/assets/videos/vega/audience-rrashaa-neutral.mp4 new file mode 100644 index 0000000..18d93b2 Binary files /dev/null and b/assets/videos/vega/audience-rrashaa-neutral.mp4 differ diff --git a/src/games/mastervega/VegaDelaunay.js b/src/games/mastervega/VegaDelaunay.js new file mode 100644 index 0000000..52c116d --- /dev/null +++ b/src/games/mastervega/VegaDelaunay.js @@ -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()]; +} diff --git a/src/games/mastervega/VegaResearchChoiceScreen.js b/src/games/mastervega/VegaResearchChoiceScreen.js new file mode 100644 index 0000000..898ec07 --- /dev/null +++ b/src/games/mastervega/VegaResearchChoiceScreen.js @@ -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; +}