// Lane B — input. The physics reads a plain InputState object; the demo (or the // integrator) can either drive it with a real InputController bound to the DOM, // or write its fields directly for scripted tests. Keeping the two separate is // what makes the controller testable without a browser. export interface InputState { moveX: number; // strafe: -1 (left) .. +1 (right) moveZ: number; // forward: -1 (back) .. +1 (forward) jump: boolean; // Space (used as jump on the ground, up while flying) sprint: boolean; // Shift flyUp: boolean; // Space, while flying flyDown: boolean; // Ctrl, while flying yaw: number; // radians, absolute (mouse look, around +Y) pitch: number; // radians, absolute, clamped to just under ±90° toggleFly: boolean; // one-shot edge; the controller consumes and clears it } export function createInputState(): InputState { return { moveX: 0, moveZ: 0, jump: false, sprint: false, flyUp: false, flyDown: false, yaw: 0, pitch: 0, toggleFly: false, }; } const PITCH_LIMIT = Math.PI / 2 - 0.01; /** * Binds keyboard + pointer-lock mouse look to an InputState. Space and Ctrl map * to both jump/flyUp and flyDown so the controller can decide per-mode which to * use. Call dispose() to detach all listeners. */ export class InputController { private keys = new Set(); private locked = false; constructor( private dom: HTMLElement, private state: InputState, private sensitivity = 0.0022, ) { window.addEventListener('keydown', this.onKeyDown); window.addEventListener('keyup', this.onKeyUp); window.addEventListener('blur', this.onBlur); this.dom.addEventListener('click', this.onClick); document.addEventListener('pointerlockchange', this.onLockChange); document.addEventListener('mousemove', this.onMouseMove); } /** Mouse-look sensitivity in radians per pixel (default 0.0022). */ setSensitivity(v: number): void { this.sensitivity = v; } dispose(): void { window.removeEventListener('keydown', this.onKeyDown); window.removeEventListener('keyup', this.onKeyUp); window.removeEventListener('blur', this.onBlur); this.dom.removeEventListener('click', this.onClick); document.removeEventListener('pointerlockchange', this.onLockChange); document.removeEventListener('mousemove', this.onMouseMove); } get isLocked(): boolean { return this.locked; } private onClick = (): void => { if (!this.locked) this.dom.requestPointerLock(); }; private onLockChange = (): void => { this.locked = document.pointerLockElement === this.dom; if (!this.locked) this.releaseAll(); }; private onBlur = (): void => { this.releaseAll(); }; private releaseAll(): void { this.keys.clear(); this.syncMovement(); } private onMouseMove = (e: MouseEvent): void => { if (!this.locked) return; this.state.yaw -= e.movementX * this.sensitivity; this.state.pitch -= e.movementY * this.sensitivity; if (this.state.pitch > PITCH_LIMIT) this.state.pitch = PITCH_LIMIT; if (this.state.pitch < -PITCH_LIMIT) this.state.pitch = -PITCH_LIMIT; }; private onKeyDown = (e: KeyboardEvent): void => { // Only drive the player while the pointer is locked — mirrors onMouseMove, so // WASD/F don't move the character when the game doesn't have focus. if (!this.locked || e.repeat) return; if (e.code === 'KeyF') { this.state.toggleFly = true; return; } this.keys.add(e.code); this.syncMovement(); }; private onKeyUp = (e: KeyboardEvent): void => { if (!this.locked) return; // keys are already cleared by releaseAll() on unlock this.keys.delete(e.code); this.syncMovement(); }; private syncMovement(): void { const k = this.keys; const fwd = (k.has('KeyW') ? 1 : 0) - (k.has('KeyS') ? 1 : 0); const str = (k.has('KeyD') ? 1 : 0) - (k.has('KeyA') ? 1 : 0); this.state.moveZ = fwd; this.state.moveX = str; this.state.sprint = k.has('ShiftLeft') || k.has('ShiftRight'); const space = k.has('Space'); this.state.jump = space; this.state.flyUp = space; this.state.flyDown = k.has('ControlLeft') || k.has('ControlRight'); } }