/**
* PHANTOM ZENITH ORIGIN — OPERATIONS TOOL: STAGE MAP (PROCGEN)
* assets/js/components/OpsTool_StageMap.jsx
* ─────────────────────────────────────────────────────────────
* Procedural stage layout generator. Visualises room placement,
* spine/branch/loop connections, and per-stage tilesets for all
* five defined stages.
*
* Adapted from standalone artifact → embeddable PZO tool.
* Key changes from the original:
* - ES module import replaced with global React destructure
* - export default removed; component registered on window
* - Outer container changed from 100vh to embedded height
* - Font-family simplified (inherits from page CSS)
*
* Registration:
* window.OpsTool_StageMap = ProceduralLevelGenerator
*
* Depends on: React 18 (global), tokens.css (via CSS vars)
* ─────────────────────────────────────────────────────────────
*/
(function () {
'use strict';
const { useState, useCallback, useRef, useEffect, useMemo } = React;
// ============================================================
// PHANTOM ZENITH ORIGIN — DESIGN TOKENS
// Mirrored from assets/css/tokens.css
// ============================================================
const T = {
bgDeep: "#080d14",
bgPanel: "#0f1923",
bgSurface: "#1a2535",
border: "#1e3a4a",
borderDim: "#162030",
cyan: "#22d3ee",
cyanDim: "#0e7490",
cyanGlow: "rgba(34,211,238,0.12)",
teal: "#5eead4",
orange: "#fdba74",
fuchsia: "#f0abfc",
textHi: "#e2e8f0",
textMid: "#94a3b8",
textLo: "#475569",
};
// Unified theme derived from tokens — all stages share this
const THEME = {
bg: T.bgDeep,
accent: T.cyan,
gridColor: "rgba(34,211,238,0.04)",
gridColorLg: "rgba(34,211,238,0.07)",
spineColor: T.teal,
branchColor: T.orange,
loopColor: T.fuchsia,
panelBg: T.bgPanel + "dd",
panelBorder: T.border,
startColor: T.teal,
exitColor: T.fuchsia,
};
// ============================================================
// GEOMETRY UTILITIES
// ============================================================
const Vec2 = {
add: (a, b) => ({ x: a.x + b.x, y: a.y + b.y }),
sub: (a, b) => ({ x: a.x - b.x, y: a.y - b.y }),
scale: (v, s) => ({ x: v.x * s, y: v.y * s }),
rotate: (v, angle) => ({
x: v.x * Math.cos(angle) - v.y * Math.sin(angle),
y: v.x * Math.sin(angle) + v.y * Math.cos(angle),
}),
dot: (a, b) => a.x * b.x + a.y * b.y,
cross: (a, b) => a.x * b.y - a.y * b.x,
length: (v) => Math.sqrt(v.x * v.x + v.y * v.y),
normalize: (v) => {
const l = Vec2.length(v);
return l > 0 ? { x: v.x / l, y: v.y / l } : { x: 0, y: 0 };
},
dist: (a, b) => Vec2.length(Vec2.sub(a, b)),
lerp: (a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }),
};
function transformRoom(room, angle, offset) {
const verts = room.vertices.map((v) => Vec2.add(Vec2.rotate(v, angle), offset));
const sockets = room.sockets.map((s) => ({
...s,
pos: Vec2.add(Vec2.rotate(s.pos, angle), offset),
normal: Vec2.rotate(s.normal, angle),
}));
return { ...room, vertices: verts, sockets, rotation: angle, position: offset };
}
function nudgeRoom(room, delta) {
return {
...room,
vertices: room.vertices.map((v) => Vec2.add(v, delta)),
sockets: room.sockets.map((s) => ({ ...s, pos: Vec2.add(s.pos, delta) })),
position: Vec2.add(room.position || { x: 0, y: 0 }, delta),
};
}
// ============================================================
// COLLISION — Direct edge-based detection
// ============================================================
function getCentroid(verts) {
const n = verts.length;
return { x: verts.reduce((s, v) => s + v.x, 0) / n, y: verts.reduce((s, v) => s + v.y, 0) / n };
}
function getBBox(verts) {
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const v of verts) {
if (v.x < minX) minX = v.x; if (v.x > maxX) maxX = v.x;
if (v.y < minY) minY = v.y; if (v.y > maxY) maxY = v.y;
}
return { minX, maxX, minY, maxY };
}
function pointInPolygon(pt, verts) {
let inside = false;
for (let i = 0, j = verts.length - 1; i < verts.length; j = i++) {
const xi = verts[i].x, yi = verts[i].y, xj = verts[j].x, yj = verts[j].y;
if (((yi > pt.y) !== (yj > pt.y)) && (pt.x < (xj - xi) * (pt.y - yi) / (yj - yi) + xi))
inside = !inside;
}
return inside;
}
function segmentsIntersect(p1, p2, p3, p4) {
const d1 = Vec2.sub(p2, p1), d2 = Vec2.sub(p4, p3);
const denom = Vec2.cross(d1, d2);
if (Math.abs(denom) < 1e-10) return false;
const d3 = Vec2.sub(p3, p1);
const t = Vec2.cross(d3, d2) / denom;
const u = Vec2.cross(d3, d1) / denom;
return t > 0 && t < 1 && u > 0 && u < 1;
}
function distPointToSeg(p, a, b) {
const ab = Vec2.sub(b, a), ap = Vec2.sub(p, a);
const lenSq = Vec2.dot(ab, ab);
if (lenSq < 1e-10) return Vec2.dist(p, a);
const t = Math.max(0, Math.min(1, Vec2.dot(ap, ab) / lenSq));
return Vec2.dist(p, Vec2.add(a, Vec2.scale(ab, t)));
}
function distSegToSeg(a1, a2, b1, b2) {
if (segmentsIntersect(a1, a2, b1, b2)) return 0;
return Math.min(distPointToSeg(a1, b1, b2), distPointToSeg(a2, b1, b2),
distPointToSeg(b1, a1, a2), distPointToSeg(b2, a1, a2));
}
function roomsCollide(a, b, margin = 16) {
const ba = getBBox(a.vertices), bb = getBBox(b.vertices);
if (ba.maxX + margin < bb.minX || bb.maxX + margin < ba.minX ||
ba.maxY + margin < bb.minY || bb.maxY + margin < ba.minY) return false;
const av = a.vertices, bv = b.vertices;
for (const v of av) if (pointInPolygon(v, bv)) return true;
for (const v of bv) if (pointInPolygon(v, av)) return true;
for (let i = 0; i < av.length; i++) {
const a1 = av[i], a2 = av[(i + 1) % av.length];
for (let j = 0; j < bv.length; j++) {
if (distSegToSeg(a1, a2, bv[j], bv[(j + 1) % bv.length]) < margin) return true;
}
}
return false;
}
function roomsCollideLight(a, b) { return roomsCollide(a, b, 4); }
function pathCrossesRoom(from, to, room) {
const v = room.vertices;
for (let i = 0; i < v.length; i++)
if (segmentsIntersect(from, to, v[i], v[(i + 1) % v.length])) return true;
return false;
}
// ============================================================
// HELPER: seeded RNG + organic blob generator
// ============================================================
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Convert any string to a 32-bit integer seed via DJB2 hash
function hashSeed(str) {
let h = 5381;
for (let i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) | 0;
return h >>> 0;
}
function makeOrganicBlob(baseRadius, vertCount, jitter, seed) {
const rng = mulberry32(seed);
return Array.from({ length: vertCount }, (_, i) => {
const a = (Math.PI * 2 * i) / vertCount;
const r = baseRadius + (rng() - 0.5) * jitter * 2;
return { x: r * Math.cos(a), y: r * Math.sin(a) };
});
}
// ============================================================
// TILESET DEFINITIONS — Phantom Zenith Origin Stages
//
// Room fill colors use muted blues within the token palette:
// large: #1e3545 (brightest — draws the eye)
// medium: #1a2d3d (default surface tone)
// small: #162535 (recedes slightly)
// corridor: #112030 (darkest — reads as passage)
// ============================================================
function makeSocket(pos, normal, width = 30, id) {
return { pos, normal: Vec2.normalize(normal), width, id, connected: false, isLoop: false };
}
// ---- STAGE 1: Infrastructure ----
const S1_ROOMS = [
{ name: "Pump Room", type: "small",
vertices: [{ x: -40, y: -40 }, { x: 40, y: -40 }, { x: 40, y: 40 }, { x: -40, y: 40 }],
sockets: [makeSocket({ x: 0, y: -40 }, { x: 0, y: -1 }, 30, "n"), makeSocket({ x: 40, y: 0 }, { x: 1, y: 0 }, 30, "e"),
makeSocket({ x: 0, y: 40 }, { x: 0, y: 1 }, 30, "s"), makeSocket({ x: -40, y: 0 }, { x: -1, y: 0 }, 30, "w")],
color: "#162535" },
{ name: "Maintenance Bay", type: "medium",
vertices: [{ x: -60, y: -40 }, { x: 60, y: -40 }, { x: 60, y: 40 }, { x: -60, y: 40 }],
sockets: [makeSocket({ x: -20, y: -40 }, { x: 0, y: -1 }, 30, "n1"), makeSocket({ x: 30, y: -40 }, { x: 0, y: -1 }, 30, "n2"),
makeSocket({ x: 60, y: 0 }, { x: 1, y: 0 }, 30, "e"), makeSocket({ x: 0, y: 40 }, { x: 0, y: 1 }, 30, "s"),
makeSocket({ x: -60, y: 0 }, { x: -1, y: 0 }, 30, "w")],
color: "#1a2d3d" },
{ name: "Cistern", type: "large",
vertices: [{ x: -80, y: -60 }, { x: 80, y: -60 }, { x: 80, y: 60 }, { x: -80, y: 60 }],
sockets: [makeSocket({ x: -30, y: -60 }, { x: 0, y: -1 }, 30, "n1"), makeSocket({ x: 30, y: -60 }, { x: 0, y: -1 }, 30, "n2"),
makeSocket({ x: 80, y: -20 }, { x: 1, y: 0 }, 30, "e1"), makeSocket({ x: 80, y: 20 }, { x: 1, y: 0 }, 30, "e2"),
makeSocket({ x: 0, y: 60 }, { x: 0, y: 1 }, 30, "s"), makeSocket({ x: -80, y: 0 }, { x: -1, y: 0 }, 30, "w")],
color: "#1e3545" },
{ name: "Drain Corridor", type: "corridor",
vertices: [{ x: -70, y: -18 }, { x: 70, y: -18 }, { x: 70, y: 18 }, { x: -70, y: 18 }],
sockets: [makeSocket({ x: -70, y: 0 }, { x: -1, y: 0 }, 28, "w"), makeSocket({ x: 70, y: 0 }, { x: 1, y: 0 }, 28, "e"),
makeSocket({ x: 0, y: -18 }, { x: 0, y: -1 }, 28, "n")],
color: "#112030" },
{ name: "L-Junction", type: "medium",
vertices: [{ x: -50, y: -50 }, { x: 20, y: -50 }, { x: 20, y: -10 }, { x: 50, y: -10 }, { x: 50, y: 50 }, { x: -50, y: 50 }],
sockets: [makeSocket({ x: -15, y: -50 }, { x: 0, y: -1 }, 30, "n"), makeSocket({ x: 50, y: 20 }, { x: 1, y: 0 }, 30, "e"),
makeSocket({ x: 0, y: 50 }, { x: 0, y: 1 }, 30, "s"), makeSocket({ x: -50, y: 0 }, { x: -1, y: 0 }, 30, "w")],
color: "#1a2d3d" },
{ name: "T-Junction", type: "medium",
vertices: [{ x: -60, y: -20 }, { x: 60, y: -20 }, { x: 60, y: 20 }, { x: 20, y: 20 }, { x: 20, y: 60 }, { x: -20, y: 60 }, { x: -20, y: 20 }, { x: -60, y: 20 }],
sockets: [makeSocket({ x: -60, y: 0 }, { x: -1, y: 0 }, 28, "w"), makeSocket({ x: 60, y: 0 }, { x: 1, y: 0 }, 28, "e"),
makeSocket({ x: 0, y: 60 }, { x: 0, y: 1 }, 28, "s")],
color: "#112030" },
];
// ---- STAGE 2: Dereliction ----
const S2_ROOMS = [
{ name: "Equipment Yard", type: "large",
vertices: [{ x: -85, y: -50 }, { x: 85, y: -55 }, { x: 80, y: 55 }, { x: -80, y: 50 }],
sockets: [makeSocket({ x: -30, y: -52 }, { x: 0, y: -1 }, 30, "n1"), makeSocket({ x: 30, y: -54 }, { x: 0, y: -1 }, 30, "n2"),
makeSocket({ x: 83, y: 0 }, { x: 1, y: 0 }, 30, "e"), makeSocket({ x: 0, y: 53 }, { x: 0, y: 1 }, 30, "s"),
makeSocket({ x: -83, y: 0 }, { x: -1, y: 0 }, 30, "w")],
color: "#1e3545" },
{ name: "Processing Ruin", type: "medium",
vertices: [{ x: -55, y: -38 }, { x: 50, y: -42 }, { x: 55, y: -10 }, { x: 48, y: 40 }, { x: -50, y: 38 }],
sockets: [makeSocket({ x: 0, y: -40 }, { x: 0, y: -1 }, 28, "n"), makeSocket({ x: 52, y: -26 }, { x: 1, y: -0.3 }, 28, "ne"),
makeSocket({ x: 0, y: 39 }, { x: 0, y: 1 }, 28, "s"), makeSocket({ x: -53, y: 0 }, { x: -1, y: 0 }, 28, "w")],
color: "#1a2d3d" },
{ name: "Extraction Pit", type: "large",
vertices: [{ x: -70, y: -55 }, { x: -20, y: -65 }, { x: 40, y: -55 }, { x: 70, y: -30 },
{ x: 65, y: 30 }, { x: 40, y: 60 }, { x: -30, y: 55 }, { x: -65, y: 25 }],
sockets: [makeSocket({ x: 10, y: -60 }, { x: 0, y: -1 }, 30, "n"), makeSocket({ x: 68, y: 0 }, { x: 1, y: 0 }, 30, "e"),
makeSocket({ x: 5, y: 58 }, { x: 0, y: 1 }, 30, "s"), makeSocket({ x: -68, y: -15 }, { x: -1, y: 0 }, 30, "w")],
color: "#1e3545" },
{ name: "Dry Riverbed", type: "corridor",
vertices: [{ x: -75, y: -14 }, { x: -40, y: -20 }, { x: 0, y: -12 }, { x: 40, y: -18 },
{ x: 75, y: -10 }, { x: 78, y: 6 }, { x: 40, y: 18 }, { x: 0, y: 12 },
{ x: -40, y: 20 }, { x: -75, y: 14 }],
sockets: [makeSocket({ x: -75, y: 0 }, { x: -1, y: 0 }, 24, "w"), makeSocket({ x: 77, y: -2 }, { x: 1, y: 0 }, 24, "e")],
color: "#112030" },
{ name: "Dust Hollow", type: "small",
vertices: makeOrganicBlob(36, 8, 10, 2001),
sockets: [makeSocket({ x: 0, y: -35 }, { x: 0, y: -1 }, 26, "n"), makeSocket({ x: 35, y: 0 }, { x: 1, y: 0 }, 26, "e"),
makeSocket({ x: 0, y: 35 }, { x: 0, y: 1 }, 26, "s")],
color: "#162535" },
{ name: "Canyon Passage", type: "corridor",
vertices: [{ x: -65, y: -22 }, { x: -30, y: -16 }, { x: 30, y: -28 }, { x: 65, y: -20 },
{ x: 68, y: 0 }, { x: 30, y: 18 }, { x: -30, y: 24 }, { x: -65, y: 18 }],
sockets: [makeSocket({ x: -65, y: -2 }, { x: -1, y: 0 }, 26, "w"), makeSocket({ x: 67, y: -10 }, { x: 1, y: 0 }, 26, "e"),
makeSocket({ x: 0, y: -22 }, { x: 0, y: -1 }, 22, "n")],
color: "#112030" },
];
// ---- STAGE 3: The Chasm ----
const S3_ROOMS = [
{ name: "Grand Cavern", type: "large", vertices: makeOrganicBlob(70, 14, 20, 42),
sockets: [makeSocket({ x: 0, y: -68 }, { x: 0, y: -1 }, 32, "n"), makeSocket({ x: 65, y: 10 }, { x: 1, y: 0 }, 32, "e"),
makeSocket({ x: -10, y: 65 }, { x: 0, y: 1 }, 32, "s"), makeSocket({ x: -65, y: -5 }, { x: -1, y: 0 }, 32, "w")],
color: "#1e3545" },
{ name: "Grotto", type: "small", vertices: makeOrganicBlob(35, 10, 12, 77),
sockets: [makeSocket({ x: 0, y: -34 }, { x: 0, y: -1 }, 28, "n"), makeSocket({ x: 34, y: 0 }, { x: 1, y: 0 }, 28, "e"),
makeSocket({ x: 0, y: 34 }, { x: 0, y: 1 }, 28, "s")],
color: "#162535" },
{ name: "Luminous Chamber", type: "medium", vertices: makeOrganicBlob(52, 12, 16, 123),
sockets: [makeSocket({ x: 5, y: -50 }, { x: 0, y: -1 }, 30, "n"), makeSocket({ x: 50, y: 5 }, { x: 1, y: 0 }, 30, "e"),
makeSocket({ x: -5, y: 50 }, { x: 0, y: 1 }, 30, "s"), makeSocket({ x: -50, y: -5 }, { x: -1, y: 0 }, 30, "w")],
color: "#1a2d3d" },
{ name: "Winding Descent", type: "corridor",
vertices: [{ x: -65, y: -16 }, { x: -30, y: -20 }, { x: 0, y: -14 }, { x: 30, y: -18 }, { x: 65, y: -14 }, { x: 68, y: 0 },
{ x: 65, y: 14 }, { x: 30, y: 18 }, { x: 0, y: 14 }, { x: -30, y: 20 }, { x: -65, y: 16 }, { x: -68, y: 0 }],
sockets: [makeSocket({ x: -68, y: 0 }, { x: -1, y: 0 }, 26, "w"), makeSocket({ x: 68, y: 0 }, { x: 1, y: 0 }, 26, "e")],
color: "#112030" },
{ name: "Deep Crevice", type: "medium", vertices: makeOrganicBlob(48, 16, 18, 256),
sockets: [makeSocket({ x: 0, y: -46 }, { x: 0, y: -1 }, 28, "n"), makeSocket({ x: 46, y: 0 }, { x: 1, y: 0 }, 28, "e"),
makeSocket({ x: 0, y: 46 }, { x: 0, y: 1 }, 28, "s"), makeSocket({ x: -46, y: 0 }, { x: -1, y: 0 }, 28, "w")],
color: "#1a2d3d" },
{ name: "Spore Den", type: "small", vertices: makeOrganicBlob(38, 11, 14, 512),
sockets: [makeSocket({ x: 0, y: -36 }, { x: 0, y: -1 }, 26, "n"), makeSocket({ x: 0, y: 36 }, { x: 0, y: 1 }, 26, "s"),
makeSocket({ x: 36, y: 0 }, { x: 1, y: 0 }, 26, "e")],
color: "#162535" },
];
// ---- STAGE 4: Mines (The Connective Tissue) ----
const S4_ROOMS = [
{ name: "Foundation Vault", type: "large",
vertices: (() => {
const r = 68;
return Array.from({ length: 6 }, (_, i) => {
const a = (Math.PI / 3) * i - Math.PI / 6;
const wobble = i % 2 === 0 ? 0 : -8;
return { x: (r + wobble) * Math.cos(a), y: (r + wobble) * Math.sin(a) };
});
})(),
sockets: [makeSocket({ x: 0, y: -64 }, { x: 0, y: -1 }, 30, "n"),
makeSocket({ x: 58, y: -30 }, Vec2.normalize({ x: 1, y: -0.5 }), 30, "ne"),
makeSocket({ x: 58, y: 30 }, Vec2.normalize({ x: 1, y: 0.5 }), 30, "se"),
makeSocket({ x: 0, y: 64 }, { x: 0, y: 1 }, 30, "s"),
makeSocket({ x: -58, y: 30 }, Vec2.normalize({ x: -1, y: 0.5 }), 30, "sw"),
makeSocket({ x: -58, y: -30 }, Vec2.normalize({ x: -1, y: -0.5 }), 30, "nw")],
color: "#1e3545" },
{ name: "Carved Chamber", type: "medium",
vertices: [{ x: -45, y: -50 }, { x: 45, y: -50 }, { x: 55, y: 0 }, { x: 45, y: 50 }, { x: -45, y: 50 }, { x: -55, y: 0 }],
sockets: [makeSocket({ x: 0, y: -50 }, { x: 0, y: -1 }, 28, "n"), makeSocket({ x: 55, y: 0 }, { x: 1, y: 0 }, 28, "e"),
makeSocket({ x: 0, y: 50 }, { x: 0, y: 1 }, 28, "s"), makeSocket({ x: -55, y: 0 }, { x: -1, y: 0 }, 28, "w")],
color: "#1a2d3d" },
{ name: "Inscription Hall", type: "large",
vertices: [{ x: -85, y: -35 }, { x: 85, y: -35 }, { x: 80, y: 35 }, { x: -80, y: 35 }],
sockets: [makeSocket({ x: -30, y: -35 }, { x: 0, y: -1 }, 28, "n1"), makeSocket({ x: 30, y: -35 }, { x: 0, y: -1 }, 28, "n2"),
makeSocket({ x: 83, y: 0 }, { x: 1, y: 0 }, 28, "e"), makeSocket({ x: 0, y: 35 }, { x: 0, y: 1 }, 28, "s"),
makeSocket({ x: -83, y: 0 }, { x: -1, y: 0 }, 28, "w")],
color: "#1e3545" },
{ name: "Ancient Passage", type: "corridor",
vertices: [{ x: -72, y: -16 }, { x: -40, y: -20 }, { x: 0, y: -15 }, { x: 40, y: -20 },
{ x: 72, y: -16 }, { x: 72, y: 16 }, { x: 40, y: 20 }, { x: 0, y: 15 },
{ x: -40, y: 20 }, { x: -72, y: 16 }],
sockets: [makeSocket({ x: -72, y: 0 }, { x: -1, y: 0 }, 26, "w"), makeSocket({ x: 72, y: 0 }, { x: 1, y: 0 }, 26, "e"),
makeSocket({ x: 0, y: -15 }, { x: 0, y: -1 }, 22, "n")],
color: "#112030" },
{ name: "Contaminant Pool", type: "medium", vertices: makeOrganicBlob(46, 12, 15, 3001),
sockets: [makeSocket({ x: 0, y: -44 }, { x: 0, y: -1 }, 28, "n"), makeSocket({ x: 44, y: 0 }, { x: 1, y: 0 }, 28, "e"),
makeSocket({ x: 0, y: 44 }, { x: 0, y: 1 }, 28, "s"), makeSocket({ x: -44, y: 0 }, { x: -1, y: 0 }, 28, "w")],
color: "#1a2d3d" },
{ name: "Seep Node", type: "small", vertices: makeOrganicBlob(34, 9, 12, 3333),
sockets: [makeSocket({ x: 0, y: -32 }, { x: 0, y: -1 }, 26, "n"),
makeSocket({ x: 32, y: 0 }, { x: 1, y: 0 }, 26, "e"),
makeSocket({ x: 0, y: 32 }, { x: 0, y: 1 }, 26, "s")],
color: "#162535" },
];
// ---- STAGE 5: The Heart ----
const S5_ROOMS = [
{ name: "Core Nexus", type: "large",
vertices: (() => { const r = 65; return Array.from({ length: 8 }, (_, i) => ({ x: r * Math.cos((Math.PI / 4) * i - Math.PI / 8), y: r * Math.sin((Math.PI / 4) * i - Math.PI / 8) })); })(),
sockets: [makeSocket({ x: 0, y: -63 }, { x: 0, y: -1 }, 30, "n"), makeSocket({ x: 45, y: -45 }, Vec2.normalize({ x: 1, y: -1 }), 30, "ne"),
makeSocket({ x: 63, y: 0 }, { x: 1, y: 0 }, 30, "e"), makeSocket({ x: 45, y: 45 }, Vec2.normalize({ x: 1, y: 1 }), 30, "se"),
makeSocket({ x: 0, y: 63 }, { x: 0, y: 1 }, 30, "s"), makeSocket({ x: -45, y: 45 }, Vec2.normalize({ x: -1, y: 1 }), 30, "sw"),
makeSocket({ x: -63, y: 0 }, { x: -1, y: 0 }, 30, "w"), makeSocket({ x: -45, y: -45 }, Vec2.normalize({ x: -1, y: -1 }), 30, "nw")],
color: "#1e3545" },
{ name: "Wound Node", type: "small",
vertices: [{ x: -20, y: -35 }, { x: 20, y: -35 }, { x: 38, y: 0 }, { x: 20, y: 35 }, { x: -20, y: 35 }, { x: -38, y: 0 }],
sockets: [makeSocket({ x: 0, y: -35 }, { x: 0, y: -1 }, 28, "n"), makeSocket({ x: 38, y: 0 }, { x: 1, y: 0 }, 28, "e"),
makeSocket({ x: 0, y: 35 }, { x: 0, y: 1 }, 28, "s"), makeSocket({ x: -38, y: 0 }, { x: -1, y: 0 }, 28, "w")],
color: "#162535" },
{ name: "Impact Chamber", type: "medium",
vertices: [{ x: 0, y: -55 }, { x: 55, y: 0 }, { x: 0, y: 55 }, { x: -55, y: 0 }],
sockets: [makeSocket({ x: 0, y: -55 }, { x: 0, y: -1 }, 28, "n"), makeSocket({ x: 55, y: 0 }, { x: 1, y: 0 }, 28, "e"),
makeSocket({ x: 0, y: 55 }, { x: 0, y: 1 }, 28, "s"), makeSocket({ x: -55, y: 0 }, { x: -1, y: 0 }, 28, "w")],
color: "#1a2d3d" },
{ name: "Scar Bridge", type: "corridor",
vertices: [{ x: -80, y: -25 }, { x: -55, y: -12 }, { x: 55, y: -12 }, { x: 80, y: -25 }, { x: 80, y: 25 }, { x: 55, y: 12 }, { x: -55, y: 12 }, { x: -80, y: 25 }],
sockets: [makeSocket({ x: -80, y: 0 }, { x: -1, y: 0 }, 28, "w"), makeSocket({ x: 80, y: 0 }, { x: 1, y: 0 }, 28, "e")],
color: "#112030" },
{ name: "Resonance Node", type: "medium",
vertices: (() => { const r = 45; return Array.from({ length: 8 }, (_, i) => ({ x: r * Math.cos((Math.PI / 4) * i - Math.PI / 8), y: r * Math.sin((Math.PI / 4) * i - Math.PI / 8) })); })(),
sockets: [makeSocket({ x: 0, y: -43 }, { x: 0, y: -1 }, 28, "n"), makeSocket({ x: 43, y: 0 }, { x: 1, y: 0 }, 28, "e"),
makeSocket({ x: 0, y: 43 }, { x: 0, y: 1 }, 28, "s"), makeSocket({ x: -43, y: 0 }, { x: -1, y: 0 }, 28, "w")],
color: "#1a2d3d" },
{ name: "Fracture Path", type: "corridor",
vertices: [{ x: -60, y: -15 }, { x: -10, y: -15 }, { x: 60, y: -50 }, { x: 68, y: -38 }, { x: 10, y: 2 }, { x: -60, y: 2 }],
sockets: [makeSocket({ x: -60, y: -7 }, { x: -1, y: 0 }, 15, "w"), makeSocket({ x: 64, y: -44 }, Vec2.normalize({ x: 1, y: -1 }), 15, "ne")],
color: "#112030" },
];
// ---- STAGE REGISTRY ----
const STAGES = [
{ key: "s1", number: "01", name: "Infrastructure", subtitle: "Sewers · Maintenance · Drainage", rooms: S1_ROOMS },
{ key: "s2", number: "02", name: "Dereliction", subtitle: "Dust Flats · Canyons · Ruins", rooms: S2_ROOMS },
{ key: "s3", number: "03", name: "The Chasm", subtitle: "Canyon Rim · Flooded Depths", rooms: S3_ROOMS },
{ key: "s4", number: "04", name: "Mines", subtitle: "Sub-Geological · Pre-City", rooms: S4_ROOMS },
{ key: "s5", number: "05", name: "The Heart", subtitle: "Impact Site · The Wound", rooms: S5_ROOMS },
];
const STAGE_MAP = {};
STAGES.forEach((s) => { STAGE_MAP[s.key] = s; });
// ============================================================
// LEVEL GENERATOR
// ============================================================
function generateLevel(stage, roomCount, seed, pathsToExit = 1, weights = null) {
const rng = mulberry32(seed);
const templates = stage.rooms;
const placed = [];
const allSockets = [];
const connections = [];
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
const w = weights || templates.map(() => 1);
function pickTemplate() {
const total = w.reduce((s, v) => s + v, 0);
if (total <= 0) return pick(templates);
let r = rng() * total;
for (let i = 0; i < templates.length; i++) { r -= w[i]; if (r <= 0) return templates[i]; }
return templates[templates.length - 1];
}
function pickTemplateExcluding(type) {
const filtered = templates.map((t, i) => ({ t, w: w[i] })).filter((x) => x.t.type !== type);
const total = filtered.reduce((s, x) => s + x.w, 0);
if (total <= 0) return pick(templates.filter((t) => t.type !== type));
let r = rng() * total;
for (const x of filtered) { r -= x.w; if (r <= 0) return x.t; }
return filtered[filtered.length - 1].t;
}
const spineAngle = (rng() - 0.5) * Math.PI * 0.6;
const spineDir = { x: Math.cos(spineAngle), y: Math.sin(spineAngle) };
function tryPlaceRoom(srcEntry, roleTag) {
const srcSocket = srcEntry.socket;
if (srcSocket.connected) return null;
// Determine if corridors are blocked for this placement.
// Rule: no chain of 3+ corridors. If the parent is a corridor
// that already connects to another corridor, block corridors here.
let blockCorridor = false;
const parentRoom = placed[srcEntry.roomIndex];
if (parentRoom && parentRoom.type === "corridor") {
for (const conn of connections) {
const neighborIdx = conn.fromRoom === srcEntry.roomIndex ? conn.toRoom :
conn.toRoom === srcEntry.roomIndex ? conn.fromRoom : -1;
if (neighborIdx >= 0 && placed[neighborIdx] && placed[neighborIdx].type === "corridor") {
blockCorridor = true; break;
}
}
}
for (let attempt = 0; attempt < 20; attempt++) {
const template = blockCorridor ? pickTemplateExcluding("corridor") : pickTemplate();
const newRoom = { ...template, vertices: [...template.vertices], sockets: template.sockets.map((s) => ({ ...s })) };
const tgtIdx = Math.floor(rng() * newRoom.sockets.length);
const tgtSocket = newRoom.sockets[tgtIdx];
const srcA = Math.atan2(srcSocket.normal.y, srcSocket.normal.x);
const tgtA = Math.atan2(tgtSocket.normal.y, tgtSocket.normal.x);
let rotation = srcA - tgtA + Math.PI + (rng() - 0.5) * 0.07;
const rotTgt = Vec2.rotate(tgtSocket.pos, rotation);
const offset = Vec2.add(Vec2.sub(srcSocket.pos, rotTgt), Vec2.scale(srcSocket.normal, 14));
const transformed = transformRoom(newRoom, rotation, offset);
let collides = false;
for (const ex of placed) {
if (ex.id === srcEntry.roomIndex) continue;
if (roomsCollide(transformed, ex)) { collides = true; break; }
}
if (!collides) {
transformed.id = placed.length;
transformed.templateName = template.name;
transformed.role = roleTag;
placed.push(transformed);
srcSocket.connected = true;
transformed.sockets[tgtIdx].connected = true;
connections.push({ fromRoom: srcEntry.roomIndex, toRoom: transformed.id,
fromSocketPos: { ...srcSocket.pos }, toSocketPos: { ...transformed.sockets[tgtIdx].pos },
isLoop: false, role: roleTag });
if (!srcSocket.connectedTo) srcSocket.connectedTo = [];
srcSocket.connectedTo.push({ roomId: transformed.id });
transformed.sockets[tgtIdx].connectedTo = [{ roomId: srcEntry.roomIndex }];
transformed.sockets.forEach((s, i) => allSockets.push({ roomIndex: transformed.id, socketIndex: i, socket: s }));
return transformed;
}
}
return null;
}
function bestSocketForDir(roomIdx, dir) {
const cands = allSockets.filter((s) => s.roomIndex === roomIdx && !s.socket.connected);
if (cands.length === 0) return null;
const scores = cands.map((c) => Vec2.dot(c.socket.normal, dir));
let best = 0;
for (let i = 1; i < scores.length; i++) if (scores[i] > scores[best]) best = i;
if (cands.length > 1 && rng() < 0.25) best = Math.floor(rng() * cands.length);
return cands[best];
}
// Phase 1: Spine
const spineTarget = Math.max(4, Math.floor(roomCount * 0.4));
const startTpl = pickTemplateExcluding("corridor");
const startRoom = transformRoom({ ...startTpl, sockets: startTpl.sockets.map((s) => ({ ...s })) }, 0, { x: 0, y: 0 });
startRoom.id = 0; startRoom.templateName = startTpl.name; startRoom.role = "start";
placed.push(startRoom);
startRoom.sockets.forEach((s, i) => allSockets.push({ roomIndex: 0, socketIndex: i, socket: s }));
let spineRoomIds = [0], curSpine = 0;
for (let i = 1; i < spineTarget; i++) {
const se = bestSocketForDir(curSpine, spineDir);
if (!se) break;
const nr = tryPlaceRoom(se, "spine");
if (nr) { spineRoomIds.push(nr.id); curSpine = nr.id; }
else { const fb = allSockets.find((s) => s.roomIndex === curSpine && !s.socket.connected);
if (fb) { const r2 = tryPlaceRoom(fb, "spine"); if (r2) { spineRoomIds.push(r2.id); curSpine = r2.id; } else break; } else break; }
}
let exitRoomId = spineRoomIds[spineRoomIds.length - 1];
// Rule: Exit room cannot be a corridor. If it is, try to extend
// the spine by one more non-corridor room. If that fails, walk
// backward through the spine to find the last non-corridor.
if (placed[exitRoomId].type === "corridor") {
// Try extending from the corridor's open sockets
const extSocket = allSockets.find((s) => s.roomIndex === exitRoomId && !s.socket.connected);
let extended = false;
if (extSocket) {
// Temporarily force non-corridor selection
const origWeights = [...w];
templates.forEach((t, i) => { if (t.type === "corridor") w[i] = 0; });
const ext = tryPlaceRoom(extSocket, "spine");
templates.forEach((_, i) => { w[i] = origWeights[i]; });
if (ext) {
spineRoomIds.push(ext.id);
exitRoomId = ext.id;
extended = true;
}
}
// If extension failed, walk back to last non-corridor on spine
if (!extended) {
for (let i = spineRoomIds.length - 1; i >= 0; i--) {
if (placed[spineRoomIds[i]].type !== "corridor") {
exitRoomId = spineRoomIds[i]; break;
}
}
}
}
placed[exitRoomId].role = "exit";
const exitPos = getCentroid(placed[exitRoomId].vertices);
// Phase 2: Branch paths
const numBranches = Math.max(0, pathsToExit - 1);
const branchPaths = [];
for (let b = 0; b < numBranches; b++) {
const minI = Math.max(1, Math.floor(spineRoomIds.length * 0.25));
const maxI = Math.max(minI + 1, Math.floor(spineRoomIds.length * 0.75));
const oIdx = minI + Math.floor(rng() * (maxI - minI));
const oRoom = spineRoomIds[Math.min(oIdx, spineRoomIds.length - 1)];
const budget = Math.max(3, Math.floor((roomCount - placed.length) * 0.4 / Math.max(1, numBranches - b)));
const bIds = [];
let cur = oRoom;
for (let i = 0; i < budget; i++) {
const cp = getCentroid(placed[cur].vertices);
const toEx = Vec2.normalize(Vec2.sub(exitPos, cp));
const bDir = Vec2.normalize(Vec2.add(Vec2.scale(toEx, 0.7), Vec2.scale(spineDir, 0.3)));
const se = bestSocketForDir(cur, bDir);
if (!se) break;
const nr = tryPlaceRoom(se, "branch");
if (nr) { bIds.push(nr.id); cur = nr.id; } else break;
}
branchPaths.push({ originRoomId: oRoom, roomIds: bIds });
}
// Phase 3: Fill
let fillAtt = 0;
const maxFill = (roomCount - placed.length) * 60;
while (placed.length < roomCount && fillAtt < maxFill) {
fillAtt++;
const open = allSockets.filter((s) => !s.socket.connected);
if (open.length === 0) break;
tryPlaceRoom(pick(open), "fill");
}
// Phase 3.5: Remove orphan corridors (iterative)
// Corridors are passages, not destinations. A corridor with only
// one connection is a dead end — structurally wrong. Remove them
// and free the parent socket so something else can use it.
// Iterate because removing one corridor can orphan the next one
// in a chain (e.g., Room → Corridor A → Corridor B: removing B
// makes A a new orphan).
let prunedCount = 0;
{
let changed = true;
while (changed) {
changed = false;
const removeIds = new Set();
for (let i = 0; i < placed.length; i++) {
const room = placed[i];
if (room.type !== "corridor") continue;
if (room.role === "start" || room.role === "exit") continue;
const connCount = room.sockets.filter((s) => s.connected).length;
if (connCount <= 1) removeIds.add(i);
}
if (removeIds.size === 0) break;
changed = true;
prunedCount += removeIds.size;
// Disconnect parent sockets that link to removed rooms
for (const rid of removeIds) {
for (const room of placed) {
if (!room || removeIds.has(room.id)) continue;
for (const s of room.sockets) {
if (s.connectedTo && s.connectedTo.some((ct) => ct.roomId === rid)) {
s.connectedTo = s.connectedTo.filter((ct) => ct.roomId !== rid);
if (s.connectedTo.length === 0) { s.connected = false; s.isLoop = false; }
}
}
}
}
// Build remap: old ID → new ID
const idMap = {};
let newIdx = 0;
for (let i = 0; i < placed.length; i++) {
if (!removeIds.has(i)) { idMap[i] = newIdx; newIdx++; }
}
// Compact placed array
const kept = placed.filter((_, i) => !removeIds.has(i));
kept.forEach((r, i) => { r.id = i; });
placed.length = 0; placed.push(...kept);
// Remap connections
const keptConns = connections.filter((c) => !removeIds.has(c.fromRoom) && !removeIds.has(c.toRoom));
keptConns.forEach((c) => { c.fromRoom = idMap[c.fromRoom]; c.toRoom = idMap[c.toRoom]; });
connections.length = 0; connections.push(...keptConns);
// Remap allSockets
const keptSocks = allSockets.filter((s) => !removeIds.has(s.roomIndex));
keptSocks.forEach((s) => { s.roomIndex = idMap[s.roomIndex]; });
allSockets.length = 0; allSockets.push(...keptSocks);
// Remap connectedTo references inside room sockets
for (const room of placed) {
for (const s of room.sockets) {
if (s.connectedTo) {
s.connectedTo = s.connectedTo.map((ct) => ({ ...ct, roomId: idMap[ct.roomId] }));
}
}
}
// Remap spine and branch tracking
spineRoomIds = spineRoomIds.filter((id) => !removeIds.has(id)).map((id) => idMap[id]);
exitRoomId = idMap[exitRoomId] ?? exitRoomId;
for (const bp of branchPaths) {
bp.originRoomId = idMap[bp.originRoomId] ?? bp.originRoomId;
bp.roomIds = bp.roomIds.filter((id) => !removeIds.has(id)).map((id) => idMap[id]);
}
}
}
// Ensure exit role is set after potential removals
placed[exitRoomId].role = "exit";
// Phase 4: Loop connections
const MAX_LOOP_DIST = 45, NUDGE_DIST = 40, NORMAL_THRESH = -0.5, MAX_NUDGE = 15;
const loopCands = allSockets.filter((s) => !s.socket.connected);
const pairs = [];
const usedSocks = new Set(), loopPairRooms = new Set();
for (let i = 0; i < loopCands.length; i++) {
for (let j = i + 1; j < loopCands.length; j++) {
const a = loopCands[i], b = loopCands[j];
if (a.roomIndex === b.roomIndex) continue;
const dist = Vec2.dist(a.socket.pos, b.socket.pos);
if (dist > NUDGE_DIST) continue;
const nd = Vec2.dot(a.socket.normal, b.socket.normal);
if (nd > NORMAL_THRESH) continue;
const ds = 1 - dist / NUDGE_DIST;
const ns = (-nd - 0.5) / 0.5;
pairs.push({ a, b, dist, quality: ds * 0.4 + Math.max(0, ns) * 0.6 });
}
}
pairs.sort((x, y) => y.quality - x.quality);
for (const pair of pairs) {
const { a, b } = pair;
const aK = `${a.roomIndex}:${a.socketIndex}`, bK = `${b.roomIndex}:${b.socketIndex}`;
if (usedSocks.has(aK) || usedSocks.has(bK) || a.socket.connected || b.socket.connected) continue;
const rpK = Math.min(a.roomIndex, b.roomIndex) + ":" + Math.max(a.roomIndex, b.roomIndex);
if (loopPairRooms.has(rpK)) continue;
let dist = Vec2.dist(a.socket.pos, b.socket.pos);
if (dist > MAX_LOOP_DIST) {
const aCn = placed[a.roomIndex].sockets.filter((s) => s.connected).length;
const bCn = placed[b.roomIndex].sockets.filter((s) => s.connected).length;
const aOk = placed[a.roomIndex].role !== "start" && placed[a.roomIndex].role !== "exit";
const bOk = placed[b.roomIndex].role !== "start" && placed[b.roomIndex].role !== "exit";
let mover = -1;
if (aOk && bOk) mover = aCn <= bCn ? a.roomIndex : b.roomIndex;
else if (aOk) mover = a.roomIndex; else if (bOk) mover = b.roomIndex; else continue;
const target = mover === a.roomIndex ? b.socket.pos : a.socket.pos;
const mSock = mover === a.roomIndex ? a.socket : b.socket;
const gap = Vec2.sub(target, mSock.pos);
const needed = Math.max(0, Vec2.length(gap) - 8);
const delta = Vec2.scale(Vec2.normalize(gap), Math.min(needed, MAX_NUDGE));
const nudged = nudgeRoom(placed[mover], delta);
let bad = false;
for (let r = 0; r < placed.length; r++) { if (r === mover) continue; if (roomsCollideLight(nudged, placed[r])) { bad = true; break; } }
if (bad) continue;
placed[mover] = nudged;
for (const se of allSockets) { if (se.roomIndex === mover) se.socket = nudged.sockets[se.socketIndex]; }
for (const cn of connections) { if (cn.fromRoom === mover) cn.fromSocketPos = Vec2.add(cn.fromSocketPos, delta); if (cn.toRoom === mover) cn.toSocketPos = Vec2.add(cn.toSocketPos, delta); }
}
const pA = a.socket.pos, pB = b.socket.pos;
if (Vec2.dist(pA, pB) > MAX_LOOP_DIST) continue;
let blocked = false;
for (let r = 0; r < placed.length; r++) { if (r === a.roomIndex || r === b.roomIndex) continue; if (pathCrossesRoom(pA, pB, placed[r])) { blocked = true; break; } }
if (blocked) continue;
if (pointInPolygon(pA, placed[b.roomIndex].vertices) || pointInPolygon(pB, placed[a.roomIndex].vertices)) continue;
a.socket.connected = true; a.socket.isLoop = true;
b.socket.connected = true; b.socket.isLoop = true;
if (!a.socket.connectedTo) a.socket.connectedTo = [];
if (!b.socket.connectedTo) b.socket.connectedTo = [];
a.socket.connectedTo.push({ roomId: b.roomIndex, isLoop: true });
b.socket.connectedTo.push({ roomId: a.roomIndex, isLoop: true });
connections.push({ fromRoom: a.roomIndex, toRoom: b.roomIndex,
fromSocketPos: { ...pA }, toSocketPos: { ...pB }, isLoop: true, role: "loop" });
usedSocks.add(aK); usedSocks.add(bK); loopPairRooms.add(rpK);
}
return { rooms: placed, connections, spineRoomIds, exitRoomId, branchPaths, spineDir, prunedCount };
}
// ============================================================
// RENDERING
// ============================================================
function RoomPolygon({ room, isHovered, onHover, onLeave }) {
const points = room.vertices.map((v) => `${v.x},${v.y}`).join(" ");
const center = getCentroid(room.vertices);
const isStart = room.role === "start", isExit = room.role === "exit";
const isSpine = room.role === "spine" || isStart || isExit, isBranch = room.role === "branch";
let fill = room.color, stroke = T.cyan, fOp = 0.45, sW = 1.2;
if (isHovered) { fill = T.cyan; fOp = 0.5; stroke = T.textHi; sW = 2; }
else if (isStart) { fill = T.teal; fOp = 0.3; stroke = T.teal; sW = 2; }
else if (isExit) { fill = T.fuchsia; fOp = 0.25; stroke = T.fuchsia; sW = 2; }
else if (isSpine) { fill = T.teal; fOp = 0.15; stroke = T.teal; sW = 1.5; }
else if (isBranch) { fill = T.orange; fOp = 0.12; stroke = T.orange; sW = 1.2; }
else { stroke = T.cyanDim; }
return (