Raise peak amplitude from 0.12 to 0.3 and extend duration from 0.06 to 0.07 seconds for better walking feedback
417 lines
16 KiB
JavaScript
Executable File
417 lines
16 KiB
JavaScript
Executable File
/**
|
|
* City10 audio engine (Web Audio API, fully procedural — no external assets).
|
|
*
|
|
* Provides background music, ambient weather layers and gameplay SFX with
|
|
* independent master / music / SFX volume controls persisted in localStorage.
|
|
* The context unlocks on the first user gesture (browser autoplay policy).
|
|
*/
|
|
class CityAudioEngine {
|
|
constructor() {
|
|
this.ctx = null;
|
|
this.master = null;
|
|
this.musicBus = null;
|
|
this.sfxBus = null;
|
|
this.musicNodes = [];
|
|
this.rainNodes = null;
|
|
this.started = false;
|
|
this.muted = false;
|
|
// Default levels are intentionally audible on laptop speakers.
|
|
this.volumes = { master: 0.85, music: 0.7, sfx: 1.0 };
|
|
this._footstepTimer = 0;
|
|
this._lastThunder = 0;
|
|
|
|
this._loadPrefs();
|
|
}
|
|
|
|
_loadPrefs() {
|
|
try {
|
|
const raw = localStorage.getItem('city10.audio');
|
|
if (!raw) return;
|
|
const parsed = JSON.parse(raw);
|
|
if (!parsed || typeof parsed !== 'object') return;
|
|
|
|
// v1 stored overly quiet defaults; migrate once to audiable levels.
|
|
if (!parsed.version || parsed.version < 2) {
|
|
this.volumes = { master: 0.85, music: 0.7, sfx: 1.0 };
|
|
this.muted = !!parsed.muted;
|
|
this._savePrefs();
|
|
return;
|
|
}
|
|
|
|
if (typeof parsed.master === 'number') this.volumes.master = clamp01(parsed.master);
|
|
if (typeof parsed.music === 'number') this.volumes.music = clamp01(parsed.music);
|
|
if (typeof parsed.sfx === 'number') this.volumes.sfx = clamp01(parsed.sfx);
|
|
this.muted = !!parsed.muted;
|
|
} catch (_) { /* ignore corrupt prefs */ }
|
|
}
|
|
|
|
_savePrefs() {
|
|
try {
|
|
localStorage.setItem('city10.audio', JSON.stringify({
|
|
...this.volumes,
|
|
muted: this.muted,
|
|
version: 2,
|
|
}));
|
|
} catch (_) { /* private mode etc. */ }
|
|
}
|
|
|
|
/** Create/resume the AudioContext. Safe to call often. */
|
|
unlock() {
|
|
if (!this.ctx) {
|
|
const AC = window.AudioContext || window.webkitAudioContext;
|
|
if (!AC) return false;
|
|
this.ctx = new AC();
|
|
|
|
this.master = this.ctx.createGain();
|
|
this.master.connect(this.ctx.destination);
|
|
|
|
this.musicBus = this.ctx.createGain();
|
|
this.musicBus.connect(this.master);
|
|
|
|
this.sfxBus = this.ctx.createGain();
|
|
this.sfxBus.connect(this.master);
|
|
|
|
this._applyVolumes();
|
|
}
|
|
|
|
if (this.ctx.state === 'suspended') {
|
|
this.ctx.resume().then(() => {
|
|
// After resume, re-apply gains in case the graph was created while suspended.
|
|
this._applyVolumes();
|
|
}).catch(() => { /* ignore */ });
|
|
}
|
|
|
|
if (!this.started) {
|
|
this.started = true;
|
|
this._startMusic();
|
|
this._applyWeatherLayer(null);
|
|
// Audible confirmation so the user knows audio is live.
|
|
if (this.ctx.state === 'running') {
|
|
this._tone(660, 0.08, { type: 'triangle', peak: 0.22 });
|
|
this._tone(990, 0.12, { type: 'triangle', peak: 0.18, when: this.ctx.currentTime + 0.08 });
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
_applyVolumes() {
|
|
if (!this.ctx) return;
|
|
const now = this.ctx.currentTime;
|
|
const m = this.muted ? 0 : 1;
|
|
this.master.gain.setTargetAtTime(this.volumes.master * m, now, 0.03);
|
|
this.musicBus.gain.setTargetAtTime(this.volumes.music, now, 0.03);
|
|
this.sfxBus.gain.setTargetAtTime(this.volumes.sfx, now, 0.03);
|
|
}
|
|
|
|
setVolume(channel, value) {
|
|
const v = clamp01(Number(value) || 0);
|
|
if (channel === 'master') this.volumes.master = v;
|
|
else if (channel === 'music') this.volumes.music = v;
|
|
else if (channel === 'sfx') this.volumes.sfx = v;
|
|
else return;
|
|
this._savePrefs();
|
|
this._applyVolumes();
|
|
}
|
|
|
|
getVolume(channel) {
|
|
if (channel === 'master') return this.volumes.master;
|
|
if (channel === 'music') return this.volumes.music;
|
|
if (channel === 'sfx') return this.volumes.sfx;
|
|
return 0;
|
|
}
|
|
|
|
setMuted(muted) {
|
|
this.muted = !!muted;
|
|
this._savePrefs();
|
|
this._applyVolumes();
|
|
}
|
|
|
|
toggleMute() {
|
|
this.setMuted(!this.muted);
|
|
return this.muted;
|
|
}
|
|
|
|
state() {
|
|
return {
|
|
ready: !!this.ctx,
|
|
contextState: this.ctx ? this.ctx.state : 'none',
|
|
started: this.started,
|
|
muted: this.muted,
|
|
master: this.volumes.master,
|
|
music: this.volumes.music,
|
|
sfx: this.volumes.sfx,
|
|
};
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Background music — soft generative ambient pad (original, procedural)
|
|
// ------------------------------------------------------------------
|
|
_startMusic() {
|
|
if (!this.ctx) return;
|
|
const ctx = this.ctx;
|
|
const t0 = ctx.currentTime;
|
|
|
|
// Warm low drone (audible bed under the pad)
|
|
const drone = ctx.createOscillator();
|
|
drone.type = 'sine';
|
|
drone.frequency.value = 55;
|
|
const droneGain = ctx.createGain();
|
|
droneGain.gain.value = 0.12;
|
|
drone.connect(droneGain).connect(this.musicBus);
|
|
drone.start(t0);
|
|
|
|
// Slow detuned pad voices
|
|
const padFreqs = [110, 164.81, 220, 329.63]; // A2 E3 A3 E4
|
|
const padFilter = ctx.createBiquadFilter();
|
|
padFilter.type = 'lowpass';
|
|
padFilter.frequency.value = 1200;
|
|
padFilter.Q.value = 0.7;
|
|
padFilter.connect(this.musicBus);
|
|
|
|
const padGain = ctx.createGain();
|
|
padGain.gain.value = 0.14;
|
|
padGain.connect(padFilter);
|
|
|
|
const pads = padFreqs.map((f, i) => {
|
|
const o = ctx.createOscillator();
|
|
o.type = i % 2 === 0 ? 'triangle' : 'sine';
|
|
o.frequency.value = f;
|
|
o.detune.value = (i - 1.5) * 6;
|
|
const g = ctx.createGain();
|
|
g.gain.value = 0.35;
|
|
o.connect(g).connect(padGain);
|
|
o.start(t0 + i * 0.1);
|
|
return o;
|
|
});
|
|
|
|
// Breathing filter sweep
|
|
const lfo = ctx.createOscillator();
|
|
lfo.type = 'sine';
|
|
lfo.frequency.value = 0.05;
|
|
const lfoGain = ctx.createGain();
|
|
lfoGain.gain.value = 350;
|
|
lfo.connect(lfoGain).connect(padFilter.frequency);
|
|
lfo.start(t0);
|
|
|
|
// Sparse bell arpeggio
|
|
const bellSeq = [440, 554.37, 659.25, 554.37, 493.88, 440];
|
|
let bellIdx = 0;
|
|
const bellTimer = setInterval(() => {
|
|
if (!this.ctx || this.ctx.state !== 'running') return;
|
|
if (this.volumes.music < 0.02 || this.muted) return;
|
|
const now = this.ctx.currentTime;
|
|
const freq = bellSeq[bellIdx % bellSeq.length];
|
|
bellIdx++;
|
|
this._bell(freq, now, 0.2);
|
|
}, 3200);
|
|
|
|
this.musicNodes = [drone, droneGain, padFilter, padGain, ...pads, lfo, lfoGain, { stop: () => clearInterval(bellTimer) }];
|
|
}
|
|
|
|
_bell(freq, when, peak) {
|
|
const ctx = this.ctx;
|
|
const o = ctx.createOscillator();
|
|
o.type = 'sine';
|
|
o.frequency.value = freq;
|
|
const g = ctx.createGain();
|
|
g.gain.setValueAtTime(0.0001, when);
|
|
g.gain.exponentialRampToValueAtTime(peak, when + 0.02);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, when + 1.4);
|
|
o.connect(g).connect(this.musicBus);
|
|
o.start(when);
|
|
o.stop(when + 1.5);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Weather ambience (rain noise layer)
|
|
// ------------------------------------------------------------------
|
|
/** @param {string|null} weather clear|cloudy|rain|storm */
|
|
setWeather(weather) {
|
|
if (!this.ctx || !this.started) return;
|
|
this._applyWeatherLayer(weather);
|
|
}
|
|
|
|
_applyWeatherLayer(weather) {
|
|
if (!this.ctx) return;
|
|
const raining = weather === 'rain' || weather === 'storm';
|
|
|
|
if (raining && !this.rainNodes) {
|
|
const ctx = this.ctx;
|
|
const bufferSize = ctx.sampleRate * 2;
|
|
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
|
const data = buffer.getChannelData(0);
|
|
for (let i = 0; i < bufferSize; i++) {
|
|
data[i] = (Math.random() * 2 - 1) * 0.5;
|
|
}
|
|
const noise = ctx.createBufferSource();
|
|
noise.buffer = buffer;
|
|
noise.loop = true;
|
|
|
|
const filter = ctx.createBiquadFilter();
|
|
filter.type = 'bandpass';
|
|
filter.frequency.value = weather === 'storm' ? 900 : 1400;
|
|
filter.Q.value = 0.6;
|
|
|
|
const gain = ctx.createGain();
|
|
gain.gain.value = 0;
|
|
gain.gain.setTargetAtTime(weather === 'storm' ? 0.12 : 0.07, ctx.currentTime, 0.8);
|
|
|
|
noise.connect(filter).connect(gain).connect(this.sfxBus);
|
|
noise.start();
|
|
this.rainNodes = { noise, filter, gain, weather };
|
|
} else if (raining && this.rainNodes) {
|
|
const target = weather === 'storm' ? 0.12 : 0.07;
|
|
this.rainNodes.gain.gain.setTargetAtTime(target, this.ctx.currentTime, 0.5);
|
|
this.rainNodes.filter.frequency.setTargetAtTime(weather === 'storm' ? 900 : 1400, this.ctx.currentTime, 0.5);
|
|
this.rainNodes.weather = weather;
|
|
} else if (!raining && this.rainNodes) {
|
|
this.rainNodes.gain.gain.setTargetAtTime(0, this.ctx.currentTime, 0.6);
|
|
const nodes = this.rainNodes;
|
|
this.rainNodes = null;
|
|
setTimeout(() => {
|
|
try { nodes.noise.stop(); } catch (_) { /* already stopped */ }
|
|
}, 2000);
|
|
}
|
|
}
|
|
|
|
/** Play a short thunder crack during storms. */
|
|
thunder() {
|
|
if (!this.ctx || this.muted) return;
|
|
const now = this.ctx.currentTime;
|
|
if (now - this._lastThunder < 4) return;
|
|
this._lastThunder = now;
|
|
|
|
const ctx = this.ctx;
|
|
const bufferSize = ctx.sampleRate * 1.2;
|
|
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
|
const data = buffer.getChannelData(0);
|
|
for (let i = 0; i < bufferSize; i++) {
|
|
const t = i / ctx.sampleRate;
|
|
data[i] = (Math.random() * 2 - 1) * Math.exp(-t * 3.5);
|
|
}
|
|
const src = ctx.createBufferSource();
|
|
src.buffer = buffer;
|
|
const filter = ctx.createBiquadFilter();
|
|
filter.type = 'lowpass';
|
|
filter.frequency.value = 400;
|
|
const g = ctx.createGain();
|
|
g.gain.value = 0.5;
|
|
src.connect(filter).connect(g).connect(this.sfxBus);
|
|
src.start(now);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// SFX primitives
|
|
// ------------------------------------------------------------------
|
|
_tone(freq, duration, { type = 'sine', peak = 0.15, attack = 0.01, bus = 'sfx', when = null, glideTo = null } = {}) {
|
|
if (!this.ctx || this.muted) return;
|
|
const ctx = this.ctx;
|
|
const t = when != null ? when : ctx.currentTime;
|
|
const o = ctx.createOscillator();
|
|
o.type = type;
|
|
o.frequency.setValueAtTime(freq, t);
|
|
if (glideTo != null) {
|
|
o.frequency.exponentialRampToValueAtTime(Math.max(1, glideTo), t + duration);
|
|
}
|
|
const g = ctx.createGain();
|
|
g.gain.setValueAtTime(0.0001, t);
|
|
g.gain.exponentialRampToValueAtTime(Math.max(0.0001, peak), t + attack);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t + duration);
|
|
o.connect(g).connect(bus === 'music' ? this.musicBus : this.sfxBus);
|
|
o.start(t);
|
|
o.stop(t + duration + 0.05);
|
|
}
|
|
|
|
/** Named one-shot effects used by the game/UI. */
|
|
play(name) {
|
|
if (!this.ctx || this.muted) return;
|
|
if (this.ctx.state !== 'running') {
|
|
this.ctx.resume().catch(() => { /* ignore */ });
|
|
}
|
|
|
|
switch (name) {
|
|
case 'ui':
|
|
this._tone(660, 0.08, { type: 'triangle', peak: 0.22 });
|
|
break;
|
|
case 'ok':
|
|
this._tone(523.25, 0.12, { type: 'sine', peak: 0.3 });
|
|
this._tone(783.99, 0.18, { type: 'sine', peak: 0.26, when: this.ctx.currentTime + 0.1 });
|
|
break;
|
|
case 'error':
|
|
this._tone(220, 0.2, { type: 'square', peak: 0.16 });
|
|
this._tone(164.81, 0.25, { type: 'square', peak: 0.14, when: this.ctx.currentTime + 0.12 });
|
|
break;
|
|
case 'eat':
|
|
this._tone(300, 0.08, { type: 'triangle', peak: 0.26, glideTo: 180 });
|
|
this._tone(400, 0.1, { type: 'triangle', peak: 0.22, when: this.ctx.currentTime + 0.12, glideTo: 220 });
|
|
break;
|
|
case 'drink':
|
|
this._tone(500, 0.15, { type: 'sine', peak: 0.22, glideTo: 700 });
|
|
break;
|
|
case 'sleep':
|
|
this._tone(392, 0.4, { type: 'sine', peak: 0.22, glideTo: 196 });
|
|
break;
|
|
case 'wake':
|
|
this._tone(261.63, 0.15, { type: 'sine', peak: 0.26 });
|
|
this._tone(329.63, 0.15, { type: 'sine', peak: 0.26, when: this.ctx.currentTime + 0.12 });
|
|
this._tone(392, 0.2, { type: 'sine', peak: 0.26, when: this.ctx.currentTime + 0.24 });
|
|
break;
|
|
case 'work':
|
|
this._tone(440, 0.1, { type: 'square', peak: 0.14 });
|
|
this._tone(554.37, 0.1, { type: 'square', peak: 0.14, when: this.ctx.currentTime + 0.1 });
|
|
break;
|
|
case 'money':
|
|
this._tone(880, 0.08, { type: 'sine', peak: 0.28 });
|
|
this._tone(1174.66, 0.12, { type: 'sine', peak: 0.24, when: this.ctx.currentTime + 0.07 });
|
|
break;
|
|
case 'build':
|
|
this._tone(180, 0.15, { type: 'sawtooth', peak: 0.16 });
|
|
this._tone(240, 0.15, { type: 'sawtooth', peak: 0.16, when: this.ctx.currentTime + 0.15 });
|
|
this._tone(320, 0.2, { type: 'sawtooth', peak: 0.16, when: this.ctx.currentTime + 0.3 });
|
|
break;
|
|
case 'hospital':
|
|
this._tone(440, 0.35, { type: 'sine', peak: 0.32, glideTo: 220 });
|
|
this._tone(330, 0.4, { type: 'sine', peak: 0.28, when: this.ctx.currentTime + 0.3, glideTo: 165 });
|
|
break;
|
|
case 'horn':
|
|
this._tone(370, 0.25, { type: 'sawtooth', peak: 0.18 });
|
|
this._tone(466.16, 0.25, { type: 'sawtooth', peak: 0.16 });
|
|
break;
|
|
case 'footstep':
|
|
// Louder than the other one-shots: footsteps are the main
|
|
// feedback while walking and sit low on the spectrum.
|
|
this._tone(90 + Math.random() * 40, 0.07, { type: 'triangle', peak: 0.3, glideTo: 60 });
|
|
break;
|
|
case 'thunder':
|
|
this.thunder();
|
|
break;
|
|
case 'reload':
|
|
this._tone(330, 0.1, { type: 'triangle', peak: 0.22 });
|
|
this._tone(494, 0.15, { type: 'triangle', peak: 0.22, when: this.ctx.currentTime + 0.08 });
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/** Continuous footstep ticks while the local player is walking. */
|
|
footsteps(dt, moving) {
|
|
if (!moving || !this.ctx || this.muted || this.ctx.state !== 'running') {
|
|
this._footstepTimer = 0;
|
|
return;
|
|
}
|
|
this._footstepTimer -= dt;
|
|
if (this._footstepTimer <= 0) {
|
|
this._footstepTimer = 0.38;
|
|
this.play('footstep');
|
|
}
|
|
}
|
|
}
|
|
|
|
function clamp01(v) {
|
|
return Math.max(0, Math.min(1, v));
|
|
}
|
|
|
|
export const cityAudio = new CityAudioEngine();
|
|
export default cityAudio; |