Spaces:
Running
Running
| // main.js — Three.js bootstrap + character-select scene for the SZL org card. | |
| // Five procedural low-poly heroes on lit daises in a slight arc, slow camera | |
| // carousel orbit, KHIPU-style particle motes, UDS NPCs in the background. | |
| // Hover reveals animated superpowers; click opens the hero's HF Space. | |
| import * as THREE from 'three'; | |
| import { HEROES, buildHero } from './heroes.js'; | |
| import { buildUDSNPCs, animateNPCs } from './uds_npcs.js'; | |
| import { HoverPanel } from './hover_panel.js'; | |
| const REDUCED = window.matchMedia('(prefers-reduced-motion: reduce)').matches; | |
| // ---- WebGL capability gate (graceful degradation to SVG grid) ---- | |
| function webglOK() { | |
| try { | |
| if (!window.WebGLRenderingContext) return false; | |
| const c = document.createElement('canvas'); | |
| return !!(c.getContext('webgl2') || c.getContext('webgl')); | |
| } catch (e) { return false; } | |
| } | |
| const stage = document.getElementById('stage'); | |
| const canvas = document.getElementById('roster'); | |
| const fallback = document.getElementById('fallback'); | |
| // The static SVG grid (#fallback) is shown by DEFAULT in the markup so the | |
| // org-card context (scripts stripped) and no-WebGL browsers always render the | |
| // 5 tiles. Only when WebGL boots do we hide it and reveal the 3D canvas. | |
| if (!webglOK()) { | |
| canvas.hidden = true; | |
| if (fallback) fallback.hidden = false; // keep visible | |
| } else { | |
| if (fallback) fallback.hidden = true; // hide static grid | |
| canvas.hidden = false; | |
| canvas.style.display = 'block'; | |
| init(); | |
| } | |
| function init() { | |
| const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| const scene = new THREE.Scene(); | |
| scene.fog = new THREE.FogExp2(0x12091f, 0.045); | |
| const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100); | |
| const CAM_RADIUS = 9.2, CAM_HEIGHT = 2.4; | |
| camera.position.set(0, CAM_HEIGHT, CAM_RADIUS); | |
| // ---- Lights ---- | |
| scene.add(new THREE.AmbientLight(0x5a4a7a, 0.55)); | |
| const key = new THREE.DirectionalLight(0xffffff, 0.85); | |
| key.position.set(4, 9, 6); key.castShadow = true; | |
| key.shadow.mapSize.set(1024, 1024); | |
| key.shadow.camera.near = 1; key.shadow.camera.far = 30; | |
| key.shadow.camera.left = -10; key.shadow.camera.right = 10; | |
| key.shadow.camera.top = 10; key.shadow.camera.bottom = -10; | |
| scene.add(key); | |
| const fill = new THREE.DirectionalLight(0x8b6cf2, 0.3); | |
| fill.position.set(-6, 4, -4); scene.add(fill); | |
| // ---- Ground plane (receives shadows, dark) ---- | |
| const ground = new THREE.Mesh( | |
| new THREE.CircleGeometry(16, 48), | |
| new THREE.MeshStandardMaterial({ color: 0x0c0718, roughness: 1, metalness: 0 }) | |
| ); | |
| ground.rotation.x = -Math.PI / 2; ground.position.y = -0.02; ground.receiveShadow = true; | |
| scene.add(ground); | |
| // ---- Heroes on daises in a slight arc, slightly elevated ---- | |
| const heroRoot = new THREE.Group(); | |
| scene.add(heroRoot); | |
| const heroEntries = []; | |
| const N = HEROES.length; | |
| const arcSpan = 2.0; // radians across which heroes are spread | |
| const arcRadius = 5.0; // distance from arc center | |
| HEROES.forEach((hero, i) => { | |
| const t = N === 1 ? 0.5 : i / (N - 1); | |
| const a = -arcSpan / 2 + t * arcSpan; | |
| const x = Math.sin(a) * arcRadius; | |
| const z = -arcRadius + Math.cos(a) * arcRadius * 0.55; // gentle forward arc | |
| const slot = new THREE.Group(); | |
| slot.position.set(x, 0.25, z); // slightly elevated | |
| slot.rotation.y = -a * 0.45; // face toward camera center | |
| // dais | |
| const daisMat = new THREE.MeshStandardMaterial({ color: 0x171022, roughness: 0.5, metalness: 0.4 }); | |
| const dais = new THREE.Mesh(new THREE.CylinderGeometry(0.95, 1.05, 0.18, 32), daisMat); | |
| dais.position.y = -0.09; dais.receiveShadow = true; dais.castShadow = true; | |
| slot.add(dais); | |
| // colored rim-light ring on the dais edge | |
| const rimMat = new THREE.MeshStandardMaterial({ | |
| color: hero.color, emissive: hero.color, emissiveIntensity: 1.0, | |
| roughness: 0.3, transparent: true, opacity: 0.95, | |
| }); | |
| const rim = new THREE.Mesh(new THREE.TorusGeometry(0.98, 0.045, 8, 48), rimMat); | |
| rim.rotation.x = Math.PI / 2; rim.position.y = 0.02; | |
| slot.add(rim); | |
| // a point light per dais (cheap; 5 total) for the colored glow | |
| const rimLight = new THREE.PointLight(new THREE.Color(hero.color), 1.4, 4.5, 2); | |
| rimLight.position.set(0, 0.6, 0.4); | |
| slot.add(rimLight); | |
| const figure = buildHero(hero); | |
| slot.add(figure); | |
| heroRoot.add(slot); | |
| heroEntries.push({ hero, slot, figure, rim, rimMat, rimLight, baseRim: 1.0, | |
| bodyMats: collectBodyMats(figure), hovered: false, scale: 1 }); | |
| }); | |
| // ---- UDS NPCs in the background ---- | |
| const { group: npcGroup, npcs } = buildUDSNPCs(4); | |
| scene.add(npcGroup); | |
| // ---- KHIPU DAG particle motes ---- | |
| const PCOUNT = REDUCED ? 0 : 420; | |
| let particles = null, pPos = null, pHome = null, pVel = null; | |
| if (PCOUNT > 0) { | |
| pPos = new Float32Array(PCOUNT * 3); | |
| pHome = new Float32Array(PCOUNT * 3); | |
| pVel = new Float32Array(PCOUNT * 3); | |
| for (let i = 0; i < PCOUNT; i++) { | |
| const r = 3 + Math.random() * 7; | |
| const a = Math.random() * Math.PI * 2; | |
| const x = Math.cos(a) * r, y = Math.random() * 6 + 0.2, z = Math.sin(a) * r - 2; | |
| pPos[i * 3] = pHome[i * 3] = x; | |
| pPos[i * 3 + 1] = pHome[i * 3 + 1] = y; | |
| pPos[i * 3 + 2] = pHome[i * 3 + 2] = z; | |
| } | |
| const pg = new THREE.BufferGeometry(); | |
| pg.setAttribute('position', new THREE.BufferAttribute(pPos, 3)); | |
| const pm = new THREE.PointsMaterial({ color: 0xcda8ff, size: 0.06, transparent: true, opacity: 0.7, | |
| depthWrite: false, blending: THREE.AdditiveBlending }); | |
| particles = new THREE.Points(pg, pm); | |
| scene.add(particles); | |
| } | |
| // ---- Hover panel (DOM overlay) ---- | |
| const panel = new HoverPanel(stage, REDUCED); | |
| // ---- Raycaster + invisible hit cylinders per hero ---- | |
| const raycaster = new THREE.Raycaster(); | |
| const pointer = new THREE.Vector2(); | |
| let pointerInside = false; | |
| const hitMeshes = heroEntries.map((e, i) => { | |
| const hit = new THREE.Mesh( | |
| new THREE.CylinderGeometry(0.9, 0.9, 2.6, 8), | |
| new THREE.MeshBasicMaterial({ visible: false }) | |
| ); | |
| hit.position.y = 1.1; | |
| hit.userData.index = i; | |
| e.slot.add(hit); | |
| return hit; | |
| }); | |
| let activeIndex = -1; | |
| function setActive(idx) { | |
| if (idx === activeIndex) return; | |
| activeIndex = idx; | |
| heroEntries.forEach((e, i) => { e.hovered = (i === idx); }); | |
| if (idx >= 0) { | |
| panel.show(heroEntries[idx].hero); | |
| burst(idx); | |
| stage.style.cursor = 'pointer'; | |
| stage.setAttribute('data-active', heroEntries[idx].hero.id); | |
| } else { | |
| panel.hide(); | |
| stage.style.cursor = 'default'; | |
| stage.removeAttribute('data-active'); | |
| } | |
| } | |
| // particle energy burst toward a hero then radiate | |
| let burstUntil = 0, burstTarget = new THREE.Vector3(); | |
| function burst(idx) { | |
| if (!particles) return; | |
| burstTarget.copy(heroEntries[idx].slot.position).add(new THREE.Vector3(0, 1.2, 0)); | |
| burstUntil = performance.now() + 900; | |
| } | |
| // ---- Pointer events ---- | |
| function updatePointer(ev) { | |
| const rect = canvas.getBoundingClientRect(); | |
| pointer.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1; | |
| pointer.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1; | |
| pointerInside = true; | |
| } | |
| canvas.addEventListener('pointermove', updatePointer); | |
| canvas.addEventListener('pointerleave', () => { pointerInside = false; setActive(-1); }); | |
| canvas.addEventListener('click', () => { | |
| if (activeIndex >= 0) window.open(heroEntries[activeIndex].hero.url, '_blank', 'noopener'); | |
| }); | |
| // ---- Keyboard accessibility: Tab cycles, Enter activates ---- | |
| // Hidden focusable proxies announce each hero and drive the 3D highlight. | |
| const a11yRoot = document.getElementById('a11y-heroes'); | |
| heroEntries.forEach((e, i) => { | |
| const b = document.createElement('button'); | |
| b.className = 'a11y-hero'; | |
| const labels = e.hero.powers.map(p => p.label.toLowerCase()).join(', '); | |
| b.setAttribute('aria-label', | |
| `${e.hero.name} — ${e.hero.role}. Superpowers: ${labels}. Click to open Space.`); | |
| b.addEventListener('focus', () => setKeyActive(i)); | |
| b.addEventListener('blur', () => { if (kbActive === i) setKeyActive(-1); }); | |
| b.addEventListener('click', () => window.open(e.hero.url, '_blank', 'noopener')); | |
| a11yRoot.appendChild(b); | |
| }); | |
| let kbActive = -1; | |
| function setKeyActive(i) { kbActive = i; setActive(i); } | |
| // ---- Resize ---- | |
| function resize() { | |
| const w = stage.clientWidth, h = stage.clientHeight; | |
| renderer.setSize(w, h, false); | |
| camera.aspect = w / h; camera.updateProjectionMatrix(); | |
| } | |
| window.addEventListener('resize', resize); | |
| resize(); | |
| // ---- Animation loop ---- | |
| const clock = new THREE.Clock(); | |
| let camAngle = 0; | |
| const _proj = new THREE.Vector3(); | |
| function frame() { | |
| const dt = clock.getDelta(); | |
| const t = clock.elapsedTime * 1000; | |
| // camera slow orbit (~60s period) unless reduced motion | |
| if (!REDUCED) camAngle = (clock.elapsedTime / 60) * Math.PI * 2; | |
| const orbitR = CAM_RADIUS; | |
| camera.position.x = Math.sin(camAngle) * orbitR; | |
| camera.position.z = Math.cos(camAngle) * orbitR; | |
| camera.position.y = CAM_HEIGHT; | |
| camera.lookAt(0, 1.0, -1.2); | |
| // hover hit-test (only when pointer is in canvas) | |
| if (pointerInside) { | |
| raycaster.setFromCamera(pointer, camera); | |
| const hits = raycaster.intersectObjects(hitMeshes, false); | |
| setActive(hits.length ? hits[0].object.userData.index : -1); | |
| } | |
| // per-hero animation | |
| heroEntries.forEach((e, i) => { | |
| const ud = e.figure.userData; | |
| // breathing: torso scale y ±2% over 3s | |
| if (!REDUCED) { | |
| const breathe = 1 + Math.sin(t * 0.00209 + i) * 0.02; | |
| ud.torsoPivot.scale.y = breathe; | |
| } | |
| // hover scale-up to 1.15x over ~200ms (ease toward target) | |
| const target = e.hovered ? 1.15 : 1.0; | |
| const lerpK = REDUCED ? 1 : Math.min(1, dt / 0.2); | |
| e.scale += (target - e.scale) * lerpK; | |
| e.slot.scale.setScalar(e.scale); | |
| // dais rim pulse (HSL lightness) + hover brighten +50% | |
| const baseI = e.hovered ? 1.5 : 1.0; | |
| const pulse = REDUCED ? 0 : Math.sin(t * 0.0016 + i * 1.3) * 0.25; | |
| e.rimMat.emissiveIntensity = baseI + pulse; | |
| e.rimLight.intensity = (e.hovered ? 2.6 : 1.4) + pulse; | |
| // dim non-hovered heroes to 50% when one is active | |
| const dim = (activeIndex >= 0 && !e.hovered) ? 0.5 : 1.0; | |
| e.bodyMats.forEach(m => { | |
| m.transparent = dim < 1; | |
| m.opacity += (dim - m.opacity) * (REDUCED ? 1 : Math.min(1, dt * 6)); | |
| }); | |
| // prop activation on hover | |
| ud.prop.visible = e.hovered; | |
| if (e.hovered && !REDUCED) animateProp(ud.prop, t); | |
| }); | |
| // NPC idle | |
| animateNPCs(npcs, t, REDUCED); | |
| // particle motes drift + burst/converge | |
| if (particles) { | |
| const now = performance.now(); | |
| const bursting = now < burstUntil; | |
| for (let i = 0; i < PCOUNT; i++) { | |
| const ix = i * 3; | |
| if (bursting) { | |
| // converge toward target then radiate (second half) | |
| const half = burstUntil - 450; | |
| const k = now < half ? 0.06 : -0.05; | |
| pPos[ix] += (burstTarget.x - pPos[ix]) * k; | |
| pPos[ix + 1] += (burstTarget.y - pPos[ix + 1]) * k; | |
| pPos[ix + 2] += (burstTarget.z - pPos[ix + 2]) * k; | |
| } else { | |
| // ease home + gentle float | |
| pPos[ix] += (pHome[ix] - pPos[ix]) * 0.02; | |
| pPos[ix + 1] += (pHome[ix + 1] - pPos[ix + 1]) * 0.02 + Math.sin(t * 0.0006 + i) * 0.0009; | |
| pPos[ix + 2] += (pHome[ix + 2] - pPos[ix + 2]) * 0.02; | |
| } | |
| } | |
| particles.geometry.attributes.position.needsUpdate = true; | |
| if (!REDUCED) particles.rotation.y = clock.elapsedTime * 0.01; | |
| } | |
| // place hover panel above active hero's head (screen projection) | |
| if (activeIndex >= 0) { | |
| const e = heroEntries[activeIndex]; | |
| _proj.copy(e.slot.position); _proj.y += 2.6; // ~60cm above head | |
| _proj.project(camera); | |
| const rect = canvas.getBoundingClientRect(); | |
| const sx = (_proj.x * 0.5 + 0.5) * rect.width; | |
| const sy = (-_proj.y * 0.5 + 0.5) * rect.height; | |
| panel.place(sx, sy); | |
| } | |
| renderer.render(scene, camera); | |
| requestAnimationFrame(frame); | |
| } | |
| document.getElementById('loading')?.remove(); | |
| requestAnimationFrame(frame); | |
| // expose for smoke tests | |
| window.__SZL_ROSTER__ = { setActive, heroEntries, HEROES }; | |
| } | |
| function collectBodyMats(figure) { | |
| const mats = []; | |
| figure.traverse(o => { if (o.isMesh && o.material && o.material.map) mats.push(o.material); }); | |
| return mats; | |
| } | |
| function animateProp(prop, t) { | |
| if (prop.userData.spin) prop.userData.spin.rotation.z = t * 0.003; | |
| if (prop.userData.orbit) { | |
| prop.userData.orbit.forEach((r, k) => { | |
| const a = (k / 8) * Math.PI * 2 + t * 0.001; | |
| r.position.set(Math.cos(a) * 0.7, 1.0 + Math.sin(t * 0.002 + k) * 0.05, Math.sin(a) * 0.7); | |
| }); | |
| } | |
| if (prop.userData.stream) { | |
| prop.userData.stream.forEach((s, k) => { | |
| s.position.x = 0.5 + ((t * 0.001 + k * 0.2) % 0.6); | |
| s.material.opacity = 0.3 + 0.6 * Math.abs(Math.sin(t * 0.003 + k)); | |
| }); | |
| } | |
| } | |