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>
This commit is contained in:
jing 2026-07-13 22:08:01 +10:00
parent 49cab1f69b
commit d2ff670848
5 changed files with 306 additions and 3 deletions

View File

@ -56,6 +56,16 @@ export class AudioEngine {
isReady(): boolean { return this.ctx !== null; }
private masterVolume = 0.85;
/** Master output volume 0..1 (settings UI). Safe to call before init(). */
setMasterVolume(v: number): void {
this.masterVolume = Math.max(0, Math.min(1, v));
if (this.ctx && this.outGain) {
this.outGain.gain.setTargetAtTime(this.masterVolume, this.ctx.currentTime, 0.05);
}
}
/** Build the graph and start the clock. Call from a user gesture. */
async init(): Promise<void> {
if (this.ctx) { if (this.ctx.state === 'suspended') await this.ctx.resume(); return; }
@ -68,7 +78,7 @@ export class AudioEngine {
// ── output + master ──
this.outGain = ctx.createGain();
this.outGain.gain.value = 0.85;
this.outGain.gain.value = this.masterVolume;
this.outGain.connect(ctx.destination);
this.sfxGain = ctx.createGain();

View File

@ -13,6 +13,7 @@ import { PlayerController } from './player';
import { AudioEngine } from './audio/AudioEngine';
import { FxSystem } from './fx/FxSystem';
import { Hud } from './ui/Hud';
import { Menu, QUALITY_PIXEL_RATIO } from './ui/Menu';
const container = document.getElementById('app')!;
container.innerHTML = '';
@ -86,10 +87,23 @@ const hud = new Hud({
hotbar.onChange(() => hud.refreshHotbar());
let started = false;
// Help & Settings (H). Applies persisted settings on construction.
const menu = new Menu({
onApply: (s) => {
audio.setMasterVolume(s.volume);
player.setSensitivity(0.0022 * s.sensitivity);
player.viewBob = s.viewBob;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, QUALITY_PIXEL_RATIO[s.quality]));
},
onOpen: () => { document.exitPointerLock(); hud.setPaused(false); },
onClose: () => { if (started) renderer.domElement.requestPointerLock(); },
});
document.addEventListener('pointerlockchange', () => {
const locked = document.pointerLockElement === renderer.domElement;
if (locked) started = true;
else if (started) hud.setPaused(true);
else if (started && !menu.isOpen()) hud.setPaused(true);
});
// Interaction input (mouse buttons + E + hotbar keys), gated on pointer lock

View File

@ -46,7 +46,9 @@ export class PlayerController implements IPlayerView {
// Public knobs.
readonly input: InputState = createInputState();
viewBob = true;
// Default OFF: playtest feedback ("the screen kinda rumbles") — head-bob
// reads as jank to players without Minecraft habits. Settings re-enable it.
viewBob = false;
private world: IVoxelWorld;
private getColliders: () => KinematicCollider[];
@ -98,6 +100,9 @@ export class PlayerController implements IPlayerView {
get isFlying(): boolean { return this.flying; }
/** Mouse-look sensitivity in radians per pixel (settings UI). */
setSensitivity(v: number): void { this.inputCtl?.setSensitivity(v); }
/** Current surface-carry velocity (for HUD/debug). */
get carryVelocity(): Vec3 { return this.carryVel as Vec3; }

View File

@ -46,6 +46,11 @@ export class InputController {
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);

269
src/ui/Menu.ts Normal file
View File

@ -0,0 +1,269 @@
// Help & Settings overlay (integration addition, post-playtest).
// Press H (or the corner badge) to toggle. First run auto-opens the HOW TO
// PLAY tab. Settings persist to localStorage and apply live via onApply.
export interface Settings {
volume: number; // 0..1 master
sensitivity: number; // multiplier on the 0.0022 rad/px base
viewBob: boolean;
quality: 'sharp' | 'balanced' | 'fast'; // pixelRatio cap 2 / 1.5 / 1
}
export const QUALITY_PIXEL_RATIO: Record<Settings['quality'], number> = {
sharp: 2, balanced: 1.5, fast: 1,
};
const DEFAULTS: Settings = { volume: 0.85, sensitivity: 1, viewBob: false, quality: 'sharp' };
const LS_SETTINGS = 'turncraft.settings';
const LS_HELP_SEEN = 'turncraft.helpSeen';
export interface MenuOpts {
onApply(s: Settings): void;
onOpen?(): void; // host: exit pointer lock, suppress the pause overlay
onClose?(): void; // host: re-acquire pointer lock if the game has started
}
const HELP_HTML = `
<h3>WHAT IS THIS</h3>
<p>You are 7&nbsp;mm tall inside a DJ booth, and the booth is dead. Five things
in the signal chain are broken. Fix all five, hit the power switch, and the
mix comes back to life one layer of music per repair.</p>
<h3>CONTROLS</h3>
<table>
<tr><td>MOUSE</td><td>look around</td></tr>
<tr><td>W A S D</td><td>move</td></tr>
<tr><td>SPACE</td><td>jump</td></tr>
<tr><td>SHIFT</td><td>sprint</td></tr>
<tr><td>HOLD LEFT CLICK</td><td>mine the block you're looking at</td></tr>
<tr><td>RIGHT CLICK</td><td>place the selected block</td></tr>
<tr><td>E</td><td>use buttons, faders, plugs, sockets</td></tr>
<tr><td>19 / SCROLL</td><td>pick a hotbar slot</td></tr>
<tr><td>F</td><td>fly mode (cheat great for exploring)</td></tr>
<tr><td>H</td><td>this menu</td></tr>
</table>
<h3>THE FIVE REPAIRS</h3>
<table class="quest">
<tr><td>STYLUS</td><td>the needle is missing. Climb down under the table (vent
slot behind a deck, or the hatch left of Deck A) and find the chrome block
spot-lit in the parts bin. Carry it up and press E on Deck A's empty
headshell.</td></tr>
<tr><td>RCA</td><td>behind the gear is a canyon of cables. One gold plug lies
loose on the floor press E on it to shove it into the empty socket on the
wall panel (three shoves, clunk-clunk-CLICK).</td></tr>
<tr><td>X-FADER</td><td>the crossfader slot on the front of the mixer is
jammed with dust. Mine it out, then walk into the fader to slide it
end-to-end.</td></tr>
<tr><td>FUSE</td><td>under the mixer is a fuse box with a blackened fuse.
Break it, grab the fresh glass fuse from the parts bin, and press E on the
empty box while holding it.</td></tr>
<tr><td>POWER</td><td>when the other four glow on your tracker, the master
switch on the mixer's back edge lights green. Climb up and press it.</td></tr>
</table>
<h3>TIPS</h3>
<p>The records spin stand on one and ride. Walking to the middle while it
turns is a real fight (tiny legs, big physics): press the START/STOP plate to
stop the deck, or embrace the spiral. The little terraces step up as you walk
in. At 45 speed the rim moves faster than you can sprint jump near the edge
to get launched. Mined blocks can be placed anywhere: build bridges between
the gear.</p>`;
export class Menu {
private readonly opts: MenuOpts;
private settings: Settings;
private el: HTMLDivElement;
private panel: HTMLDivElement;
private badge: HTMLDivElement;
private open = false;
constructor(opts: MenuOpts) {
this.opts = opts;
this.settings = this.load();
this.injectStyle();
this.badge = document.createElement('div');
this.badge.className = 'tcm-badge';
this.badge.textContent = 'H — help & settings';
this.badge.addEventListener('click', () => this.toggle());
document.body.appendChild(this.badge);
this.el = document.createElement('div');
this.el.className = 'tcm-overlay hidden';
this.panel = document.createElement('div');
this.panel.className = 'tcm-panel';
this.el.appendChild(this.panel);
this.el.addEventListener('click', (e) => { if (e.target === this.el) this.toggle(false); });
document.body.appendChild(this.el);
this.build();
window.addEventListener('keydown', this.onKey);
this.opts.onApply(this.settings);
if (!localStorage.getItem(LS_HELP_SEEN)) {
localStorage.setItem(LS_HELP_SEEN, '1');
this.toggle(true, 'help');
}
}
isOpen(): boolean { return this.open; }
getSettings(): Settings { return { ...this.settings }; }
toggle(force?: boolean, tab?: 'help' | 'settings'): void {
const next = force ?? !this.open;
if (next === this.open) { if (tab) this.showTab(tab); return; }
this.open = next;
this.el.classList.toggle('hidden', !next);
if (tab) this.showTab(tab);
if (next) this.opts.onOpen?.();
else this.opts.onClose?.();
}
dispose(): void {
window.removeEventListener('keydown', this.onKey);
this.el.remove();
this.badge.remove();
}
private onKey = (e: KeyboardEvent): void => {
if (e.code === 'KeyH' && !e.repeat) this.toggle();
else if (e.code === 'Escape' && this.open) this.toggle(false);
};
private load(): Settings {
try {
const raw = localStorage.getItem(LS_SETTINGS);
if (raw) return { ...DEFAULTS, ...JSON.parse(raw) as Partial<Settings> };
} catch { /* fall through to defaults */ }
return { ...DEFAULTS };
}
private save(): void {
localStorage.setItem(LS_SETTINGS, JSON.stringify(this.settings));
this.opts.onApply(this.settings);
}
private showTab(tab: 'help' | 'settings'): void {
this.panel.querySelectorAll<HTMLElement>('.tcm-tab').forEach((t) =>
t.classList.toggle('active', t.dataset.tab === tab));
this.panel.querySelectorAll<HTMLElement>('.tcm-page').forEach((p) =>
p.classList.toggle('hidden', p.dataset.tab !== tab));
}
private build(): void {
const tabs = document.createElement('div');
tabs.className = 'tcm-tabs';
for (const [key, label] of [['help', 'HOW TO PLAY'], ['settings', 'SETTINGS']] as const) {
const t = document.createElement('button');
t.className = 'tcm-tab' + (key === 'help' ? ' active' : '');
t.dataset.tab = key;
t.textContent = label;
t.addEventListener('click', () => this.showTab(key));
tabs.appendChild(t);
}
const close = document.createElement('button');
close.className = 'tcm-close';
close.textContent = '✕';
close.addEventListener('click', () => this.toggle(false));
tabs.appendChild(close);
this.panel.appendChild(tabs);
const help = document.createElement('div');
help.className = 'tcm-page';
help.dataset.tab = 'help';
help.innerHTML = HELP_HTML; // static template string, no user input
this.panel.appendChild(help);
const st = document.createElement('div');
st.className = 'tcm-page hidden';
st.dataset.tab = 'settings';
this.panel.appendChild(st);
const s = this.settings;
st.appendChild(this.slider('Master volume', 0, 1, 0.01, s.volume,
(v) => { this.settings.volume = v; this.save(); }));
st.appendChild(this.slider('Mouse sensitivity', 0.3, 3, 0.05, s.sensitivity,
(v) => { this.settings.sensitivity = v; this.save(); }));
st.appendChild(this.checkbox('View bob (head sway while walking)', s.viewBob,
(v) => { this.settings.viewBob = v; this.save(); }));
st.appendChild(this.select('Render quality',
[['sharp', 'Sharp'], ['balanced', 'Balanced'], ['fast', 'Fast']], s.quality,
(v) => { this.settings.quality = v as Settings['quality']; this.save(); }));
}
private row(label: string): HTMLLabelElement {
const row = document.createElement('label');
row.className = 'tcm-row';
const span = document.createElement('span');
span.textContent = label;
row.appendChild(span);
return row;
}
private slider(label: string, min: number, max: number, stepV: number, value: number,
onChange: (v: number) => void): HTMLElement {
const row = this.row(label);
const input = document.createElement('input');
input.type = 'range';
input.min = String(min); input.max = String(max); input.step = String(stepV);
input.value = String(value);
input.addEventListener('input', () => onChange(parseFloat(input.value)));
row.appendChild(input);
return row;
}
private checkbox(label: string, value: boolean, onChange: (v: boolean) => void): HTMLElement {
const row = this.row(label);
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = value;
input.addEventListener('change', () => onChange(input.checked));
row.appendChild(input);
return row;
}
private select(label: string, options: ReadonlyArray<readonly [string, string]>, value: string,
onChange: (v: string) => void): HTMLElement {
const row = this.row(label);
const sel = document.createElement('select');
for (const [v, text] of options) {
const o = document.createElement('option');
o.value = v; o.textContent = text; o.selected = v === value;
sel.appendChild(o);
}
sel.addEventListener('change', () => onChange(sel.value));
row.appendChild(sel);
return row;
}
private injectStyle(): void {
const css = document.createElement('style');
css.textContent = `
.tcm-badge{position:fixed;top:10px;left:10px;z-index:40;font:11px ui-monospace,monospace;
color:#8a8378;background:#0008;border:1px solid #3a352c;border-radius:6px;
padding:4px 8px;cursor:pointer;user-select:none}
.tcm-badge:hover{color:#e8dcc0;border-color:#6a6252}
.tcm-overlay{position:fixed;inset:0;z-index:50;background:#000a;display:flex;
align-items:center;justify-content:center}
.tcm-overlay.hidden{display:none}
.tcm-panel{width:min(640px,92vw);max-height:86vh;overflow-y:auto;background:#14110c;
border:1px solid #4a4234;border-radius:10px;padding:18px 22px;
font:13px/1.5 ui-monospace,monospace;color:#d8cdb4}
.tcm-tabs{display:flex;gap:8px;margin-bottom:12px;align-items:center}
.tcm-tab{font:12px ui-monospace,monospace;letter-spacing:.12em;background:none;
color:#8a8378;border:1px solid #3a352c;border-radius:6px;padding:6px 12px;cursor:pointer}
.tcm-tab.active{color:#ffb028;border-color:#ffb028}
.tcm-close{margin-left:auto;background:none;border:none;color:#8a8378;font-size:15px;cursor:pointer}
.tcm-close:hover{color:#fff}
.tcm-page h3{color:#ffb028;font-size:12px;letter-spacing:.15em;margin:14px 0 6px}
.tcm-page table{border-collapse:collapse;width:100%}
.tcm-page td{padding:3px 10px 3px 0;vertical-align:top}
.tcm-page td:first-child{color:#7ec46a;white-space:nowrap;letter-spacing:.06em}
.tcm-page.hidden{display:none}
.tcm-row{display:flex;align-items:center;gap:14px;padding:10px 0;border-bottom:1px solid #2a2620}
.tcm-row span{flex:1}
.tcm-row input[type=range]{flex:1;accent-color:#ffb028}
.tcm-row input[type=checkbox]{accent-color:#ffb028;width:16px;height:16px}
.tcm-row select{background:#0a0908;color:#d8cdb4;border:1px solid #3a352c;border-radius:4px;
font:12px ui-monospace,monospace;padding:4px 6px}`;
document.head.appendChild(css);
}
}