feat(city): add touch mode and drag-to-look camera controls

- Add touchMode flag and lookDrag state for non-pointer-lock input
- Implement dismissGate() and tryRequestLock() for graceful fallback
- Add applyLook() helper for camera rotation with pitch clamping
- Support drag-to-look via pointer events on renderer.domElement
- Prevent context menu and page scroll during active camera control
This commit is contained in:
2026-10-31 16:47:56 -03:00
parent bd6801994a
commit 4f497844bc
+101 -17
View File
@@ -3098,27 +3098,84 @@ const player = {
}; };
const keys = {}; const keys = {};
let locked = false; let locked = false;
// Gate dismissed without pointer lock (touch devices, or when the browser
// denies pointer lock). The camera is then steered with drag-to-look.
let touchMode = false;
let lookDrag = null;
function applyLook(dx, dy, sens) {
player.yaw -= dx * sens;
player.pitch -= dy * sens;
player.pitch = Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, player.pitch));
}
function dismissGate() {
touchMode = true;
startGate.style.display = 'none';
posPanel.style.display = 'block';
}
function tryRequestLock() {
try {
const p = renderer.domElement.requestPointerLock?.();
if (p && typeof p.catch === 'function') p.catch(() => dismissGate());
} catch (_) {
dismissGate();
}
}
renderer.domElement.addEventListener('click', () => { renderer.domElement.addEventListener('click', () => {
if (!locked) renderer.domElement.requestPointerLock(); if (locked || touchMode) return;
tryRequestLock();
}); });
startGate.addEventListener('click', () => { startGate.addEventListener('click', () => {
renderer.domElement.requestPointerLock(); // Touch devices have no pointer lock: start immediately and steer the
// camera by dragging on the scene (the virtual joystick drives walking).
if (window.matchMedia?.('(pointer: coarse)')?.matches) {
dismissGate();
return;
}
tryRequestLock();
}); });
document.addEventListener('pointerlockchange', () => { document.addEventListener('pointerlockchange', () => {
locked = document.pointerLockElement === renderer.domElement; locked = document.pointerLockElement === renderer.domElement;
startGate.style.display = locked ? 'none' : 'grid'; startGate.style.display = (locked || touchMode) ? 'none' : 'grid';
posPanel.style.display = locked ? 'block' : 'none'; posPanel.style.display = (locked || touchMode) ? 'block' : 'none';
});
document.addEventListener('pointerlockerror', () => {
// Lock denied (embedded context, automation, permissions): fall back to
// drag-to-look so the player is never stuck behind the gate.
dismissGate();
}); });
document.addEventListener('mousemove', (e) => { document.addEventListener('mousemove', (e) => {
if (!locked) return; if (!locked) return;
player.yaw -= e.movementX * 0.0022; applyLook(e.movementX, e.movementY, 0.0022);
player.pitch -= e.movementY * 0.0022;
player.pitch = Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, player.pitch));
}); });
// Drag-to-look fallback when pointer lock is not in effect: drag anywhere
// on the scene to turn (standard mobile first-person controls).
renderer.domElement.addEventListener('pointerdown', (e) => {
if (locked) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
lookDrag = { id: e.pointerId, x: e.clientX, y: e.clientY };
try { renderer.domElement.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ }
});
renderer.domElement.addEventListener('pointermove', (e) => {
if (locked || !lookDrag || lookDrag.id !== e.pointerId) return;
applyLook(e.clientX - lookDrag.x, e.clientY - lookDrag.y, 0.005);
lookDrag.x = e.clientX;
lookDrag.y = e.clientY;
});
const endLookDrag = (e) => {
if (lookDrag && lookDrag.id === e.pointerId) lookDrag = null;
};
renderer.domElement.addEventListener('pointerup', endLookDrag);
renderer.domElement.addEventListener('pointercancel', endLookDrag);
renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault());
function isTypingTarget(el) { function isTypingTarget(el) {
if (!el) return false; if (!el) return false;
const tag = (el.tagName || '').toLowerCase(); const tag = (el.tagName || '').toLowerCase();
@@ -3127,6 +3184,13 @@ function isTypingTarget(el) {
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (isTypingTarget(e.target)) return; if (isTypingTarget(e.target)) return;
// Keep arrows/space from scrolling the page while the scene controls
// are active (arrows turn the camera, space jumps).
if ((locked || touchMode)
&& (e.code === 'ArrowLeft' || e.code === 'ArrowRight'
|| e.code === 'ArrowUp' || e.code === 'ArrowDown' || e.code === 'Space')) {
e.preventDefault();
}
keys[e.code] = true; keys[e.code] = true;
}); });
document.addEventListener('keyup', (e) => { document.addEventListener('keyup', (e) => {
@@ -3360,16 +3424,23 @@ function updatePlayer(dt) {
const right = new THREE.Vector3(Math.cos(player.yaw), 0, -Math.sin(player.yaw)); const right = new THREE.Vector3(Math.cos(player.yaw), 0, -Math.sin(player.yaw));
const wish = new THREE.Vector3(); const wish = new THREE.Vector3();
// Allow walking with WASD/arrows even before pointer lock so the // Allow walking with WASD even before pointer lock so the character is
// character is never completely stuck on the overlay screen. // never completely stuck on the overlay screen. A/D strafe; the arrow
// keys turn the first-person camera instead of strafing, so every
// forward step always follows the direction the camera is facing.
if (keys['KeyW'] || keys['ArrowUp']) wish.add(forward); if (keys['KeyW'] || keys['ArrowUp']) wish.add(forward);
if (keys['KeyS'] || keys['ArrowDown']) wish.sub(forward); if (keys['KeyS'] || keys['ArrowDown']) wish.sub(forward);
if (keys['KeyA'] || keys['ArrowLeft']) wish.sub(right); if (keys['KeyA']) wish.sub(right);
if (keys['KeyD'] || keys['ArrowRight']) wish.add(right); if (keys['KeyD']) wish.add(right);
const turn = (keys['ArrowLeft'] ? 1 : 0) - (keys['ArrowRight'] ? 1 : 0);
if (turn) player.yaw += turn * 2.4 * dt;
if (mobileInput.active) { if (mobileInput.active) {
// Joystick: +y on screen is forward for the player. // Joystick: vertical walks along the camera facing, horizontal
// turns the camera (mobile first-person convention).
wish.addScaledVector(forward, -mobileInput.y); wish.addScaledVector(forward, -mobileInput.y);
wish.addScaledVector(right, mobileInput.x); if (Math.abs(mobileInput.x) > 0.15) {
player.yaw -= mobileInput.x * 2.4 * dt;
}
} }
if (wish.lengthSq() > 0) { if (wish.lengthSq() > 0) {
@@ -3913,9 +3984,10 @@ function animate() {
syncPositionThrottled(); syncPositionThrottled();
drawMinimap(); drawMinimap();
// Footsteps while the local player is actually walking. // Footsteps while the local player is actually walking (not just turning).
const walking = locked && (keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD'] const walking = (locked || touchMode) && (keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']
|| keys['ArrowUp'] || keys['ArrowDown'] || keys['ArrowLeft'] || keys['ArrowRight']); || keys['ArrowUp'] || keys['ArrowDown']
|| (mobileInput.active && Math.abs(mobileInput.y) > 0.15));
cityAudio.footsteps(dt, walking && !isHospitalizedNow()); cityAudio.footsteps(dt, walking && !isHospitalizedNow());
statsTimer += dt; statsTimer += dt;
@@ -4164,7 +4236,7 @@ function bindMobileJoystick() {
base.addEventListener('pointerdown', (e) => { base.addEventListener('pointerdown', (e) => {
activeId = e.pointerId; activeId = e.pointerId;
base.setPointerCapture(e.pointerId); try { base.setPointerCapture(e.pointerId); } catch (_) { /* synthetic or released pointer */ }
const rect = base.getBoundingClientRect(); const rect = base.getBoundingClientRect();
setKnob(e.clientX - (rect.left + rect.width / 2), e.clientY - (rect.top + rect.height / 2)); setKnob(e.clientX - (rect.left + rect.width / 2), e.clientY - (rect.top + rect.height / 2));
e.preventDefault(); e.preventDefault();
@@ -4397,6 +4469,18 @@ window.__city10 = {
return out; return out;
}, },
playerCrossingAllowed, playerCrossingAllowed,
/** Local player pose for movement/look tests. */
playerPose() {
return {
x: +player.pos.x.toFixed(3),
y: +player.pos.y.toFixed(3),
z: +player.pos.z.toFixed(3),
yaw: +player.yaw.toFixed(4),
pitch: +player.pitch.toFixed(4),
locked,
touchMode,
};
},
get barriersDown() { return trainSystem ? trainSystem.blocks : null; }, get barriersDown() { return trainSystem ? trainSystem.blocks : null; },
setHour(h) { setHour(h) {
sim.hour = ((h % 24) + 24) % 24; sim.hour = ((h % 24) + 24) % 24;