feat: add ring of floating runes around Nexus center platform (#110)
Some checks failed
CI / validate (pull_request) Has been cancelled
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:
241
app.js
241
app.js
@@ -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 ...
|
||||
|
||||
21
index.html
21
index.html
@@ -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>
|
||||
|
||||
64
style.css
64
style.css
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user