From 9ef27bec9f278b4c43fd7625a67b3930b328f043 Mon Sep 17 00:00:00 2001
From: alexpaynex <55271826-alexpaynex@users.noreply.replit.com>
Date: Fri, 20 Mar 2026 01:01:03 +0000
Subject: [PATCH] Add new FPS-style navigation and AR label features
Rebuilds the Tower project with Vite, introduces first-person navigation controls for desktop and mobile, and adds AR floating labels for agent information.
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 90c7a60b-2c61-4699-b5c6-6a1ac7469a4d
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 07379049-28ff-4b1c-aeeb-17e250821a43
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/9f85e954-647c-46a5-90a7-396e495a805a/90c7a60b-2c61-4699-b5c6-6a1ac7469a4d/hoGhXo5
Replit-Helium-Checkpoint-Created: true
---
.replit | 16 --
artifacts/api-server/src/app.ts | 1 +
the-matrix/dist/.vite/manifest.json | 2 +-
the-matrix/dist/index.html | 122 ++++++++++++++-
the-matrix/dist/sw.js | 2 +-
the-matrix/index.html | 94 +++++++++++
the-matrix/js/agents.js | 1 +
the-matrix/js/hud-labels.js | 175 +++++++++++++++++++++
the-matrix/js/interaction.js | 194 ++++++++++++++---------
the-matrix/js/main.js | 30 +++-
the-matrix/js/navigation.js | 235 ++++++++++++++++++++++++++++
the-matrix/js/websocket.js | 16 +-
12 files changed, 791 insertions(+), 97 deletions(-)
create mode 100644 the-matrix/js/hud-labels.js
create mode 100644 the-matrix/js/navigation.js
diff --git a/.replit b/.replit
index 22a82ee..b1e5756 100644
--- a/.replit
+++ b/.replit
@@ -33,19 +33,3 @@ channel = "stable-25_05"
[userenv]
[userenv.shared]
-
-[[ports]]
-localPort = 8080
-externalPort = 80
-
-[[ports]]
-localPort = 8081
-externalPort = 8081
-
-[[ports]]
-localPort = 8082
-externalPort = 3000
-
-[[ports]]
-localPort = 18115
-externalPort = 3001
diff --git a/artifacts/api-server/src/app.ts b/artifacts/api-server/src/app.ts
index 9d88983..4eec1c6 100644
--- a/artifacts/api-server/src/app.ts
+++ b/artifacts/api-server/src/app.ts
@@ -25,6 +25,7 @@ const allowedOrigins: string[] = rawOrigins
"https://www.alexanderwhitestone.com",
"https://alexanderwhitestone.ai",
"https://www.alexanderwhitestone.ai",
+ "https://hermes.tailb74b2d.ts.net",
]
: [];
diff --git a/the-matrix/dist/.vite/manifest.json b/the-matrix/dist/.vite/manifest.json
index 5512109..54c47cb 100644
--- a/the-matrix/dist/.vite/manifest.json
+++ b/the-matrix/dist/.vite/manifest.json
@@ -1,6 +1,6 @@
{
"index.html": {
- "file": "assets/index-BAg_vlZE.js",
+ "file": "assets/index-CBu1T9J9.js",
"name": "index",
"src": "index.html",
"isEntry": true
diff --git a/the-matrix/dist/index.html b/the-matrix/dist/index.html
index 7a1abc6..d5b482a 100644
--- a/the-matrix/dist/index.html
+++ b/the-matrix/dist/index.html
@@ -367,6 +367,84 @@
}
.panel-link:hover { color: #5577aa; }
+ /* ── AR pulse animation ──────────────────────────────────────────── */
+ @keyframes ar-pulse {
+ 0%, 100% { opacity: 0.6; transform: scale(1); }
+ 50% { opacity: 1; transform: scale(1.5); }
+ }
+
+ /* ── Crosshair ───────────────────────────────────────────────────── */
+ #crosshair {
+ position: fixed; top: 50%; left: 50%;
+ transform: translate(-50%, -50%);
+ pointer-events: none; z-index: 12;
+ opacity: 0.5;
+ }
+ #crosshair::before, #crosshair::after {
+ content: ''; position: absolute;
+ background: rgba(180, 160, 220, 0.7);
+ border-radius: 1px;
+ }
+ #crosshair::before { width: 16px; height: 1px; top: 0; left: -8px; }
+ #crosshair::after { width: 1px; height: 16px; top: -8px; left: 0; }
+
+ /* ── Lock hint (desktop) ─────────────────────────────────────────── */
+ #lock-hint {
+ position: fixed; inset: 0;
+ display: none;
+ align-items: center; justify-content: center;
+ z-index: 11; pointer-events: none;
+ }
+ #lock-hint .lock-badge {
+ background: rgba(8, 6, 20, 0.72);
+ border: 1px solid rgba(80, 100, 160, 0.5);
+ border-radius: 8px;
+ color: #5577aa;
+ font-family: 'Courier New', monospace;
+ font-size: 11px; letter-spacing: 2px;
+ padding: 10px 24px;
+ text-align: center;
+ backdrop-filter: blur(6px);
+ -webkit-backdrop-filter: blur(6px);
+ animation: lock-fade 2s ease-in-out infinite alternate;
+ }
+ @keyframes lock-fade {
+ 0% { opacity: 0.45; }
+ 100% { opacity: 0.9; }
+ }
+
+ /* ── Virtual joystick (mobile) ───────────────────────────────────── */
+ #joy-pad {
+ position: fixed;
+ display: none; /* shown by navigation.js on mobile */
+ align-items: center; justify-content: center;
+ width: 110px; height: 110px;
+ border-radius: 50%;
+ background: rgba(40, 30, 70, 0.38);
+ border: 1.5px solid rgba(100, 80, 180, 0.45);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ z-index: 18;
+ pointer-events: none; /* touches handled on canvas */
+ opacity: 0.35;
+ transition: opacity 0.25s;
+ bottom: 80px; left: 20px;
+ }
+ #joy-nub {
+ width: 38px; height: 38px; border-radius: 50%;
+ background: rgba(140, 110, 220, 0.65);
+ border: 1.5px solid rgba(180, 150, 255, 0.6);
+ position: absolute;
+ top: 50%; left: 50%;
+ transform: translate(-50%, -50%);
+ box-shadow: 0 0 14px rgba(140, 110, 220, 0.5);
+ transition: transform 0.05s linear;
+ pointer-events: none;
+ }
+
+ /* ── AR label pulse ──────────────────────────────────────────────── */
+ .ar-label { transition: opacity 0.25s; }
+
/* ── WebGL recovery overlay ──────────────────────────────────────── */
#webgl-recovery-overlay {
display: none; position: fixed; inset: 0; z-index: 200;
@@ -383,8 +461,27 @@
0%, 100% { opacity: 1; }
50% { opacity: 0.2; }
}
+
+ /* ── Timmy identity card ──────────────────────────────────────────── */
+ #timmy-id-card {
+ position: fixed; bottom: 80px; right: 16px;
+ font-size: 10px; color: #334466;
+ pointer-events: all; z-index: 10;
+ text-align: right; line-height: 1.8;
+ }
+ #timmy-id-card .id-label {
+ letter-spacing: 2px; color: #223355;
+ text-transform: uppercase; font-size: 9px;
+ }
+ #timmy-id-card .id-npub {
+ color: #4466aa; cursor: pointer;
+ text-decoration: underline dotted;
+ font-size: 10px; letter-spacing: 0.5px;
+ }
+ #timmy-id-card .id-npub:hover { color: #88aadd; }
+ #timmy-id-card .id-zaps { color: #556688; font-size: 9px; }
-
+
element that gets repositioned every frame by projecting
+ * the agent's 3D world position through the camera to screen space.
+ *
+ * Exports:
+ * initHudLabels(scene, camera, agentDefs, getTimmyPosition)
+ * updateHudLabels(camera, renderer, agentStates)
+ * setLabelState(id, state) — called from websocket.js on agent_state events
+ * disposeHudLabels()
+ */
+
+import * as THREE from 'three';
+import { colorToCss } from './agent-defs.js';
+
+const _proj = new THREE.Vector3();
+let _camera = null;
+let _labels = []; // { el, worldPos: THREE.Vector3, id }
+
+// ── State cache (updated from WS) ────────────────────────────────────────────
+const _states = {};
+
+// ── Inspect popup ─────────────────────────────────────────────────────────────
+let _inspectEl = null;
+let _inspectTimer = null;
+
+export function initHudLabels(camera, agentDefs, timmyWorldPos) {
+ _camera = camera;
+
+ const container = document.getElementById('ar-labels') || (() => {
+ const c = document.createElement('div');
+ c.id = 'ar-labels';
+ c.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:15;overflow:hidden;';
+ document.body.appendChild(c);
+ return c;
+ })();
+
+ // Timmy label
+ _labels.push(_makeLabel(container, 'timmy', 'TIMMY', 'Wizard · Master Agent', '#c77dff',
+ timmyWorldPos.clone().add(new THREE.Vector3(0, 1.1, 0))));
+
+ // Sub-agent labels
+ for (const def of agentDefs) {
+ const col = colorToCss(def.color);
+ const pos = new THREE.Vector3(def.x, 2.8, def.z);
+ _labels.push(_makeLabel(container, def.id, def.label, def.role, col, pos));
+ _states[def.id] = 'idle';
+ }
+
+ _states['timmy'] = 'idle';
+
+ // Inspect popup (shared, shown on tap)
+ _inspectEl = document.createElement('div');
+ _inspectEl.id = 'inspect-popup';
+ _inspectEl.style.cssText = [
+ 'position:fixed;transform:translate(-50%,-100%) translateY(-14px)',
+ 'background:rgba(8,6,20,0.92);border:1px solid rgba(120,80,200,0.6)',
+ 'border-radius:10px;padding:10px 16px;min-width:140px;max-width:220px',
+ 'color:#ddd;font-family:Courier New,monospace;font-size:11px;line-height:1.7',
+ 'pointer-events:none;z-index:30;display:none',
+ 'box-shadow:0 0 24px rgba(100,60,180,0.4)',
+ 'backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)',
+ ].join(';');
+ document.body.appendChild(_inspectEl);
+}
+
+function _makeLabel(container, id, name, role, color, worldPos) {
+ const el = document.createElement('div');
+ el.className = 'ar-label';
+ el.dataset.id = id;
+ el.style.cssText = [
+ 'position:absolute;transform:translate(-50%,-100%)',
+ 'pointer-events:none;transition:opacity 0.3s',
+ 'text-align:center;line-height:1.4',
+ ].join(';');
+
+ el.innerHTML = `
+
+ ${name}
+
+
+ ${role}
+
+
+
+ idle
+
+
+ `;
+
+ container.appendChild(el);
+ return { el, worldPos, id, color };
+}
+
+export function setLabelState(id, state) {
+ _states[id] = state;
+ const entry = _labels.find(l => l.id === id);
+ if (!entry) return;
+ const stateEl = entry.el.querySelector('.ar-state-text');
+ const dot = entry.el.querySelector('.ar-dot');
+ if (stateEl) stateEl.textContent = state;
+ const pulse = state !== 'idle';
+ if (dot) dot.style.animation = pulse ? 'ar-pulse 1s ease-in-out infinite' : '';
+}
+
+export function showInspectPopup(id, screenX, screenY) {
+ if (!_inspectEl) return;
+ const entry = _labels.find(l => l.id === id);
+ if (!entry) return;
+
+ const state = _states[id] || 'idle';
+ const uptime = Math.floor(performance.now() / 1000);
+ _inspectEl.innerHTML = `
+
+ ${id.toUpperCase()}
+
+
state : ${state}
+
uptime : ${uptime}s
+
network: connected
+ `;
+ _inspectEl.style.left = `${screenX}px`;
+ _inspectEl.style.top = `${screenY}px`;
+ _inspectEl.style.display = 'block';
+
+ if (_inspectTimer) clearTimeout(_inspectTimer);
+ _inspectTimer = setTimeout(() => {
+ if (_inspectEl) _inspectEl.style.display = 'none';
+ }, 2800);
+}
+
+const _ndc = new THREE.Vector3();
+
+export function updateHudLabels(camera, renderer) {
+ if (!camera) return;
+
+ const W = renderer.domElement.clientWidth || window.innerWidth;
+ const H = renderer.domElement.clientHeight || window.innerHeight;
+
+ for (const label of _labels) {
+ _ndc.copy(label.worldPos);
+ _ndc.project(camera);
+
+ // Behind camera or outside NDC → hide
+ if (_ndc.z > 1 || Math.abs(_ndc.x) > 1.8 || Math.abs(_ndc.y) > 1.8) {
+ label.el.style.opacity = '0';
+ continue;
+ }
+
+ const sx = (_ndc.x * 0.5 + 0.5) * W;
+ const sy = (_ndc.y * -0.5 + 0.5) * H;
+
+ label.el.style.left = `${sx}px`;
+ label.el.style.top = `${sy}px`;
+
+ // Fade with distance: full opacity 3–20 units, fade out beyond 20
+ const dist = _proj.copy(label.worldPos).distanceTo(camera.position);
+ const alpha = Math.max(0, Math.min(1, 1 - (dist - 20) / 10));
+ label.el.style.opacity = alpha.toFixed(2);
+ }
+}
+
+export function disposeHudLabels() {
+ for (const l of _labels) l.el.remove();
+ _labels = [];
+ _inspectEl?.remove();
+ _inspectEl = null;
+}
diff --git a/the-matrix/js/interaction.js b/the-matrix/js/interaction.js
index 5a7d831..39a25d3 100644
--- a/the-matrix/js/interaction.js
+++ b/the-matrix/js/interaction.js
@@ -1,20 +1,33 @@
-import * as THREE from 'three';
-import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+/**
+ * interaction.js — Click/tap interaction in FPS navigation mode.
+ *
+ * - Raycasts against Timmy on pointerdown to apply slap.
+ * - Short-tap detection (touchstart + touchend < 200ms) on right-half of
+ * mobile screen to also attempt slap / agent inspect.
+ * - No OrbitControls — navigation is handled by navigation.js.
+ */
+
+import * as THREE from 'three';
+import { AGENT_DEFS } from './agent-defs.js';
+import { showInspectPopup } from './hud-labels.js';
+
+let _canvas = null;
+let _camera = null;
+let _timmyGroup = null;
+let _applySlap = null;
-let controls;
-let _canvas;
-let _camera = null;
-let _timmyGroup = null;
-let _applySlap = null;
const _raycaster = new THREE.Raycaster();
const _pointer = new THREE.Vector2();
const _noCtxMenu = e => e.preventDefault();
-// ── registerSlapTarget ────────────────────────────────────────────────────────
-// Call after initAgents() with Timmy's group and the applySlap function.
-// The capture-phase pointerdown listener will hit-test against the group and
-// call applySlap(hitPoint) — also suppressing the OrbitControls drag.
+// Agent inspect proxy — screen-space proximity check (80px threshold)
+const INSPECT_RADIUS_PX = 80;
+// Timmy world pos (for screen-space proximity check)
+const _TIMMY_WORLD = new THREE.Vector3(0, 1.9, -2);
+const _agentWorlds = AGENT_DEFS.map(d => ({ id: d.id, pos: new THREE.Vector3(d.x, 2.8, d.z) }));
+
+// ── Public API ────────────────────────────────────────────────────────────────
export function registerSlapTarget(timmyGroup, applyFn) {
_timmyGroup = timmyGroup;
_applySlap = applyFn;
@@ -22,32 +35,44 @@ export function registerSlapTarget(timmyGroup, applyFn) {
export function initInteraction(camera, renderer) {
_camera = camera;
-
- controls = new OrbitControls(camera, renderer.domElement);
- controls.enableDamping = true;
- controls.dampingFactor = 0.05;
- controls.screenSpacePanning = false;
- controls.minDistance = 5;
- controls.maxDistance = 80;
- controls.maxPolarAngle = Math.PI / 2.1;
- controls.target.set(0, 0, 0);
- controls.update();
-
_canvas = renderer.domElement;
+
_canvas.addEventListener('contextmenu', _noCtxMenu);
-
- // Capture phase so we intercept before OrbitControls' bubble-phase handler.
- // If Timmy is hit we call stopImmediatePropagation() to suppress the orbit drag.
_canvas.addEventListener('pointerdown', _onPointerDown, { capture: true });
-
- // touchstart fallback for older mobile browsers that lack Pointer Events
- if (!window.PointerEvent) {
- _canvas.addEventListener('touchstart', _onTouchStart, { capture: true, passive: false });
- }
}
-function _hitTest(clientX, clientY) {
- if (!_timmyGroup || !_applySlap || !_camera) return false;
+export function updateControls() { /* no-op — navigation.js handles movement */ }
+
+export function disposeInteraction() {
+ if (_canvas) {
+ _canvas.removeEventListener('contextmenu', _noCtxMenu);
+ _canvas.removeEventListener('pointerdown', _onPointerDown, { capture: true });
+ _canvas = null;
+ }
+ _camera = null;
+ _timmyGroup = null;
+ _applySlap = null;
+}
+
+// ── Internal ──────────────────────────────────────────────────────────────────
+function _toScreen(worldPos) {
+ const v = worldPos.clone().project(_camera);
+ return {
+ x: ( v.x * 0.5 + 0.5) * window.innerWidth,
+ y: (-v.y * 0.5 + 0.5) * window.innerHeight,
+ behind: v.z > 1,
+ };
+}
+
+function _distPx(sx, sy, screenPos) {
+ if (screenPos.behind) return Infinity;
+ const dx = sx - screenPos.x;
+ const dy = sy - screenPos.y;
+ return Math.sqrt(dx * dx + dy * dy);
+}
+
+function _hitTestTimmy(clientX, clientY) {
+ if (!_timmyGroup || !_applySlap || !_camera || !_canvas) return false;
const rect = _canvas.getBoundingClientRect();
_pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
@@ -58,53 +83,80 @@ function _hitTest(clientX, clientY) {
if (hits.length > 0) {
_applySlap(hits[0].point);
- // 150 ms lockout to avoid accidental orbit drag immediately after a slap
- if (controls) {
- controls.enabled = false;
- setTimeout(() => { if (controls) controls.enabled = true; }, 150);
- }
+ return true;
+ }
+
+ // Fallback: screen-space proximity to Timmy (for mobile when mesh is small)
+ const ts = _toScreen(_TIMMY_WORLD);
+ if (_distPx(clientX, clientY, ts) < INSPECT_RADIUS_PX) {
+ _applySlap(new THREE.Vector3(0, 1.2, -2));
return true;
}
return false;
}
+function _tryAgentInspect(clientX, clientY) {
+ // Check Timmy first
+ const ts = _toScreen(_TIMMY_WORLD);
+ if (_distPx(clientX, clientY, ts) < INSPECT_RADIUS_PX) {
+ showInspectPopup('timmy', clientX, clientY);
+ return true;
+ }
+ // Sub-agents
+ let best = null, bestDist = INSPECT_RADIUS_PX;
+ for (const ag of _agentWorlds) {
+ const s = _toScreen(ag.pos);
+ const d = _distPx(clientX, clientY, s);
+ if (d < bestDist) { best = ag; bestDist = d; }
+ }
+ if (best) {
+ showInspectPopup(best.id, clientX, clientY);
+ return true;
+ }
+ return false;
+}
+
+// Track touch for short-tap detection on mobile
+let _tapStart = 0;
+let _tapX = 0, _tapY = 0;
+
function _onPointerDown(event) {
- if (_hitTest(event.clientX, event.clientY)) {
- event.stopImmediatePropagation(); // block OrbitControls drag
- }
-}
+ // Record tap start for short-tap detection
+ _tapStart = Date.now();
+ _tapX = event.clientX;
+ _tapY = event.clientY;
-function _onTouchStart(event) {
- if (!event.touches || event.touches.length === 0) return;
- const t = event.touches[0];
- if (_hitTest(t.clientX, t.clientY)) {
- event.stopImmediatePropagation();
- event.preventDefault(); // suppress subsequent mouse events (ghost click)
- }
-}
+ const isTouch = event.pointerType === 'touch';
-export function updateControls() {
- if (controls) controls.update();
-}
-
-/**
- * Dispose OrbitControls event listeners.
- * Called before context-loss teardown.
- */
-export function disposeInteraction() {
- if (_canvas) {
- _canvas.removeEventListener('contextmenu', _noCtxMenu);
- _canvas.removeEventListener('pointerdown', _onPointerDown, { capture: true });
- if (!window.PointerEvent) {
- _canvas.removeEventListener('touchstart', _onTouchStart, { capture: true });
+ if (isTouch) {
+ // For mobile: add a pointerup listener to detect short tap
+ const onUp = (upEvent) => {
+ _canvas.removeEventListener('pointerup', onUp);
+ const dt = Date.now() - _tapStart;
+ const moved = Math.hypot(upEvent.clientX - _tapX, upEvent.clientY - _tapY);
+ if (dt < 220 && moved < 18) {
+ // Short tap — try slap then inspect
+ if (!_hitTestTimmy(upEvent.clientX, upEvent.clientY)) {
+ _tryAgentInspect(upEvent.clientX, upEvent.clientY);
+ }
+ }
+ };
+ _canvas.addEventListener('pointerup', onUp, { once: true });
+ } else {
+ // Desktop click: only fire when pointer lock is already active
+ // (otherwise navigation.js requests lock on this same click — that's fine,
+ // both can fire; slap + lock request together is acceptable)
+ if (document.pointerLockElement === _canvas) {
+ // Cast from screen center in FPS mode
+ _pointer.set(0, 0);
+ _raycaster.setFromCamera(_pointer, _camera);
+ if (_timmyGroup && _applySlap) {
+ const hits = _raycaster.intersectObject(_timmyGroup, true);
+ if (hits.length > 0) {
+ _applySlap(hits[0].point);
+ event.stopImmediatePropagation();
+ }
+ }
}
- _canvas = null;
}
- if (controls) {
- controls.dispose();
- controls = null;
- }
- _camera = null;
- _timmyGroup = null;
- _applySlap = null;
}
diff --git a/the-matrix/js/main.js b/the-matrix/js/main.js
index aa9d515..3c25033 100644
--- a/the-matrix/js/main.js
+++ b/the-matrix/js/main.js
@@ -3,6 +3,7 @@ import {
initAgents, updateAgents, getAgentCount,
disposeAgents, getAgentStates, applyAgentStates,
getTimmyGroup, applySlap, getCameraShakeStrength,
+ TIMMY_WORLD_POS,
} from './agents.js';
import { initEffects, updateEffects, disposeEffects } from './effects.js';
import { initUI, updateUI } from './ui.js';
@@ -14,9 +15,13 @@ import { initNostrIdentity } from './nostr-identity.js';
import { warmup as warmupEdgeWorker, onReady as onEdgeWorkerReady } from './edge-worker-client.js';
import { setEdgeWorkerReady } from './ui.js';
import { initTimmyId } from './timmy-id.js';
+import { AGENT_DEFS } from './agent-defs.js';
+import { initNavigation, updateNavigation, disposeNavigation } from './navigation.js';
+import { initHudLabels, updateHudLabels, disposeHudLabels } from './hud-labels.js';
let running = false;
let canvas = null;
+let _lastTime = performance.now();
function buildWorld(firstInit, stateSnapshot) {
const { scene, camera, renderer } = initWorld(canvas);
@@ -27,20 +32,23 @@ function buildWorld(firstInit, stateSnapshot) {
if (stateSnapshot) applyAgentStates(stateSnapshot);
+ // Navigation replaces OrbitControls
+ initNavigation(camera, renderer);
+
initInteraction(camera, renderer);
registerSlapTarget(getTimmyGroup(), applySlap);
+ // AR floating labels
+ initHudLabels(camera, AGENT_DEFS, TIMMY_WORLD_POS);
+
if (firstInit) {
initUI();
initWebSocket(scene);
initPaymentPanel();
initSessionPanel();
- // Nostr identity init (async — non-blocking)
void initNostrIdentity('/api');
- // Warm up edge-worker models in the background; show ready badge when done
warmupEdgeWorker();
onEdgeWorkerReady(() => setEdgeWorkerReady());
- // Fetch Timmy's Nostr identity and populate identity card
void initTimmyId();
}
@@ -58,6 +66,9 @@ function buildWorld(firstInit, stateSnapshot) {
requestAnimationFrame(animate);
const now = performance.now();
+ const deltaMs = now - _lastTime;
+ _lastTime = now;
+
frameCount++;
if (now - lastFpsTime >= 1000) {
currentFps = Math.round(frameCount * 1000 / (now - lastFpsTime));
@@ -65,6 +76,9 @@ function buildWorld(firstInit, stateSnapshot) {
lastFpsTime = now;
}
+ // FPS navigation
+ updateNavigation(deltaMs);
+
updateEffects(now);
updateAgents(now);
updateUI({
@@ -74,7 +88,7 @@ function buildWorld(firstInit, stateSnapshot) {
connectionState: getConnectionState(),
});
- // Camera shake — apply transient offset, render, then restore (no drift)
+ // Camera shake
const shakeStr = getCameraShakeStrength();
let sx = 0, sy = 0;
if (shakeStr > 0) {
@@ -84,11 +98,16 @@ function buildWorld(firstInit, stateSnapshot) {
camera.position.x += sx;
camera.position.y += sy;
}
+
renderer.render(scene, camera);
+
if (shakeStr > 0) {
camera.position.x -= sx;
camera.position.y -= sy;
}
+
+ // AR label positions (after render so NDC is current)
+ updateHudLabels(camera, renderer);
}
animate();
@@ -98,7 +117,9 @@ function buildWorld(firstInit, stateSnapshot) {
function teardown({ scene, renderer, ac }) {
running = false;
ac.abort();
+ disposeNavigation();
disposeInteraction();
+ disposeHudLabels();
disposeEffects();
disposeAgents();
disposeWorld(renderer, scene);
@@ -117,6 +138,7 @@ function main() {
canvas.addEventListener('webglcontextrestored', () => {
const snapshot = getAgentStates();
teardown(handle);
+ _lastTime = performance.now();
handle = buildWorld(false, snapshot);
if ($overlay) $overlay.style.display = 'none';
});
diff --git a/the-matrix/js/navigation.js b/the-matrix/js/navigation.js
new file mode 100644
index 0000000..a6c23db
--- /dev/null
+++ b/the-matrix/js/navigation.js
@@ -0,0 +1,235 @@
+/**
+ * navigation.js — FPS-style first-person navigation for the Tower world.
+ *
+ * Desktop : Click canvas to lock pointer → WASD to move, mouse to look. Esc to release.
+ * Mobile : Left-side joystick to move, drag right side to look.
+ *
+ * Exports:
+ * initNavigation(camera, renderer)
+ * updateNavigation(deltaMs) — call each frame; returns true if camera moved
+ * disposeNavigation()
+ */
+
+import * as THREE from 'three';
+
+const EYE_HEIGHT = 1.75; // camera Y (units)
+const MOVE_SPEED = 5.5; // units / second
+const MOUSE_SENSITIVITY = 0.0022; // rad / pixel
+const TOUCH_SENSITIVITY = 0.0030; // rad / pixel
+const MAX_PITCH = Math.PI / 2.1;
+const BOUNDS = 11.5; // half-side of roamable square (floor is 28×28)
+const JOY_DEADZONE = 0.08;
+
+let _camera = null;
+let _canvas = null;
+let _yaw = 0;
+let _pitch = 0.08;
+let _yawΔ = 0;
+let _pitchΔ = 0;
+let _keys = {};
+let _joy = { x: 0, y: 0 };
+let _locked = false;
+let _mobile = false;
+
+// ── touch tracking ────────────────────────────────────────────────────────────
+let _joyTouchId = null;
+let _joyOrigin = { x: 0, y: 0 };
+let _lookTouchId = null;
+let _lookLast = { x: 0, y: 0 };
+
+const JOY_HALF_W = 55; // px — joystick knob travel radius
+
+// ── DOM refs ──────────────────────────────────────────────────────────────────
+let $joyNub = null;
+let $joyPad = null;
+let $lockHint = null;
+
+// ── helpers ───────────────────────────────────────────────────────────────────
+const _tmp = new THREE.Vector3();
+const _fwd = new THREE.Vector3();
+const _rgt = new THREE.Vector3();
+const _UP = new THREE.Vector3(0, 1, 0);
+
+function _applyRotation() {
+ const qY = new THREE.Quaternion().setFromAxisAngle(_UP, _yaw);
+ const qX = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), _pitch);
+ _camera.quaternion.copy(qY).multiply(qX);
+}
+
+// ── keyboard ──────────────────────────────────────────────────────────────────
+function _onKeyDown(e) { _keys[e.code] = true; }
+function _onKeyUp(e) { delete _keys[e.code]; }
+
+// ── pointer lock (desktop) ────────────────────────────────────────────────────
+function _requestLock() { _canvas.requestPointerLock(); }
+function _onLockChange() {
+ _locked = document.pointerLockElement === _canvas;
+ if ($lockHint) $lockHint.style.display = _locked ? 'none' : 'flex';
+}
+function _onMouseMove(e) {
+ if (!_locked) return;
+ _yawΔ -= e.movementX * MOUSE_SENSITIVITY;
+ _pitchΔ -= e.movementY * MOUSE_SENSITIVITY;
+}
+
+// ── touch (mobile) ────────────────────────────────────────────────────────────
+function _onTouchStart(e) {
+ for (const t of e.changedTouches) {
+ const leftHalf = t.clientX < window.innerWidth * 0.45;
+ const botHalf = t.clientY > window.innerHeight * 0.45;
+
+ if (leftHalf && botHalf && _joyTouchId === null) {
+ // Joystick zone — initialise centred on first touch point
+ _joyTouchId = t.identifier;
+ _joyOrigin = { x: t.clientX, y: t.clientY };
+ _updateJoy(t.clientX, t.clientY);
+
+ if ($joyPad) {
+ const offset = JOY_HALF_W + 12;
+ $joyPad.style.left = `${Math.max(offset, Math.min(window.innerWidth - offset, t.clientX) - JOY_HALF_W)}px`;
+ $joyPad.style.bottom = `${window.innerHeight - Math.min(window.innerHeight - 12, t.clientY) - JOY_HALF_W}px`;
+ $joyPad.style.opacity = '1';
+ }
+ } else if (!leftHalf && _lookTouchId === null) {
+ // Look zone
+ _lookTouchId = t.identifier;
+ _lookLast = { x: t.clientX, y: t.clientY };
+ }
+ }
+}
+function _onTouchMove(e) {
+ e.preventDefault();
+ for (const t of e.changedTouches) {
+ if (t.identifier === _joyTouchId) {
+ _updateJoy(t.clientX, t.clientY);
+ } else if (t.identifier === _lookTouchId) {
+ const dx = t.clientX - _lookLast.x;
+ const dy = t.clientY - _lookLast.y;
+ _yawΔ -= dx * TOUCH_SENSITIVITY;
+ _pitchΔ -= dy * TOUCH_SENSITIVITY;
+ _lookLast = { x: t.clientX, y: t.clientY };
+ }
+ }
+}
+function _onTouchEnd(e) {
+ for (const t of e.changedTouches) {
+ if (t.identifier === _joyTouchId) {
+ _joyTouchId = null;
+ _joy = { x: 0, y: 0 };
+ if ($joyNub) $joyNub.style.transform = 'translate(-50%,-50%)';
+ if ($joyPad) $joyPad.style.opacity = '0.35';
+ }
+ if (t.identifier === _lookTouchId) _lookTouchId = null;
+ }
+}
+
+function _updateJoy(cx, cy) {
+ const dx = cx - _joyOrigin.x;
+ const dy = cy - _joyOrigin.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ const travel = Math.min(dist, JOY_HALF_W);
+ const angle = Math.atan2(dy, dx);
+ const nx = Math.cos(angle) * travel;
+ const ny = Math.sin(angle) * travel;
+ _joy.x = nx / JOY_HALF_W;
+ _joy.y = ny / JOY_HALF_W;
+ if ($joyNub) $joyNub.style.transform = `translate(calc(-50% + ${nx}px), calc(-50% + ${ny}px))`;
+}
+
+// ── public API ────────────────────────────────────────────────────────────────
+export function initNavigation(camera, renderer) {
+ _camera = camera;
+ _canvas = renderer.domElement;
+ _mobile = 'ontouchstart' in window && !window.matchMedia('(pointer:fine)').matches;
+
+ _camera.position.set(0, EYE_HEIGHT, 9);
+ _yaw = 0;
+ _pitch = 0.08;
+ _applyRotation();
+
+ $joyNub = document.getElementById('joy-nub');
+ $joyPad = document.getElementById('joy-pad');
+ $lockHint = document.getElementById('lock-hint');
+
+ window.addEventListener('keydown', _onKeyDown);
+ window.addEventListener('keyup', _onKeyUp);
+
+ if (_mobile) {
+ _canvas.addEventListener('touchstart', _onTouchStart, { passive: false });
+ _canvas.addEventListener('touchmove', _onTouchMove, { passive: false });
+ _canvas.addEventListener('touchend', _onTouchEnd, { passive: false });
+ _canvas.addEventListener('touchcancel', _onTouchEnd, { passive: false });
+ if ($joyPad) $joyPad.style.display = 'flex';
+ } else {
+ _canvas.addEventListener('click', _requestLock);
+ document.addEventListener('pointerlockchange', _onLockChange);
+ document.addEventListener('mousemove', _onMouseMove);
+ if ($lockHint) $lockHint.style.display = 'flex';
+ if ($joyPad) $joyPad.style.display = 'none';
+ }
+}
+
+export function updateNavigation(deltaMs) {
+ if (!_camera) return false;
+ const dt = Math.min(deltaMs / 1000, 0.05);
+
+ // Apply look
+ if (_yawΔ !== 0 || _pitchΔ !== 0) {
+ _yaw += _yawΔ;
+ _pitch = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, _pitch + _pitchΔ));
+ _yawΔ = 0;
+ _pitchΔ = 0;
+ _applyRotation();
+ }
+
+ // Keyboard input
+ let mx = 0, mz = 0;
+ if (_keys['KeyW'] || _keys['ArrowUp']) mz -= 1;
+ if (_keys['KeyS'] || _keys['ArrowDown']) mz += 1;
+ if (_keys['KeyA'] || _keys['ArrowLeft']) mx -= 1;
+ if (_keys['KeyD'] || _keys['ArrowRight']) mx += 1;
+
+ // Joystick (additive)
+ const jx = Math.abs(_joy.x) > JOY_DEADZONE ? _joy.x : 0;
+ const jy = Math.abs(_joy.y) > JOY_DEADZONE ? _joy.y : 0;
+ mx += jx;
+ mz += jy;
+
+ if (mx !== 0 || mz !== 0) {
+ const len = Math.sqrt(mx * mx + mz * mz);
+ mx /= len; mz /= len;
+
+ _camera.getWorldDirection(_fwd);
+ _fwd.y = 0;
+ if (_fwd.lengthSq() < 0.001) _fwd.set(0, 0, -1);
+ _fwd.normalize();
+ _rgt.crossVectors(_UP, _fwd).normalize();
+
+ _tmp.set(0, 0, 0)
+ .addScaledVector(_fwd, -mz)
+ .addScaledVector(_rgt, -mx);
+
+ _camera.position.addScaledVector(_tmp, MOVE_SPEED * dt);
+ _camera.position.x = Math.max(-BOUNDS, Math.min(BOUNDS, _camera.position.x));
+ _camera.position.z = Math.max(-BOUNDS, Math.min(BOUNDS, _camera.position.z));
+ _camera.position.y = EYE_HEIGHT;
+ return true;
+ }
+ return false;
+}
+
+export function disposeNavigation() {
+ window.removeEventListener('keydown', _onKeyDown);
+ window.removeEventListener('keyup', _onKeyUp);
+ if (_mobile) {
+ _canvas?.removeEventListener('touchstart', _onTouchStart);
+ _canvas?.removeEventListener('touchmove', _onTouchMove);
+ _canvas?.removeEventListener('touchend', _onTouchEnd);
+ _canvas?.removeEventListener('touchcancel', _onTouchEnd);
+ } else {
+ _canvas?.removeEventListener('click', _requestLock);
+ document.removeEventListener('pointerlockchange', _onLockChange);
+ document.removeEventListener('mousemove', _onMouseMove);
+ if (document.pointerLockElement === _canvas) document.exitPointerLock();
+ }
+}
diff --git a/the-matrix/js/websocket.js b/the-matrix/js/websocket.js
index 1be6dd5..8ef4a04 100644
--- a/the-matrix/js/websocket.js
+++ b/the-matrix/js/websocket.js
@@ -1,6 +1,7 @@
import { setAgentState, setSpeechBubble, applyAgentStates, setMood } from './agents.js';
import { appendSystemMessage } from './ui.js';
import { sentiment } from './edge-worker-client.js';
+import { setLabelState } from './hud-labels.js';
function resolveWsUrl() {
const explicit = import.meta.env.VITE_WS_URL;
@@ -80,20 +81,29 @@ function handleMessage(msg) {
}
case 'agent_state': {
- if (msg.agentId && msg.state) setAgentState(msg.agentId, msg.state);
+ if (msg.agentId && msg.state) {
+ setAgentState(msg.agentId, msg.state);
+ setLabelState(msg.agentId, msg.state);
+ }
break;
}
case 'job_started': {
jobCount++;
- if (msg.agentId) setAgentState(msg.agentId, 'active');
+ if (msg.agentId) {
+ setAgentState(msg.agentId, 'active');
+ setLabelState(msg.agentId, 'active');
+ }
appendSystemMessage(`job ${(msg.jobId || '').slice(0, 8)} started`);
break;
}
case 'job_completed': {
if (jobCount > 0) jobCount--;
- if (msg.agentId) setAgentState(msg.agentId, 'idle');
+ if (msg.agentId) {
+ setAgentState(msg.agentId, 'idle');
+ setLabelState(msg.agentId, 'idle');
+ }
appendSystemMessage(`job ${(msg.jobId || '').slice(0, 8)} complete`);
break;
}