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:
16
.replit
16
.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
|
||||
|
||||
@@ -25,6 +25,7 @@ const allowedOrigins: string[] = rawOrigins
|
||||
"https://www.alexanderwhitestone.com",
|
||||
"https://alexanderwhitestone.ai",
|
||||
"https://www.alexanderwhitestone.ai",
|
||||
"https://hermes.tailb74b2d.ts.net",
|
||||
]
|
||||
: [];
|
||||
|
||||
|
||||
2
the-matrix/dist/.vite/manifest.json
vendored
2
the-matrix/dist/.vite/manifest.json
vendored
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"index.html": {
|
||||
"file": "assets/index-BAg_vlZE.js",
|
||||
"file": "assets/index-CBu1T9J9.js",
|
||||
"name": "index",
|
||||
"src": "index.html",
|
||||
"isEntry": true
|
||||
|
||||
122
the-matrix/dist/index.html
vendored
122
the-matrix/dist/index.html
vendored
@@ -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>
|
||||
|
||||
2
the-matrix/dist/sw.js
vendored
2
the-matrix/dist/sw.js
vendored
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
175
the-matrix/js/hud-labels.js
Normal 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 : <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 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
235
the-matrix/js/navigation.js
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user