forked from Rockachopa/the-matrix
fix: QA sprint v1 — 7 issues resolved
Fixes: - #22 OrbitControls damping: call updateControls() in animate loop - #23 Empty catch blocks: add console.warn + error surfacing to chat panel - #24 escapeHtml: add quote escaping (" '), use in renderAgentList - #25 WS reconnect: check close code (1000/1001) before reconnecting, add exponential backoff + heartbeat zombie detection - #26 IDLE state visibility: brighten from near-invisible to #005500 - #5 PWA: manifest.json, service worker (network-first), theme-color, favicon, loading screen, safe-area-inset padding, apple-mobile-web-app - #14 Adaptive render quality: new quality.js hardware detection (low/ medium/high tiers), tiered particle counts, grid density, antialias, pixel ratio caps New files: - js/quality.js — hardware detection + quality tier logic - manifest.json — PWA manifest - public/sw.js — service worker (network-first with offline cache) - public/favicon.svg — SVG favicon - icons/icon-192.svg, icons/icon-512.svg — PWA icons
This commit is contained in:
4
icons/icon-192.svg
Normal file
4
icons/icon-192.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
|
||||
<rect width="192" height="192" fill="#000"/>
|
||||
<text x="96" y="138.24" text-anchor="middle" font-family="Courier New, monospace" font-weight="bold" font-size="115.19999999999999" fill="#00ff41" style="text-shadow: 0 0 9.600000000000001px #00ff41">M</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 352 B |
4
icons/icon-512.svg
Normal file
4
icons/icon-512.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" fill="#000"/>
|
||||
<text x="256" y="368.64" text-anchor="middle" font-family="Courier New, monospace" font-weight="bold" font-size="307.2" fill="#00ff41" style="text-shadow: 0 0 25.6px #00ff41">M</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 327 B |
39
index.html
39
index.html
@@ -2,12 +2,34 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="3D visualization of the Timmy agent network" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
<meta name="apple-mobile-web-app-title" content="Tower World" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.svg" />
|
||||
<title>Timmy Tower World</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #000; overflow: hidden; font-family: 'Courier New', monospace; }
|
||||
canvas { display: block; }
|
||||
|
||||
/* Loading screen — hidden by main.js after init */
|
||||
#loading-screen {
|
||||
position: fixed; inset: 0; z-index: 100;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #000;
|
||||
color: #00ff41; font-size: 14px; letter-spacing: 4px;
|
||||
text-shadow: 0 0 12px #00ff41;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
#loading-screen.hidden { display: none; }
|
||||
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
#loading-screen span { animation: blink 1.2s ease-in-out infinite; }
|
||||
|
||||
#ui-overlay {
|
||||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
pointer-events: none; z-index: 10;
|
||||
@@ -39,9 +61,18 @@
|
||||
font-size: 11px; color: #555;
|
||||
}
|
||||
#connection-status.connected { color: #00ff41; text-shadow: 0 0 6px #00ff41; }
|
||||
|
||||
/* Safe area padding for notched devices */
|
||||
@supports (padding: env(safe-area-inset-top)) {
|
||||
#hud { top: calc(16px + env(safe-area-inset-top)); left: calc(16px + env(safe-area-inset-left)); }
|
||||
#status-panel { top: calc(16px + env(safe-area-inset-top)); right: calc(16px + env(safe-area-inset-right)); }
|
||||
#chat-panel { bottom: calc(16px + env(safe-area-inset-bottom)); left: calc(16px + env(safe-area-inset-left)); right: calc(16px + env(safe-area-inset-right)); }
|
||||
#connection-status { bottom: calc(16px + env(safe-area-inset-bottom)); right: calc(16px + env(safe-area-inset-right)); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="loading-screen"><span>INITIALIZING...</span></div>
|
||||
<div id="ui-overlay">
|
||||
<div id="hud">
|
||||
<h1>TIMMY TOWER WORLD</h1>
|
||||
@@ -56,5 +87,11 @@
|
||||
<div id="connection-status">OFFLINE</div>
|
||||
</div>
|
||||
<script type="module" src="./js/main.js"></script>
|
||||
<script>
|
||||
// Register service worker for PWA / offline support
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
31
js/effects.js
vendored
31
js/effects.js
vendored
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
90
js/quality.js
Normal file
90
js/quality.js
Normal file
@@ -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;
|
||||
}
|
||||
41
js/ui.js
41
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 `<div class="agent-row">
|
||||
<span class="label">[</span>
|
||||
<span style="color:${css}">${a.label}</span>
|
||||
<span style="color:${css}">${safeLabel}</span>
|
||||
<span class="label">]</span>
|
||||
<span id="agent-state-${a.id}" style="color:#003300"> IDLE</span>
|
||||
<span id="agent-state-${safeId}" style="color:${IDLE_COLOR}"> IDLE</span>
|
||||
</div>`;
|
||||
}).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 = `<span class="agent-name" style="color:${color}">${agentLabel}</span>: ${escapeHtml(message)}`;
|
||||
entry.innerHTML = `<span class="agent-name" style="color:${color}">${escapeHtml(agentLabel)}</span>: ${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, '"')
|
||||
.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, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
13
js/world.js
13
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);
|
||||
|
||||
|
||||
22
manifest.json
Normal file
22
manifest.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
4
public/favicon.svg
Normal file
4
public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" fill="#000"/>
|
||||
<text x="16" y="23.04" text-anchor="middle" font-family="Courier New, monospace" font-weight="bold" font-size="19.2" fill="#00ff41" style="text-shadow: 0 0 1.6px #00ff41">M</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 317 B |
48
public/sw.js
Normal file
48
public/sw.js
Normal file
@@ -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))
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user