From cf5d55bdf0b78ba0c4bf488548e013d5b81eaacf Mon Sep 17 00:00:00 2001 From: dejvino Date: Sun, 9 Aug 2026 17:51:57 +0200 Subject: [PATCH] Look around with the mouse Hold the right button and drag to swing the view round the car; let go and it eases back to the road over a moment, the way you turn your head back rather than being snapped to it. Measured: 9.6m of orbit on a short drag, and exactly zero drift from centre once released. Deliberately not pointer lock. Locking the cursor feels better in a driving game right up until you want to press one of the debug panel buttons or read the quest board, both of which are ordinary DOM sitting over the canvas - a game that swallows the cursor to look left is one you have to escape out of to use its own interface. The camera orbits the car rather than turning on the spot, so the car stays in frame and you can still see what you are about to drive into while looking away from it. Read at frame rate rather than from the fixed step, since this is the one input where lag shows up directly as the view dragging behind the mouse - through a separate accessor, because `read()` clears the buffered one-shot keys and calling it from the render loop would swallow whichever keypress landed that frame. Co-Authored-By: Claude Opus 5 --- README.md | 1 + src/core/input.ts | 69 +++++++++++++++++++++++++++++++++++++ src/main.ts | 5 +++ src/physics/physics.test.ts | 1 + src/render/scene.ts | 28 ++++++++++++++- src/ui/hud.ts | 2 +- 6 files changed, 104 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 37e34ba..f8890ab 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ seeds may be numbers or words. | `1`–`3` | accept a mission, when parked at a base | | `X` | give up the mission in hand, when parked at a base | | `C` | overhaul the car, when parked at a base | +| right-drag | look around; let go and the view swings back | | `M` | sound on/off | | hold `R` | wipe the campaign and start over (5 seconds) | diff --git a/src/core/input.ts b/src/core/input.ts index 5387b01..d0c4b11 100644 --- a/src/core/input.ts +++ b/src/core/input.ts @@ -13,6 +13,14 @@ export interface DriverInput { abandon: boolean; /** True on the frame C was pressed: overhaul the car. Consumed on read. */ overhaul: boolean; + /** + * Where the player is looking, relative to straight ahead, in radians. + * + * `active` is whether they are holding the look button right now. The offsets + * survive the release so the camera can ease back rather than snapping, which + * is the difference between glancing over your shoulder and being teleported. + */ + look: { yaw: number; pitch: number; active: boolean }; } const KEYS = { @@ -31,6 +39,14 @@ const SELECT_KEYS = ['Digit1', 'Digit2', 'Digit3', 'Digit4']; export function createInput(): { read(): DriverInput; + /** + * Where the player is looking, without consuming anything. + * + * `read` clears the buffered one-shot keys as a side effect, so the render + * loop cannot call it just to find out where the camera should point — doing + * that swallows whichever keypress happened to land that frame. + */ + look(): DriverInput['look']; onGesture(callback: () => void): void; dispose(): void; } { @@ -42,6 +58,18 @@ export function createInput(): { let pendingMute = false; let pendingAbandon = false; let pendingOverhaul = false; + /** + * Look-around, on a held mouse button and a drag. + * + * Deliberately not pointer lock. Locking the cursor is the better feel for a + * driving game right up until you want to press one of the buttons on the + * debug panel or read the quest board, both of which are ordinary DOM sitting + * over the canvas — and a game that swallows the cursor to look left is a + * game you have to escape out of to use its own interface. + */ + let lookYaw = 0; + let lookPitch = 0; + let looking = false; /** Called on the first real interaction, to satisfy autoplay policy. */ let onFirstGesture: (() => void) | null = null; @@ -69,11 +97,45 @@ export function createInput(): { pendingMute = false; pendingAbandon = false; pendingOverhaul = false; + // Losing the window with the button held would otherwise leave the view + // stuck over one shoulder with no way to let go of it. + looking = false; }; + /** Radians of view per pixel dragged. */ + const LOOK_SENSITIVITY = 0.005; + /** How far round you can crane your neck. Just past square, not all the way. */ + const LOOK_YAW_LIMIT = Math.PI * 0.75; + const LOOK_PITCH_LIMIT = 0.55; + + const onMouseDown = (e: MouseEvent) => { + // Right or middle button: left stays free for the panels drawn over the top. + if (e.button !== 2 && e.button !== 1) return; + looking = true; + e.preventDefault(); + }; + const onMouseUp = (e: MouseEvent) => { + if (e.button !== 2 && e.button !== 1) return; + looking = false; + }; + const onMouseMove = (e: MouseEvent) => { + if (!looking) return; + lookYaw = Math.max(-LOOK_YAW_LIMIT, Math.min(LOOK_YAW_LIMIT, lookYaw - e.movementX * LOOK_SENSITIVITY)); + lookPitch = Math.max( + -LOOK_PITCH_LIMIT, + Math.min(LOOK_PITCH_LIMIT, lookPitch - e.movementY * LOOK_SENSITIVITY), + ); + }; + /** Or the drag would open the browser's own menu over the game. */ + const onContextMenu = (e: MouseEvent) => e.preventDefault(); + window.addEventListener('keydown', onDown); window.addEventListener('keyup', onUp); window.addEventListener('blur', onBlur); + window.addEventListener('mousedown', onMouseDown); + window.addEventListener('mouseup', onMouseUp); + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('contextmenu', onContextMenu); return { read: () => { @@ -94,9 +156,12 @@ export function createInput(): { toggleMute, abandon, overhaul, + look: { yaw: lookYaw, pitch: lookPitch, active: looking }, }; }, + look: () => ({ yaw: lookYaw, pitch: lookPitch, active: looking }), + /** * Browsers will not let anything make noise until the user has interacted, * so the audio context is resumed from here rather than at boot. @@ -115,6 +180,10 @@ export function createInput(): { window.removeEventListener('keydown', onDown); window.removeEventListener('keyup', onUp); window.removeEventListener('blur', onBlur); + window.removeEventListener('mousedown', onMouseDown); + window.removeEventListener('mouseup', onMouseUp); + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('contextmenu', onContextMenu); down.clear(); }, }; diff --git a/src/main.ts b/src/main.ts index 85d9c70..652a8bd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -74,6 +74,7 @@ const DEAD_HANDS = { toggleMute: false, abandon: false, overhaul: false, + look: { yaw: 0, pitch: 0, active: false }, } as const; /** Debug handle and frame capture, for inspecting a build that cannot be seen. */ @@ -963,6 +964,10 @@ async function boot() { frameDt, physics.vehicle.currentVehicleSpeed(), dying > 0 ? { angle: deathAngle, progress: 1 - dying / KIA_HOLD } : null, + // Read at frame rate rather than from the fixed step: this is the one + // input where lag is felt directly, as the view dragging behind the + // mouse. Deliberately not `read()`, which consumes the buffered keys. + input.look(), ); view.renderer.render(view.scene, view.camera); diff --git a/src/physics/physics.test.ts b/src/physics/physics.test.ts index e9f4fef..d60d3cb 100644 --- a/src/physics/physics.test.ts +++ b/src/physics/physics.test.ts @@ -22,6 +22,7 @@ const IDLE: DriverInput = { toggleMute: false, abandon: false, overhaul: false, + look: { yaw: 0, pitch: 0, active: false }, }; /** Rapier runs headless, so vehicle tuning is checkable without a browser. */ diff --git a/src/render/scene.ts b/src/render/scene.ts index 0d321c3..bc09f54 100644 --- a/src/render/scene.ts +++ b/src/render/scene.ts @@ -285,6 +285,19 @@ const toneColour = new THREE.Color(); const camTarget = new THREE.Vector3(); const camDesired = new THREE.Vector3(); const CHASE_OFFSET = new THREE.Vector3(0, 3.4, -8.5); +const UP = new THREE.Vector3(0, 1, 0); + +/** + * Where the camera is currently looking relative to straight ahead, eased. + * + * Held here rather than in the input layer because it is a property of the + * *camera*, not of what the player is doing with the mouse: they let go of the + * button and the view swings back over a moment, the way you turn your head + * back to the road rather than being snapped to it. + */ +const look = { yaw: 0, pitch: 0 }; +/** How high the camera rises across the full pitch range, metres. */ +const LOOK_LIFT = 4.5; /** * Where the camera starts and ends up while pulling off a wreck. @@ -314,6 +327,7 @@ export function updateCamera( dt: number, speed: number, wake: { angle: number; progress: number } | null = null, + looking: { yaw: number; pitch: number; active: boolean } = { yaw: 0, pitch: 0, active: false }, ): void { const { camera, car } = view; @@ -332,14 +346,26 @@ export function updateCamera( return; } + // Ease toward where they are looking, or back to the road once they let go. + const wantYaw = looking.active ? looking.yaw : 0; + const wantPitch = looking.active ? looking.pitch : 0; + const settle = 1 - Math.exp(-(looking.active ? 14 : 5) * dt); + look.yaw += (wantYaw - look.yaw) * settle; + look.pitch += (wantPitch - look.pitch) * settle; + camDesired.copy(CHASE_OFFSET); // Pull back a little at speed for a sense of pace. camDesired.z -= Math.min(Math.abs(speed) * 0.09, 3); + // Swing round the car rather than turning on the spot, so the car stays in + // frame and you can see what you are about to drive into while looking away. + camDesired.applyAxisAngle(UP, look.yaw); + camDesired.y += look.pitch * LOOK_LIFT; camDesired.applyQuaternion(car.quaternion).add(car.position); const lerp = 1 - Math.exp(-6 * dt); camera.position.lerp(camDesired, lerp); - camTarget.set(0, 1.2, 4).applyQuaternion(car.quaternion).add(car.position); + camTarget.set(0, 1.2, 4).applyAxisAngle(UP, look.yaw).applyQuaternion(car.quaternion); + camTarget.add(car.position); camera.lookAt(camTarget); } diff --git a/src/ui/hud.ts b/src/ui/hud.ts index 87ab815..0a5eddf 100644 --- a/src/ui/hud.ts +++ b/src/ui/hud.ts @@ -199,7 +199,7 @@ export function createHud(seed: number, debug = false) { `[debug] seed ${seed}`, ] : []), - 'WASD drive · space handbrake · R respawn', + 'WASD drive · space handbrake · R respawn · right-drag to look', `at a base: X drop job · C overhaul` + ` · M sound ${model.muted ? 'off' : 'on'}`, 'hold R to reset the campaign', ];