Files
warlords-fate/js/audio.js
T
deepseek f040bb6be0 Three Kingdoms: Warlord's Fate — complete playable game
- Stylized 3D ink-painting map of China (12 provinces, 32 cities, 17 factions)
- Custom warlord creation (8 origins, banner, starting city) or historical factions
- City management: 7 buildings, 5 dev tiers, recruitment from levies
- Character system: stats, traits, loyalty, relationships, wounds, capture, death, succession
- Turn-based tactical battles with formations, stances, hero skills, cinematic 3D replay
- Sieges: assault, starvation, bribery, infiltration
- Diplomacy with trust memory, alliances, NAPs, trade, marriage, espionage, betrayal
- Scripted diverging history (Dong Zhuo, Guandu, Red Cliffs...) + world crises + court events
- AI factions with distinct personalities; prisoners (execute/release/recruit/ransom)
- Procedural guqin/taiko WebAudio score; save/load; victory + dynasty chronicle screens
- View-relative camera controls; headless test suites (smoke, stress, map validator)
2026-08-23 06:59:40 +00:00

248 lines
8.9 KiB
JavaScript

// ============================================================
// AUDIO — procedural Chinese-inspired score (WebAudio)
// Guqin-style Karplus-Strong plucks, taiko drums, wind
// ============================================================
const PENT = [0, 2, 4, 7, 9]; // major pentatonic
const BASE = 220; // A3
export class GameAudio {
constructor() {
this.ctx = null;
this.enabled = true;
this.musicVol = 0.5;
this.sfxVol = 0.7;
this.mode = null;
this._melodyTimer = null;
this._windNodes = null;
}
init() {
if (this.ctx) return;
try {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
this.master = this.ctx.createGain();
this.master.gain.value = 0.9;
this.master.connect(this.ctx.destination);
this.musicGain = this.ctx.createGain();
this.musicGain.gain.value = this.musicVol;
this.musicGain.connect(this.master);
this.sfxGain = this.ctx.createGain();
this.sfxGain.gain.value = this.sfxVol;
this.sfxGain.connect(this.master);
} catch { this.ctx = null; }
}
setMusicVol(v) { this.musicVol = v; if (this.musicGain) this.musicGain.gain.value = v; }
setSfxVol(v) { this.sfxVol = v; if (this.sfxGain) this.sfxGain.gain.value = v; }
// ---------- pluck (Karplus-Strong-ish via noise burst + filtered feedback delay) ----------
pluck(freq, when = 0, dur = 1.8, gain = 0.5, bright = 0.35) {
if (!this.ctx) return;
const t0 = this.ctx.currentTime + when;
const sr = this.ctx.sampleRate;
const N = Math.floor(sr * dur);
const buf = this.ctx.createBuffer(1, N, sr);
const data = buf.getChannelData(0);
// excitation
const period = Math.max(2, Math.floor(sr / freq));
for (let i = 0; i < period && i < N; i++) {
data[i] = Math.random() * 2 - 1;
}
// string loop with damping
let last = 0;
const damp = 0.996 - bright * 0.12;
for (let i = period; i < N; i++) {
const s = (data[i - period] + data[i - period + 1 >= N ? i - 1 : i - period + 1]) * 0.5 * damp;
data[i] = s + last * 0.0001; // tiny body resonance
last = s;
}
const src = this.ctx.createBufferSource();
src.buffer = buf;
const g = this.ctx.createGain();
g.gain.value = gain;
g.gain.setValueAtTime(gain, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
const lp = this.ctx.createBiquadFilter();
lp.type = "lowpass"; lp.frequency.value = 2600; lp.Q.value = 0.4;
src.connect(lp).connect(g).connect(this.musicGain);
src.start(t0);
src.stop(t0 + dur + 0.05);
}
noteFreq(deg, oct = 0) {
const semis = PENT[((deg % 5) + 5) % 5] + 12 * (oct + Math.floor(deg / 5));
return BASE * Math.pow(2, semis / 12);
}
// ---------- melody scheduler ----------
playMusic(mode) {
this.init();
if (!this.ctx || this.mode === mode) return;
this.mode = mode;
clearInterval(this._melodyTimer);
this.stopWind();
if (mode === "off") { this.musicGain?.gain.setTargetAtTime(0, this.ctx.currentTime, 0.4); return; }
this.musicGain?.gain.setTargetAtTime(this.musicVol, this.ctx.currentTime, 0.3);
if (mode === "map" || mode === "menu") {
this.startWind(0.05);
const tempo = mode === "menu" ? 3400 : 2600;
const phrase = () => {
if (!this.enabled || !this.ctx) return;
// gentle wandering pentatonic phrase
let deg = Math.floor(Math.random() * 5);
const steps = 3 + Math.floor(Math.random() * 4);
for (let i = 0; i < steps; i++) {
deg += [-2, -1, 1, 1, 2, 3][Math.floor(Math.random() * 6)];
const f = this.noteFreq(deg, deg > 6 ? 0 : 1);
this.pluck(f, i * (tempo / 1000) * (0.6 + Math.random() * 0.5), 2.4, 0.32 + Math.random() * 0.15, 0.25);
}
// low drone pluck occasionally
if (Math.random() < 0.6) this.pluck(this.noteFreq(0, -1), 0, 4.5, 0.22, 0.05);
};
phrase();
this._melodyTimer = setInterval(phrase, tempo);
} else if (mode === "battle") {
this.startWind(0.09);
// war drums pattern
const beat = () => {
if (!this.enabled || !this.ctx || this.mode !== "battle") return;
this.drum(0, 0.9);
setTimeout(() => this.drum(0, 0.5), 380);
setTimeout(() => this.drum(0, 0.75), 760);
};
beat();
this._melodyTimer = setInterval(beat, 1900);
// tense low strings
this.pluck(this.noteFreq(0, -2), 0, 6, 0.3, 0.02);
this.pluck(this.noteFreq(3, -2), 2.1, 6, 0.24, 0.02);
} else if (mode === "victory") {
const seq = [0, 2, 4, 7, 9, 7, 9];
seq.forEach((d, i) => this.pluck(this.noteFreq(d, 1), i * 0.16, 2.6, 0.4, 0.3));
this.pluck(this.noteFreq(0, -1), 0.1, 5, 0.3, 0.04);
this.gong(0.9);
}
}
// ---------- percussion ----------
drum(when = 0, gain = 0.8, pitch = 72) {
if (!this.ctx) return;
const t0 = this.ctx.currentTime + when;
const osc = this.ctx.createOscillator();
osc.type = "sine";
osc.frequency.setValueAtTime(pitch * 2.2, t0);
osc.frequency.exponentialRampToValueAtTime(pitch * 0.55, t0 + 0.28);
const g = this.ctx.createGain();
g.gain.setValueAtTime(gain * 0.9, t0);
g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.42);
// skin noise transient
const nb = this.ctx.createBufferSource();
const nbuf = this.ctx.createBuffer(1, 2000, this.ctx.sampleRate);
const nd = nbuf.getChannelData(0);
for (let i = 0; i < nd.length; i++) nd[i] = (Math.random() * 2 - 1) * (1 - i / nd.length);
nb.buffer = nbuf;
const ng = this.ctx.createGain(); ng.gain.value = gain * 0.35;
nb.connect(ng).connect(this.sfxGain);
osc.connect(g).connect(this.sfxGain);
osc.start(t0); osc.stop(t0 + 0.45);
nb.start(t0);
}
gong(when = 0, gain = 0.5) {
if (!this.ctx) return;
const t0 = this.ctx.currentTime + when;
for (const f of [146, 213, 297, 402]) {
const o = this.ctx.createOscillator();
o.type = "triangle";
o.frequency.value = f * (1 + Math.random() * 0.01);
const g = this.ctx.createGain();
g.gain.setValueAtTime(0, t0);
g.gain.linearRampToValueAtTime(gain / 4, t0 + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + 3.4);
o.connect(g).connect(this.sfxGain);
o.start(t0); o.stop(t0 + 3.5);
}
}
horn(when = 0, gain = 0.4) {
if (!this.ctx) return;
const t0 = this.ctx.currentTime + when;
const o = this.ctx.createOscillator();
o.type = "sawtooth";
o.frequency.setValueAtTime(98, t0);
o.frequency.linearRampToValueAtTime(110, t0 + 0.5);
const lp = this.ctx.createBiquadFilter();
lp.type = "lowpass"; lp.frequency.value = 900;
const g = this.ctx.createGain();
g.gain.setValueAtTime(0, t0);
g.gain.linearRampToValueAtTime(gain, t0 + 0.12);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + 1.4);
o.connect(lp).connect(g).connect(this.sfxGain);
o.start(t0); o.stop(t0 + 1.5);
}
click() {
if (!this.ctx) return;
const t0 = this.ctx.currentTime;
const o = this.ctx.createOscillator();
o.type = "square";
o.frequency.value = 1400;
const g = this.ctx.createGain();
g.gain.setValueAtTime(0.06, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.07);
o.connect(g).connect(this.sfxGain);
o.start(t0); o.stop(t0 + 0.08);
}
swordClash() {
if (!this.ctx) return;
const t0 = this.ctx.currentTime;
const nb = this.ctx.createBufferSource();
const buf = this.ctx.createBuffer(1, 4000, this.ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / d.length, 2);
nb.buffer = buf;
const bp = this.ctx.createBiquadFilter();
bp.type = "bandpass"; bp.frequency.value = 3200; bp.Q.value = 2;
const g = this.ctx.createGain(); g.gain.value = 0.25;
nb.connect(bp).connect(g).connect(this.sfxGain);
nb.start(t0);
}
// ---------- wind ambience ----------
startWind(gainV = 0.06) {
if (!this.ctx || this._windNodes) return;
const sr = this.ctx.sampleRate;
const buf = this.ctx.createBuffer(1, sr * 4, sr);
const d = buf.getChannelData(0);
let v = 0;
for (let i = 0; i < d.length; i++) { v += (Math.random() * 2 - 1 - v) * 0.02; d[i] = v * 3; }
const src = this.ctx.createBufferSource();
src.buffer = buf; src.loop = true;
const lp = this.ctx.createBiquadFilter();
lp.type = "lowpass"; lp.frequency.value = 480;
const g = this.ctx.createGain(); g.gain.value = gainV;
src.connect(lp).connect(g).connect(this.musicGain);
src.start();
this._windNodes = { src, g };
}
stopWind() {
if (this._windNodes) {
try { this._windNodes.src.stop(); } catch { }
this._windNodes = null;
}
}
toggleEnabled() {
this.enabled = !this.enabled;
if (this.ctx) {
this.master.gain.setTargetAtTime(this.enabled ? 0.9 : 0, this.ctx.currentTime, 0.1);
}
return this.enabled;
}
}
export const audio = new GameAudio();