| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| <title>Bonds of the Realm</title> |
| <style> |
| * { box-sizing: border-box; margin: 0; padding: 0; } |
| html, body { |
| width: 100%; height: 100%; overflow: hidden; |
| background: radial-gradient(ellipse at 50% 38%, #1a1410 0%, #0a0806 72%); |
| font-family: Georgia, 'Times New Roman', serif; |
| } |
| #canvas { display: block; width: 100%; height: 100%; touch-action: none; cursor: grab; } |
| #canvas:active { cursor: grabbing; } |
| #loading { |
| position: fixed; inset: 0; display: flex; flex-direction: column; |
| align-items: center; justify-content: center; color: #e8c873; z-index: 10; |
| background: #0a0806; transition: opacity 0.6s; |
| } |
| #loading.hidden { opacity: 0; pointer-events: none; } |
| .ring { |
| width: 42px; height: 42px; border: 2px solid rgba(232,200,115,0.2); |
| border-top-color: #e8c873; border-radius: 50%; |
| animation: spin 1s linear infinite; margin-bottom: 0.8rem; |
| } |
| @keyframes spin { to { transform: rotate(360deg); } } |
| #hint { |
| position: fixed; bottom: 0.65rem; left: 50%; transform: translateX(-50%); |
| color: rgba(232, 216, 176, 0.55); font-size: 0.72rem; letter-spacing: 0.08em; |
| pointer-events: none; z-index: 5; |
| } |
| #vignette { |
| position: fixed; inset: 0; pointer-events: none; z-index: 4; |
| background: radial-gradient(ellipse at 50% 44%, transparent 42%, rgba(4,3,2,0.55) 100%); |
| } |
| </style> |
| </head> |
| <body> |
| <div id="loading"><div class="ring"></div><p>Weaving the web of bonds…</p></div> |
| <canvas id="canvas"></canvas> |
| <div id="vignette"></div> |
| <div id="hint">Drag to orbit · scroll to zoom</div> |
| <script type="importmap"> |
| { "imports": { "three": "./vendor/three.module.js" } } |
| </script> |
| <script type="module"> |
| import * as THREE from 'three'; |
| import { OrbitControls } from './vendor/OrbitControls.js'; |
| |
| const canvas = document.getElementById('canvas'); |
| const loading = document.getElementById('loading'); |
| |
| function decodeScene() { |
| const raw = location.hash.slice(1); |
| if (!raw.startsWith('data=')) return null; |
| try { |
| return JSON.parse(atob(decodeURIComponent(raw.slice(5)))); |
| } catch (_) { |
| return null; |
| } |
| } |
| |
| const sceneData = decodeScene() || { nodes: [], edges: [] }; |
| |
| const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
| renderer.setSize(window.innerWidth, window.innerHeight); |
| renderer.outputColorSpace = THREE.SRGBColorSpace; |
| |
| const scene = new THREE.Scene(); |
| scene.fog = new THREE.FogExp2(0x0a0806, 0.028); |
| |
| const camera = new THREE.PerspectiveCamera(52, window.innerWidth / window.innerHeight, 0.1, 200); |
| camera.position.set(0, 8, 28); |
| |
| const controls = new OrbitControls(camera, canvas); |
| controls.enableDamping = true; |
| controls.dampingFactor = 0.06; |
| controls.autoRotate = true; |
| controls.autoRotateSpeed = 0.35; |
| controls.minDistance = 10; |
| controls.maxDistance = 55; |
| controls.target.set(0, 0, 0); |
| |
| scene.add(new THREE.AmbientLight(0xc8a86a, 0.45)); |
| const key = new THREE.DirectionalLight(0xfff0d0, 0.9); |
| key.position.set(12, 18, 10); |
| scene.add(key); |
| const rim = new THREE.DirectionalLight(0x9070b8, 0.35); |
| rim.position.set(-10, 4, -12); |
| scene.add(rim); |
| |
| const nodeGroup = new THREE.Group(); |
| const edgeGroup = new THREE.Group(); |
| const labelSprites = []; |
| scene.add(edgeGroup); |
| scene.add(nodeGroup); |
| |
| const nodeMeshes = new Map(); |
| |
| function hexToColor(hex) { |
| return new THREE.Color(hex || '#d4a84b'); |
| } |
| |
| function makeLabel(text) { |
| const c = document.createElement('canvas'); |
| const ctx = c.getContext('2d'); |
| const fontSize = 22; |
| ctx.font = `600 ${fontSize}px Georgia, serif`; |
| const w = Math.min(280, ctx.measureText(text).width + 24); |
| c.width = w; |
| c.height = fontSize + 14; |
| ctx.font = `600 ${fontSize}px Georgia, serif`; |
| ctx.fillStyle = 'rgba(10,8,6,0.55)'; |
| ctx.fillRect(0, 0, c.width, c.height); |
| ctx.fillStyle = '#f8efd9'; |
| ctx.textAlign = 'center'; |
| ctx.textBaseline = 'middle'; |
| ctx.fillText(text, c.width / 2, c.height / 2); |
| const tex = new THREE.CanvasTexture(c); |
| tex.needsUpdate = true; |
| const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }); |
| const sprite = new THREE.Sprite(mat); |
| const scale = 0.018 * c.width; |
| sprite.scale.set(scale, scale * (c.height / c.width), 1); |
| return sprite; |
| } |
| |
| for (const node of sceneData.nodes) { |
| const color = hexToColor(node.color); |
| const radius = 0.55 + (node.degree || 1) * 0.08; |
| const geo = new THREE.SphereGeometry(radius, 24, 24); |
| const mat = new THREE.MeshStandardMaterial({ |
| color, |
| emissive: color, |
| emissiveIntensity: 0.35, |
| metalness: 0.25, |
| roughness: 0.42, |
| }); |
| const mesh = new THREE.Mesh(geo, mat); |
| mesh.position.set(node.x || 0, node.y || 0, node.z || 0); |
| nodeGroup.add(mesh); |
| nodeMeshes.set(node.id, mesh); |
| |
| const glow = new THREE.Mesh( |
| new THREE.SphereGeometry(radius * 1.55, 16, 16), |
| new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.12 }) |
| ); |
| glow.position.copy(mesh.position); |
| nodeGroup.add(glow); |
| |
| if (node.showLabel) { |
| const label = makeLabel(node.name || ''); |
| label.position.copy(mesh.position); |
| label.position.y += radius + 0.9; |
| nodeGroup.add(label); |
| labelSprites.push(label); |
| } |
| } |
| |
| for (const edge of sceneData.edges) { |
| const a = nodeMeshes.get(edge.a); |
| const b = nodeMeshes.get(edge.b); |
| if (!a || !b) continue; |
| const color = hexToColor(edge.color); |
| const strength = edge.strength || 1; |
| const curve = new THREE.QuadraticBezierCurve3( |
| a.position.clone(), |
| new THREE.Vector3( |
| (a.position.x + b.position.x) / 2 + (Math.random() - 0.5) * 2, |
| (a.position.y + b.position.y) / 2 + 1.2 + strength * 0.15, |
| (a.position.z + b.position.z) / 2 + (Math.random() - 0.5) * 2 |
| ), |
| b.position.clone() |
| ); |
| const points = curve.getPoints(24); |
| const geo = new THREE.BufferGeometry().setFromPoints(points); |
| const mat = new THREE.LineBasicMaterial({ |
| color, |
| transparent: true, |
| opacity: 0.35 + strength * 0.12, |
| linewidth: 1, |
| }); |
| edgeGroup.add(new THREE.Line(geo, mat)); |
| } |
| |
| |
| const dustGeo = new THREE.BufferGeometry(); |
| const dustCount = 120; |
| const dustPos = new Float32Array(dustCount * 3); |
| for (let i = 0; i < dustCount; i++) { |
| dustPos[i * 3] = (Math.random() - 0.5) * 40; |
| dustPos[i * 3 + 1] = (Math.random() - 0.5) * 24; |
| dustPos[i * 3 + 2] = (Math.random() - 0.5) * 40; |
| } |
| dustGeo.setAttribute('position', new THREE.BufferAttribute(dustPos, 3)); |
| const dust = new THREE.Points( |
| dustGeo, |
| new THREE.PointsMaterial({ color: 0xe8c873, size: 0.08, transparent: true, opacity: 0.35 }) |
| ); |
| scene.add(dust); |
| |
| function resize() { |
| const w = window.innerWidth; |
| const h = window.innerHeight; |
| camera.aspect = w / h; |
| camera.updateProjectionMatrix(); |
| renderer.setSize(w, h); |
| } |
| window.addEventListener('resize', resize); |
| |
| let t = 0; |
| function animate() { |
| requestAnimationFrame(animate); |
| t += 0.01; |
| nodeGroup.children.forEach((child, i) => { |
| if (child.isMesh && child.geometry?.type === 'SphereGeometry') { |
| child.position.y += Math.sin(t + i) * 0.0008; |
| } |
| }); |
| dust.rotation.y += 0.0006; |
| controls.update(); |
| renderer.render(scene, camera); |
| } |
| |
| setTimeout(() => { |
| loading.classList.add('hidden'); |
| animate(); |
| }, 350); |
| </script> |
| </body> |
| </html> |
|
|