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', ];