README / js /main.js
Yachay
fix(roster): static SVG grid visible by default for org-card / no-JS
949d59b
Raw
History Blame
13.3 kB
// 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));
});
}
}