Files
timmy-tower/the-matrix/js/navigation.js
alexpaynex 9ef27bec9f 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
2026-03-20 01:01:03 +00:00

236 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
}
}