Some checks failed
CI / validate (pull_request) Has been cancelled
- 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
253 lines
7.4 KiB
JavaScript
253 lines
7.4 KiB
JavaScript
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();
|
||
});
|