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; } - +
@@ -400,6 +497,13 @@
OFFLINE
+ +
+
TIMMY IDENTITY
+
+
⚡ 0 zaps sent
+
+
@@ -551,6 +655,22 @@
+ +
+ + +
+
CLICK TO ENTER · WASD TO MOVE · ESC TO EXIT
+
+ + +
+
+
+ + +
+
GPU context lost — recovering...
diff --git a/the-matrix/dist/sw.js b/the-matrix/dist/sw.js index 459ed6e..86cf4c6 100644 --- a/the-matrix/dist/sw.js +++ b/the-matrix/dist/sw.js @@ -9,7 +9,7 @@ const PRECACHE_URLS = [ "/manifest.json", "/icons/icon-192.png", "/icons/icon-512.png", - "/assets/index-BAg_vlZE.js" + "/assets/index-CBu1T9J9.js" ]; self.addEventListener('install', event => { diff --git a/the-matrix/index.html b/the-matrix/index.html index d670edb..b5fac26 100644 --- a/the-matrix/index.html +++ b/the-matrix/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; @@ -576,6 +654,22 @@
+ +
+ + +
+
CLICK TO ENTER · WASD TO MOVE · ESC TO EXIT
+
+ + +
+
+
+ + +
+
GPU context lost — recovering...
diff --git a/the-matrix/js/agents.js b/the-matrix/js/agents.js index a6573e5..c8cba65 100644 --- a/the-matrix/js/agents.js +++ b/the-matrix/js/agents.js @@ -1,6 +1,7 @@ import * as THREE from 'three'; const TIMMY_POS = new THREE.Vector3(0, 0, -2); +export const TIMMY_WORLD_POS = TIMMY_POS.clone(); const CRYSTAL_POS = new THREE.Vector3(0.6, 1.15, -4.1); const agentStates = { alpha: 'idle', beta: 'idle', gamma: 'idle', delta: 'idle' }; diff --git a/the-matrix/js/hud-labels.js b/the-matrix/js/hud-labels.js new file mode 100644 index 0000000..8e1432a --- /dev/null +++ b/the-matrix/js/hud-labels.js @@ -0,0 +1,175 @@ +/** + * hud-labels.js — AR floating HTML labels projected from 3D world positions. + * + * Each label is a
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; }