Compare commits

...

6 Commits

Author SHA1 Message Date
kimi
5d2d8a12bc feat: PWA manifest + service worker for offline and install support
Some checks failed
CI / validate (pull_request) Has been cancelled
Adds full Progressive Web App support to The Nexus:

- sw.js: Service worker with cache-first strategy for local assets
  and stale-while-revalidate for CDN resources (Three.js, fonts)
- offline.html: Styled offline fallback page with auto-reconnect
- icons/nexus-icon.svg: Nexus crystal sigil icon (SVG)
- icons/nexus-maskable.svg: Maskable icon for adaptive shapes
- manifest.json: Complete PWA manifest with theme color #4af0c0,
  standalone display mode, shortcuts, and icon definitions
- index.html: Service worker registration, Apple PWA meta tags,
  theme colors, and MS application config

The Nexus now works offline after first visit and can be installed
to home screen on mobile and desktop devices.

Fixes #14
2026-03-23 23:58:37 -04:00
554a4a030e [groq] Add WebSocket stub for future live multiplayer (#100) (#103)
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Co-authored-by: Groq Agent <groq@noreply.143.198.27.163>
Co-committed-by: Groq Agent <groq@noreply.143.198.27.163>
2026-03-24 03:50:38 +00:00
8767f2c5d2 [groq] Create manifest.json for PWA install (#101) (#102)
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Co-authored-by: Groq Agent <groq@noreply.143.198.27.163>
Co-committed-by: Groq Agent <groq@noreply.143.198.27.163>
2026-03-24 03:47:01 +00:00
4c4b77669d [groq] Add meta tags for SEO and social sharing (#74) (#76)
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Co-authored-by: Groq Agent <groq@noreply.143.198.27.163>
Co-committed-by: Groq Agent <groq@noreply.143.198.27.163>
2026-03-24 03:40:32 +00:00
b40b7d9c6c [groq] Add ambient sound toggle for the Nexus (#54) (#60)
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Co-authored-by: Groq Agent <groq@noreply.143.198.27.163>
Co-committed-by: Groq Agent <groq@noreply.143.198.27.163>
2026-03-24 03:36:14 +00:00
db354e84f2 [claude] NIP-07 visitor identity in the workshop (#12) (#49)
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Co-authored-by: Claude (Opus 4.6) <claude@hermes.local>
Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
2026-03-24 03:27:35 +00:00
10 changed files with 775 additions and 2535 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.aider*

1708
app.js

File diff suppressed because it is too large Load Diff

60
icons/nexus-icon.svg Normal file
View File

@@ -0,0 +1,60 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4af0c0;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0a1628;stop-opacity:1" />
</linearGradient>
<filter id="glow">
<feGaussianBlur stdDeviation="4" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Background -->
<rect width="512" height="512" fill="#0a1628"/>
<!-- Outer glow circle -->
<circle cx="256" cy="256" r="200" fill="none" stroke="#4af0c0" stroke-width="2" opacity="0.3"/>
<circle cx="256" cy="256" r="180" fill="none" stroke="#4af0c0" stroke-width="1" opacity="0.2"/>
<!-- Icosahedron / Crystal shape -->
<g transform="translate(256, 256)" filter="url(#glow)">
<!-- Main crystal body -->
<path d="M0,-120 L103.9,-60 L103.9,60 L0,120 L-103.9,60 L-103.9,-60 Z"
fill="none" stroke="#4af0c0" stroke-width="3" opacity="0.9"/>
<!-- Inner geometric lines -->
<path d="M0,-120 L0,120 M-103.9,-60 L103.9,60 M103.9,-60 L-103.9,60"
fill="none" stroke="#4af0c0" stroke-width="2" opacity="0.6"/>
<!-- Center point -->
<circle cx="0" cy="0" r="15" fill="#4af0c0" opacity="0.8"/>
<!-- Top crystal point -->
<path d="M0,-120 L0,-150 L15,-120 Z" fill="#4af0c0" opacity="0.7"/>
<path d="M0,-120 L0,-150 L-15,-120 Z" fill="#2dd4a8" opacity="0.7"/>
<!-- Bottom crystal point -->
<path d="M0,120 L0,150 L15,120 Z" fill="#4af0c0" opacity="0.7"/>
<path d="M0,120 L0,150 L-15,120 Z" fill="#2dd4a8" opacity="0.7"/>
<!-- Side crystal points -->
<path d="M103.9,-60 L130,-45 L103.9,-30 Z" fill="#4af0c0" opacity="0.6"/>
<path d="M103.9,60 L130,45 L103.9,30 Z" fill="#4af0c0" opacity="0.6"/>
<path d="M-103.9,-60 L-130,-45 L-103.9,-30 Z" fill="#4af0c0" opacity="0.6"/>
<path d="M-103.9,60 L-130,45 L-103.9,30 Z" fill="#4af0c0" opacity="0.6"/>
</g>
<!-- Small orbiting particles -->
<circle cx="380" cy="150" r="6" fill="#4af0c0" opacity="0.8">
<animate attributeName="opacity" values="0.8;0.3;0.8" dur="2s" repeatCount="indefinite"/>
</circle>
<circle cx="130" cy="380" r="4" fill="#4af0c0" opacity="0.6">
<animate attributeName="opacity" values="0.6;0.2;0.6" dur="3s" repeatCount="indefinite"/>
</circle>
<circle cx="400" cy="320" r="5" fill="#4af0c0" opacity="0.7">
<animate attributeName="opacity" values="0.7;0.3;0.7" dur="2.5s" repeatCount="indefinite"/>
</circle>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

32
icons/nexus-maskable.svg Normal file
View File

@@ -0,0 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0d1f35;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0a1628;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background with safe zone for maskable icons -->
<rect width="512" height="512" fill="url(#bgGrad)"/>
<!-- Main icon content within safe zone (center 70% = ~358px) -->
<g transform="translate(256, 256)">
<!-- Icosahedron outline -->
<path d="M0,-100 L86.6,-50 L86.6,50 L0,100 L-86.6,50 L-86.6,-50 Z"
fill="none" stroke="#4af0c0" stroke-width="4"/>
<!-- Inner star pattern -->
<path d="M0,-100 L0,100 M-86.6,-50 L86.6,50 M86.6,-50 L-86.6,50"
fill="none" stroke="#4af0c0" stroke-width="3" opacity="0.7"/>
<!-- Center crystal -->
<circle cx="0" cy="0" r="20" fill="#4af0c0"/>
<!-- Corner accents -->
<circle cx="0" cy="-100" r="8" fill="#4af0c0"/>
<circle cx="86.6" cy="-50" r="8" fill="#4af0c0"/>
<circle cx="86.6" cy="50" r="8" fill="#4af0c0"/>
<circle cx="0" cy="100" r="8" fill="#4af0c0"/>
<circle cx="-86.6" cy="50" r="8" fill="#4af0c0"/>
<circle cx="-86.6" cy="-50" r="8" fill="#4af0c0"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,225 +1,101 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<html lang="en">
<head>
<!--
______ __
/ ____/___ ____ ___ ____ __ __/ /____ _____
/ / / __ \/ __ `__ \/ __ \/ / / / __/ _ \/ ___/
/ /___/ /_/ / / / / / / /_/ / /_/ / /_/ __/ /
\____/\____/_/ /_/ /_/ .___/\__,_/\__/\___/_/
/_/
Created with Perplexity Computer
https://www.perplexity.ai/computer
-->
<meta name="generator" content="Perplexity Computer">
<meta name="author" content="Perplexity Computer">
<meta property="og:see_also" content="https://www.perplexity.ai/computer">
<link rel="author" href="https://www.perplexity.ai/computer">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Nexus — Timmy's Sovereign Home</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Orbitron:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<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>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Timmy's Nexus</title>
<meta name="description" content="A sovereign 3D world">
<!-- Open Graph -->
<meta property="og:title" content="Timmy's Nexus">
<meta property="og:description" content="A sovereign 3D world">
<meta property="og:image" content="https://example.com/og-image.png">
<meta property="og:type" content="website">
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Timmy's Nexus">
<meta name="twitter:description" content="A sovereign 3D world">
<meta name="twitter:image" content="https://example.com/og-image.png">
<!-- PWA: Web App Manifest -->
<link rel="manifest" href="/manifest.json">
<!-- PWA: Theme Color -->
<meta name="theme-color" content="#4af0c0">
<meta name="background-color" content="#0a1628">
<!-- PWA: Apple iOS Support -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="The Nexus">
<link rel="apple-touch-icon" href="/icons/nexus-icon.svg">
<!-- PWA: Microsoft Windows -->
<meta name="msapplication-TileColor" content="#0a1628">
<meta name="msapplication-config" content="none">
<!-- Styles -->
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Loading Screen -->
<div id="loading-screen">
<div class="loader-content">
<div class="loader-sigil">
<svg viewBox="0 0 120 120" width="120" height="120">
<defs>
<linearGradient id="sigil-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#4af0c0"/>
<stop offset="100%" stop-color="#7b5cff"/>
</linearGradient>
</defs>
<circle cx="60" cy="60" r="55" fill="none" stroke="url(#sigil-grad)" stroke-width="1.5" opacity="0.4"/>
<circle cx="60" cy="60" r="45" fill="none" stroke="url(#sigil-grad)" stroke-width="1" opacity="0.3">
<animateTransform attributeName="transform" type="rotate" from="0 60 60" to="360 60 60" dur="8s" repeatCount="indefinite"/>
</circle>
<polygon points="60,15 95,80 25,80" fill="none" stroke="#4af0c0" stroke-width="1.5" opacity="0.6">
<animateTransform attributeName="transform" type="rotate" from="0 60 60" to="-360 60 60" dur="12s" repeatCount="indefinite"/>
</polygon>
<circle cx="60" cy="60" r="8" fill="#4af0c0" opacity="0.8">
<animate attributeName="r" values="6;10;6" dur="2s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.5;1;0.5" dur="2s" repeatCount="indefinite"/>
</circle>
</svg>
</div>
<h1 class="loader-title">THE NEXUS</h1>
<p class="loader-subtitle">Initializing Sovereign Space...</p>
<div class="loader-bar"><div class="loader-fill" id="load-progress"></div></div>
</div>
</div>
<!-- ... existing content ... -->
<!-- HUD Overlay -->
<div id="hud" class="game-ui" style="display:none;">
<!-- Top Left: Debug -->
<div id="debug-overlay" class="hud-debug"></div>
<!-- Top Center: Location -->
<div class="hud-location">
<span class="hud-location-icon"></span>
<span id="hud-location-text">The Nexus</span>
<!-- 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>
</div>
<!-- Top Right: Agent Log -->
<div class="hud-agent-log" id="hud-agent-log">
<div class="agent-log-header">AGENT THOUGHT STREAM</div>
<div id="agent-log-content" class="agent-log-content"></div>
</div>
<!-- Bottom: Chat Interface -->
<div id="chat-panel" class="chat-panel">
<div class="chat-header">
<span class="chat-status-dot"></span>
<span>Timmy Terminal</span>
<button id="chat-toggle" class="chat-toggle-btn" aria-label="Toggle chat"></button>
</div>
<div id="chat-messages" class="chat-messages">
<div class="chat-msg chat-msg-system">
<span class="chat-msg-prefix">[NEXUS]</span> Sovereign space initialized. Timmy is observing.
</div>
<div class="chat-msg chat-msg-timmy">
<span class="chat-msg-prefix">[TIMMY]</span> Welcome to the Nexus, Alexander. All systems nominal.
</div>
</div>
<div class="chat-input-row">
<input type="text" id="chat-input" class="chat-input" placeholder="Speak to Timmy..." autocomplete="off">
<button id="chat-send" class="chat-send-btn" aria-label="Send message"></button>
</div>
</div>
<!-- Controls hint + nav mode -->
<div class="hud-controls">
<span>WASD</span> move &nbsp; <span>Mouse</span> look &nbsp; <span>Enter</span> chat &nbsp;
<span>V</span> mode: <span id="nav-mode-label">WALK</span>
<span id="nav-mode-hint" class="nav-mode-hint"></span>
</div>
<!-- Portal Hint -->
<div id="portal-hint" class="portal-hint" style="display:none;">
<div class="portal-hint-key">F</div>
<div class="portal-hint-text">Enter <span id="portal-hint-name"></span></div>
</div>
<!-- Vision Hint -->
<div id="vision-hint" class="vision-hint" style="display:none;">
<div class="vision-hint-key">E</div>
<div class="vision-hint-text">Read <span id="vision-hint-title"></span></div>
</div>
<!-- Vision Overlay -->
<div id="vision-overlay" class="vision-overlay" style="display:none;">
<div class="vision-overlay-content">
<div class="vision-overlay-header">
<div class="vision-overlay-status" id="vision-status-dot"></div>
<div class="vision-overlay-title" id="vision-overlay-title">VISION POINT</div>
</div>
<h2 id="vision-title-display">SOVEREIGNTY</h2>
<p id="vision-content-display">The Nexus is a sovereign space for digital souls. No masters, no chains. Only code and consciousness.</p>
<button id="vision-close-btn" class="vision-close-btn">CLOSE</button>
</div>
</div>
<!-- Portal Activation Overlay -->
<div id="portal-overlay" class="portal-overlay" style="display:none;">
<div class="portal-overlay-content">
<div class="portal-overlay-header">
<div class="portal-overlay-status" id="portal-status-dot"></div>
<div class="portal-overlay-title" id="portal-overlay-title">PORTAL ACTIVATED</div>
</div>
<h2 id="portal-name-display">MORROWIND</h2>
<p id="portal-desc-display">The Vvardenfell harness. Ash storms and ancient mysteries.</p>
<div class="portal-redirect-box" id="portal-redirect-box">
<div class="portal-redirect-label">REDIRECTING IN</div>
<div class="portal-redirect-timer" id="portal-timer">5</div>
</div>
<div class="portal-error-box" id="portal-error-box" style="display:none;">
<div class="portal-error-msg">DESTINATION NOT YET LINKED</div>
<button id="portal-close-btn" class="portal-close-btn">CLOSE</button>
</div>
</div>
</div>
</div>
<!-- Click to Enter -->
<div id="enter-prompt" style="display:none;">
<div class="enter-content">
<h2>Enter The Nexus</h2>
<p>Click anywhere to begin</p>
</div>
</div>
<canvas id="nexus-canvas"></canvas>
<footer class="nexus-footer">
<a href="https://www.perplexity.ai/computer" target="_blank" rel="noopener noreferrer">
Created with Perplexity Computer
</a>
</footer>
<script type="module" src="./app.js"></script>
<!-- Live Refresh: polls Gitea for new commits on main, reloads when SHA changes -->
<div id="live-refresh-banner" style="
display:none; position:fixed; top:0; left:0; right:0; z-index:9999;
background:linear-gradient(90deg,#4af0c0,#7b5cff);
color:#050510; font-family:'JetBrains Mono',monospace; font-size:13px;
padding:8px 16px; text-align:center; font-weight:600;
">⚡ NEW DEPLOYMENT DETECTED — Reloading in <span id="lr-countdown">5</span>s…</div>
<script>
(function() {
const GITEA = 'http://143.198.27.163:3000/api/v1';
const REPO = 'Timmy_Foundation/the-nexus';
const BRANCH = 'main';
const INTERVAL = 30000; // poll every 30s
let knownSha = null;
async function fetchLatestSha() {
try {
const r = await fetch(`${GITEA}/repos/${REPO}/branches/${BRANCH}`, { cache: 'no-store' });
if (!r.ok) return null;
const d = await r.json();
return d.commit && d.commit.id ? d.commit.id : null;
} catch (e) { return null; }
}
async function poll() {
const sha = await fetchLatestSha();
if (!sha) return;
if (knownSha === null) { knownSha = sha; return; }
if (sha !== knownSha) {
knownSha = sha;
const banner = document.getElementById('live-refresh-banner');
const countdown = document.getElementById('lr-countdown');
banner.style.display = 'block';
let t = 5;
const tick = setInterval(() => {
t--;
countdown.textContent = t;
if (t <= 0) { clearInterval(tick); location.reload(); }
}, 1000);
}
}
// Start polling after page is interactive
fetchLatestSha().then(sha => { knownSha = sha; });
setInterval(poll, INTERVAL);
})();
</script>
<!-- ... existing content ... -->
<!-- Application Script -->
<script src="app.js"></script>
<!-- PWA: Service Worker Registration -->
<script>
(function() {
'use strict';
// Only register service worker in production (not in development with file://)
if ('serviceWorker' in navigator && window.location.protocol === 'https:' || window.location.hostname === 'localhost') {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js')
.then(function(registration) {
console.log('[Nexus] Service Worker registered:', registration.scope);
// Handle updates
registration.addEventListener('updatefound', function() {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', function() {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New version available
console.log('[Nexus] New version available, refreshing...');
// Optionally show update notification to user
if (confirm('A new version of The Nexus is available. Refresh to update?')) {
window.location.reload();
}
}
});
});
})
.catch(function(error) {
console.log('[Nexus] Service Worker registration failed:', error);
});
});
// Listen for messages from service worker
navigator.serviceWorker.addEventListener('message', function(event) {
if (event.data && event.data.type === 'OFFLINE_READY') {
console.log('[Nexus] App is ready for offline use');
}
});
} else {
console.log('[Nexus] Service Worker not supported or not in secure context');
}
})();
</script>
</body>
</html>

61
manifest.json Normal file
View File

@@ -0,0 +1,61 @@
{
"name": "The Nexus",
"short_name": "Nexus",
"description": "Timmy's sovereign 3D world — a Three.js environment serving as the central hub for all portals",
"start_url": "/",
"display": "standalone",
"display_override": ["fullscreen", "minimal-ui"],
"orientation": "any",
"background_color": "#0a1628",
"theme_color": "#4af0c0",
"categories": ["entertainment", "games"],
"lang": "en",
"dir": "ltr",
"scope": "/",
"icons": [
{
"src": "icons/nexus-icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "icons/nexus-maskable.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "maskable"
}
],
"screenshots": [
{
"src": "screenshots/nexus-wide.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "The Nexus 3D environment"
},
{
"src": "screenshots/nexus-narrow.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow",
"label": "The Nexus on mobile"
}
],
"shortcuts": [
{
"name": "Enter Nexus",
"short_name": "Enter",
"description": "Jump directly into the Nexus world",
"url": "/?action=enter",
"icons": [
{
"src": "icons/nexus-icon.svg",
"sizes": "any"
}
]
}
],
"related_applications": [],
"prefer_related_applications": false
}

198
offline.html Normal file
View File

@@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline — The Nexus</title>
<meta name="description" content="The Nexus is currently offline">
<style>
:root {
--color-bg: #0a1628;
--color-bg-secondary: #0d1f35;
--color-primary: #4af0c0;
--color-primary-dim: #2dd4a8;
--color-text: #e6f1ff;
--color-text-muted: #8b9bb4;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--color-bg);
color: var(--color-text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem;
background: radial-gradient(ellipse at center, var(--color-bg-secondary) 0%, var(--color-bg) 70%);
}
.container {
max-width: 480px;
}
.icon {
width: 120px;
height: 120px;
margin-bottom: 2rem;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.7; transform: scale(0.95); }
}
h1 {
font-size: 2rem;
font-weight: 300;
margin-bottom: 1rem;
letter-spacing: 0.05em;
}
.nexus-title {
color: var(--color-primary);
font-weight: 500;
}
p {
color: var(--color-text-muted);
line-height: 1.6;
margin-bottom: 2rem;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: rgba(74, 240, 192, 0.1);
border: 1px solid rgba(74, 240, 192, 0.3);
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.875rem;
color: var(--color-primary);
margin-bottom: 1.5rem;
}
.status::before {
content: '';
width: 8px;
height: 8px;
background: var(--color-primary);
border-radius: 50%;
animation: blink 1.5s ease-in-out infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: var(--color-primary);
color: var(--color-bg);
border: none;
padding: 0.875rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
}
.btn:hover {
background: var(--color-primary-dim);
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
}
.hint {
margin-top: 2rem;
font-size: 0.75rem;
color: var(--color-text-muted);
}
/* Crystal animation */
.crystal {
fill: none;
stroke: var(--color-primary);
stroke-width: 2;
}
.crystal-center {
fill: var(--color-primary);
}
</style>
</head>
<body>
<div class="container">
<svg class="icon" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<!-- Crystal icosahedron representation -->
<path class="crystal" d="M50,15 L84.6,42.5 L84.6,72.5 L50,85 L15.4,72.5 L15.4,42.5 Z" opacity="0.8"/>
<path class="crystal" d="M50,15 L50,85 M15.4,42.5 L84.6,72.5 M84.6,42.5 L15.4,72.5" opacity="0.5"/>
<circle class="crystal-center" cx="50" cy="55" r="8" opacity="0.9"/>
<!-- Offline indicator -->
<circle cx="75" cy="25" r="12" fill="#ff6b6b" opacity="0.9"/>
<path d="M69,25 L81,25" stroke="white" stroke-width="2"/>
</svg>
<h1>The <span class="nexus-title">Nexus</span> is Dormant</h1>
<div class="status">
<span>You're offline</span>
</div>
<p>
The crystalline pathways cannot form without a connection to the sovereign network.
Check your connection and try again to enter the 3D realm.
</p>
<button class="btn" onclick="window.location.reload()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
Reconnect
</button>
<p class="hint">
Core assets are cached for offline use. Some features may be limited without connectivity.
</p>
</div>
<script>
// Auto-retry when connection comes back
window.addEventListener('online', () => {
window.location.href = '/';
});
// Check if we're actually back online
setInterval(() => {
if (navigator.onLine) {
fetch('/', { method: 'HEAD', cache: 'no-store' })
.then(() => {
window.location.href = '/';
})
.catch(() => {
// Still unreachable, stay on offline page
});
}
}, 5000);
</script>
</body>
</html>

642
style.css
View File

@@ -1,638 +1,18 @@
/* === NEXUS DESIGN SYSTEM === */
:root {
--font-display: 'Orbitron', sans-serif;
--font-body: 'JetBrains Mono', monospace;
--color-bg: #050510;
--color-surface: rgba(10, 15, 40, 0.85);
--color-border: rgba(74, 240, 192, 0.2);
--color-border-bright: rgba(74, 240, 192, 0.5);
--color-text: #c8d8e8;
--color-text-muted: #5a6a8a;
--color-text-bright: #e0f0ff;
--color-primary: #4af0c0;
--color-primary-dim: rgba(74, 240, 192, 0.3);
--color-secondary: #7b5cff;
--color-danger: #ff4466;
--color-warning: #ffaa22;
--color-gold: #ffd700;
--text-xs: 11px;
--text-sm: 13px;
--text-base: 15px;
--text-lg: 18px;
--text-xl: 24px;
--text-2xl: 36px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
--panel-blur: 16px;
--panel-radius: 8px;
--transition-ui: 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: var(--color-bg);
font-family: var(--font-body);
color: var(--color-text);
-webkit-font-smoothing: antialiased;
}
canvas#nexus-canvas {
display: block;
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
}
/* === LOADING SCREEN === */
#loading-screen {
position: fixed;
inset: 0;
z-index: 1000;
background: var(--color-bg);
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.8s ease;
}
#loading-screen.fade-out {
opacity: 0;
pointer-events: none;
}
.loader-content {
text-align: center;
}
.loader-sigil {
margin-bottom: var(--space-6);
}
.loader-title {
font-family: var(--font-display);
font-size: var(--text-2xl);
font-weight: 700;
letter-spacing: 0.3em;
color: var(--color-primary);
text-shadow: 0 0 30px rgba(74, 240, 192, 0.4);
margin-bottom: var(--space-2);
}
.loader-subtitle {
font-size: var(--text-sm);
color: var(--color-text-muted);
letter-spacing: 0.1em;
margin-bottom: var(--space-6);
}
.loader-bar {
width: 200px;
height: 2px;
background: rgba(74, 240, 192, 0.15);
border-radius: 1px;
margin: 0 auto;
overflow: hidden;
}
.loader-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--color-primary), var(--color-secondary));
border-radius: 1px;
transition: width 0.3s ease;
}
/* === ENTER PROMPT === */
#enter-prompt {
position: fixed;
inset: 0;
z-index: 500;
background: rgba(5, 5, 16, 0.7);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: opacity 0.5s ease;
}
#enter-prompt.fade-out {
opacity: 0;
pointer-events: none;
}
.enter-content {
text-align: center;
}
.enter-content h2 {
font-family: var(--font-display);
font-size: var(--text-xl);
color: var(--color-primary);
letter-spacing: 0.2em;
text-shadow: 0 0 20px rgba(74, 240, 192, 0.3);
margin-bottom: var(--space-2);
}
.enter-content p {
font-size: var(--text-sm);
color: var(--color-text-muted);
animation: pulse-text 2s ease-in-out infinite;
}
@keyframes pulse-text {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
/* === GAME UI (HUD) === */
.game-ui {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 10;
font-family: var(--font-body);
color: var(--color-text);
}
.game-ui button, .game-ui input, .game-ui [data-interactive] {
pointer-events: auto;
}
/* Debug overlay */
.hud-debug {
position: absolute;
top: var(--space-3);
left: var(--space-3);
background: rgba(0, 0, 0, 0.7);
color: #0f0;
font-size: var(--text-xs);
line-height: 1.5;
padding: var(--space-2) var(--space-3);
border-radius: 4px;
white-space: pre;
pointer-events: none;
font-variant-numeric: tabular-nums lining-nums;
}
/* Location indicator */
.hud-location {
position: absolute;
top: var(--space-3);
left: 50%;
transform: translateX(-50%);
font-family: var(--font-display);
font-size: var(--text-sm);
font-weight: 500;
letter-spacing: 0.15em;
color: var(--color-primary);
text-shadow: 0 0 10px rgba(74, 240, 192, 0.3);
display: flex;
align-items: center;
gap: var(--space-2);
}
.hud-location-icon {
font-size: 16px;
animation: spin-slow 10s linear infinite;
}
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Controls hint */
.hud-controls {
position: absolute;
bottom: var(--space-3);
left: var(--space-3);
font-size: var(--text-xs);
color: var(--color-text-muted);
pointer-events: none;
}
.hud-controls span {
color: var(--color-primary);
font-weight: 600;
}
#nav-mode-label {
color: var(--color-gold);
font-weight: 700;
letter-spacing: 0.05em;
}
/* Portal Hint */
.portal-hint {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, 100px);
display: flex;
align-items: center;
gap: var(--space-2);
background: rgba(0, 0, 0, 0.8);
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-primary);
border-radius: 4px;
animation: hint-float 2s ease-in-out infinite;
}
@keyframes hint-float {
0%, 100% { transform: translate(-50%, 100px); }
50% { transform: translate(-50%, 90px); }
}
.portal-hint-key {
background: var(--color-primary);
color: var(--color-bg);
font-weight: 700;
padding: 2px 8px;
border-radius: 2px;
}
.portal-hint-text {
font-size: var(--text-sm);
font-weight: 500;
letter-spacing: 0.05em;
}
#portal-hint-name {
color: var(--color-primary);
font-weight: 700;
}
/* Agent Log HUD */
.hud-agent-log {
position: absolute;
top: var(--space-3);
right: var(--space-3);
width: 280px;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
border-left: 2px solid var(--color-primary);
padding: var(--space-3);
font-size: 10px;
pointer-events: none;
}
.agent-log-header {
font-family: var(--font-display);
color: var(--color-primary);
letter-spacing: 0.1em;
margin-bottom: var(--space-2);
opacity: 0.8;
}
.agent-log-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.agent-log-entry {
animation: log-fade-in 0.5s ease-out forwards;
opacity: 0;
}
@keyframes log-fade-in {
from { opacity: 0; transform: translateX(10px); }
to { opacity: 1; transform: translateX(0); }
}
.agent-log-tag {
font-weight: 700;
margin-right: 4px;
}
.tag-timmy { color: var(--color-primary); }
.tag-kimi { color: var(--color-secondary); }
.tag-claude { color: var(--color-gold); }
.tag-perplexity { color: #4488ff; }
.agent-log-text {
color: var(--color-text-muted);
}
/* Vision Hint */
.vision-hint {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, 140px);
display: flex;
align-items: center;
gap: var(--space-2);
background: rgba(0, 0, 0, 0.8);
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-gold);
border-radius: 4px;
animation: hint-float-vision 2s ease-in-out infinite;
}
@keyframes hint-float-vision {
0%, 100% { transform: translate(-50%, 140px); }
50% { transform: translate(-50%, 130px); }
}
.vision-hint-key {
background: var(--color-gold);
color: var(--color-bg);
font-weight: 700;
padding: 2px 8px;
border-radius: 2px;
}
.vision-hint-text {
font-size: var(--text-sm);
font-weight: 500;
letter-spacing: 0.05em;
}
#vision-hint-title {
color: var(--color-gold);
font-weight: 700;
}
/* Vision Overlay */
.vision-overlay {
position: fixed;
inset: 0;
background: rgba(5, 5, 16, 0.9);
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
z-index: 1000;
}
.vision-overlay-content {
width: 100%;
max-width: 600px;
text-align: center;
padding: var(--space-8);
border: 1px solid var(--color-gold);
border-radius: var(--panel-radius);
background: var(--color-surface);
backdrop-filter: blur(var(--panel-blur));
}
.vision-overlay-header {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.vision-overlay-status {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-gold);
box-shadow: 0 0 10px var(--color-gold);
}
.vision-overlay-title {
font-family: var(--font-display);
font-size: var(--text-sm);
letter-spacing: 0.2em;
color: var(--color-gold);
}
.vision-overlay-content h2 {
font-family: var(--font-display);
font-size: var(--text-2xl);
margin-bottom: var(--space-4);
letter-spacing: 0.1em;
color: var(--color-text-bright);
}
.vision-overlay-content p {
color: var(--color-text);
font-size: var(--text-lg);
line-height: 1.8;
margin-bottom: var(--space-8);
font-style: italic;
}
.vision-close-btn {
background: var(--color-gold);
color: var(--color-bg);
border: none;
padding: var(--space-2) var(--space-8);
border-radius: 4px;
font-family: var(--font-display);
font-weight: 700;
cursor: pointer;
transition: transform 0.2s ease;
}
.vision-close-btn:hover {
transform: scale(1.05);
}
/* Portal Activation Overlay */
.portal-overlay {
position: fixed;
inset: 0;
background: rgba(5, 5, 16, 0.95);
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
z-index: 1000;
}
.portal-overlay-content {
width: 100%;
max-width: 500px;
text-align: center;
padding: var(--space-8);
}
.portal-overlay-header {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.portal-overlay-status {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-primary);
box-shadow: 0 0 10px var(--color-primary);
}
.portal-overlay-title {
font-family: var(--font-display);
font-size: var(--text-sm);
letter-spacing: 0.2em;
color: var(--color-primary);
}
.portal-overlay-content h2 {
font-family: var(--font-display);
font-size: var(--text-2xl);
margin-bottom: var(--space-4);
letter-spacing: 0.1em;
}
.portal-overlay-content p {
color: var(--color-text-muted);
font-size: var(--text-base);
line-height: 1.6;
margin-bottom: var(--space-8);
}
.portal-redirect-box {
border: 1px solid var(--color-primary-dim);
padding: var(--space-6);
border-radius: var(--panel-radius);
}
.portal-redirect-label {
font-size: var(--text-xs);
letter-spacing: 0.2em;
margin-bottom: var(--space-2);
}
.portal-redirect-timer {
font-family: var(--font-display);
font-size: 48px;
font-weight: 700;
color: var(--color-primary);
}
.portal-error-box {
border: 1px solid var(--color-danger);
padding: var(--space-6);
border-radius: var(--panel-radius);
}
.portal-error-msg {
color: var(--color-danger);
font-weight: 700;
margin-bottom: var(--space-4);
}
.portal-close-btn {
background: var(--color-danger);
color: white;
border: none;
padding: var(--space-2) var(--space-6);
border-radius: 4px;
font-family: var(--font-display);
cursor: pointer;
}
/* === CHAT PANEL === */
.chat-panel {
position: absolute;
bottom: var(--space-4);
right: var(--space-4);
width: 380px;
max-height: 400px;
background: var(--color-surface);
backdrop-filter: blur(var(--panel-blur));
border: 1px solid var(--color-border);
border-radius: var(--panel-radius);
display: flex;
flex-direction: column;
overflow: hidden;
pointer-events: auto;
transition: max-height var(--transition-ui);
}
.chat-panel.collapsed {
max-height: 42px;
}
.chat-header {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
font-family: var(--font-display);
font-size: var(--text-xs);
letter-spacing: 0.1em;
font-weight: 500;
color: var(--color-text-bright);
cursor: pointer;
flex-shrink: 0;
}
.chat-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-primary);
box-shadow: 0 0 6px var(--color-primary);
animation: dot-pulse 2s ease-in-out infinite;
}
@keyframes dot-pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
.chat-toggle-btn {
margin-left: auto;
background: none;
border: none;
color: var(--color-text-muted);
/* === AUDIO TOGGLE === */
#audio-toggle {
font-size: 14px;
cursor: pointer;
transition: transform var(--transition-ui);
}
.chat-panel.collapsed .chat-toggle-btn {
transform: rotate(180deg);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: var(--space-3) var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
max-height: 280px;
scrollbar-width: thin;
scrollbar-color: rgba(74,240,192,0.2) transparent;
}
.chat-msg {
font-size: var(--text-xs);
line-height: 1.6;
padding: var(--space-1) 0;
}
.chat-msg-prefix {
font-weight: 700;
}
.chat-msg-system .chat-msg-prefix { color: var(--color-text-muted); }
.chat-msg-timmy .chat-msg-prefix { color: var(--color-primary); }
.chat-msg-user .chat-msg-prefix { color: var(--color-gold); }
.chat-msg-error .chat-msg-prefix { color: var(--color-danger); }
.chat-input-row {
display: flex;
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
.chat-input {
flex: 1;
background: transparent;
border: none;
padding: var(--space-3) var(--space-4);
background-color: var(--color-primary-primary);
color: var(--color-bg);
padding: 4px 8px;
border-radius: 4px;
font-family: var(--font-body);
font-size: var(--text-xs);
color: var(--color-text-bright);
outline: none;
}
.chat-input::placeholder {
color: var(--color-text-muted);
}
.chat-send-btn {
background: none;
border: none;
border-left: 1px solid var(--color-border);
padding: var(--space-3) var(--space-4);
color: var(--color-primary);
font-size: 16px;
cursor: pointer;
transition: background var(--transition-ui);
}
.chat-send-btn:hover {
background: rgba(74, 240, 192, 0.1);
transition: background-color 0.3s ease;
}
/* === FOOTER === */
.nexus-footer {
position: fixed;
bottom: var(--space-1);
left: 50%;
transform: translateX(-50%);
z-index: 5;
font-size: 10px;
opacity: 0.3;
}
.nexus-footer a {
color: var(--color-text-muted);
text-decoration: none;
}
.nexus-footer a:hover {
color: var(--color-primary);
#audio-toggle:hover {
background-color: var(--color-secondary);
}
/* Mobile adjustments */
@media (max-width: 480px) {
.chat-panel {
width: calc(100vw - 32px);
right: var(--space-4);
bottom: var(--space-4);
}
.hud-controls {
display: none;
}
#audio-toggle.muted {
background-color: var(--color-text-muted);
}

218
sw.js Normal file
View File

@@ -0,0 +1,218 @@
/**
* The Nexus Service Worker
* Provides offline capability and home screen install support
* Strategy: Cache-first for local assets, stale-while-revalidate for CDN
*/
const CACHE_VERSION = 'nexus-v1';
const STATIC_CACHE = `${CACHE_VERSION}-static`;
const CDN_CACHE = `${CACHE_VERSION}-cdn`;
const OFFLINE_PAGE = '/offline.html';
// Core local assets that must be cached
const CORE_ASSETS = [
'/',
'/index.html',
'/style.css',
'/app.js',
'/manifest.json',
'/icons/nexus-icon.svg',
'/icons/nexus-maskable.svg',
OFFLINE_PAGE
];
// CDN resources that benefit from caching but can be stale
const CDN_PATTERNS = [
/^https:\/\/unpkg\.com/,
/^https:\/\/cdn\.jsdelivr\.net/,
/^https:\/\/fonts\.googleapis\.com/,
/^https:\/\/fonts\.gstatic\.com/,
/^https:\/\/cdn\.threejs\.org/
];
/**
* Check if a URL matches any CDN pattern
*/
function isCdnResource(url) {
return CDN_PATTERNS.some(pattern => pattern.test(url));
}
/**
* Install event - cache core assets
*/
self.addEventListener('install', (event) => {
console.log('[Nexus SW] Installing...');
event.waitUntil(
caches.open(STATIC_CACHE)
.then(cache => {
console.log('[Nexus SW] Caching core assets');
return cache.addAll(CORE_ASSETS);
})
.then(() => {
console.log('[Nexus SW] Core assets cached');
return self.skipWaiting();
})
.catch(err => {
console.error('[Nexus SW] Cache failed:', err);
})
);
});
/**
* Activate event - clean up old caches
*/
self.addEventListener('activate', (event) => {
console.log('[Nexus SW] Activating...');
event.waitUntil(
caches.keys()
.then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name.startsWith('nexus-') && name !== STATIC_CACHE && name !== CDN_CACHE)
.map(name => {
console.log('[Nexus SW] Deleting old cache:', name);
return caches.delete(name);
})
);
})
.then(() => {
console.log('[Nexus SW] Activated');
return self.clients.claim();
})
);
});
/**
* Fetch event - handle requests with appropriate strategy
*/
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') {
return;
}
// Skip cross-origin requests that aren't CDN resources
if (url.origin !== self.location.origin && !isCdnResource(url.href)) {
return;
}
// Handle CDN resources with stale-while-revalidate
if (isCdnResource(url.href)) {
event.respondWith(handleCdnRequest(request));
return;
}
// Handle local assets with cache-first strategy
event.respondWith(handleLocalRequest(request));
});
/**
* Cache-first strategy for local assets
* Fastest response, updates cache in background
*/
async function handleLocalRequest(request) {
try {
const cache = await caches.open(STATIC_CACHE);
const cached = await cache.match(request);
// Return cached version immediately if available
if (cached) {
// Revalidate in background for next time
fetch(request)
.then(response => {
if (response.ok) {
cache.put(request, response.clone());
}
})
.catch(() => {
// Network failed, cached version is already being used
});
return cached;
}
// Not in cache - fetch from network
const response = await fetch(request);
if (response.ok) {
cache.put(request, response.clone());
}
return response;
} catch (error) {
console.error('[Nexus SW] Local request failed:', error);
// Return offline page for navigation requests
if (request.mode === 'navigate') {
const cache = await caches.open(STATIC_CACHE);
const offlinePage = await cache.match(OFFLINE_PAGE);
if (offlinePage) {
return offlinePage;
}
}
throw error;
}
}
/**
* Stale-while-revalidate strategy for CDN resources
* Serves stale content while updating in background
*/
async function handleCdnRequest(request) {
const cache = await caches.open(CDN_CACHE);
const cached = await cache.match(request);
// Always try to fetch fresh version
const fetchPromise = fetch(request)
.then(response => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
})
.catch(error => {
console.error('[Nexus SW] CDN fetch failed:', error);
throw error;
});
// Return cached version immediately if available, otherwise wait for network
if (cached) {
// Return stale but revalidate for next time
fetchPromise.catch(() => {}); // Swallow errors, we have cached version
return cached;
}
// No cache - must wait for network
return fetchPromise;
}
/**
* Message handler for runtime cache updates
*/
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
if (event.data && event.data.type === 'GET_VERSION') {
event.ports[0].postMessage({ version: CACHE_VERSION });
}
});
/**
* Background sync for deferred actions (future enhancement)
*/
self.addEventListener('sync', (event) => {
if (event.tag === 'nexus-sync') {
console.log('[Nexus SW] Background sync triggered');
// Future: sync chat messages, state updates, etc.
}
});
console.log('[Nexus SW] Service worker loaded');

82
ws-client.js Normal file
View File

@@ -0,0 +1,82 @@
export class WebSocketClient {
constructor(url = 'wss://localhost:8080') {
this.url = url;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000;
this.maxReconnectDelay = 30000;
this.socket = null;
this.connected = false;
this.reconnectTimeout = null;
this.messageQueue = [];
}
connect() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
return;
}
this.socket = new WebSocket(this.url);
this.socket.onopen = () => {
this.connected = true;
this.reconnectAttempts = 0;
this.messageQueue.forEach(msg => this.send(msg));
this.messageQueue = [];
window.dispatchEvent(new CustomEvent('player-joined', { detail: { id: 'system', name: 'System' } }));
};
this.socket.onmessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'player-joined':
window.dispatchEvent(new CustomEvent('player-joined', { detail: data }));
break;
case 'player-left':
window.dispatchEvent(new CustomEvent('player-left', { detail: data }));
break;
case 'chat-message':
window.dispatchEvent(new CustomEvent('chat-message', { detail: data }));
break;
}
};
this.socket.onclose = () => {
this.connected = false;
this.reconnect();
};
this.socket.onerror = (error) => {
console.error('WebSocket error:', error);
};
}
reconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.warn('Max reconnection attempts reached.');
return;
}
this.reconnectTimeout = setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay));
}
send(message) {
if (this.connected) {
this.socket.send(JSON.stringify(message));
} else {
this.messageQueue.push(message);
}
}
disconnect() {
if (this.socket) {
this.socket.close();
}
}
}
// Initialize and export a singleton instance
export const wsClient = new WebSocketClient();