/** * 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 ( onHover(room.id)} onMouseLeave={onLeave}> {(isStart || isExit) && <> {isStart ? "▶ START" : "◼ EXIT"} } {room.templateName} #{room.id} {room.sockets.map((s, i) => ( {isHovered && !s.connected && } {s.isLoop && } ))} ); } function ConnectionLines({ connections }) { return (<>{connections.map((c, i) => { if (c.isLoop) { const mid = Vec2.lerp(c.fromSocketPos, c.toSocketPos, 0.5); const dir = Vec2.sub(c.toSocketPos, c.fromSocketPos); const perp = { x: -dir.y * 0.15, y: dir.x * 0.15 }; const ctrl = Vec2.add(mid, perp); const d = `M ${c.fromSocketPos.x},${c.fromSocketPos.y} Q ${ctrl.x},${ctrl.y} ${c.toSocketPos.x},${c.toSocketPos.y}`; return ( ); } const isSp = c.role === "spine", isBr = c.role === "branch", isMrg = c.role === "branch-merge"; const col = isMrg ? T.fuchsia : isSp ? T.teal : isBr ? T.orange : T.cyanDim; const w = isSp ? 2.5 : isBr ? 1.8 : isMrg ? 2 : 1; const op = isSp ? 0.45 : isBr ? 0.3 : isMrg ? 0.5 : 0.15; return ( {(isSp || isMrg) && } ); })}); } // ============================================================ // STAGE SELECTOR // ============================================================ function StageSelector({ stageKey, onSelect }) { return (
{STAGES.map((s) => { const active = stageKey === s.key; return ( ); })}
); } // ============================================================ // MAIN APP // ============================================================ function ProceduralLevelGenerator() { const [stageKey, setStageKey] = useState("s1"); const [roomCount, setRoomCount] = useState(22); const [seed, setSeed] = useState("D212000"); const [pathsToExit, setPathsToExit] = useState(2); const [roomWeights, setRoomWeights] = useState({}); const [hoveredRoom, setHoveredRoom] = useState(null); const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 }); const [isPanning, setIsPanning] = useState(false); const [panStart, setPanStart] = useState({ x: 0, y: 0 }); const containerRef = useRef(null); const stage = STAGE_MAP[stageKey]; const seedNum = useMemo(() => hashSeed(String(seed)), [seed]); const weights = useMemo(() => stage.rooms.map((r, i) => roomWeights[`${stageKey}:${i}`] ?? 1), [stageKey, roomWeights, stage.rooms] ); const setWeight = useCallback((idx, val) => { setRoomWeights((prev) => ({ ...prev, [`${stageKey}:${idx}`]: val })); }, [stageKey]); const level = useMemo(() => generateLevel(stage, roomCount, seedNum, pathsToExit, weights), [stageKey, roomCount, seedNum, pathsToExit, weights]); const { rooms, connections } = level; useEffect(() => { if (rooms.length === 0) return; const bbox = getBBox(rooms.flatMap((r) => r.vertices)); const c = containerRef.current; if (!c) return; const cw = c.clientWidth, ch = c.clientHeight; const lw = bbox.maxX - bbox.minX + 120, lh = bbox.maxY - bbox.minY + 120; const s = Math.min(cw / lw, ch / lh, 2.5) * 0.85; setTransform({ x: cw / 2 - ((bbox.minX + bbox.maxX) / 2) * s, y: ch / 2 - ((bbox.minY + bbox.maxY) / 2) * s, scale: s }); }, [rooms]); const handleMouseDown = useCallback((e) => { if (e.button === 0) { setIsPanning(true); setPanStart({ x: e.clientX - transform.x, y: e.clientY - transform.y }); } }, [transform]); const handleMouseMove = useCallback((e) => { if (isPanning) setTransform((t) => ({ ...t, x: e.clientX - panStart.x, y: e.clientY - panStart.y })); }, [isPanning, panStart]); const handleMouseUp = useCallback(() => setIsPanning(false), []); const handleWheel = useCallback((e) => { e.preventDefault(); const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; const mx = e.clientX - rect.left, my = e.clientY - rect.top, f = e.deltaY > 0 ? 0.9 : 1.1; setTransform((t) => { const ns = Math.max(0.1, Math.min(8, t.scale * f)), r = ns / t.scale; return { scale: ns, x: mx - (mx - t.x) * r, y: my - (my - t.y) * r }; }); }, []); useEffect(() => { const el = containerRef.current; if (el) { el.addEventListener("wheel", handleWheel, { passive: false }); return () => el.removeEventListener("wheel", handleWheel); } }, [handleWheel]); const hRoom = hoveredRoom !== null ? rooms[hoveredRoom] : null; // Shared panel style const panelStyle = { background: THEME.panelBg, border: `1px solid ${T.border}`, borderRadius: 4, backdropFilter: "blur(6px)", }; return (
{/* ── Topbar ────────────────────────────────────────────── */}
Contract Map Simulator
Seed setSeed(e.target.value)} spellCheck={false} autoComplete="off" style={{ width: 80, padding: "2px 6px", fontSize: 10, fontFamily: "inherit", background: T.bgPanel, border: `1px solid ${T.borderDim}`, color: T.cyan, borderRadius: 3, outline: "none", letterSpacing: 1 }} onFocus={(e) => { e.target.style.borderColor = T.cyan; }} onBlur={(e) => { e.target.style.borderColor = T.borderDim; }} />
{/* ── Viewport ──────────────────────────────────────────── */}
{rooms.map((room) => ( setHoveredRoom(null)} />))} {/* Zoom indicator — stays in viewport */}
{Math.round(transform.scale * 100)}% · SCROLL ZOOM · DRAG PAN
{/* Hover tooltip — stays in viewport */} {hRoom &&
{hRoom.templateName}
ID: #{hRoom.id}
ROLE: {(hRoom.role || "fill").toUpperCase()}
TYPE: {hRoom.type}
SOCKETS: {hRoom.sockets.filter((s) => s.connected).length}/{hRoom.sockets.length}
{hRoom.sockets.some((s) => s.isLoop) &&
◆ Loop connection
}
}
{/* ── Bottom bar — Generation controls + Room weights ─── */} {/* Mirrors the topbar visually. Two sections separated by a vertical divider: fixed-width generation controls on the left, flexible room-weight grid on the right. */}
e.stopPropagation()} onWheel={(e) => e.stopPropagation()} style={{ display: "flex", alignItems: "stretch", gap: 0, padding: 0, borderTop: `1px solid ${T.borderDim}`, background: T.bgPanel + "ee", backdropFilter: "blur(8px)", fontSize: 9, letterSpacing: 1, flexShrink: 0, }}> {/* Left section: Rooms + Paths to Exit */}
{/* Rooms slider */}
Rooms {roomCount}
setRoomCount(parseInt(e.target.value))} style={{ width: "100%", accentColor: T.cyan, height: 10, display: "block" }} />
{/* Paths to Exit slider */}
Paths to Exit {pathsToExit}
setPathsToExit(parseInt(e.target.value))} style={{ width: "100%", accentColor: T.orange, height: 10, display: "block" }} />
{/* Vertical divider */}
{/* Right section: Room weights — 2-column wrap grid */}
Room Weights
{stage.rooms.map((rm, i) => { const w = weights[i]; const isZero = w === 0; return (
{/* Room name — fixed width, truncated */} {rm.name} {/* Slider */} setWeight(i, parseFloat(e.target.value))} style={{ flex: 1, minWidth: 40, accentColor: isZero ? T.textLo : T.cyanDim, height: 8, display: "block", }} /> {/* Value readout */} {w === 0 ? "—" : w.toFixed(1)}
); })}
); } // ── Global registration ────────────────────────────────────── // The OperationsHub reads this to render the tool when active. window.OpsTool_StageMap = ProceduralLevelGenerator; }());