diff --git a/icons/icon-192.svg b/icons/icon-192.svg new file mode 100644 index 0000000..ef04043 --- /dev/null +++ b/icons/icon-192.svg @@ -0,0 +1,4 @@ + + + M + \ No newline at end of file diff --git a/icons/icon-512.svg b/icons/icon-512.svg new file mode 100644 index 0000000..47bcec0 --- /dev/null +++ b/icons/icon-512.svg @@ -0,0 +1,4 @@ + + + M + \ No newline at end of file diff --git a/index.html b/index.html index 9e3624f..862a9d4 100644 --- a/index.html +++ b/index.html @@ -2,12 +2,34 @@ - + + + + + + + + + Timmy Tower World +
INITIALIZING...

TIMMY TOWER WORLD

@@ -56,5 +87,11 @@
OFFLINE
+ diff --git a/js/effects.js b/js/effects.js index b514b53..513c549 100644 --- a/js/effects.js +++ b/js/effects.js @@ -1,22 +1,27 @@ import * as THREE from 'three'; +import { getQualityTier } from './quality.js'; let rainParticles; let rainPositions; let rainVelocities; -const RAIN_COUNT = 2000; +let rainCount = 0; export function initEffects(scene) { - initMatrixRain(scene); - initStarfield(scene); + const tier = getQualityTier(); + initMatrixRain(scene, tier); + initStarfield(scene, tier); } -function initMatrixRain(scene) { - const geo = new THREE.BufferGeometry(); - const positions = new Float32Array(RAIN_COUNT * 3); - const velocities = new Float32Array(RAIN_COUNT); - const colors = new Float32Array(RAIN_COUNT * 3); +function initMatrixRain(scene, tier) { + // Scale particle count by quality tier + rainCount = tier === 'low' ? 500 : tier === 'medium' ? 1200 : 2000; - for (let i = 0; i < RAIN_COUNT; i++) { + const geo = new THREE.BufferGeometry(); + const positions = new Float32Array(rainCount * 3); + const velocities = new Float32Array(rainCount); + const colors = new Float32Array(rainCount * 3); + + for (let i = 0; i < rainCount; i++) { positions[i * 3] = (Math.random() - 0.5) * 100; positions[i * 3 + 1] = Math.random() * 50 + 5; positions[i * 3 + 2] = (Math.random() - 0.5) * 100; @@ -34,7 +39,7 @@ function initMatrixRain(scene) { rainVelocities = velocities; const mat = new THREE.PointsMaterial({ - size: 0.12, + size: tier === 'low' ? 0.16 : 0.12, vertexColors: true, transparent: true, opacity: 0.7, @@ -45,8 +50,8 @@ function initMatrixRain(scene) { scene.add(rainParticles); } -function initStarfield(scene) { - const count = 500; +function initStarfield(scene, tier) { + const count = tier === 'low' ? 150 : tier === 'medium' ? 350 : 500; const geo = new THREE.BufferGeometry(); const positions = new Float32Array(count * 3); @@ -72,7 +77,7 @@ function initStarfield(scene) { export function updateEffects(_time) { if (!rainParticles) return; - for (let i = 0; i < RAIN_COUNT; i++) { + for (let i = 0; i < rainCount; i++) { rainPositions[i * 3 + 1] -= rainVelocities[i]; if (rainPositions[i * 3 + 1] < -1) { rainPositions[i * 3 + 1] = 40 + Math.random() * 20; diff --git a/js/main.js b/js/main.js index df4b2c0..8040cf9 100644 --- a/js/main.js +++ b/js/main.js @@ -2,7 +2,7 @@ import { initWorld, onWindowResize } from './world.js'; import { initAgents, updateAgents, getAgentCount } from './agents.js'; import { initEffects, updateEffects } from './effects.js'; import { initUI, updateUI } from './ui.js'; -import { initInteraction } from './interaction.js'; +import { initInteraction, updateControls } from './interaction.js'; import { initWebSocket, getConnectionState, getJobCount } from './websocket.js'; let frameCount = 0; @@ -20,6 +20,10 @@ function main() { window.addEventListener('resize', () => onWindowResize(camera, renderer)); + // Dismiss loading screen + const loadingScreen = document.getElementById('loading-screen'); + if (loadingScreen) loadingScreen.classList.add('hidden'); + function animate() { requestAnimationFrame(animate); @@ -31,6 +35,7 @@ function main() { lastFpsTime = now; } + updateControls(); updateEffects(now); updateAgents(now); updateUI({ diff --git a/js/quality.js b/js/quality.js new file mode 100644 index 0000000..3ff51bc --- /dev/null +++ b/js/quality.js @@ -0,0 +1,90 @@ +/** + * quality.js — Detect hardware capability and return a quality tier. + * + * Tiers: + * 'low' — older iPads, phones, low-end GPUs (reduce particles, simpler effects) + * 'medium' — mid-range (moderate particle count) + * 'high' — desktop, modern iPad Pro (full quality) + * + * Detection uses a combination of: + * - Device pixel ratio (low DPR = likely low-end) + * - Logical core count (navigator.hardwareConcurrency) + * - Device memory (navigator.deviceMemory, Chrome/Edge only) + * - Screen size (small viewport = likely mobile) + * - Touch capability (touch + small screen = phone/tablet) + * - WebGL renderer string (if available) + */ + +let cachedTier = null; + +export function getQualityTier() { + if (cachedTier) return cachedTier; + + let score = 0; + + // Core count: 1-2 = low, 4 = mid, 8+ = high + const cores = navigator.hardwareConcurrency || 2; + if (cores >= 8) score += 3; + else if (cores >= 4) score += 2; + else score += 0; + + // Device memory (Chrome/Edge): < 4GB = low, 4-8 = mid, 8+ = high + const mem = navigator.deviceMemory || 4; + if (mem >= 8) score += 3; + else if (mem >= 4) score += 2; + else score += 0; + + // Screen dimensions (logical pixels) + const maxDim = Math.max(window.screen.width, window.screen.height); + if (maxDim < 768) score -= 1; // phone + else if (maxDim >= 1920) score += 1; // large desktop + + // DPR: high DPR on small screens = more GPU work + const dpr = window.devicePixelRatio || 1; + if (dpr > 2 && maxDim < 1024) score -= 1; // retina phone + + // Touch-only device heuristic + const touchOnly = 'ontouchstart' in window && !window.matchMedia('(pointer: fine)').matches; + if (touchOnly) score -= 1; + + // Try reading WebGL renderer for GPU hints + try { + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl') || canvas.getContext('webgl2'); + if (gl) { + const debugExt = gl.getExtension('WEBGL_debug_renderer_info'); + if (debugExt) { + const renderer = gl.getParameter(debugExt.UNMASKED_RENDERER_WEBGL).toLowerCase(); + // Known low-end GPU strings + if (renderer.includes('swiftshader') || renderer.includes('llvmpipe') || renderer.includes('software')) { + score -= 3; // software renderer + } + if (renderer.includes('apple gpu') || renderer.includes('apple m')) { + score += 2; // Apple Silicon is good + } + } + gl.getExtension('WEBGL_lose_context')?.loseContext(); + } + } catch { + // Can't probe GPU, use other signals + } + + // Map score to tier + if (score <= 1) cachedTier = 'low'; + else if (score <= 4) cachedTier = 'medium'; + else cachedTier = 'high'; + + console.info(`[Matrix Quality] Tier: ${cachedTier} (score: ${score}, cores: ${cores}, mem: ${mem}GB, dpr: ${dpr}, touch: ${touchOnly})`); + + return cachedTier; +} + +/** + * Get the recommended pixel ratio cap for the renderer. + */ +export function getMaxPixelRatio() { + const tier = getQualityTier(); + if (tier === 'low') return 1; + if (tier === 'medium') return 1.5; + return 2; +} diff --git a/js/ui.js b/js/ui.js index 7ea5193..6a3b073 100644 --- a/js/ui.js +++ b/js/ui.js @@ -11,6 +11,9 @@ const $chatPanel = document.getElementById('chat-panel'); const MAX_CHAT_ENTRIES = 12; const chatEntries = []; +const IDLE_COLOR = '#005500'; +const ACTIVE_COLOR = '#00ff41'; + export function initUI() { renderAgentList(); } @@ -18,12 +21,14 @@ export function initUI() { function renderAgentList() { const defs = getAgentDefs(); $agentList.innerHTML = defs.map(a => { - const css = colorToCss(a.color); + const css = escapeAttr(colorToCss(a.color)); + const safeLabel = escapeHtml(a.label); + const safeId = escapeAttr(a.id); return `
[ - ${a.label} + ${safeLabel} ] - IDLE + IDLE
`; }).join(''); } @@ -49,7 +54,7 @@ export function updateUI({ fps, agentCount, jobCount, connectionState }) { const el = document.getElementById(`agent-state-${a.id}`); if (el) { el.textContent = ` ${a.state.toUpperCase()}`; - el.style.color = a.state === 'active' ? '#00ff41' : '#003300'; + el.style.color = a.state === 'active' ? ACTIVE_COLOR : IDLE_COLOR; } }); } @@ -61,24 +66,42 @@ export function updateUI({ fps, agentCount, jobCount, connectionState }) { * @param {string} cssColor — CSS color string, e.g. '#00ff88' */ export function appendChatMessage(agentLabel, message, cssColor) { - const color = cssColor || '#00ff41'; + const color = escapeAttr(cssColor || '#00ff41'); const entry = document.createElement('div'); entry.className = 'chat-entry'; - entry.innerHTML = `${agentLabel}: ${escapeHtml(message)}`; + entry.innerHTML = `${escapeHtml(agentLabel)}: ${escapeHtml(message)}`; + chatEntries.push(entry); - if (chatEntries.length > MAX_CHAT_ENTRIES) { + while (chatEntries.length > MAX_CHAT_ENTRIES) { const removed = chatEntries.shift(); - $chatPanel.removeChild(removed); + try { $chatPanel.removeChild(removed); } catch { /* already removed */ } } $chatPanel.appendChild(entry); $chatPanel.scrollTop = $chatPanel.scrollHeight; } +/** + * Escape HTML text content — prevents tag injection. + */ function escapeHtml(str) { - return str + return String(str) .replace(/&/g, '&') .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Escape a value for use inside an HTML attribute (style="...", id="..."). + */ +function escapeAttr(str) { + return String(str) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>'); } diff --git a/js/websocket.js b/js/websocket.js index 0a758c8..a219a12 100644 --- a/js/websocket.js +++ b/js/websocket.js @@ -10,7 +10,15 @@ let ws = null; let connectionState = 'disconnected'; let jobCount = 0; let reconnectTimer = null; -const RECONNECT_DELAY_MS = 5000; +let reconnectAttempts = 0; + +const RECONNECT_BASE_MS = 2000; +const RECONNECT_MAX_MS = 30000; +const HEARTBEAT_INTERVAL_MS = 30000; +const HEARTBEAT_TIMEOUT_MS = 5000; + +let heartbeatTimer = null; +let heartbeatTimeout = null; export function initWebSocket(_scene) { if (!WS_URL) { @@ -30,7 +38,9 @@ function connect() { try { ws = new WebSocket(WS_URL); - } catch { + } catch (err) { + console.warn('[Matrix WS] Connection failed:', err.message || err); + logEvent('WebSocket connection failed'); connectionState = 'disconnected'; scheduleReconnect(); return; @@ -38,7 +48,9 @@ function connect() { ws.onopen = () => { connectionState = 'connected'; + reconnectAttempts = 0; clearTimeout(reconnectTimer); + startHeartbeat(); ws.send(JSON.stringify({ type: 'subscribe', channel: 'agents', @@ -47,27 +59,75 @@ function connect() { }; ws.onmessage = (event) => { + // Any message counts as a heartbeat response + resetHeartbeatTimeout(); try { handleMessage(JSON.parse(event.data)); - } catch { + } catch (err) { + console.warn('[Matrix WS] Message parse/handle error:', err.message, '| raw:', event.data?.slice?.(0, 200)); } }; - ws.onerror = () => { + ws.onerror = (event) => { + console.warn('[Matrix WS] Error event:', event); connectionState = 'disconnected'; }; - ws.onclose = () => { + ws.onclose = (event) => { connectionState = 'disconnected'; + stopHeartbeat(); + + // Don't reconnect on clean close (1000) or going away (1001) + if (event.code === 1000 || event.code === 1001) { + console.info('[Matrix WS] Clean close (code ' + event.code + '), not reconnecting'); + return; + } + + console.warn('[Matrix WS] Unexpected close — code:', event.code, 'reason:', event.reason || '(none)'); scheduleReconnect(); }; } function scheduleReconnect() { clearTimeout(reconnectTimer); - reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS); + // Exponential backoff: 2s, 4s, 8s, 16s, capped at 30s + const delay = Math.min(RECONNECT_BASE_MS * Math.pow(2, reconnectAttempts), RECONNECT_MAX_MS); + reconnectAttempts++; + console.info('[Matrix WS] Reconnecting in', Math.round(delay / 1000), 's (attempt', reconnectAttempts + ')'); + reconnectTimer = setTimeout(connect, delay); } +/* ── Heartbeat / zombie connection detection ── */ + +function startHeartbeat() { + stopHeartbeat(); + heartbeatTimer = setInterval(() => { + if (ws && ws.readyState === WebSocket.OPEN) { + try { + ws.send(JSON.stringify({ type: 'ping' })); + } catch { /* ignore send failures, onclose will fire */ } + heartbeatTimeout = setTimeout(() => { + console.warn('[Matrix WS] Heartbeat timeout — closing zombie connection'); + if (ws) ws.close(4000, 'heartbeat timeout'); + }, HEARTBEAT_TIMEOUT_MS); + } + }, HEARTBEAT_INTERVAL_MS); +} + +function stopHeartbeat() { + clearInterval(heartbeatTimer); + clearTimeout(heartbeatTimeout); + heartbeatTimer = null; + heartbeatTimeout = null; +} + +function resetHeartbeatTimeout() { + clearTimeout(heartbeatTimeout); + heartbeatTimeout = null; +} + +/* ── Message handler ── */ + function handleMessage(msg) { switch (msg.type) { case 'agent_state': { @@ -95,15 +155,17 @@ function handleMessage(msg) { } break; } + case 'pong': case 'agent_count': break; default: + console.debug('[Matrix WS] Unhandled message type:', msg.type); break; } } function logEvent(text) { - appendChatMessage('SYS', text, colorToCss(0x003300)); + appendChatMessage('SYS', text, '#005500'); } export function getConnectionState() { diff --git a/js/world.js b/js/world.js index 1620eb1..e9bb286 100644 --- a/js/world.js +++ b/js/world.js @@ -1,4 +1,5 @@ import * as THREE from 'three'; +import { getMaxPixelRatio, getQualityTier } from './quality.js'; let scene, camera, renderer; @@ -11,14 +12,15 @@ export function initWorld() { camera.position.set(0, 12, 28); camera.lookAt(0, 0, 0); - renderer = new THREE.WebGLRenderer({ antialias: true }); + const tier = getQualityTier(); + renderer = new THREE.WebGLRenderer({ antialias: tier !== 'low' }); renderer.setSize(window.innerWidth, window.innerHeight); - renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, getMaxPixelRatio())); renderer.outputColorSpace = THREE.SRGBColorSpace; document.body.prepend(renderer.domElement); addLights(scene); - addGrid(scene); + addGrid(scene, tier); return { scene, camera, renderer }; } @@ -36,8 +38,9 @@ function addLights(scene) { scene.add(fill); } -function addGrid(scene) { - const grid = new THREE.GridHelper(100, 40, 0x003300, 0x001a00); +function addGrid(scene, tier) { + const gridDivisions = tier === 'low' ? 20 : 40; + const grid = new THREE.GridHelper(100, gridDivisions, 0x003300, 0x001a00); grid.position.y = -0.01; scene.add(grid); diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..fd685f2 --- /dev/null +++ b/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "Timmy Tower World", + "short_name": "Tower World", + "description": "3D visualization of the Timmy agent network", + "start_url": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#000000", + "theme_color": "#000000", + "icons": [ + { + "src": "/icons/icon-192.svg", + "sizes": "192x192", + "type": "image/svg+xml" + }, + { + "src": "/icons/icon-512.svg", + "sizes": "512x512", + "type": "image/svg+xml" + } + ] +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..4726ebc --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,4 @@ + + + M + \ No newline at end of file diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..b49d106 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,48 @@ +/** + * Service Worker for Timmy Tower World PWA. + * + * Strategy: network-first with offline fallback. + * Caches the shell on install so the app works when iPad is offline. + */ + +const CACHE_NAME = 'matrix-v1'; +const SHELL_URLS = [ + '/', + '/index.html', + '/manifest.json', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_URLS)) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + // Skip non-GET and WebSocket upgrade requests + if (event.request.method !== 'GET') return; + if (event.request.headers.get('upgrade') === 'websocket') return; + + event.respondWith( + fetch(event.request) + .then((response) => { + // Cache successful responses + if (response.ok) { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); + } + return response; + }) + .catch(() => caches.match(event.request)) + ); +});