- 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>
270 lines
11 KiB
TypeScript
270 lines
11 KiB
TypeScript
// 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 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>1–9 / 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);
|
||
}
|
||
}
|