feat: add performance auto-detection and quality presets
Some checks failed
CI / validate (pull_request) Has been cancelled
Some checks failed
CI / validate (pull_request) Has been cancelled
- detectPerformanceTier() measures real frame time over 60 frames using requestAnimationFrame during scene init - If avg FPS < 30 → LOW quality: bloom disabled, particles reduced from 3000 → 800, core material simplified to wireframe MeshLambertMaterial - Quality indicator badge added to HUD showing tier + detected FPS - Full Three.js scene: stars, nexus core (icosahedron), particle cloud, floor grid, orbit controls, UnrealBloom post-processing - CSS quality states: quality-high (green dot), quality-low (orange dot), quality-detecting (muted) Fixes #94
This commit is contained in:
463
app.js
463
app.js
@@ -1,27 +1,450 @@
|
||||
// ... existing code ...
|
||||
|
||||
// === WEBSOCKET CLIENT ===
|
||||
// === THE NEXUS — app.js ===
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
||||
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
||||
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
||||
import { wsClient } from './ws-client.js';
|
||||
|
||||
// Initialize WebSocket client
|
||||
wsClient.connect();
|
||||
// === COLOR PALETTE ===
|
||||
const NEXUS = {
|
||||
colors: {
|
||||
primary: 0x00ffff,
|
||||
secondary: 0xff00ff,
|
||||
accent: 0xffff00,
|
||||
bg: 0x020408,
|
||||
starWhite: 0xc0e8ff,
|
||||
coreGlow: 0x00ffff,
|
||||
nebula1: 0x1a0040,
|
||||
nebula2: 0x001a40,
|
||||
particle: 0x88ccff,
|
||||
}
|
||||
};
|
||||
|
||||
// Handle WebSocket events
|
||||
window.addEventListener('player-joined', (event) => {
|
||||
console.log('Player joined:', event.detail);
|
||||
});
|
||||
// === QUALITY PRESETS ===
|
||||
const QUALITY_HIGH = 'high';
|
||||
const QUALITY_LOW = 'low';
|
||||
let currentQuality = null;
|
||||
|
||||
window.addEventListener('player-left', (event) => {
|
||||
console.log('Player left:', event.detail);
|
||||
});
|
||||
// Scene globals
|
||||
let scene, camera, renderer, controls;
|
||||
let composer, bloomPass;
|
||||
let nexusCore, particles, particlePositions;
|
||||
const PARTICLE_COUNT_HIGH = 3000;
|
||||
const PARTICLE_COUNT_LOW = 800;
|
||||
|
||||
window.addEventListener('chat-message', (event) => {
|
||||
console.log('Chat message:', event.detail);
|
||||
});
|
||||
// === PERFORMANCE AUTO-DETECTION ===
|
||||
// Measures actual frame time over N frames, returns average FPS.
|
||||
function measureFPS(frameCount) {
|
||||
return new Promise((resolve) => {
|
||||
const times = [];
|
||||
let prev = performance.now();
|
||||
|
||||
// Clean up on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
wsClient.disconnect();
|
||||
});
|
||||
function tick() {
|
||||
const now = performance.now();
|
||||
times.push(now - prev);
|
||||
prev = now;
|
||||
|
||||
// ... existing code ...
|
||||
if (times.length < frameCount) {
|
||||
requestAnimationFrame(tick);
|
||||
} else {
|
||||
const avgMs = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
resolve(1000 / avgMs);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
}
|
||||
|
||||
async function detectPerformanceTier() {
|
||||
setLoadingStatus('Measuring performance…');
|
||||
setLoadingProgress(50);
|
||||
|
||||
const fps = await measureFPS(60);
|
||||
console.log(`[Nexus] Performance: ${fps.toFixed(1)} FPS avg over 60 frames`);
|
||||
|
||||
const tier = fps >= 30 ? QUALITY_HIGH : QUALITY_LOW;
|
||||
applyQualityPreset(tier, fps);
|
||||
return tier;
|
||||
}
|
||||
|
||||
// === QUALITY APPLICATION ===
|
||||
function applyQualityPreset(tier, fps) {
|
||||
currentQuality = tier;
|
||||
|
||||
if (tier === QUALITY_LOW) {
|
||||
// Disable bloom
|
||||
if (composer && bloomPass) {
|
||||
bloomPass.enabled = false;
|
||||
}
|
||||
|
||||
// Reduce particle count
|
||||
if (particles) {
|
||||
rebuildParticles(PARTICLE_COUNT_LOW);
|
||||
}
|
||||
|
||||
// Simplify core material
|
||||
if (nexusCore) {
|
||||
nexusCore.material.dispose();
|
||||
nexusCore.material = new THREE.MeshLambertMaterial({
|
||||
color: NEXUS.colors.primary,
|
||||
wireframe: true,
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[Nexus] Quality: LOW — bloom off, particles reduced, materials simplified');
|
||||
} else {
|
||||
// Ensure bloom is enabled (default)
|
||||
if (composer && bloomPass) {
|
||||
bloomPass.enabled = true;
|
||||
}
|
||||
console.log('[Nexus] Quality: HIGH — full rendering enabled');
|
||||
}
|
||||
|
||||
updateQualityIndicator(tier, fps);
|
||||
}
|
||||
|
||||
function updateQualityIndicator(tier, fps) {
|
||||
const el = document.getElementById('quality-indicator');
|
||||
const label = document.getElementById('quality-label');
|
||||
if (!el || !label) return;
|
||||
|
||||
el.classList.remove('quality-high', 'quality-low', 'quality-detecting');
|
||||
el.classList.add(`quality-${tier}`);
|
||||
label.textContent = `${tier.toUpperCase()} · ${Math.round(fps)} FPS`;
|
||||
}
|
||||
|
||||
// === SCENE SETUP ===
|
||||
function initScene() {
|
||||
scene = new THREE.Scene();
|
||||
scene.fog = new THREE.FogExp2(NEXUS.colors.bg, 0.018);
|
||||
scene.background = new THREE.Color(NEXUS.colors.bg);
|
||||
|
||||
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 600);
|
||||
camera.position.set(0, 2, 12);
|
||||
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
canvas: document.getElementById('canvas'),
|
||||
antialias: true,
|
||||
powerPreference: 'high-performance',
|
||||
});
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.0;
|
||||
|
||||
controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.06;
|
||||
controls.minDistance = 3;
|
||||
controls.maxDistance = 80;
|
||||
controls.target.set(0, 0, 0);
|
||||
|
||||
// Post-processing
|
||||
composer = new EffectComposer(renderer);
|
||||
composer.addPass(new RenderPass(scene, camera));
|
||||
bloomPass = new UnrealBloomPass(
|
||||
new THREE.Vector2(window.innerWidth, window.innerHeight),
|
||||
1.2, // strength
|
||||
0.5, // radius
|
||||
0.75 // threshold
|
||||
);
|
||||
composer.addPass(bloomPass);
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
composer.setSize(window.innerWidth, window.innerHeight);
|
||||
}
|
||||
|
||||
// === LIGHTING ===
|
||||
function buildLighting() {
|
||||
scene.add(new THREE.AmbientLight(0x112233, 0.8));
|
||||
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 1.2);
|
||||
sun.position.set(10, 20, 10);
|
||||
scene.add(sun);
|
||||
|
||||
const rimLight = new THREE.PointLight(NEXUS.colors.primary, 2, 30);
|
||||
rimLight.position.set(-6, 4, -6);
|
||||
scene.add(rimLight);
|
||||
|
||||
const fill = new THREE.PointLight(NEXUS.colors.secondary, 1, 20);
|
||||
fill.position.set(6, -2, 6);
|
||||
scene.add(fill);
|
||||
}
|
||||
|
||||
// === STAR FIELD ===
|
||||
function buildStars() {
|
||||
const count = 4000;
|
||||
const geo = new THREE.BufferGeometry();
|
||||
const pos = new Float32Array(count * 3);
|
||||
|
||||
for (let i = 0; i < count * 3; i++) {
|
||||
pos[i] = (Math.random() - 0.5) * 500;
|
||||
}
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
||||
|
||||
const mat = new THREE.PointsMaterial({
|
||||
color: NEXUS.colors.starWhite,
|
||||
size: 0.35,
|
||||
sizeAttenuation: true,
|
||||
transparent: true,
|
||||
opacity: 0.85,
|
||||
});
|
||||
|
||||
scene.add(new THREE.Points(geo, mat));
|
||||
}
|
||||
|
||||
// === NEXUS CORE ===
|
||||
function buildNexusCore() {
|
||||
const geo = new THREE.IcosahedronGeometry(1.2, 1);
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: NEXUS.colors.primary,
|
||||
emissive: NEXUS.colors.primary,
|
||||
emissiveIntensity: 0.4,
|
||||
metalness: 0.8,
|
||||
roughness: 0.15,
|
||||
});
|
||||
nexusCore = new THREE.Mesh(geo, mat);
|
||||
nexusCore.position.set(0, 0.5, 0);
|
||||
scene.add(nexusCore);
|
||||
|
||||
// Pedestal
|
||||
const pedGeo = new THREE.CylinderGeometry(0.3, 0.5, 0.5, 8);
|
||||
const pedMat = new THREE.MeshStandardMaterial({ color: 0x224466, metalness: 0.6, roughness: 0.4 });
|
||||
const pedestal = new THREE.Mesh(pedGeo, pedMat);
|
||||
pedestal.position.set(0, -0.85, 0);
|
||||
scene.add(pedestal);
|
||||
|
||||
// Glow ring
|
||||
const ringGeo = new THREE.TorusGeometry(1.8, 0.04, 8, 64);
|
||||
const ringMat = new THREE.MeshBasicMaterial({ color: NEXUS.colors.primary });
|
||||
const ring = new THREE.Mesh(ringGeo, ringMat);
|
||||
ring.rotation.x = Math.PI / 2;
|
||||
ring.position.y = 0.5;
|
||||
scene.add(ring);
|
||||
}
|
||||
|
||||
// === PARTICLE SYSTEM ===
|
||||
function buildParticles(count) {
|
||||
const geo = new THREE.BufferGeometry();
|
||||
particlePositions = new Float32Array(count * 3);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
const r = 3 + Math.random() * 14;
|
||||
particlePositions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
||||
particlePositions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
|
||||
particlePositions[i * 3 + 2] = r * Math.cos(phi);
|
||||
}
|
||||
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(particlePositions, 3));
|
||||
|
||||
const mat = new THREE.PointsMaterial({
|
||||
color: NEXUS.colors.particle,
|
||||
size: 0.12,
|
||||
sizeAttenuation: true,
|
||||
transparent: true,
|
||||
opacity: 0.7,
|
||||
});
|
||||
|
||||
if (particles) {
|
||||
scene.remove(particles);
|
||||
particles.geometry.dispose();
|
||||
particles.material.dispose();
|
||||
}
|
||||
|
||||
particles = new THREE.Points(geo, mat);
|
||||
scene.add(particles);
|
||||
}
|
||||
|
||||
function rebuildParticles(count) {
|
||||
buildParticles(count);
|
||||
}
|
||||
|
||||
// === FLOOR GRID ===
|
||||
function buildFloor() {
|
||||
const helper = new THREE.GridHelper(40, 20, 0x0a2030, 0x071520);
|
||||
helper.position.y = -1.2;
|
||||
scene.add(helper);
|
||||
|
||||
const floorGeo = new THREE.PlaneGeometry(40, 40);
|
||||
const floorMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x030d18,
|
||||
roughness: 0.9,
|
||||
metalness: 0.1,
|
||||
transparent: true,
|
||||
opacity: 0.85,
|
||||
});
|
||||
const floor = new THREE.Mesh(floorGeo, floorMat);
|
||||
floor.rotation.x = -Math.PI / 2;
|
||||
floor.position.y = -1.2;
|
||||
scene.add(floor);
|
||||
}
|
||||
|
||||
// === ANIMATION LOOP ===
|
||||
let clock;
|
||||
|
||||
function startLoop() {
|
||||
clock = new THREE.Clock();
|
||||
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
const t = clock.getElapsedTime();
|
||||
|
||||
// Rotate core
|
||||
if (nexusCore) {
|
||||
nexusCore.rotation.y = t * 0.4;
|
||||
nexusCore.rotation.x = t * 0.15;
|
||||
}
|
||||
|
||||
// Drift particles
|
||||
if (particles) {
|
||||
particles.rotation.y = t * 0.02;
|
||||
particles.rotation.x = t * 0.005;
|
||||
}
|
||||
|
||||
controls.update();
|
||||
composer.render();
|
||||
}
|
||||
|
||||
animate();
|
||||
}
|
||||
|
||||
// === CHAT ===
|
||||
function initChat() {
|
||||
const toggle = document.getElementById('chat-toggle');
|
||||
const panel = document.getElementById('chat-panel');
|
||||
const close = document.getElementById('chat-close');
|
||||
const input = document.getElementById('chat-input');
|
||||
const send = document.getElementById('chat-send');
|
||||
const msgs = document.getElementById('chat-messages');
|
||||
|
||||
function openPanel() { panel.classList.add('open'); toggle.style.display = 'none'; }
|
||||
function closePanel() { panel.classList.remove('open'); toggle.style.display = ''; }
|
||||
|
||||
toggle.addEventListener('click', openPanel);
|
||||
close.addEventListener('click', closePanel);
|
||||
|
||||
function sendMsg() {
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
appendMsg('You', text, '#aaddff');
|
||||
wsClient.send({ type: 'chat-message', text });
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
send.addEventListener('click', sendMsg);
|
||||
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendMsg(); });
|
||||
|
||||
function appendMsg(from, text, color) {
|
||||
const p = document.createElement('p');
|
||||
p.innerHTML = `<span style="color:${color}">[${from}]</span> ${text}`;
|
||||
msgs.appendChild(p);
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
}
|
||||
|
||||
window.addEventListener('chat-message', (e) => {
|
||||
appendMsg('Timmy', e.detail.text || e.detail.message || '…', '#00ffcc');
|
||||
});
|
||||
}
|
||||
|
||||
// === AUDIO ===
|
||||
function initAudio() {
|
||||
const btn = document.getElementById('audio-toggle');
|
||||
const audio = document.getElementById('ambient-sound');
|
||||
let muted = true;
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
muted = !muted;
|
||||
if (muted) {
|
||||
audio.pause();
|
||||
btn.textContent = '🔇';
|
||||
btn.classList.add('muted');
|
||||
} else {
|
||||
audio.play().catch(() => {});
|
||||
btn.textContent = '🔊';
|
||||
btn.classList.remove('muted');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// === WEBSOCKET ===
|
||||
function initWebSocket() {
|
||||
wsClient.connect();
|
||||
|
||||
window.addEventListener('player-joined', (e) => {
|
||||
console.log('[Nexus] Player joined:', e.detail);
|
||||
});
|
||||
window.addEventListener('player-left', (e) => {
|
||||
console.log('[Nexus] Player left:', e.detail);
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => wsClient.disconnect());
|
||||
}
|
||||
|
||||
// === LOADING HELPERS ===
|
||||
function setLoadingProgress(pct) {
|
||||
const bar = document.getElementById('loading-progress');
|
||||
if (bar) bar.style.width = `${pct}%`;
|
||||
}
|
||||
|
||||
function setLoadingStatus(msg) {
|
||||
const el = document.getElementById('loading-status');
|
||||
if (el) el.textContent = msg;
|
||||
}
|
||||
|
||||
function hideLoading() {
|
||||
const el = document.getElementById('loading-screen');
|
||||
if (el) {
|
||||
el.classList.add('hidden');
|
||||
setTimeout(() => el.remove(), 900);
|
||||
}
|
||||
}
|
||||
|
||||
// === BOOT ===
|
||||
async function boot() {
|
||||
// Set quality indicator to detecting state
|
||||
const qEl = document.getElementById('quality-indicator');
|
||||
if (qEl) qEl.classList.add('quality-detecting');
|
||||
|
||||
setLoadingProgress(10);
|
||||
setLoadingStatus('Building scene…');
|
||||
|
||||
initScene();
|
||||
buildLighting();
|
||||
buildStars();
|
||||
buildNexusCore();
|
||||
buildParticles(PARTICLE_COUNT_HIGH);
|
||||
buildFloor();
|
||||
|
||||
setLoadingProgress(30);
|
||||
setLoadingStatus('Starting render loop…');
|
||||
|
||||
// Start rendering so the GPU warms up before measurement
|
||||
startLoop();
|
||||
|
||||
setLoadingProgress(40);
|
||||
|
||||
// Measure real FPS over 60 frames (blocks ~1 second at 60 FPS)
|
||||
await detectPerformanceTier();
|
||||
|
||||
setLoadingProgress(90);
|
||||
setLoadingStatus('Ready.');
|
||||
|
||||
initChat();
|
||||
initAudio();
|
||||
initWebSocket();
|
||||
|
||||
setLoadingProgress(100);
|
||||
setTimeout(hideLoading, 300);
|
||||
}
|
||||
|
||||
boot();
|
||||
|
||||
62
index.html
62
index.html
@@ -14,18 +14,64 @@
|
||||
<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://unpkg.com/three@0.183.0/build/three.module.js",
|
||||
"three/addons/": "https://unpkg.com/three@0.183.0/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- ... existing content ... -->
|
||||
<canvas id="canvas"></canvas>
|
||||
|
||||
<!-- 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;">
|
||||
🔊
|
||||
</button>
|
||||
<audio id="ambient-sound" src="ambient.mp3" loop></audio>
|
||||
<!-- Loading Screen -->
|
||||
<div id="loading-screen">
|
||||
<div class="loading-inner">
|
||||
<div class="loading-title">◈ THE NEXUS</div>
|
||||
<div class="loading-bar"><div id="loading-progress"></div></div>
|
||||
<div id="loading-status">Initializing...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ... existing content ... -->
|
||||
<!-- HUD -->
|
||||
<div id="hud">
|
||||
<!-- Top-left: Nexus label -->
|
||||
<div id="hud-title">◈ NEXUS</div>
|
||||
|
||||
<!-- Top-right: Quality indicator + audio toggle -->
|
||||
<div id="hud-top-right">
|
||||
<div id="quality-indicator" class="hud-badge" title="Rendering quality tier">
|
||||
<span id="quality-label">DETECTING...</span>
|
||||
</div>
|
||||
<div id="audio-control">
|
||||
<button id="audio-toggle" class="hud-btn" aria-label="Toggle ambient sound">🔊</button>
|
||||
<audio id="ambient-sound" src="ambient.mp3" loop></audio>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom-left: navigation hint -->
|
||||
<div id="hud-hint">WASD + drag to explore</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Panel (Timmy Terminal) -->
|
||||
<div id="chat-panel">
|
||||
<div id="chat-header">
|
||||
<span>TIMMY TERMINAL</span>
|
||||
<button id="chat-close" class="hud-btn" aria-label="Close chat">✕</button>
|
||||
</div>
|
||||
<div id="chat-messages"></div>
|
||||
<div id="chat-input-row">
|
||||
<input id="chat-input" type="text" placeholder="Message Timmy..." autocomplete="off">
|
||||
<button id="chat-send" class="hud-btn">SEND</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat toggle -->
|
||||
<button id="chat-toggle" class="hud-btn" aria-label="Toggle chat">💬</button>
|
||||
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
244
style.css
244
style.css
@@ -1,18 +1,242 @@
|
||||
/* === NEXUS DESIGN SYSTEM === */
|
||||
:root {
|
||||
--color-bg: #020408;
|
||||
--color-primary: #00ffff;
|
||||
--color-secondary: #ff00ff;
|
||||
--color-accent: #ffff00;
|
||||
--color-text: #c0e8ff;
|
||||
--color-text-muted: #4a7a9b;
|
||||
--color-panel: rgba(0, 20, 40, 0.85);
|
||||
--color-border: rgba(0, 255, 255, 0.25);
|
||||
--font-body: 'Courier New', Courier, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
overflow: hidden;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#canvas {
|
||||
display: block;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
}
|
||||
|
||||
/* === LOADING SCREEN === */
|
||||
#loading-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
transition: opacity 0.8s ease;
|
||||
}
|
||||
#loading-screen.hidden { opacity: 0; pointer-events: none; }
|
||||
|
||||
.loading-inner { text-align: center; }
|
||||
.loading-title {
|
||||
font-size: 2.5rem;
|
||||
color: var(--color-primary);
|
||||
letter-spacing: 0.3em;
|
||||
margin-bottom: 1.5rem;
|
||||
text-shadow: 0 0 20px var(--color-primary);
|
||||
}
|
||||
.loading-bar {
|
||||
width: 240px;
|
||||
height: 4px;
|
||||
background: var(--color-border);
|
||||
border-radius: 2px;
|
||||
margin: 0 auto 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
#loading-progress {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: var(--color-primary);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
box-shadow: 0 0 8px var(--color-primary);
|
||||
}
|
||||
#loading-status {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
/* === HUD === */
|
||||
#hud {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#hud-title {
|
||||
position: absolute;
|
||||
top: 12px; left: 16px;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.3em;
|
||||
color: var(--color-primary);
|
||||
text-shadow: 0 0 12px var(--color-primary);
|
||||
}
|
||||
|
||||
#hud-top-right {
|
||||
position: absolute;
|
||||
top: 8px; right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Quality Indicator */
|
||||
.hud-badge {
|
||||
background: var(--color-panel);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.12em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.hud-badge::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--quality-dot, var(--color-text-muted));
|
||||
box-shadow: 0 0 4px var(--quality-dot, transparent);
|
||||
}
|
||||
#quality-indicator.quality-high {
|
||||
--quality-dot: #00ff88;
|
||||
border-color: rgba(0, 255, 136, 0.4);
|
||||
color: #00ff88;
|
||||
}
|
||||
#quality-indicator.quality-low {
|
||||
--quality-dot: #ff8800;
|
||||
border-color: rgba(255, 136, 0, 0.4);
|
||||
color: #ff8800;
|
||||
}
|
||||
#quality-indicator.quality-detecting {
|
||||
--quality-dot: var(--color-text-muted);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
#hud-hint {
|
||||
position: absolute;
|
||||
bottom: 16px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-text-muted);
|
||||
letter-spacing: 0.1em;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* === BUTTONS === */
|
||||
.hud-btn {
|
||||
background: var(--color-panel);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(4px);
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.hud-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* === CHAT PANEL === */
|
||||
#chat-panel {
|
||||
position: fixed;
|
||||
bottom: 16px; right: 16px;
|
||||
width: 320px;
|
||||
max-height: 380px;
|
||||
background: var(--color-panel);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 20;
|
||||
transform: translateY(calc(100% + 16px));
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
#chat-panel.open { transform: translateY(0); }
|
||||
|
||||
#chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
#chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
min-height: 180px;
|
||||
}
|
||||
#chat-input-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
#chat-input {
|
||||
flex: 1;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
}
|
||||
#chat-input:focus { border-color: var(--color-primary); }
|
||||
|
||||
#chat-toggle {
|
||||
position: fixed;
|
||||
bottom: 16px; right: 16px;
|
||||
z-index: 15;
|
||||
font-size: 1.1rem;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
#chat-panel.open ~ #chat-toggle { opacity: 0; pointer-events: none; }
|
||||
|
||||
/* === AUDIO TOGGLE === */
|
||||
#audio-toggle {
|
||||
font-size: 14px;
|
||||
background-color: var(--color-primary-primary);
|
||||
color: var(--color-bg);
|
||||
background-color: var(--color-panel);
|
||||
color: var(--color-text);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-body);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
#audio-toggle:hover {
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
|
||||
#audio-toggle.muted {
|
||||
background-color: var(--color-text-muted);
|
||||
}
|
||||
#audio-toggle:hover { background-color: rgba(0, 255, 255, 0.1); }
|
||||
#audio-toggle.muted { color: var(--color-text-muted); }
|
||||
|
||||
Reference in New Issue
Block a user