diff --git a/world/index.html b/world/index.html index c708fe8..460d09a 100644 --- a/world/index.html +++ b/world/index.html @@ -19,8 +19,57 @@ color: #6a6050; } .placeholder h1 { font-size: 1.2rem; font-weight: normal; margin-bottom: 1rem; } - .placeholder p { font-size: 0.85rem; } + .placeholder p { font-size: 0.85rem; margin-bottom: 0.5rem; } .placeholder a { color: #8a7f6a; } + + /* Connection status HUD */ + #status-hud { + position: fixed; + top: 12px; + right: 12px; + background: rgba(10, 10, 15, 0.85); + border: 1px solid #2a2520; + border-radius: 6px; + padding: 8px 14px; + font-family: 'Courier New', monospace; + font-size: 0.75rem; + color: #6a6050; + z-index: 100; + min-width: 180px; + } + #status-hud .status-line { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; + } + #status-hud .status-line:last-child { margin-bottom: 0; } + #status-hud .dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + } + #status-hud .dot.connecting { background: #b8860b; animation: pulse 1.2s ease-in-out infinite; } + #status-hud .dot.online { background: #4a9; } + #status-hud .dot.offline { background: #a44; } + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } + } + #retry-btn { + display: none; + margin-top: 6px; + padding: 3px 10px; + background: #2a2520; + border: 1px solid #4a4030; + border-radius: 3px; + color: #8a7f6a; + font-family: 'Courier New', monospace; + font-size: 0.7rem; + cursor: pointer; + } + #retry-btn:hover { background: #3a3530; color: #c0b8a8; } @@ -31,6 +80,16 @@

← Back to the Tower

+ +
+
+ + INITIALIZING +
+
AGENTS: 0
+ +
+ - + diff --git a/world/main.js b/world/main.js index ae518fd..f63cfd3 100644 --- a/world/main.js +++ b/world/main.js @@ -1,20 +1,124 @@ /** * The Workshop — Three.js scene bootstrap * - * This file will initialize the 3D world where Timmy lives. - * Currently a placeholder until tech decisions are made: - * - 3D engine confirmed (Three.js vs Babylon.js) - * - Character design direction chosen - * - WebSocket bridge to Timmy's soul designed (#243) + * Initializes the 3D world where Timmy lives. + * Handles WebSocket connection to tower-hermes backend with + * timeout, retry, and clear status display. * * See: #242 (3D world), #243 (WebSocket bridge), #265 (presence schema) */ // Future: import * as THREE from 'three'; -export function initWorkshop(container) { - // TODO: Initialize 3D scene - // TODO: Load wizard character model - // TODO: Connect to Timmy presence WebSocket - console.log('[Workshop] Scene container ready:', container.id); +const HERMES_WS_URL = (location.protocol === 'https:' ? 'wss://' : 'ws://') + + location.host + '/ws/tower'; +const CONNECT_TIMEOUT_MS = 5000; +const RETRY_DELAY_MS = 3000; +const MAX_AUTO_RETRIES = 3; + +const Status = { CONNECTING: 'connecting', ONLINE: 'online', OFFLINE: 'offline' }; + +const dom = { + dot: document.getElementById('status-dot'), + text: document.getElementById('status-text'), + agents: document.getElementById('agent-count'), + retryBtn: document.getElementById('retry-btn'), +}; + +let ws = null; +let autoRetries = 0; +let connectTimer = null; + +function setStatus(state, message) { + dom.dot.className = 'dot ' + state; + dom.text.textContent = message; + dom.retryBtn.style.display = state === Status.OFFLINE ? 'block' : 'none'; } + +function setAgentCount(n) { + dom.agents.textContent = n; +} + +function cleanup() { + clearTimeout(connectTimer); + if (ws) { + ws.onopen = null; + ws.onclose = null; + ws.onerror = null; + ws.onmessage = null; + if (ws.readyState <= WebSocket.OPEN) ws.close(); + ws = null; + } +} + +function connect() { + cleanup(); + setStatus(Status.CONNECTING, 'CONNECTING\u2026'); + setAgentCount(0); + + try { + ws = new WebSocket(HERMES_WS_URL); + } catch (err) { + console.error('[Workshop] WebSocket creation failed:', err); + onFail(); + return; + } + + connectTimer = setTimeout(function () { + console.warn('[Workshop] Connection timeout after ' + CONNECT_TIMEOUT_MS + 'ms'); + cleanup(); + onFail(); + }, CONNECT_TIMEOUT_MS); + + ws.onopen = function () { + clearTimeout(connectTimer); + autoRetries = 0; + setStatus(Status.ONLINE, 'ONLINE'); + console.log('[Workshop] Connected to tower-hermes'); + }; + + ws.onmessage = function (evt) { + try { + var msg = JSON.parse(evt.data); + if (typeof msg.agents === 'number') setAgentCount(msg.agents); + } catch (_) { + // non-JSON messages are ignored + } + }; + + ws.onclose = function () { + clearTimeout(connectTimer); + console.log('[Workshop] Connection closed'); + onFail(); + }; + + ws.onerror = function () { + clearTimeout(connectTimer); + console.error('[Workshop] WebSocket error'); + // onclose will fire after this, which calls onFail + }; +} + +function onFail() { + if (autoRetries < MAX_AUTO_RETRIES) { + autoRetries++; + setStatus(Status.CONNECTING, 'RETRYING (' + autoRetries + '/' + MAX_AUTO_RETRIES + ')\u2026'); + setTimeout(connect, RETRY_DELAY_MS); + } else { + setStatus(Status.OFFLINE, 'OFFLINE \u2014 backend unreachable'); + } +} + +// Manual retry resets the counter +dom.retryBtn.addEventListener('click', function () { + autoRetries = 0; + connect(); +}); + +// Boot +export function initWorkshop(container) { + console.log('[Workshop] Scene container ready:', container.id); + connect(); +} + +initWorkshop(document.getElementById('scene'));