Some checks failed
CI / validate (pull_request) Failing after 16s
- Build complete Three.js scene with stars, fog, grid and portal rings loaded from portals.json - Each portal has a canvas-rendered holographic sign showing name, description and status, colour-matched to the portal colour - Signs float up/down, pulse opacity, and have a drifting scanline overlay for a holographic look - Portal rings spin slowly; point lights pulse intensity in sync - Vertical connecting beam links each sign to its portal ring - Full HTML with Three.js import map (v0.183), HUD elements, CSS design system with dark-space theme and CSS variables Fixes #108
326 lines
9.3 KiB
JavaScript
326 lines
9.3 KiB
JavaScript
import * as THREE from 'three';
|
|
import { wsClient } from './ws-client.js';
|
|
|
|
// ============================================================
|
|
// NEXUS COLOR PALETTE
|
|
// ============================================================
|
|
const NEXUS = {
|
|
colors: {
|
|
bg: 0x000814,
|
|
primary: 0x00f5ff,
|
|
secondary: 0x7b00ff,
|
|
accent: 0xff006e,
|
|
text: 0xe0e0e0,
|
|
textMuted: 0x888888,
|
|
gridLine: 0x001133,
|
|
star: 0xffffff,
|
|
}
|
|
};
|
|
|
|
// ============================================================
|
|
// SCENE SETUP
|
|
// ============================================================
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(NEXUS.colors.bg);
|
|
scene.fog = new THREE.FogExp2(NEXUS.colors.bg, 0.018);
|
|
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
renderer.toneMappingExposure = 1.2;
|
|
document.body.appendChild(renderer.domElement);
|
|
|
|
const camera = new THREE.PerspectiveCamera(
|
|
70,
|
|
window.innerWidth / window.innerHeight,
|
|
0.1,
|
|
600
|
|
);
|
|
camera.position.set(0, 6, 18);
|
|
camera.lookAt(0, 3, 0);
|
|
|
|
// Lighting
|
|
scene.add(new THREE.AmbientLight(0x112244, 2.5));
|
|
const sun = new THREE.DirectionalLight(0x4466ff, 1.2);
|
|
sun.position.set(10, 30, 10);
|
|
scene.add(sun);
|
|
|
|
// Floor grid
|
|
scene.add(new THREE.GridHelper(200, 80, NEXUS.colors.gridLine, NEXUS.colors.gridLine));
|
|
|
|
// Stars
|
|
{
|
|
const N = 2500;
|
|
const pos = new Float32Array(N * 3);
|
|
for (let i = 0; i < N * 3; i++) pos[i] = (Math.random() - 0.5) * 500;
|
|
const geo = new THREE.BufferGeometry();
|
|
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
|
scene.add(new THREE.Points(geo, new THREE.PointsMaterial({ color: NEXUS.colors.star, size: 0.25 })));
|
|
}
|
|
|
|
// ============================================================
|
|
// HOLOGRAPHIC SIGN — CANVAS TEXTURE
|
|
// ============================================================
|
|
function makeSignTexture(portal) {
|
|
const W = 512, H = 256;
|
|
const cv = document.createElement('canvas');
|
|
cv.width = W; cv.height = H;
|
|
const ctx = cv.getContext('2d');
|
|
|
|
// Dark translucent background
|
|
ctx.fillStyle = 'rgba(0,4,20,0.88)';
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
const hex = portal.color;
|
|
|
|
// Outer border
|
|
ctx.strokeStyle = hex;
|
|
ctx.lineWidth = 3;
|
|
ctx.shadowColor = hex;
|
|
ctx.shadowBlur = 24;
|
|
ctx.strokeRect(3, 3, W - 6, H - 6);
|
|
|
|
// Inner accent line
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeStyle = hex + '88';
|
|
ctx.shadowBlur = 0;
|
|
ctx.strokeRect(10, 10, W - 20, H - 20);
|
|
|
|
// Portal name
|
|
ctx.font = 'bold 52px "Courier New", monospace';
|
|
ctx.fillStyle = hex;
|
|
ctx.shadowColor = hex;
|
|
ctx.shadowBlur = 20;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'alphabetic';
|
|
ctx.fillText(portal.name.toUpperCase(), W / 2, 78);
|
|
|
|
// Divider line
|
|
ctx.shadowBlur = 0;
|
|
ctx.strokeStyle = hex + '55';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(30, 92); ctx.lineTo(W - 30, 92);
|
|
ctx.stroke();
|
|
|
|
// Description — word-wrap
|
|
ctx.font = '20px "Courier New", monospace';
|
|
ctx.fillStyle = '#c8deff';
|
|
ctx.shadowBlur = 4;
|
|
ctx.shadowColor = '#4488ff';
|
|
const words = portal.description.split(' ');
|
|
let line = '', y = 125;
|
|
for (const w of words) {
|
|
const test = line + w + ' ';
|
|
if (ctx.measureText(test).width > W - 60 && line !== '') {
|
|
ctx.fillText(line.trimEnd(), W / 2, y);
|
|
line = w + ' ';
|
|
y += 28;
|
|
} else {
|
|
line = test;
|
|
}
|
|
}
|
|
if (line.trim()) ctx.fillText(line.trimEnd(), W / 2, y);
|
|
|
|
// Status badge
|
|
const isOnline = portal.status === 'online';
|
|
const statusColor = isOnline ? '#00ff88' : '#ff4455';
|
|
ctx.font = 'bold 17px "Courier New", monospace';
|
|
ctx.fillStyle = statusColor;
|
|
ctx.shadowColor = statusColor;
|
|
ctx.shadowBlur = 12;
|
|
ctx.fillText(`● ${portal.status.toUpperCase()}`, W / 2, H - 18);
|
|
|
|
const tex = new THREE.CanvasTexture(cv);
|
|
return tex;
|
|
}
|
|
|
|
// Repeating scanline texture (horizontal dark bands)
|
|
function makeScanlineTex() {
|
|
const cv = document.createElement('canvas');
|
|
cv.width = 4; cv.height = 8;
|
|
const ctx = cv.getContext('2d');
|
|
ctx.clearRect(0, 0, 4, 8);
|
|
ctx.fillStyle = 'rgba(0,0,0,0.22)';
|
|
ctx.fillRect(0, 0, 4, 3);
|
|
const tex = new THREE.CanvasTexture(cv);
|
|
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
|
tex.repeat.set(1, 10);
|
|
return tex;
|
|
}
|
|
const scanlineTex = makeScanlineTex();
|
|
|
|
// ============================================================
|
|
// PORTAL + SIGN CONSTRUCTION
|
|
// ============================================================
|
|
const portals = await fetch('./portals.json').then(r => r.json());
|
|
|
|
const animData = []; // holds refs needed each frame
|
|
|
|
for (const portal of portals) {
|
|
const { x, y, z } = portal.position;
|
|
const rotY = portal.rotation?.y ?? 0;
|
|
const color = new THREE.Color(portal.color);
|
|
|
|
// --- Portal ring ---
|
|
const ringGeo = new THREE.TorusGeometry(3, 0.14, 20, 80);
|
|
const ringMat = new THREE.MeshStandardMaterial({
|
|
color,
|
|
emissive: color,
|
|
emissiveIntensity: 1.0,
|
|
});
|
|
const ring = new THREE.Mesh(ringGeo, ringMat);
|
|
ring.position.set(x, y + 3.2, z);
|
|
ring.rotation.y = rotY;
|
|
scene.add(ring);
|
|
|
|
// Portal inner glow disc
|
|
const discGeo = new THREE.CircleGeometry(2.86, 64);
|
|
const discMat = new THREE.MeshBasicMaterial({
|
|
color,
|
|
transparent: true,
|
|
opacity: 0.12,
|
|
side: THREE.DoubleSide,
|
|
depthWrite: false,
|
|
});
|
|
const disc = new THREE.Mesh(discGeo, discMat);
|
|
disc.position.copy(ring.position);
|
|
disc.rotation.y = rotY;
|
|
scene.add(disc);
|
|
|
|
// Portal point light
|
|
const pLight = new THREE.PointLight(color, 2.5, 18, 2);
|
|
pLight.position.set(x, y + 3.2, z);
|
|
scene.add(pLight);
|
|
|
|
// --- Holographic sign group ---
|
|
const signBaseY = y + 8.0;
|
|
const W = 5.2, H = 2.6;
|
|
|
|
// Glow backing (slightly larger, blurred by transparency)
|
|
const glowGeo = new THREE.PlaneGeometry(W + 0.5, H + 0.3);
|
|
const glowMat = new THREE.MeshBasicMaterial({
|
|
color,
|
|
transparent: true,
|
|
opacity: 0.18,
|
|
side: THREE.DoubleSide,
|
|
depthWrite: false,
|
|
});
|
|
const glowMesh = new THREE.Mesh(glowGeo, glowMat);
|
|
glowMesh.position.set(x, signBaseY, z);
|
|
glowMesh.rotation.y = rotY;
|
|
scene.add(glowMesh);
|
|
|
|
// Main sign panel
|
|
const signTex = makeSignTexture(portal);
|
|
const signGeo = new THREE.PlaneGeometry(W, H);
|
|
const signMat = new THREE.MeshBasicMaterial({
|
|
map: signTex,
|
|
transparent: true,
|
|
opacity: 0.92,
|
|
side: THREE.DoubleSide,
|
|
depthWrite: false,
|
|
});
|
|
const signMesh = new THREE.Mesh(signGeo, signMat);
|
|
signMesh.position.set(x, signBaseY, z + 0.02);
|
|
signMesh.rotation.y = rotY;
|
|
scene.add(signMesh);
|
|
|
|
// Scanline overlay
|
|
const slGeo = new THREE.PlaneGeometry(W, H);
|
|
const slMat = new THREE.MeshBasicMaterial({
|
|
map: scanlineTex,
|
|
transparent: true,
|
|
opacity: 0.28,
|
|
side: THREE.DoubleSide,
|
|
depthWrite: false,
|
|
});
|
|
const slMesh = new THREE.Mesh(slGeo, slMat);
|
|
slMesh.position.set(x, signBaseY, z + 0.04);
|
|
slMesh.rotation.y = rotY;
|
|
scene.add(slMesh);
|
|
|
|
// Vertical connecting beam (sign to ring)
|
|
const beamGeo = new THREE.CylinderGeometry(0.03, 0.03, signBaseY - (y + 3.2), 8);
|
|
const beamMat = new THREE.MeshBasicMaterial({
|
|
color,
|
|
transparent: true,
|
|
opacity: 0.35,
|
|
});
|
|
const beam = new THREE.Mesh(beamGeo, beamMat);
|
|
beam.position.set(x, (signBaseY + y + 3.2) / 2, z);
|
|
scene.add(beam);
|
|
|
|
animData.push({
|
|
ring,
|
|
disc,
|
|
glowMesh,
|
|
signMesh,
|
|
slMesh,
|
|
pLight,
|
|
baseY: signBaseY,
|
|
phaseOffset: Math.random() * Math.PI * 2,
|
|
color,
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// ANIMATION LOOP
|
|
// ============================================================
|
|
const clock = new THREE.Clock();
|
|
|
|
function animate() {
|
|
requestAnimationFrame(animate);
|
|
const t = clock.getElapsedTime();
|
|
|
|
for (const d of animData) {
|
|
// Float sign up/down
|
|
const floatY = d.baseY + Math.sin(t * 0.7 + d.phaseOffset) * 0.22;
|
|
d.signMesh.position.y = floatY;
|
|
d.glowMesh.position.y = floatY;
|
|
d.slMesh.position.y = floatY;
|
|
|
|
// Pulse glow opacity
|
|
const pulse = 0.5 + 0.5 * Math.sin(t * 1.4 + d.phaseOffset);
|
|
d.glowMesh.material.opacity = 0.08 + pulse * 0.22;
|
|
d.signMesh.material.opacity = 0.78 + pulse * 0.18;
|
|
|
|
// Pulse portal light
|
|
d.pLight.intensity = 1.8 + pulse * 2.0;
|
|
|
|
// Slow ring spin
|
|
d.ring.rotation.z += 0.004;
|
|
|
|
// Disc flicker
|
|
d.disc.material.opacity = 0.08 + pulse * 0.12;
|
|
|
|
// Scanline drift
|
|
d.slMesh.material.map.offset.y = (t * 0.15) % 1;
|
|
d.slMesh.material.map.needsUpdate = true;
|
|
}
|
|
|
|
renderer.render(scene, camera);
|
|
}
|
|
|
|
animate();
|
|
|
|
// ============================================================
|
|
// RESIZE HANDLER
|
|
// ============================================================
|
|
window.addEventListener('resize', () => {
|
|
camera.aspect = window.innerWidth / window.innerHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
});
|
|
|
|
// ============================================================
|
|
// WEBSOCKET
|
|
// ============================================================
|
|
wsClient.connect();
|
|
|
|
window.addEventListener('player-joined', (e) => console.log('Player joined:', e.detail));
|
|
window.addEventListener('player-left', (e) => console.log('Player left:', e.detail));
|
|
window.addEventListener('chat-message', (e) => console.log('Chat message:', e.detail));
|
|
window.addEventListener('beforeunload', () => wsClient.disconnect());
|