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 = {};
|
||||
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', () => {
|
||||
if (!locked) renderer.domElement.requestPointerLock();
|
||||
if (locked || touchMode) return;
|
||||
tryRequestLock();
|
||||
});
|
||||
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', () => {
|
||||
locked = document.pointerLockElement === renderer.domElement;
|
||||
startGate.style.display = locked ? 'none' : 'grid';
|
||||
posPanel.style.display = locked ? 'block' : 'none';
|
||||
startGate.style.display = (locked || touchMode) ? 'none' : 'grid';
|
||||
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) => {
|
||||
if (!locked) return;
|
||||
player.yaw -= e.movementX * 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));
|
||||
applyLook(e.movementX, e.movementY, 0.0022);
|
||||
});
|
||||
|
||||
// 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) {
|
||||
if (!el) return false;
|
||||
const tag = (el.tagName || '').toLowerCase();
|
||||
@@ -3127,6 +3184,13 @@ function isTypingTarget(el) {
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
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;
|
||||
});
|
||||
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 wish = new THREE.Vector3();
|
||||
|
||||
// Allow walking with WASD/arrows even before pointer lock so the
|
||||
// character is never completely stuck on the overlay screen.
|
||||
// Allow walking with WASD even before pointer lock so the character is
|
||||
// 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['KeyS'] || keys['ArrowDown']) wish.sub(forward);
|
||||
if (keys['KeyA'] || keys['ArrowLeft']) wish.sub(right);
|
||||
if (keys['KeyD'] || keys['ArrowRight']) wish.add(right);
|
||||
if (keys['KeyA']) wish.sub(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) {
|
||||
// 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(right, mobileInput.x);
|
||||
if (Math.abs(mobileInput.x) > 0.15) {
|
||||
player.yaw -= mobileInput.x * 2.4 * dt;
|
||||
}
|
||||
}
|
||||
|
||||
if (wish.lengthSq() > 0) {
|
||||
@@ -3913,9 +3984,10 @@ function animate() {
|
||||
syncPositionThrottled();
|
||||
drawMinimap();
|
||||
|
||||
// Footsteps while the local player is actually walking.
|
||||
const walking = locked && (keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']
|
||||
|| keys['ArrowUp'] || keys['ArrowDown'] || keys['ArrowLeft'] || keys['ArrowRight']);
|
||||
// Footsteps while the local player is actually walking (not just turning).
|
||||
const walking = (locked || touchMode) && (keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']
|
||||
|| keys['ArrowUp'] || keys['ArrowDown']
|
||||
|| (mobileInput.active && Math.abs(mobileInput.y) > 0.15));
|
||||
cityAudio.footsteps(dt, walking && !isHospitalizedNow());
|
||||
|
||||
statsTimer += dt;
|
||||
@@ -4164,7 +4236,7 @@ function bindMobileJoystick() {
|
||||
|
||||
base.addEventListener('pointerdown', (e) => {
|
||||
activeId = e.pointerId;
|
||||
base.setPointerCapture(e.pointerId);
|
||||
try { base.setPointerCapture(e.pointerId); } catch (_) { /* synthetic or released pointer */ }
|
||||
const rect = base.getBoundingClientRect();
|
||||
setKnob(e.clientX - (rect.left + rect.width / 2), e.clientY - (rect.top + rect.height / 2));
|
||||
e.preventDefault();
|
||||
@@ -4397,6 +4469,18 @@ window.__city10 = {
|
||||
return out;
|
||||
},
|
||||
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; },
|
||||
setHour(h) {
|
||||
sim.hour = ((h % 24) + 24) % 24;
|
||||
|
||||
Reference in New Issue
Block a user