- 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>
117 lines
4.1 KiB
TypeScript
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');
|
|
}
|
|
}
|