TURNCRAFT/src/player/input.ts
jing d2ff670848 Add Help & Settings menu (H), default view-bob off
- src/ui/Menu.ts: overlay with HOW TO PLAY (controls, the five repairs,
  tips) and SETTINGS (master volume, mouse sensitivity, view bob, render
  quality); persists to localStorage, auto-opens on first visit, corner
  badge + H toggles it.
- View bob now defaults OFF (playtest: reads as screen rumble to
  non-Minecraft players); re-enable in settings.
- New hooks: PlayerController.setSensitivity, InputController.setSensitivity,
  AudioEngine.setMasterVolume (safe pre-init), quality -> pixelRatio in main.
- Pause overlay suppressed while the menu is open; closing re-locks pointer.

Verified: menu renders over splash, both tabs, quality rescales the
framebuffer 2->1, settings persist, zero console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:08:01 +10:00

117 lines
4.1 KiB
TypeScript

// 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<string>();
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');
}
}