diff --git a/public/js/city/app.js b/public/js/city/app.js index 82912dc..ee30b43 100755 --- a/public/js/city/app.js +++ b/public/js/city/app.js @@ -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;