TURNCRAFT/src/ui/Menu.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

270 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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