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 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-09 17:51:57 +02:00
parent 4bec70ba38
commit cf5d55bdf0
6 changed files with 104 additions and 2 deletions

View File

@ -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) |

View File

@ -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();
},
};

View File

@ -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);

View File

@ -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. */

View File

@ -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);
}

View File

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