From abb626afd222521fc304b6021e3b44d09dded276 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Mon, 23 Mar 2026 23:58:48 -0400 Subject: [PATCH] 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 --- app.js | 344 +++++++++++++++++++++++++++++++++++++++++++++++++---- index.html | 30 ++++- style.css | 108 ++++++++++++++++- 3 files changed, 447 insertions(+), 35 deletions(-) diff --git a/app.js b/app.js index 051fe82..4e96efc 100644 --- a/app.js +++ b/app.js @@ -1,27 +1,325 @@ -// ... existing code ... - -// === WEBSOCKET CLIENT === +import * as THREE from 'three'; import { wsClient } from './ws-client.js'; -// Initialize WebSocket client +// ============================================================ +// 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(); -// Handle WebSocket events -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); -}); - -// Clean up on page unload -window.addEventListener('beforeunload', () => { - wsClient.disconnect(); -}); - -// ... existing code ... +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()); diff --git a/index.html b/index.html index 34af931..bf7c2df 100644 --- a/index.html +++ b/index.html @@ -14,18 +14,36 @@ + + + - + + +
+ NEXUS + Timmy's Sovereign Hub +
-
- +
+
- + +
+ 3 PORTALS ACTIVE +
+ + diff --git a/style.css b/style.css index a2aab15..8dff388 100644 --- a/style.css +++ b/style.css @@ -1,18 +1,114 @@ +/* === DESIGN SYSTEM: NEXUS — DARK SPACE THEME === */ + +:root { + --color-bg: #000814; + --color-primary: #00f5ff; + --color-secondary: #7b00ff; + --color-accent: #ff006e; + --color-text: #e0e0e0; + --color-text-muted: #888888; + --color-panel-bg: rgba(0, 8, 20, 0.82); + --color-panel-border: rgba(0, 245, 255, 0.3); + + --font-body: "Courier New", "Courier", monospace; + --font-display: "Courier New", "Courier", monospace; +} + +*, *::before, *::after { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + overflow: hidden; + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-body); +} + +/* Three.js canvas fills the viewport */ +canvas { + display: block; + position: fixed; + top: 0; left: 0; + width: 100%; + height: 100%; + z-index: 0; +} + +/* === HUD PANELS === */ +.hud-panel { + position: fixed; + z-index: 10; + background: var(--color-panel-bg); + border: 1px solid var(--color-panel-border); + border-radius: 4px; + padding: 6px 14px; + display: flex; + flex-direction: column; + gap: 2px; + pointer-events: none; + backdrop-filter: blur(4px); +} + +#hud-title { + top: 14px; + left: 14px; +} + +.hud-label { + font-size: 13px; + font-weight: bold; + color: var(--color-primary); + letter-spacing: 0.12em; + text-shadow: 0 0 8px var(--color-primary); +} + +.hud-sub { + font-size: 10px; + color: var(--color-text-muted); + letter-spacing: 0.06em; +} + +.hud-bottom { + bottom: 14px; + left: 50%; + transform: translateX(-50%); + flex-direction: row; + align-items: center; + gap: 8px; +} + /* === AUDIO TOGGLE === */ +.hud-controls { + position: fixed; + top: 14px; + right: 14px; + z-index: 10; +} + #audio-toggle { font-size: 14px; - background-color: var(--color-primary-primary); - color: var(--color-bg); - padding: 4px 8px; + background: var(--color-panel-bg); + color: var(--color-primary); + border: 1px solid var(--color-panel-border); + padding: 5px 10px; border-radius: 4px; font-family: var(--font-body); - transition: background-color 0.3s ease; + cursor: pointer; + transition: background-color 0.2s, box-shadow 0.2s; + backdrop-filter: blur(4px); } #audio-toggle:hover { - background-color: var(--color-secondary); + background: rgba(0, 245, 255, 0.1); + box-shadow: 0 0 10px var(--color-primary); } #audio-toggle.muted { - background-color: var(--color-text-muted); + color: var(--color-text-muted); + border-color: rgba(136, 136, 136, 0.3); } -- 2.43.0