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:
2026-10-17 21:46:23 -03:00
parent 79d12fa2f4
commit 79ce2a1825
132 changed files with 11809 additions and 1036 deletions
+415
View File
@@ -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;