Files
the-nexus/app.js
Alexander Whitestone abb626afd2
Some checks failed
CI / validate (pull_request) Failing after 16s
feat: add animated holographic signs near each portal (#108)
- 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
2026-03-23 23:58:48 -04:00

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());