diff --git a/CHANGELOG.md b/CHANGELOG.md index 987e86b..e8e5d54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,36 @@ > a **believable floorplan with real individual cells + prop collision** (no walking through walls/ > bars/counters/beds), interactable props, doors/gates with schedule-driven locking + NPC schedule > anchors, object reservations, character creation, save/load (v12), and a **chaos layer** (lockdowns, -> alarm, riot pressure, area tension, guard checkpoints, abstract escape attempts). **Not yet built:** -> audio, Capacitor/IPA, deep riot warfare. The `v1.x` entries are **archived legacy history** for the original +> alarm, riot pressure, area tension, guard checkpoints, abstract escape attempts), plus **procedural +> audio** (synthesized SFX + ambient bed). **Not yet built:** music, Capacitor/IPA, deep riot warfare. +> The `v1.x` entries are **archived legacy history** for the original > prototype that now lives under `src/legacy/` (excluded from the build) — those features are > **not** active in the current game. Latest QA pass: **Stage QA 2.4** (truth/docs/hardening). +## v3.9.0-audio — Stage 3.9 Audio +The game's first **sound**. 100% **procedural** WebAudio (no asset files, in keeping with the +procedural art), and pure **presentation** — `AudioSystem` only listens to the `EventBus` and reads a +per-frame tension snapshot; it never touches the simulation. Typecheck + build pass, smoke test green +(0 console/page errors), save format unchanged (v12). +- **New `src/audio/AudioSystem.ts`**: a lazily-created `AudioContext` (resumed on the first user + gesture for mobile autoplay policy), a master gain driven by mute/volume, and synthesized cues + (enveloped oscillators + filtered noise bursts). One-shots stop + disconnect on `ended` so nothing + leaks over a long session; every cue is throttled so bursts collapse instead of crackling. +- **Event cues** (wired in `core/Game.ts` off the existing bus): combat **thud** on `impact`, a typed + cue per `alert` (`fight`→hit, `lockdown`→tone+clang, `critical`/`search`/`guard`/`warning` stings, + `player`/`trade`→positive two-note), and a UI **confirm** on `actionResult`. `info`/`system`/`phase` + stay silent. +- **Door audio**: `updateDoors` now detects open/close transitions per room door and plays a sliding + rattle (open) or metal clang (close) — primed on the first frame so there's no startup burst, and + silent while paused / at the title. +- **Ambient bed + alarm**: a quiet institutional **drone** (detuned low saws through a lowpass) whose + gain + filter cutoff rise with **riot pressure**, ducked overnight and when paused/in menus; a + pulsing **klaxon** kicks in during alarm/lockdown. Driven each frame from the game loop. +- **Mute toggle** (`ui/HUD.ts` topbar 🔊/🔇 button + `style.css`): mutes everything incl. ambient; + mute + volume **persist to `localStorage`** and restore on boot. Kept in the topbar so the 5-button + bottom action bar stays uncrowded on mobile. +- Version → `v3.9.0-audio`. + ## v3.8.1-atmosphere — Stage 3.8B Atmosphere & Bloom A small **presentation** follow-up to the 3.8 / 3.8A overhaul — adds depth and "juice" on top of the brighter palette without touching the simulation. Render-only (`RenderSync` stays read-only), art diff --git a/README.md b/README.md index 044ccba..ccb20ae 100644 --- a/README.md +++ b/README.md @@ -25,13 +25,14 @@ low-poly geometry** — no external art/audio assets. - **Three.js** for rendering (orthographic isometric camera) - **DOM / CSS** overlay HUD (no UI framework) - **`localStorage`** saves (versioned, currently v12) -- **Procedural low-poly geometry** built at runtime (no model/texture/audio files) +- **Procedural low-poly geometry** built at runtime (no model/texture files) +- **Procedural audio** — synthesized at runtime via WebAudio (no sound files, Stage 3.9) - A small **ECS-lite** simulation (`src/ecs` + `src/sim`) -There is **no** audio and no *WebGL* post-processing pipeline in the current build. The cinematic -look (edge vignette, cool/warm colour grade, faint film grain — Stage 3.8B) is a **zero-cost CSS -overlay** layered over the canvas, not a render pass, so it stays mobile-cheap. Controls are -touch/mouse only (no keyboard/gamepad). +There is no *WebGL* post-processing pipeline in the current build. The cinematic look (edge vignette, +cool/warm colour grade, faint film grain — Stage 3.8B) is a **zero-cost CSS overlay** layered over the +canvas, not a render pass, so it stays mobile-cheap. Audio is fully **synthesized** (no asset files) +and mutes/persists from a topbar toggle. Controls are touch/mouse only (no keyboard/gamepad). --- @@ -144,6 +145,7 @@ Gangs are purely fictional game data. You can build standing with a crew, get in - **Faction progression**: build standing → NPC gang invite → join, ranks (Associate→Shot Caller), crew goals, small perks, rival consequences, Gangs menu - **Economy / contraband depth**: item categories + dynamic prices (demand/supply/heat/relationship/gang), trade panel (buy/sell), item use, stash capacity/risk, job payouts + streak, daily market restock, gang/crew supply, economy objectives & daily summary - **Cinematic atmosphere (3.8B)**: zero-cost CSS overlay (edge vignette + cool/warm colour grade + film grain) over the canvas, **fake-bloom glow halos** on emissive props (ceiling lamps, security light, signs, scanner) via additive sprites — no post-processing pass — and a soft additive **ground-glow pool** under the selected/player inmate (gold for *you*, green for a selected NPC) +- **Procedural audio (3.9)**: synthesized WebAudio (no sound files) — combat thud, door slide/clang, typed event cues (fight / lockdown / search / alarm / trade…), UI confirm, an **ambient bed** that rises with riot pressure + an **alarm klaxon**, all ducked when paused. Topbar mute toggle, persisted to `localStorage`; AudioContext unlocks on first tap (mobile autoplay-safe) **Partial** - Guard AI (roles + routes + checkpoints, but no formal squad tactics / dynamic routes) @@ -157,9 +159,9 @@ Gangs are purely fictional game data. You can build standing with a crew, get in **Planned / Future** - Deeper riot warfare, event director, deeper gang hierarchy/squad commands -- Economy/contraband depth, settings menu, audio -- World expansion / more areas, deeper appearance / cosmetics -- Real audio / SFX +- Settings menu (volume slider, difficulty), deeper appearance / cosmetics +- World expansion / more areas +- Audio depth: music / stingers, positional per-area mix - More animation - Capacitor / iOS `.ipa` packaging - Performance profiling @@ -199,6 +201,8 @@ src/ Glow.ts # additive fake-bloom sprites + ground-glow (no post pipeline) VisualTheme.ts # colours / lighting / camera constants textures/ # procedural CanvasTextures + audio/ + AudioSystem.ts # procedural WebAudio: event cues + ambient bed (presentation; bus-only) ui/ HUD.ts # DOM overlay HUD (topbar, alerts, panel, action bar, bottom bar) data/ @@ -230,7 +234,7 @@ versioned snapshot of sim state. Full details + future refactor plan: [`docs/ARC - Single hand-authored prison (no procedural prison generation). - Guard/NPC AI is intentionally light; deeper planning is a future stage. - The chaos layer is a first vertical slice — riot *events* are small and controlled, not full riot warfare. -- No audio yet. Character creation, menus, objectives, reputation tiers, and gang lean exist; gang joining is added in Stage 3.6. +- Audio is procedural (synthesized SFX + ambient bed) — no music yet. Character creation, menus, objectives, reputation tiers, and gang lean exist; gang joining is added in Stage 3.6. - Balance is rough and subject to change. - The follow camera reframes the subject slightly left of centre so the right-side panel doesn't cover it. @@ -244,7 +248,9 @@ versioned snapshot of sim state. Full details + future refactor plan: [`docs/ARC - **Tuning (3.1)**: eased Heat that decays when calm, smoothed riot pressure with hysteresis + cooldowns, lockdown re-entry cooldown, deduped alerts, contextual player panel, and `?debug` playtest telemetry (`sim.metrics`). ## Planned next -Economy/contraband depth, audio/polish, or world expansion — then iOS (Capacitor) packaging prep. +With audio (3.9) and the visual passes (3.8 / 3.8A / 3.8B) shipped, the next lanes are **world +expansion** (more areas + routing/schedule anchors), a **settings menu** (volume slider, difficulty), +or deeper **audio** (music / stingers, per-area mix) — then iOS (Capacitor) packaging prep. --- diff --git a/package.json b/package.json index ab078bc..e5d0720 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lockdown-life-3d", "private": true, - "version": "3.8.1-atmosphere", + "version": "3.9.0-audio", "type": "module", "description": "Lockdown Life 3D - a gritty 3D isometric prison-life sandbox simulator", "scripts": { diff --git a/src/audio/AudioSystem.ts b/src/audio/AudioSystem.ts new file mode 100644 index 0000000..2abc2ac --- /dev/null +++ b/src/audio/AudioSystem.ts @@ -0,0 +1,156 @@ +// Procedural audio — 100% synthesized via WebAudio (no asset files, in keeping with the +// rest of the game). Pure PRESENTATION: it only listens to EventBus feedback + reads a per-frame +// ambient snapshot from the game loop. It never touches the simulation. +// +// The AudioContext is created lazily and resumed on the first user gesture (mobile autoplay +// policy). One-shot cues are throttled so bursts (e.g. all cell gates opening at once, or a flurry +// of alerts) collapse instead of crackling. Mute/volume persist to localStorage. + +const LS_KEY = 'll3d_audio'; + +type Cue = 'confirm' | 'blip' | 'tap'; + +export class AudioSystem { + private ctx: AudioContext | null = null; + private master!: GainNode; // volume * !muted + private busGain!: GainNode; // one-shots + private ambGain!: GainNode; // ambient bed + private alarmGain!: GainNode; // smooth siren bus + private started = false; // persistent nodes built + private muted = false; + private volume = 0.7; + private last = new Map(); // cue -> last play time (throttle) + private static UNLOCK_EVENTS = ['pointerdown', 'touchstart', 'keydown', 'click']; + private unlockOnce = () => this.unlock(); + + constructor() { + try { const s = JSON.parse(localStorage.getItem(LS_KEY) || '{}'); if (typeof s.muted === 'boolean') this.muted = s.muted; if (typeof s.volume === 'number' && isFinite(s.volume)) this.volume = Math.max(0, Math.min(1, s.volume)); } catch { /* defaults */ } + for (const ev of AudioSystem.UNLOCK_EVENTS) document.addEventListener(ev, this.unlockOnce, { passive: true }); + } + + // ---- lifecycle ---- + private ensure(): AudioContext | null { + if (this.ctx) return this.ctx; + const AC = (window.AudioContext || (window as any).webkitAudioContext) as typeof AudioContext | undefined; + if (!AC) return null; + const ctx = new AC(); + this.ctx = ctx; + this.master = ctx.createGain(); this.master.gain.value = this.muted ? 0 : this.volume; this.master.connect(ctx.destination); + this.busGain = ctx.createGain(); this.busGain.gain.value = 0.9; this.busGain.connect(this.master); + this.ambGain = ctx.createGain(); this.ambGain.gain.value = 0; this.ambGain.connect(this.master); + this.alarmGain = ctx.createGain(); this.alarmGain.gain.value = 0; this.alarmGain.connect(this.master); + return ctx; + } + unlock() { + const ctx = this.ensure(); if (!ctx) return; + if (ctx.state === 'suspended') ctx.resume(); + this.startPersistent(); + for (const ev of AudioSystem.UNLOCK_EVENTS) document.removeEventListener(ev, this.unlockOnce); // fire once + } + + // ---- settings ---- + isMuted() { return this.muted; } + toggleMute() { this.setMuted(!this.muted); return this.muted; } + setMuted(m: boolean) { this.muted = m; this.applyMaster(); this.persist(); } + setVolume(v: number) { this.volume = Math.max(0, Math.min(1, v)); this.applyMaster(); this.persist(); } + private applyMaster() { if (this.ctx) this.master.gain.setTargetAtTime(this.muted ? 0 : this.volume, this.ctx.currentTime, 0.02); } + private persist() { try { localStorage.setItem(LS_KEY, JSON.stringify({ muted: this.muted, volume: this.volume })); } catch { /* ignore */ } } + + // ---- synthesis helpers ---- + private throttled(key: string, minGap: number): boolean { + const ctx = this.ctx!; const t = ctx.currentTime; const prev = this.last.get(key) ?? -999; + if (t - prev < minGap) return true; this.last.set(key, t); return false; + } + // a short enveloped oscillator tone + private tone(freq: number, dur: number, type: OscillatorType, gain: number, glideTo?: number, dest?: AudioNode) { + const ctx = this.ctx!; const t = ctx.currentTime; + const o = ctx.createOscillator(); const g = ctx.createGain(); + o.type = type; o.frequency.setValueAtTime(freq, t); + if (glideTo) o.frequency.exponentialRampToValueAtTime(Math.max(1, glideTo), t + dur); + g.gain.setValueAtTime(0.0001, t); + g.gain.exponentialRampToValueAtTime(gain, t + Math.min(0.02, dur * 0.3)); + g.gain.exponentialRampToValueAtTime(0.0001, t + dur); + o.connect(g).connect(dest ?? this.busGain); + o.start(t); o.stop(t + dur + 0.02); + o.onended = () => { g.disconnect(); }; + } + // a filtered noise burst (impacts / clangs / air) + private noise(dur: number, gain: number, filter: 'lowpass' | 'bandpass' | 'highpass', freq: number, q = 1, dest?: AudioNode) { + const ctx = this.ctx!; const t = ctx.currentTime; + const n = Math.floor(ctx.sampleRate * dur); + const buf = ctx.createBuffer(1, n, ctx.sampleRate); const data = buf.getChannelData(0); + for (let i = 0; i < n; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / n); // decaying noise + const src = ctx.createBufferSource(); src.buffer = buf; + const bp = ctx.createBiquadFilter(); bp.type = filter; bp.frequency.value = freq; bp.Q.value = q; + const g = ctx.createGain(); g.gain.setValueAtTime(gain, t); g.gain.exponentialRampToValueAtTime(0.0001, t + dur); + src.connect(bp).connect(g).connect(dest ?? this.busGain); + src.start(t); src.stop(t + dur + 0.02); + src.onended = () => { g.disconnect(); }; + } + + // ---- one-shot cues ---- + hit() { if (!this.ensure() || this.throttled('hit', 0.05)) return; this.noise(0.07, 0.4, 'lowpass', 280, 1); this.tone(130, 0.14, 'sine', 0.26, 60); } // short smack + low thud (not a hiss) + ui(kind: Cue) { + if (!this.ensure() || this.throttled('ui' + kind, 0.04)) return; + if (kind === 'confirm') this.tone(520, 0.1, 'sine', 0.16, 660); + else if (kind === 'blip') this.tone(660, 0.06, 'triangle', 0.08, 720); + else this.tone(420, 0.04, 'sine', 0.1); + } + // classify an action result by its text → positive confirm vs a soft 'no' (failures shouldn't chime) + result(text: string) { + if (!this.ensure()) return; + if (/no path|locked|caught|interrupt|can'?t|cannot|no reachable|nothing|find a|back off|refuse|fail|too far|busy|occupied/i.test(text || '')) { + if (!this.throttled('deny', 0.06)) this.tone(280, 0.14, 'sine', 0.1, 180); // gentle descending 'no' + } else this.ui('confirm'); + } + // metallic, TONAL door cues (no noise — white noise read as "static"). Slide = a soft descending + // mechanical tone; clank = a short tick over two ringing low partials, like a steel gate. + door(open: boolean) { + if (!this.ensure() || this.throttled('door', 0.08)) return; + if (open) { this.tone(240, 0.22, 'triangle', 0.09, 150); this.tone(120, 0.12, 'sine', 0.1, 90); } + else { this.tone(520, 0.05, 'square', 0.07); this.tone(165, 0.22, 'triangle', 0.14, 110); this.tone(330, 0.16, 'triangle', 0.06); } + } + // map an alert type to a cue (only the meaningful ones make noise; throttled per-type) + alert(type: string) { + if (!this.ensure()) return; + switch (type) { + case 'fight': if (!this.throttled('a-fight', 0.12)) this.hit(); break; + case 'lockdown': if (!this.throttled('a-lock', 0.4)) { this.tone(330, 0.28, 'sawtooth', 0.18, 220); this.door(false); } break; + case 'critical': if (!this.throttled('a-crit', 0.3)) this.tone(440, 0.22, 'sawtooth', 0.2, 300); break; + case 'search': if (!this.throttled('a-search', 0.3)) this.tone(300, 0.16, 'triangle', 0.14, 360); break; + case 'guard': if (!this.throttled('a-guard', 0.3)) this.tone(380, 0.08, 'square', 0.1, 420); break; // radio blip + case 'warning': if (!this.throttled('a-warn', 0.3)) this.tone(420, 0.14, 'triangle', 0.12, 480); break; + case 'player': case 'trade': if (!this.throttled('a-good', 0.2)) { this.tone(523, 0.1, 'sine', 0.12); this.tone(784, 0.12, 'sine', 0.1); } break; + default: break; // info / system / phase: silent + } + } + + // ---- persistent ambient bed + alarm klaxon ---- + private startPersistent() { + if (this.started || !this.ensure()) return; this.started = true; + const ctx = this.ctx!; + // drone: smooth low SINES (not saws — saws read as buzzy "static") = a soft institutional hum + const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 340; lp.Q.value = 0.4; lp.connect(this.ambGain); + for (const [f, gain] of [[60, 0.5], [90, 0.3], [150, 0.14]] as [number, number][]) { const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = f; const g = ctx.createGain(); g.gain.value = gain; o.connect(g).connect(lp); o.start(); } + this.ambLp = lp; + // smooth two-tone SIREN (triangle, slow hi-lo sweep + gentle tremolo) — a sustained but soft + // alarm voice that runs while alarm/lockdown is active (replaces the harsh square klaxon). + const sir = ctx.createGain(); sir.gain.value = 0.55; sir.connect(this.alarmGain); + const so = ctx.createOscillator(); so.type = 'triangle'; so.frequency.value = 480; so.connect(sir); so.start(); + const sweep = ctx.createOscillator(); sweep.type = 'sine'; sweep.frequency.value = 0.7; const sweepG = ctx.createGain(); sweepG.gain.value = 90; sweep.connect(sweepG).connect(so.frequency); sweep.start(); + const trem = ctx.createOscillator(); trem.type = 'sine'; trem.frequency.value = 0.7; const tremG = ctx.createGain(); tremG.gain.value = 0.18; trem.connect(tremG).connect(sir.gain); trem.start(); + } + private ambLp: BiquadFilterNode | null = null; + + // called every frame from the game loop with a snapshot of game tension + updateAmbient(playing: boolean, riot: number, alarm: boolean, hour: number) { + if (!this.ctx || !this.started) return; + const t = this.ctx.currentTime; + const night = (hour >= 22 || hour < 6) ? 0.5 : 1; // quieter overnight + const bed = playing ? (0.012 + Math.min(1, riot) * 0.09) * night : 0.0; // near-silent when calm + this.ambGain.gain.setTargetAtTime(bed, t, 0.8); + if (this.ambLp) this.ambLp.frequency.setTargetAtTime(300 + Math.min(1, riot) * 260, t, 0.8); + // sustained but SMOOTH siren while an alarm/lockdown is active; gentle fade in / out + this.alarmGain.gain.setTargetAtTime(alarm && playing ? 0.2 : 0.0, t, 0.4); + } +} diff --git a/src/core/Game.ts b/src/core/Game.ts index 6cb90d7..79375f7 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -3,6 +3,7 @@ import { ThreeApp } from '../render/ThreeApp'; import { IsoCamera } from '../render/IsoCamera'; import { RenderSync } from '../render/RenderSync'; import { Feedback } from '../render/Feedback'; +import { AudioSystem } from '../audio/AudioSystem'; import { buildPrison } from '../render/WorldRenderer'; import { dressRooms } from '../render/PropRenderer'; import { Simulation } from '../sim/Simulation'; @@ -29,6 +30,7 @@ export class Game { private sim: Simulation; private sync: RenderSync; private feedback!: Feedback; + private audio = new AudioSystem(); private hud: HUD; private menus!: Menus; private clock = new THREE.Clock(); @@ -83,9 +85,11 @@ export class Game { hasSave: () => SaveManager.has(), onAction: (key) => this.doAction(key), onItem: (key) => { const r = this.sim.dropItem(key); if (r) this.hud.alert(r, 'trade'); this.panelDirty = true; this.refreshPanel(); }, - onToggleCam: () => { this.cam.toggleMode(); this.hud.setCamMode(this.cam.isCharMode); this.cam.recenter(); } + onToggleCam: () => { this.cam.toggleMode(); this.hud.setCamMode(this.cam.isCharMode); this.cam.recenter(); }, + onToggleSound: () => { const muted = this.audio.toggleMute(); this.audio.unlock(); this.hud.setMuted(muted); } }); this.select(this.playerEntity); // panel shows the player by default + this.hud.setMuted(this.audio.isMuted()); // reflect persisted mute state // menus / title / pause / daily summary (Stage 3.4) this.menus = new Menus({ @@ -109,7 +113,7 @@ export class Game { hasSave: () => SaveManager.has(), saveInfo: () => { const d: any = SaveManager.load(); return d && Array.isArray(d.ents) ? { name: (d.ents.find((e: any) => e.isPlayer)?.brain?.name) || 'Inmate', day: d.day || 1 } : null; }, snapshot: () => this.sim.uiSnapshot(), - version: 'v3.8.1-atmosphere' + version: 'v3.9.0-audio' }); this.menus.showTitle(); this.paused = true; // start at the title screen @@ -121,6 +125,10 @@ export class Game { this.bus.on('float', ({ x, z, text, color }) => this.feedback.float(x, z, text, color)); this.bus.on('bubble', ({ e, text, kind, dur }) => this.feedback.bubble(e, text, kind, dur)); this.bus.on('actionResult', ({ text }) => this.hud.alert(text, 'info')); + // audio (read-only presentation listeners): combat thud, typed event cues, UI confirm + this.bus.on('impact', () => this.audio.hit()); + this.bus.on('alert', ({ type }) => this.audio.alert(type)); + this.bus.on('actionResult', ({ text }) => this.audio.result(text)); // confirm on success, soft 'no' on failure const onResize = () => { this.app.resize(); this.cam.resize(); }; window.addEventListener('resize', onResize); @@ -132,7 +140,7 @@ export class Game { this.loop(); } - private doorVisuals: { id: string; pivot: THREE.Object3D; baseRot: number; lampMat: THREE.MeshStandardMaterial }[] = []; + private doorVisuals: { id: string; pivot: THREE.Object3D; baseRot: number; lampMat: THREE.MeshStandardMaterial; lastOpen?: boolean }[] = []; private static RESTRICTED_ROOMS = ['guardroom', 'intake', 'storage', 'solitary']; // register each room's door/gate as an interactable object (hitbox + visible, stateful leaf) @@ -216,6 +224,8 @@ export class Game { const flash = chaos ? 0.5 + Math.abs(Math.sin(this.clock.elapsedTime * 6)) * 1.2 : 1.2; for (const d of this.doorVisuals) { const o = this.sim.getObj(d.id); if (!o) continue; + if (d.lastOpen === undefined) d.lastOpen = o.open; // prime; no sound on first frame + else if (o.open !== d.lastOpen) { d.lastOpen = o.open; this.audio.door(o.open); } // slide / clang on change const target = o.open ? Math.PI * 0.46 : 0; // swing the leaf open/closed d.pivot.rotation.y += (target - d.pivot.rotation.y) * Math.min(1, dt * 8); const col = o.restricted ? 0xff3322 : o.locked ? 0xffaa22 : o.open ? 0x33ff66 : 0xccbb44; @@ -422,6 +432,7 @@ export class Game { SaveManager.clear(); this.sim.startNewRun(setup, this.interactableDefs as any); this.sync.reset(); this.feedback.reset(); this.sync.setEcs(this.sim.ecs); + for (const d of this.doorVisuals) d.lastOpen = undefined; // re-prime door SFX (no stray clang on new run) this.playerEntity = this.sim.player(); const sp = this.sim.ecs.get(this.playerEntity, 'Position'); if (sp) this.cam.focus(sp.x, sp.z); this.speedIdx = 0; this.paused = false; this.hud.setSpeed('1×'); @@ -437,6 +448,7 @@ export class Game { this.sync.reset(); this.feedback.reset(); this.sync.setEcs(this.sim.ecs); + for (const d of this.doorVisuals) d.lastOpen = undefined; // re-prime door SFX (no stray clang on load) this.playerEntity = this.sim.player(); this.panelDirty = true; this.hud.clearAlerts(); // drop any stale alert lines from before the load @@ -537,6 +549,8 @@ export class Game { lockdown: this.sim.lockdown.active, lockdownTimer: this.sim.lockdown.timer, lockdownReason: this.sim.lockdown.reason, alarm: this.sim.alarm.active, level: this.sim.riotLevel, objective: this.sim.playerObjective }); + // ambient audio bed: rises with riot pressure, klaxon on alarm/lockdown, ducked when paused/menus + this.audio.updateAmbient(!this.paused, riot, this.sim.alarm.active || this.sim.lockdown.active, this.sim.hour); // objective tracker (throttled with the panel) + once-a-day summary modal if (this.panelTimer >= 0.14) this.hud.setObjectives(this.sim.objectives, this.sim.tier()); if (this.sim.pendingSummary && !this.menus.isOpen()) { this.menus.showSummary(this.sim.takeSummary()); this.paused = true; } diff --git a/src/style.css b/src/style.css index a87c750..8bdb36d 100644 --- a/src/style.css +++ b/src/style.css @@ -32,7 +32,21 @@ button { font-family: inherit; cursor: pointer; } #topbar { position: absolute; top: 10px; left: 12px; right: 12px; display: flex; justify-content: space-between; align-items: flex-start; } .tb-block { background: linear-gradient(180deg, rgba(30,34,42,0.95), rgba(16,18,24,0.95)); border: 1px solid rgba(255,255,255,0.14); border-top-color: rgba(255,255,255,0.22); border-radius: 10px; padding: 7px 14px; backdrop-filter: blur(5px); box-shadow: 0 3px 10px rgba(0,0,0,0.4); } -.tb-block.right { display: flex; gap: 8px; background: transparent; border: none; padding: 0; box-shadow: none; } +.tb-block.right { display: flex; gap: 8px; align-items: stretch; background: transparent; border: none; padding: 0; box-shadow: none; } +/* 3.9 sound toggle (topbar) — compact icon button, doesn't crowd the bottom action bar */ +.snd-btn { display: flex; align-items: center; justify-content: center; min-width: 42px; font-size: 18px; line-height: 1; color: var(--ink); + background: linear-gradient(180deg, rgba(30,34,42,0.95), rgba(16,18,24,0.95)); border: 1px solid rgba(255,255,255,0.14); border-top-color: rgba(255,255,255,0.22); border-radius: 9px; box-shadow: 0 3px 10px rgba(0,0,0,0.4); } +.snd-btn:active { transform: translateY(1px) scale(0.96); } +.snd-btn.muted { opacity: 0.5; } +/* small phones: let the topbar-right cluster shrink + wrap so snd-btn + HEAT/RIOT/LOCK never overflow */ +@media (max-width: 560px) { + #topbar { gap: 6px; } + .tb-block.right { gap: 6px; flex-wrap: wrap; justify-content: flex-end; } + .tb-chip { min-width: 44px; padding: 4px 9px; } + .tb-chip span { font-size: 8px; letter-spacing: 1px; } + .tb-chip b { font-size: 14px; } + .snd-btn { min-width: 36px; font-size: 16px; } +} .tb-label { font-size: 9px; letter-spacing: 2px; opacity: 0.5; } .tb-big { font-size: 17px; font-weight: 800; color: var(--accent); letter-spacing: 0.5px; } .tb-sub { font-size: 12px; opacity: 0.85; } diff --git a/src/ui/HUD.ts b/src/ui/HUD.ts index 14d6e66..d1298af 100644 --- a/src/ui/HUD.ts +++ b/src/ui/HUD.ts @@ -15,6 +15,7 @@ export interface HUDHooks { onAction: (key: string) => void; // interaction / self-action / job button onItem: (key: string) => void; // tap an inventory item (drop) onToggleCam?: () => void; // Stage 3.8A: toggle iso / character camera + onToggleSound?: () => void; // Stage 3.9: mute / unmute audio } export class HUD { @@ -33,6 +34,7 @@ export class HUD {
Day 1 · 6:00
+
HEAT0
RIOT0%
@@ -51,7 +53,7 @@ export class HUD {
`; document.getElementById('ui-root')!.appendChild(this.root); - ['tb-phase', 'tb-day', 'tb-time', 'tb-heat', 'tb-riot', 'chip-riot', 'chip-heat', 'chip-lock', 'tb-lock', 'chaos-banner', 'alert-feed', 'obj-tracker', 'panel', 'speed-x', 'alarm', 'cam-ico'].forEach((id) => this.els[id] = this.root.querySelector('#' + id) as HTMLElement); + ['tb-phase', 'tb-day', 'tb-time', 'tb-heat', 'tb-riot', 'chip-riot', 'chip-heat', 'chip-lock', 'tb-lock', 'chaos-banner', 'alert-feed', 'obj-tracker', 'panel', 'speed-x', 'alarm', 'cam-ico', 'snd-toggle'].forEach((id) => this.els[id] = this.root.querySelector('#' + id) as HTMLElement); this.root.querySelectorAll('#bottombar button').forEach((b) => { const k = (b as HTMLElement).dataset.b!; b.addEventListener('click', () => { @@ -60,6 +62,7 @@ export class HUD { else if (k === 'cam' && hooks.onToggleCam) hooks.onToggleCam(); }); }); + this.els['snd-toggle']?.addEventListener('click', () => hooks.onToggleSound?.()); this.hooks = hooks; } private hooks!: HUDHooks; @@ -77,6 +80,7 @@ export class HUD { } setSpeed(label: string) { this.els['speed-x'].textContent = label; } setCamMode(isChar: boolean) { this.els['cam-ico'].textContent = isChar ? '🏠' : '🎥'; } + setMuted(muted: boolean) { const b = this.els['snd-toggle']; if (b) { b.textContent = muted ? '🔇' : '🔊'; b.classList.toggle('muted', muted); } } setAlarm(level: number) { this.els['alarm'].style.opacity = level > 0.6 ? String(Math.min(0.42, (level - 0.6) * 1.05)) : '0'; } // chaos banner + lockdown chip. info.level: 'calm' | 'warning' | 'event' setChaos(info: { lockdown: boolean; lockdownTimer: number; lockdownReason: string; alarm: boolean; level: string; objective: string }) {