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
This commit is contained in:
alexpaynex
2026-03-20 01:01:03 +00:00
parent f50d62332b
commit 9ef27bec9f
12 changed files with 791 additions and 97 deletions

16
.replit
View File

@@ -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

View File

@@ -25,6 +25,7 @@ const allowedOrigins: string[] = rawOrigins
"https://www.alexanderwhitestone.com",
"https://alexanderwhitestone.ai",
"https://www.alexanderwhitestone.ai",
"https://hermes.tailb74b2d.ts.net",
]
: [];

View File

@@ -1,6 +1,6 @@
{
"index.html": {
"file": "assets/index-BAg_vlZE.js",
"file": "assets/index-CBu1T9J9.js",
"name": "index",
"src": "index.html",
"isEntry": true

View File

@@ -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; }
</style>
<script type="module" crossorigin src="/tower/assets/index-BAg_vlZE.js"></script>
<script type="module" crossorigin src="/tower/assets/index-CBu1T9J9.js"></script>
</head>
<body>
<div id="hud">
@@ -400,6 +497,13 @@
<div id="connection-status">OFFLINE</div>
<div id="event-log"></div>
<!-- ── Timmy identity card ────────────────────────────────────────── -->
<div id="timmy-id-card">
<div class="id-label">TIMMY IDENTITY</div>
<div class="id-npub" id="timmy-npub" title="Click to copy Timmy's Nostr npub"></div>
<div class="id-zaps" id="timmy-zap-count">⚡ 0 zaps sent</div>
</div>
<!-- ── Top action buttons ─────────────────────────────────────────── -->
<div id="top-buttons">
<button id="open-panel-btn">⚡ SUBMIT JOB</button>
@@ -551,6 +655,22 @@
<div id="session-error"></div>
</div>
<!-- ── FPS crosshair ─────────────────────────────────────────────── -->
<div id="crosshair"></div>
<!-- ── Pointer-lock hint (desktop) ──────────────────────────────── -->
<div id="lock-hint">
<div class="lock-badge">CLICK TO ENTER · WASD TO MOVE · ESC TO EXIT</div>
</div>
<!-- ── Virtual joystick (mobile) ─────────────────────────────────── -->
<div id="joy-pad">
<div id="joy-nub"></div>
</div>
<!-- ── AR floating labels container ──────────────────────────────── -->
<div id="ar-labels" style="position:fixed;inset:0;pointer-events:none;z-index:15;overflow:hidden;"></div>
<div id="webgl-recovery-overlay">
<span class="recovery-text">GPU context lost — recovering...</span>
</div>

View File

@@ -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 => {

View File

@@ -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 @@
<div id="session-error"></div>
</div>
<!-- ── FPS crosshair ─────────────────────────────────────────────── -->
<div id="crosshair"></div>
<!-- ── Pointer-lock hint (desktop) ──────────────────────────────── -->
<div id="lock-hint">
<div class="lock-badge">CLICK TO ENTER · WASD TO MOVE · ESC TO EXIT</div>
</div>
<!-- ── Virtual joystick (mobile) ─────────────────────────────────── -->
<div id="joy-pad">
<div id="joy-nub"></div>
</div>
<!-- ── AR floating labels container ──────────────────────────────── -->
<div id="ar-labels" style="position:fixed;inset:0;pointer-events:none;z-index:15;overflow:hidden;"></div>
<div id="webgl-recovery-overlay">
<span class="recovery-text">GPU context lost — recovering...</span>
</div>

View File

@@ -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' };

175
the-matrix/js/hud-labels.js Normal file
View File

@@ -0,0 +1,175 @@
/**
* hud-labels.js — AR floating HTML labels projected from 3D world positions.
*
* Each label is a <div> 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 = `
<div class="ar-label-name" style="color:${color};font-family:Courier New,monospace;
font-size:11px;font-weight:bold;letter-spacing:2px;
text-shadow:0 0 10px ${color}88;white-space:nowrap;">
${name}
</div>
<div class="ar-label-role" style="color:${color}99;font-family:Courier New,monospace;
font-size:9px;letter-spacing:1px;text-transform:uppercase;white-space:nowrap;">
${role}
</div>
<div class="ar-label-state" style="margin-top:2px;">
<span class="ar-dot" style="display:inline-block;width:6px;height:6px;
border-radius:50%;background:${color};opacity:0.7;
box-shadow:0 0 6px ${color};margin-right:4px;vertical-align:middle;"></span>
<span class="ar-state-text" style="color:${color}bb;font-family:Courier New,monospace;
font-size:9px;letter-spacing:1px;">idle</span>
</div>
<div class="ar-label-tick" style="width:1px;height:14px;background:${color}55;
margin:3px auto 0;"></div>
`;
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 = `
<div style="color:${entry.color};font-weight:bold;letter-spacing:2px;font-size:12px;margin-bottom:6px;">
${id.toUpperCase()}
</div>
<div style="color:#aaa;margin-bottom:2px;">state&nbsp;&nbsp;: <span style="color:${entry.color}">${state}</span></div>
<div style="color:#aaa;margin-bottom:2px;">uptime : ${uptime}s</div>
<div style="color:#aaa;">network: <span style="color:#44ff88">connected</span></div>
`;
_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 320 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;
}

View File

@@ -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.
*/
let controls;
let _canvas;
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;
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)) {
const isTouch = event.pointerType === 'touch';
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();
event.preventDefault(); // suppress subsequent mouse events (ghost click)
}
}
}
}
}
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 });
}
_canvas = null;
}
if (controls) {
controls.dispose();
controls = null;
}
_camera = null;
_timmyGroup = null;
_applySlap = null;
}

View File

@@ -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';
});

235
the-matrix/js/navigation.js Normal file
View File

@@ -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();
}
}

View File

@@ -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;
}