feat: add ring of floating runes around Nexus center platform (#110)
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
This commit is contained in:
Alexander Whitestone
2026-03-23 23:58:06 -04:00
parent 554a4a030e
commit 4f1b86eea2
3 changed files with 310 additions and 16 deletions

241
app.js
View File

@@ -1,12 +1,240 @@
// ... existing code ...
// === WEBSOCKET CLIENT ===
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { wsClient } from './ws-client.js';
// Initialize WebSocket client
// === 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();
// Handle WebSocket events
window.addEventListener('player-joined', (event) => {
console.log('Player joined:', event.detail);
});
@@ -19,9 +247,6 @@ window.addEventListener('chat-message', (event) => {
console.log('Chat message:', event.detail);
});
// Clean up on page unload
window.addEventListener('beforeunload', () => {
wsClient.disconnect();
});
// ... existing code ...

View File

@@ -14,18 +14,31 @@
<meta name="twitter:description" content="A sovereign 3D world">
<meta name="twitter:image" content="https://example.com/og-image.png">
<link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="style.css">
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.183.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.183.0/examples/jsm/"
}
}
</script>
</head>
<body>
<!-- ... existing content ... -->
<canvas id="nexus-canvas"></canvas>
<div id="hud">
<div id="hud-title">THE NEXUS</div>
</div>
<!-- Top Right: Audio Toggle -->
<div id="audio-control" class="hud-controls" style="position: absolute; top: 8px; right: 8px;">
<button id="audio-toggle" class="chat-toggle-btn" aria-label="Toggle ambient sound" style="background-color: var(--color-primary); color: var(--color-bg); padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer;">
<div id="audio-control" class="hud-controls">
<button id="audio-toggle" class="chat-toggle-btn" aria-label="Toggle ambient sound">
🔊
</button>
<audio id="ambient-sound" src="ambient.mp3" loop></audio>
</div>
<!-- ... existing content ... -->
<script type="module" src="app.js"></script>
</body>
</html>

View File

@@ -1,18 +1,74 @@
:root {
--color-bg: #0a0a1a;
--color-primary: #00ffcc;
--color-secondary: #ff44ff;
--color-text: #e0e0ff;
--color-text-muted: #666699;
--font-body: 'Courier New', monospace;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
overflow: hidden;
width: 100vw;
height: 100vh;
}
#nexus-canvas {
display: block;
width: 100vw;
height: 100vh;
}
#hud {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
pointer-events: none;
}
#hud-title {
font-size: 13px;
letter-spacing: 6px;
color: var(--color-primary);
text-shadow: 0 0 10px var(--color-primary);
opacity: 0.7;
}
/* === AUDIO TOGGLE === */
#audio-control {
position: absolute;
top: 8px;
right: 8px;
}
#audio-toggle {
font-size: 14px;
background-color: var(--color-primary-primary);
color: var(--color-bg);
background-color: rgba(0, 255, 204, 0.15);
color: var(--color-primary);
border: 1px solid var(--color-primary);
padding: 4px 8px;
border-radius: 4px;
font-family: var(--font-body);
transition: background-color 0.3s ease;
cursor: pointer;
}
#audio-toggle:hover {
background-color: var(--color-secondary);
background-color: rgba(0, 255, 204, 0.3);
}
#audio-toggle.muted {
background-color: var(--color-text-muted);
color: var(--color-text-muted);
border-color: var(--color-text-muted);
background-color: transparent;
}