Files
the-nexus/app.js
Alexander Whitestone 4f1b86eea2
Some checks failed
CI / validate (pull_request) Has been cancelled
feat: add ring of floating runes around Nexus center platform (#110)
- Build full Three.js scene with scene/camera/renderer/OrbitControls
- Add center platform (cylinder) with edge glow ring and pulsing disc
- Create 12 Elder Futhark rune billboards arranged in an orbiting ring
- Runes float vertically (per-rune phase offset) and slowly orbit platform
- Alternating cyan/magenta glow colors via canvas texture
- Faint orbit indicator torus at rune height
- Starfield background (2500 points)
- Pulsing point lights for living atmosphere
- Import map in index.html wires Three.js 0.183 from CDN
- Retain WebSocket client integration and audio toggle

Fixes #110
2026-03-23 23:58:06 -04:00

253 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { wsClient } from './ws-client.js';
// === COLOR PALETTE ===
const NEXUS = {
colors: {
bg: 0x0a0a1a,
platform: 0x12122a,
platformEdge: 0x4444aa,
runeGlow: 0x00ffcc,
runeAlt: 0xff44ff,
ambient: 0x111133,
star: 0xffffff,
}
};
// === SCENE SETUP ===
const canvas = document.getElementById('nexus-canvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
const scene = new THREE.Scene();
scene.background = new THREE.Color(NEXUS.colors.bg);
scene.fog = new THREE.FogExp2(NEXUS.colors.bg, 0.012);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 9, 20);
camera.lookAt(0, 1, 0);
// === ORBIT CONTROLS ===
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.minDistance = 6;
controls.maxDistance = 60;
controls.maxPolarAngle = Math.PI * 0.52;
controls.target.set(0, 1, 0);
// === LIGHTING ===
const ambientLight = new THREE.AmbientLight(NEXUS.colors.ambient, 1.2);
scene.add(ambientLight);
const coreLight = new THREE.PointLight(NEXUS.colors.runeGlow, 3, 40);
coreLight.position.set(0, 4, 0);
scene.add(coreLight);
const fillLight = new THREE.PointLight(NEXUS.colors.runeAlt, 1, 25);
fillLight.position.set(0, 2, 0);
scene.add(fillLight);
// === CENTER PLATFORM ===
const platformGeo = new THREE.CylinderGeometry(5, 5.5, 0.5, 48);
const platformMat = new THREE.MeshStandardMaterial({
color: NEXUS.colors.platform,
metalness: 0.7,
roughness: 0.35,
envMapIntensity: 1.0,
});
const platform = new THREE.Mesh(platformGeo, platformMat);
platform.receiveShadow = true;
scene.add(platform);
// Platform top face glow disc
const glowDiscGeo = new THREE.CircleGeometry(4.8, 48);
const glowDiscMat = new THREE.MeshBasicMaterial({
color: NEXUS.colors.platformEdge,
transparent: true,
opacity: 0.08,
});
const glowDisc = new THREE.Mesh(glowDiscGeo, glowDiscMat);
glowDisc.rotation.x = -Math.PI / 2;
glowDisc.position.y = 0.26;
scene.add(glowDisc);
// Platform edge ring
const edgeRingGeo = new THREE.TorusGeometry(5.05, 0.06, 8, 64);
const edgeRingMat = new THREE.MeshBasicMaterial({ color: NEXUS.colors.platformEdge });
const edgeRing = new THREE.Mesh(edgeRingGeo, edgeRingMat);
edgeRing.rotation.x = Math.PI / 2;
edgeRing.position.y = 0.25;
scene.add(edgeRing);
// === FLOATING RUNES RING ===
const ELDER_FUTHARK = ['ᚠ', 'ᚢ', 'ᚦ', 'ᚨ', 'ᚱ', '', '', 'ᚹ', 'ᚺ', 'ᚾ', '', 'ᛃ'];
const RUNE_RING_RADIUS = 7.5;
const RUNE_BASE_Y = 2.2;
function createRuneTexture(symbol, glowColor, textColor) {
const size = 128;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, size, size);
// Outer glow halo
ctx.shadowColor = glowColor;
ctx.shadowBlur = 28;
// Draw symbol twice for intensity
ctx.fillStyle = textColor;
ctx.font = 'bold 70px serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(symbol, size / 2, size / 2);
ctx.fillText(symbol, size / 2, size / 2);
return new THREE.CanvasTexture(canvas);
}
const runeGroup = new THREE.Group();
scene.add(runeGroup);
const runeObjects = [];
ELDER_FUTHARK.forEach((symbol, i) => {
const isPrimary = i % 2 === 0;
const glowColor = isPrimary ? '#00ffcc' : '#ff44ff';
const textColor = isPrimary ? '#aaffee' : '#ffaaff';
const texture = createRuneTexture(symbol, glowColor, textColor);
const geo = new THREE.PlaneGeometry(1.1, 1.1);
const mat = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
opacity: 0.92,
});
const rune = new THREE.Mesh(geo, mat);
const baseAngle = (i / ELDER_FUTHARK.length) * Math.PI * 2;
rune.userData.baseAngle = baseAngle;
rune.userData.phaseOffset = (i / ELDER_FUTHARK.length) * Math.PI * 2;
rune.position.set(
Math.cos(baseAngle) * RUNE_RING_RADIUS,
RUNE_BASE_Y,
Math.sin(baseAngle) * RUNE_RING_RADIUS
);
runeGroup.add(rune);
runeObjects.push(rune);
});
// Rune ring orbit indicator (faint torus)
const orbitRingGeo = new THREE.TorusGeometry(RUNE_RING_RADIUS, 0.025, 6, 80);
const orbitRingMat = new THREE.MeshBasicMaterial({
color: NEXUS.colors.runeGlow,
transparent: true,
opacity: 0.12,
});
const orbitRing = new THREE.Mesh(orbitRingGeo, orbitRingMat);
orbitRing.rotation.x = Math.PI / 2;
orbitRing.position.y = RUNE_BASE_Y;
scene.add(orbitRing);
// === STARFIELD ===
const starGeo = new THREE.BufferGeometry();
const STAR_COUNT = 2500;
const starPositions = new Float32Array(STAR_COUNT * 3);
for (let i = 0; i < STAR_COUNT; i++) {
starPositions[i * 3 + 0] = (Math.random() - 0.5) * 500;
starPositions[i * 3 + 1] = (Math.random() - 0.5) * 500;
starPositions[i * 3 + 2] = (Math.random() - 0.5) * 500;
}
starGeo.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
const starMat = new THREE.PointsMaterial({
color: NEXUS.colors.star,
size: 0.18,
sizeAttenuation: true,
transparent: true,
opacity: 0.8,
});
scene.add(new THREE.Points(starGeo, starMat));
// === ANIMATION LOOP ===
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
// Slowly orbit runes around the platform
runeObjects.forEach((rune) => {
const angle = rune.userData.baseAngle + t * 0.18;
rune.position.x = Math.cos(angle) * RUNE_RING_RADIUS;
rune.position.z = Math.sin(angle) * RUNE_RING_RADIUS;
// Gentle vertical float, each rune out of phase
rune.position.y = RUNE_BASE_Y + Math.sin(t * 0.9 + rune.userData.phaseOffset) * 0.35;
// Always face the camera (billboard)
rune.lookAt(camera.position);
});
// Pulse the core light
coreLight.intensity = 2.5 + Math.sin(t * 1.8) * 0.6;
fillLight.intensity = 0.8 + Math.sin(t * 2.3 + 1.2) * 0.3;
// Slowly rotate the glowing disc
glowDisc.rotation.z = t * 0.05;
controls.update();
renderer.render(scene, camera);
}
animate();
// === RESIZE HANDLER ===
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// === AUDIO TOGGLE ===
const audioToggle = document.getElementById('audio-toggle');
const ambientSound = document.getElementById('ambient-sound');
if (audioToggle && ambientSound) {
audioToggle.addEventListener('click', () => {
if (ambientSound.paused) {
ambientSound.play().catch(() => {});
audioToggle.classList.remove('muted');
audioToggle.textContent = '🔊';
} else {
ambientSound.pause();
audioToggle.classList.add('muted');
audioToggle.textContent = '🔇';
}
});
}
// === WEBSOCKET CLIENT ===
wsClient.connect();
window.addEventListener('player-joined', (event) => {
console.log('Player joined:', event.detail);
});
window.addEventListener('player-left', (event) => {
console.log('Player left:', event.detail);
});
window.addEventListener('chat-message', (event) => {
console.log('Chat message:', event.detail);
});
window.addEventListener('beforeunload', () => {
wsClient.disconnect();
});