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:
parent
49cab1f69b
commit
d2ff670848
@ -56,6 +56,16 @@ export class AudioEngine {
|
|||||||
|
|
||||||
isReady(): boolean { return this.ctx !== null; }
|
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. */
|
/** Build the graph and start the clock. Call from a user gesture. */
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
if (this.ctx) { if (this.ctx.state === 'suspended') await this.ctx.resume(); return; }
|
if (this.ctx) { if (this.ctx.state === 'suspended') await this.ctx.resume(); return; }
|
||||||
@ -68,7 +78,7 @@ export class AudioEngine {
|
|||||||
|
|
||||||
// ── output + master ──
|
// ── output + master ──
|
||||||
this.outGain = ctx.createGain();
|
this.outGain = ctx.createGain();
|
||||||
this.outGain.gain.value = 0.85;
|
this.outGain.gain.value = this.masterVolume;
|
||||||
this.outGain.connect(ctx.destination);
|
this.outGain.connect(ctx.destination);
|
||||||
|
|
||||||
this.sfxGain = ctx.createGain();
|
this.sfxGain = ctx.createGain();
|
||||||
|
|||||||
16
src/main.ts
16
src/main.ts
@ -13,6 +13,7 @@ import { PlayerController } from './player';
|
|||||||
import { AudioEngine } from './audio/AudioEngine';
|
import { AudioEngine } from './audio/AudioEngine';
|
||||||
import { FxSystem } from './fx/FxSystem';
|
import { FxSystem } from './fx/FxSystem';
|
||||||
import { Hud } from './ui/Hud';
|
import { Hud } from './ui/Hud';
|
||||||
|
import { Menu, QUALITY_PIXEL_RATIO } from './ui/Menu';
|
||||||
|
|
||||||
const container = document.getElementById('app')!;
|
const container = document.getElementById('app')!;
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
@ -86,10 +87,23 @@ const hud = new Hud({
|
|||||||
hotbar.onChange(() => hud.refreshHotbar());
|
hotbar.onChange(() => hud.refreshHotbar());
|
||||||
|
|
||||||
let started = false;
|
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', () => {
|
document.addEventListener('pointerlockchange', () => {
|
||||||
const locked = document.pointerLockElement === renderer.domElement;
|
const locked = document.pointerLockElement === renderer.domElement;
|
||||||
if (locked) started = true;
|
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
|
// Interaction input (mouse buttons + E + hotbar keys), gated on pointer lock
|
||||||
|
|||||||
@ -46,7 +46,9 @@ export class PlayerController implements IPlayerView {
|
|||||||
|
|
||||||
// Public knobs.
|
// Public knobs.
|
||||||
readonly input: InputState = createInputState();
|
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 world: IVoxelWorld;
|
||||||
private getColliders: () => KinematicCollider[];
|
private getColliders: () => KinematicCollider[];
|
||||||
@ -98,6 +100,9 @@ export class PlayerController implements IPlayerView {
|
|||||||
|
|
||||||
get isFlying(): boolean { return this.flying; }
|
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). */
|
/** Current surface-carry velocity (for HUD/debug). */
|
||||||
get carryVelocity(): Vec3 { return this.carryVel as Vec3; }
|
get carryVelocity(): Vec3 { return this.carryVel as Vec3; }
|
||||||
|
|
||||||
|
|||||||
@ -46,6 +46,11 @@ export class InputController {
|
|||||||
document.addEventListener('mousemove', this.onMouseMove);
|
document.addEventListener('mousemove', this.onMouseMove);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mouse-look sensitivity in radians per pixel (default 0.0022). */
|
||||||
|
setSensitivity(v: number): void {
|
||||||
|
this.sensitivity = v;
|
||||||
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
window.removeEventListener('keydown', this.onKeyDown);
|
window.removeEventListener('keydown', this.onKeyDown);
|
||||||
window.removeEventListener('keyup', this.onKeyUp);
|
window.removeEventListener('keyup', this.onKeyUp);
|
||||||
|
|||||||
269
src/ui/Menu.ts
Normal file
269
src/ui/Menu.ts
Normal 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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user