feat(world): add persistent citizen simulation with needs, jobs, and social features
- Adds Character model with survival needs (hunger, thirst, energy) and state machine - Implements city:tick world ticker for offline progression every minute - Adds city territory expansion and building construction system - Introduces job economy, inventory, and transaction audit trail - Adds social graph (friends, marriage, children) and character chat - Implements admin approval workflow for new registrations - Adds hospital admission system for traffic accidents - Includes comprehensive feature tests and world seeders
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* 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':
|
||||
this._tone(90 + Math.random() * 40, 0.06, { type: 'triangle', peak: 0.12, 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;
|
||||
Reference in New Issue
Block a user