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:
+101
-17
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user