// WorldGen — network graph (option 5), cleaned up for clarity. // After the input submit, nodes burst out and edges form, settling into a // readable world map. Only KEY relations are kept (5 instead of 8). Different // shapes per type, annotations under each label, colored relation edges. const { useState, useEffect, useRef } = React; const PROMPT = { en: "Dwarven smiths in the steam-empire of Steamwald.", ru: "Дварфийские кузнецы в стимпанк-империи Стимвальд.", }; const FILE_NAME = "campaign.epub"; // Pre-calculated positions (in % of stage) const NODES = [ { id: "root", x: 50, y: 56, z: 0, size: "lg", sizeScale: 0.9, kind: "world", label: { en: "Steamwald", ru: "Стимвальд" }, summary: { en: "your world", ru: "ваш мир" }, IconKey: "Planet" }, { id: "brogan", x: 14, y: 24, z: 140, size: "md", kind: "char", label: { en: "Brogan", ru: "Броган" }, summary: { en: "Dwarf cleric · L7", ru: "Дварф-жрец · 7ур." }, img: "assets/portrait-dwarf.png" }, { id: "ealdrin", x: 86, y: 26, z: -3, size: "md", kind: "char", label: { en: "Ealdrin", ru: "Элдрин" }, summary: { en: "Grey mage", ru: "Серый маг" }, img: "assets/portrait-wizard.png" }, { id: "chandos", x: 8, y: 64, z: -52, size: "sm", kind: "loc", label: { en: "Chandos", ru: "Чандос" }, summary: { en: "Capital city", ru: "Столица" }, IconKey: "Tower" }, { id: "coraltide", x: 24, y: 86, z: 25, size: "sm", kind: "loc", label: { en: "Coraltide", ru: "Коралтайд" }, summary: { en: "Sea drift", ru: "Морской дрейф" }, IconKey: "Tower" }, { id: "flame", x: 76, y: 86, z: 8, size: "sm", kind: "fact", label: { en: "Flame Order", ru: "Орден Пламени" }, summary: { en: "Religious order", ru: "Орден" }, IconKey: "Shield" }, { id: "vael", x: 92, y: 64, z: -45, size: "sm", kind: "fact", label: { en: "House Vael", ru: "Дом Вэйль" }, summary: { en: "Noble house", ru: "Дом знати" }, IconKey: "Shield" }, { id: "rapier", x: 50, y: 12, z: -20, size: "sm", kind: "quest", label: { en: "Rapier", ru: "Древняя рапира" }, summary: { en: "Lost blade", ru: "Потерянный клинок" }, IconKey: "Paper" }, ]; // Edge colors per relation type const REL_COLORS = { bond: "#88D497", // mentor / ally origin: "#FFD580", // native of faction: "#ADDCFF", // sworn / founded artifact: "#BD9FF5", // wields }; // 4 key relations between characters and entities const RELATION_EDGES = [ { a: "brogan", b: "ealdrin", type: "bond", label: { en: "trained by", ru: "обучен" }, lift: 0.55 }, { a: "brogan", b: "chandos", type: "origin", label: { en: "native of", ru: "родом из" } }, { a: "brogan", b: "rapier", type: "artifact", label: { en: "wields", ru: "владеет" } }, { a: "ealdrin", b: "flame", type: "faction", label: { en: "founded", ru: "основал" } }, ]; const PRIMARY_EDGES = [ ["root", "brogan"], ["root", "ealdrin"], ["root", "chandos"], ["root", "coraltide"], ["root", "flame"], ["root", "vael"], ["root", "rapier"], ]; const DURATION = 10500; // Node radii in % of stage — used to trim edges so they don't pass through nodes const NODE_R = { lg: 7.5, md: 5.0, sm: 3.9 }; function trimmedArc(A, B, rA, rB, lift = 0.08) { const dx = B.x - A.x, dy = B.y - A.y; const len = Math.hypot(dx, dy); if (len < 1) return `M ${A.x} ${A.y} L ${B.x} ${B.y}`; const ux = dx / len, uy = dy / len; const gap = 1.8; const sx = A.x + ux * (rA + gap); const sy = A.y + uy * (rA + gap); const ex = B.x - ux * (rB + gap); const ey = B.y - uy * (rB + gap); const mx = (sx + ex) / 2, my = (sy + ey) / 2; const px = -uy, py = ux; const newLen = Math.hypot(ex - sx, ey - sy); const lf = lift * newLen * 0.5; const cx = mx + px * lf; const cy = my + py * lf; return `M ${sx.toFixed(2)} ${sy.toFixed(2)} Q ${cx.toFixed(2)} ${cy.toFixed(2)}, ${ex.toFixed(2)} ${ey.toFixed(2)}`; } function effectiveR(n) { const baseR = NODE_R[n.size]; const scale = (n.sizeScale || 1) * 1.1 * (1 + (n.z || 0) * 0.0035); return baseR * scale; } function arcPath(x1, y1, x2, y2, lift = 0.08) { const mx = (x1 + x2) / 2; const my = (y1 + y2) / 2; const dx = x2 - x1, dy = y2 - y1; const len = Math.hypot(dx, dy); if (len < 1) return `M ${x1} ${y1} L ${x2} ${y2}`; const px = -dy / len, py = dx / len; const lf = lift * len * 0.5; const cx = mx + px * lf; const cy = my + py * lf; return `M ${x1.toFixed(2)} ${y1.toFixed(2)} Q ${cx.toFixed(2)} ${cy.toFixed(2)}, ${x2.toFixed(2)} ${y2.toFixed(2)}`; } function WorldGen({ lang, parallaxK = 1 }) { const [t, setT] = useState(0); const [hover, setHover] = useState(null); const startRef = useRef(null); const stageRef = useRef(null); const [tilt, setTilt] = useState({ x: 0, y: 0 }); useEffect(() => { let raf; const tick = (now) => { if (!startRef.current) startRef.current = now; const elapsed = now - startRef.current; const next = Math.min(1, elapsed / DURATION); setT(next); if (next < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, []); useEffect(() => { const handler = (e) => { const rect = stageRef.current?.getBoundingClientRect(); if (!rect) return; const cx = rect.left + rect.width / 2; const cy = rect.top + rect.height / 2; const dx = (e.clientX - cx) / window.innerWidth; const dy = (e.clientY - cy) / window.innerHeight; setTilt({ x: 0, y: 0 }); }; window.addEventListener("mousemove", handler); return () => window.removeEventListener("mousemove", handler); }, [parallaxK]); const prompt = PROMPT[lang]; const tStart = 0.03, tEnd = 0.16; const typed = t < tStart ? 0 : t > tEnd ? prompt.length : Math.floor(((t - tStart) / (tEnd - tStart)) * prompt.length); const fileDrop = t > 0.18; const fileProgress = t < 0.20 ? 0 : t > 0.30 ? 100 : ((t - 0.20) / 0.10) * 100; const fileDone = t > 0.30; const submitPulse = t > 0.30 && t < 0.46; const rootBurst = t > 0.40 && t < 0.50; const inputDone = t > 0.42; // Burst window: each node animates from center to its target. const burstStart = 0.42; const burstStagger = 0.024; const nodeProgress = (i) => { if (i === 0) { if (t < 0.40) return 0; if (t > 0.50) return 1; return (t - 0.40) / 0.10; } const s = burstStart + (i - 1) * burstStagger; const e = s + 0.12; if (t < s) return 0; if (t > e) return 1; return (t - s) / (e - s); }; const easeOut = (p) => 1 - Math.pow(1 - p, 3); const nodeState = (n, i) => { const p = nodeProgress(i); if (p <= 0) return { x: 50, y: 50, scale: 0, opacity: 0 }; if (p >= 1) return { x: n.x, y: n.y, scale: 1, opacity: 1 }; const e = easeOut(p); return { x: 50 + (n.x - 50) * e, y: 50 + (n.y - 50) * e, scale: 0.4 + 0.6 * e, opacity: Math.min(1, p * 2), }; }; const primaryEdgeActive = (i) => t > burstStart + i * burstStagger + 0.04; const relEdgeActive = (i) => t > 0.80 + i * 0.030; const relLabelVisible = (i) => t > 0.82 + i * 0.030; const statsVisible = t > 0.90; const renderIcon = (key) => { const I = window.FigmaIcons?.[key]; return I ? : null; }; const nodeById = Object.fromEntries(NODES.map((n, i) => [n.id, { ...n, state: nodeState(n, i) }])); const adjacentTo = (id) => { const adj = new Set(); PRIMARY_EDGES.forEach(([a, b]) => { if (a === id) adj.add(b); if (b === id) adj.add(a); }); RELATION_EDGES.forEach(({ a, b }) => { if (a === id) adj.add(b); if (b === id) adj.add(a); }); return adj; }; const dimmed = hover ? (id) => id !== hover && !adjacentTo(hover).has(id) : () => false; const edgeDimmed = (a, b) => hover && a !== hover && b !== hover; const edgeHighlighted = (a, b) => hover && (a === hover || b === hover); return (