diff --git a/src/audio/AudioEngine.ts b/src/audio/AudioEngine.ts index dea1df8..ac392ee 100644 --- a/src/audio/AudioEngine.ts +++ b/src/audio/AudioEngine.ts @@ -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 { 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(); diff --git a/src/main.ts b/src/main.ts index 1ee2d27..c99b930 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts index 742ea3d..6b1c608 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -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; } diff --git a/src/player/input.ts b/src/player/input.ts index 0913204..a5d2e4c 100644 --- a/src/player/input.ts +++ b/src/player/input.ts @@ -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); diff --git a/src/ui/Menu.ts b/src/ui/Menu.ts new file mode 100644 index 0000000..b41b517 --- /dev/null +++ b/src/ui/Menu.ts @@ -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 = { + 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 = ` +

WHAT IS THIS

+

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.

+

CONTROLS

+ + + + + + + + + + + +
MOUSElook around
W A S Dmove
SPACEjump
SHIFTsprint
HOLD LEFT CLICKmine the block you're looking at
RIGHT CLICKplace the selected block
Euse — buttons, faders, plugs, sockets
1–9 / SCROLLpick a hotbar slot
Ffly mode (cheat — great for exploring)
Hthis menu
+

THE FIVE REPAIRS

+ + + + + + +
STYLUSthe 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.
RCAbehind 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).
X-FADERthe 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.
FUSEunder 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.
POWERwhen the other four glow on your tracker, the master +switch on the mixer's back edge lights green. Climb up and press it.
+

TIPS

+

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.

`; + +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 }; + } 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('.tcm-tab').forEach((t) => + t.classList.toggle('active', t.dataset.tab === tab)); + this.panel.querySelectorAll('.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, 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); + } +}