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
236 lines
8.9 KiB
JavaScript
236 lines
8.9 KiB
JavaScript
/**
|
||
* 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();
|
||
}
|
||
}
|