342 lines
19 KiB
JavaScript
342 lines
19 KiB
JavaScript
"use strict";
|
|
|
|
// Sound IDs are intentionally stable. To replace a synthesized placeholder with a file,
|
|
// add a path to samplePaths using the same ID, for example:
|
|
// sfx_genkotsu_impact: "assets/sounds/sfx_genkotsu_impact.mp3"
|
|
const audio = {
|
|
enabled: true,
|
|
ctx: null,
|
|
samples: {},
|
|
sampleLast: {},
|
|
activeVoices: 0,
|
|
maxConcurrent: 96,
|
|
failedSamples: {},
|
|
lastPlayedId: "",
|
|
lastPlayedAt: 0,
|
|
soundPackVersion: "inline",
|
|
masterGainNode: null,
|
|
compressorNode: null,
|
|
_synthIdGain: 1,
|
|
idGain: {},
|
|
idMinGap: {},
|
|
categoryEnabled: { voice: false, notify: true, ops: true },
|
|
categoryGain: {},
|
|
samplePaths: {},
|
|
soundCategories: {},
|
|
soundMap: {},
|
|
applySoundPack(pack = window.TARINAI_SOUND_PACK) {
|
|
if (!pack || typeof pack !== "object") return false;
|
|
this.soundPackVersion = String(pack.version || "custom");
|
|
if (pack.categoryGain && typeof pack.categoryGain === "object") this.categoryGain = { ...this.categoryGain, ...pack.categoryGain };
|
|
if (pack.idGain && typeof pack.idGain === "object") this.idGain = { ...this.idGain, ...pack.idGain };
|
|
if (pack.idMinGap && typeof pack.idMinGap === "object") this.idMinGap = { ...this.idMinGap, ...pack.idMinGap };
|
|
if (pack.samplePaths && typeof pack.samplePaths === "object") this.samplePaths = { ...this.samplePaths, ...pack.samplePaths };
|
|
if (pack.categories && typeof pack.categories === "object") this.soundCategories = { ...this.soundCategories, ...pack.categories };
|
|
if (pack.soundMap && typeof pack.soundMap === "object") this.soundMap = { ...this.soundMap, ...pack.soundMap };
|
|
this._samplesReady = false;
|
|
this.samples = {};
|
|
return true;
|
|
},
|
|
ensure() {
|
|
const AudioClass = window.AudioContext || window.webkitAudioContext;
|
|
if (!AudioClass) return null;
|
|
if (!this.ctx) this.ctx = new AudioClass();
|
|
if (!this.masterGainNode) {
|
|
const ctx = this.ctx;
|
|
this.masterGainNode = ctx.createGain();
|
|
this.masterGainNode.gain.setValueAtTime(1.85, ctx.currentTime);
|
|
if (typeof ctx.createDynamicsCompressor === "function") {
|
|
this.compressorNode = ctx.createDynamicsCompressor();
|
|
this.compressorNode.threshold.value = -18;
|
|
this.compressorNode.knee.value = 24;
|
|
this.compressorNode.ratio.value = 8;
|
|
this.compressorNode.attack.value = 0.002;
|
|
this.compressorNode.release.value = 0.18;
|
|
this.masterGainNode.connect(this.compressorNode).connect(ctx.destination);
|
|
} else {
|
|
this.masterGainNode.connect(ctx.destination);
|
|
}
|
|
}
|
|
if (this.ctx.state === "suspended") this.ctx.resume().catch?.(() => {});
|
|
this.initSamples();
|
|
return this.ctx;
|
|
},
|
|
outputNode() {
|
|
this.ensure();
|
|
return this.masterGainNode || this.ctx?.destination;
|
|
},
|
|
loadSettings() {
|
|
try {
|
|
const raw = localStorage.getItem("tarinai_audio_settings_v2");
|
|
if (!raw) return;
|
|
const parsed = JSON.parse(raw);
|
|
if (typeof parsed.enabled === "boolean") this.enabled = parsed.enabled;
|
|
if (parsed.categories && typeof parsed.categories === "object") {
|
|
for (const key of Object.keys(this.categoryEnabled)) {
|
|
if (typeof parsed.categories[key] === "boolean") this.categoryEnabled[key] = parsed.categories[key];
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
},
|
|
saveSettings() {
|
|
try {
|
|
localStorage.setItem("tarinai_audio_settings_v2", JSON.stringify({
|
|
enabled: Boolean(this.enabled),
|
|
categories: { ...this.categoryEnabled },
|
|
}));
|
|
} catch (_) {}
|
|
},
|
|
initSamples() {
|
|
if (this._samplesReady) return;
|
|
this._samplesReady = true;
|
|
for (const [key, path] of Object.entries(this.samplePaths)) {
|
|
if (!path) continue;
|
|
const a = new Audio(path);
|
|
a.preload = "auto";
|
|
a.volume = this.sampleVolumeFor(key);
|
|
a.addEventListener("error", () => {
|
|
this.failedSamples[key] = path;
|
|
window.TarinaiEvents?.emit("audio:sample-error", { id: key, path });
|
|
}, { once: true });
|
|
this.samples[key] = a;
|
|
}
|
|
},
|
|
categoryFor(id, fallback = "ops") {
|
|
return this.soundMap[id] || fallback;
|
|
},
|
|
categoryOn(category = "ops") {
|
|
return Boolean(this.categoryEnabled[category] ?? true);
|
|
},
|
|
gainFor(category = "ops", base = 1) {
|
|
return Math.max(0.1, Number(base) || 1) * (this.categoryGain?.[category] ?? 1);
|
|
},
|
|
sampleVolumeFor(key = "") {
|
|
const category = this.categoryFor(key, key.startsWith("voice") ? "voice" : "ops");
|
|
const base = category === "voice" ? 0.95 : 1.0;
|
|
return Math.min(1, base * Math.max(1, (this.categoryGain?.[category] ?? 1) * 0.35) * (this.idGain?.[key] ?? 1));
|
|
},
|
|
distanceGain(opts = {}) {
|
|
if (!opts || opts.category === "notify" || !Number.isFinite(opts.x) || !Number.isFinite(opts.y)) return 1;
|
|
const w = window.world;
|
|
if (!w || !w.worldToScreen) return 1;
|
|
const p = w.worldToScreen(opts.x, opts.y);
|
|
const margin = 140;
|
|
const vw = w.viewportW || w.w || 1000;
|
|
const vh = w.viewportH || w.h || 720;
|
|
if (p.x < -margin || p.y < -margin || p.x > vw + margin || p.y > vh + margin) return 0;
|
|
const cx = vw / 2, cy = vh / 2;
|
|
const d = Math.hypot(p.x - cx, p.y - cy);
|
|
return clamp(1 - d / Math.max(vw, vh) * 0.65, 0.35, 1);
|
|
},
|
|
beginVoice(dur = 0.1) {
|
|
this.activeVoices = (this.activeVoices || 0) + 1;
|
|
setTimeout(() => { this.activeVoices = Math.max(0, (this.activeVoices || 0) - 1); }, Math.max(60, dur * 1000 + 80));
|
|
},
|
|
setCategory(category, on) {
|
|
if (!(category in this.categoryEnabled)) return;
|
|
this.categoryEnabled[category] = Boolean(on);
|
|
this.saveSettings();
|
|
},
|
|
toggleCategory(category) {
|
|
if (!(category in this.categoryEnabled)) return false;
|
|
this.setCategory(category, !this.categoryEnabled[category]);
|
|
return this.categoryEnabled[category];
|
|
},
|
|
setEnabled(on) {
|
|
this.enabled = Boolean(on);
|
|
if (this.enabled) this.ensure();
|
|
this.saveSettings();
|
|
return this.enabled;
|
|
},
|
|
toggle() {
|
|
return this.setEnabled(!this.enabled);
|
|
},
|
|
canPlay(id, category = null, minGap = 0.04) {
|
|
if (!this.enabled) return false;
|
|
const cat = category || this.categoryFor(id);
|
|
if (!this.categoryOn(cat)) return false;
|
|
if ((this.activeVoices || 0) >= (this.maxConcurrent || 16) && cat !== "notify") return false;
|
|
const gap = Math.max(Number(minGap) || 0, this.idMinGap?.[id] ?? 0);
|
|
const t = performance.now() / 1000;
|
|
if (t - (this.sampleLast[id] || 0) < gap) return false;
|
|
this.sampleLast[id] = t;
|
|
return true;
|
|
},
|
|
playSample(key, minGap = 0.16, category = null) {
|
|
const base = this.samples[key];
|
|
if (!base) return false;
|
|
const cat = category || this.categoryFor(key, key.startsWith("voice") ? "voice" : "ops");
|
|
if (!this.canPlay(key, cat, minGap)) return false;
|
|
this.lastPlayedId = key;
|
|
this.lastPlayedAt = performance.now() / 1000;
|
|
window.TarinaiEvents?.emit("audio:play", { id: key, category: cat, synthetic: false });
|
|
this.ensure();
|
|
const node = base.cloneNode(true);
|
|
node.volume = Math.min(1, base.volume * (this.categoryGain?.[cat] ?? 1) * (this.idGain?.[key] ?? 1));
|
|
this.beginVoice(Math.max(0.08, Number(node.duration) || 0.16));
|
|
node.play().catch(() => {});
|
|
return true;
|
|
},
|
|
tone(freq, dur = 0.08, type = "sine", gain = 0.035, slide = 0, category = "ops") {
|
|
if (!this.enabled || !this.categoryOn(category)) return;
|
|
const ctx = this.ensure();
|
|
if (!ctx) return;
|
|
if ((this.activeVoices || 0) >= (this.maxConcurrent || 16) && category !== "notify") return;
|
|
this.beginVoice(dur);
|
|
gain = Math.min(0.95, Math.max(0.0002, gain * (this.categoryGain?.[category] ?? 1) * (this._synthIdGain || 1)));
|
|
const t0 = ctx.currentTime;
|
|
const osc = ctx.createOscillator();
|
|
const g = ctx.createGain();
|
|
osc.type = type;
|
|
osc.frequency.setValueAtTime(freq, t0);
|
|
if (slide) osc.frequency.linearRampToValueAtTime(Math.max(36, freq + slide), t0 + dur);
|
|
g.gain.setValueAtTime(0.0001, t0);
|
|
g.gain.exponentialRampToValueAtTime(Math.max(0.0002, gain), t0 + Math.min(0.018, dur * 0.35));
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
|
osc.connect(g).connect(this.outputNode() || ctx.destination);
|
|
osc.start(t0);
|
|
osc.stop(t0 + dur + 0.03);
|
|
},
|
|
noise(dur = 0.08, gain = 0.030, category = "ops", filterFreq = 900) {
|
|
if (!this.enabled || !this.categoryOn(category)) return;
|
|
const ctx = this.ensure();
|
|
if (!ctx) return;
|
|
if ((this.activeVoices || 0) >= (this.maxConcurrent || 16) && category !== "notify") return;
|
|
this.beginVoice(dur);
|
|
gain = Math.min(0.90, Math.max(0.0002, gain * (this.categoryGain?.[category] ?? 1) * (this._synthIdGain || 1)));
|
|
const length = Math.max(1, Math.floor(ctx.sampleRate * dur));
|
|
const buffer = ctx.createBuffer(1, length, ctx.sampleRate);
|
|
const data = buffer.getChannelData(0);
|
|
for (let i = 0; i < length; i++) data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 1.5);
|
|
const src = ctx.createBufferSource();
|
|
const filter = ctx.createBiquadFilter();
|
|
const g = ctx.createGain();
|
|
filter.type = "lowpass";
|
|
filter.frequency.value = filterFreq;
|
|
g.gain.setValueAtTime(gain, ctx.currentTime);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + dur);
|
|
src.buffer = buffer;
|
|
src.connect(filter).connect(g).connect(this.outputNode() || ctx.destination);
|
|
src.start();
|
|
},
|
|
play(id, opts = {}) {
|
|
const category = opts.category || this.categoryFor(id, "ops");
|
|
const minGap = opts.minGap ?? 0.10;
|
|
if (!this.canPlay(id, category, minGap)) return false;
|
|
this.lastPlayedId = id;
|
|
this.lastPlayedAt = performance.now() / 1000;
|
|
window.TarinaiEvents?.emit("audio:play", { id, category, synthetic: !this.samples[id] });
|
|
if (this.samples[id]) {
|
|
const base = this.samples[id];
|
|
this.ensure();
|
|
const node = base.cloneNode(true);
|
|
node.volume = Math.min(1, base.volume * (this.categoryGain?.[category] ?? 1) * (this.idGain?.[id] ?? 1));
|
|
this.beginVoice(Math.max(0.08, Number(node.duration) || 0.16));
|
|
node.play().catch(() => {});
|
|
return true;
|
|
}
|
|
this.synth(id, category);
|
|
return true;
|
|
},
|
|
synth(id, category) {
|
|
const prevSynthGain = this._synthIdGain || 1;
|
|
this._synthIdGain = Math.max(0.25, this.idGain?.[id] ?? 1);
|
|
const t = (ms, fn) => setTimeout(() => {
|
|
const prev = this._synthIdGain || 1;
|
|
this._synthIdGain = Math.max(0.25, this.idGain?.[id] ?? 1);
|
|
try { fn(); } finally { this._synthIdGain = prev; }
|
|
}, ms);
|
|
try {
|
|
switch (id) {
|
|
case "sfx_notify": case "sfx_log_push":
|
|
this.tone(880, 0.055, "sine", 0.022, 80, category); t(62, () => this.tone(1180, 0.075, "triangle", 0.020, -40, category)); break;
|
|
case "sfx_ui_click": this.tone(520, 0.035, "square", 0.010, 80, category); break;
|
|
case "sfx_ui_fold": this.tone(420, 0.045, "triangle", 0.012, -130, category); break;
|
|
case "sfx_place": this.tone(360, 0.055, "triangle", 0.020, -80, category); break;
|
|
case "sfx_delete": this.noise(0.06, 0.018, category, 1600); this.tone(740, 0.045, "sine", 0.012, -240, category); break;
|
|
case "sfx_poke": this.tone(180, 0.085, "sawtooth", 0.023, -70, category); break;
|
|
case "sfx_grab": this.tone(300, 0.050, "triangle", 0.018, 90, category); break;
|
|
case "sfx_drop": this.tone(220, 0.060, "triangle", 0.018, -80, category); break;
|
|
case "sfx_water_hose": this.noise(0.13, 0.018, category, 2400); break;
|
|
case "sfx_water_clean": this.noise(0.08, 0.014, category, 3200); this.tone(1120, 0.040, "sine", 0.009, 180, category); break;
|
|
case "sfx_drink": this.tone(720, 0.045, "sine", 0.012, -120, category); t(42, () => this.tone(620, 0.040, "sine", 0.010, -80, category)); break;
|
|
case "sfx_ball_hit": this.tone(260, 0.065, "triangle", 0.020, 120, category); break;
|
|
case "sfx_stone_impact": this.tone(110, 0.080, "sawtooth", 0.032, -50, category); this.noise(0.05, 0.018, category, 600); break;
|
|
case "sfx_firecracker_fuse": this.noise(0.18, 0.018, category, 5200); break;
|
|
case "sfx_firecracker_explode": this.tone(90, 0.120, "sawtooth", 0.060, -30, category); t(42, () => this.tone(55, 0.200, "square", 0.040, -20, category)); this.noise(0.16, 0.032, category, 900); break;
|
|
case "sfx_genkotsu_fall": this.tone(760, 0.20, "sawtooth", 0.018, -500, category); this.noise(0.16, 0.018, category, 1800); break;
|
|
case "sfx_genkotsu_impact": this.tone(64, 0.20, "square", 0.070, -18, category); this.noise(0.18, 0.040, category, 720); break;
|
|
case "sfx_ground_rumble": this.tone(46, 0.34, "sawtooth", 0.032, -8, category); t(120, () => this.tone(39, 0.32, "triangle", 0.026, 6, category)); break;
|
|
case "sfx_grass": this.noise(0.08, 0.011, category, 3000); break;
|
|
case "sfx_zunchi": this.tone(96, 0.070, "triangle", 0.014, -24, category); this.noise(0.05, 0.010, category, 460); break;
|
|
case "sfx_ant_drag": this.noise(0.12, 0.012, category, 1800); this.tone(210, 0.040, "triangle", 0.010, -70, category); break;
|
|
case "sfx_ant_die": this.tone(150, 0.038, "square", 0.012, -55, category); break;
|
|
case "sfx_ant_nest": this.noise(0.11, 0.018, category, 780); this.tone(170, 0.060, "triangle", 0.014, -40, category); break;
|
|
case "sfx_queen_ant": this.tone(240, 0.085, "triangle", 0.025, -40, category); t(70, () => this.noise(0.08, 0.014, category, 1300)); break;
|
|
case "sfx_birth": this.tone(520, 0.08, "sine", 0.028, 100, category); t(70, () => this.tone(760, 0.10, "triangle", 0.024, 80, category)); break;
|
|
case "sfx_death": this.tone(150, 0.22, "sine", 0.028, -70, category); break;
|
|
case "sfx_damage_soft": this.tone(300, 0.055, "triangle", 0.018, -90, category); break;
|
|
case "sfx_damage_heavy": this.tone(120, 0.090, "sawtooth", 0.028, -70, category); break;
|
|
case "sfx_fight_hit": this.tone(110, 0.055, "square", 0.018, 70, category); t(35, () => this.tone(90, 0.045, "sawtooth", 0.014, -40, category)); break;
|
|
case "sfx_fight_finish": this.tone(150, 0.090, "triangle", 0.024, -60, category); t(80, () => this.tone(260, 0.070, "sine", 0.016, 60, category)); break;
|
|
case "sfx_sleep": this.tone(320, 0.12, "sine", 0.016, -90, category); break;
|
|
case "sfx_wake": this.tone(650, 0.055, "triangle", 0.017, 170, category); break;
|
|
case "sfx_disease": this.tone(210, 0.11, "sawtooth", 0.020, -130, category); this.noise(0.07, 0.012, category, 700); break;
|
|
case "sfx_heal": this.tone(720, 0.060, "sine", 0.018, 150, category); t(55, () => this.tone(960, 0.075, "triangle", 0.018, 130, category)); break;
|
|
case "sfx_phase": this.tone(440, 0.06, "sine", 0.018, 60, category); break;
|
|
default: this.tone(440, 0.06, "sine", 0.016, 0, category);
|
|
}
|
|
} finally {
|
|
this._synthIdGain = prevSynthGain;
|
|
}
|
|
},
|
|
bubble(text) {
|
|
const s = String(text || "");
|
|
if (s.includes("\u3082\u3050")) return this.playSample("sfx_eat", 0.20, "voice") || this.play("sfx_eat", { category: "voice", minGap: 0.20 });
|
|
if (s.includes("\u3076\u308a")) return this.playSample("voice_poop", 0.28, "voice") || this.play("sfx_zunchi", { category: "ops", minGap: 0.28 });
|
|
if (s.includes("\u306f\u3063")) return this.playSample("voice_stress", 0.80, "voice") || this.play("sfx_damage_soft", { category: "voice", minGap: 0.80 });
|
|
if (s.includes("Zzz")) return this.playSample("sfx_sleep", 1.25, "voice") || this.play("sfx_sleep", { category: "voice", minGap: 1.25 });
|
|
if (s.includes("\u306f\u3041") || s.includes("\u306f\u3042")) return this.playSample("voice_flee", 0.70, "voice") || this.play("sfx_damage_heavy", { category: "voice", minGap: 0.70 });
|
|
if (s.includes("\u306f\u3046") || s.includes("\u306f\u3045")) return this.playSample("voice_hau", 0.45, "voice") || this.play("sfx_wake", { category: "voice", minGap: 0.45 });
|
|
return false;
|
|
},
|
|
eat() { if (!this.playSample("sfx_eat", 0.20, "voice")) this.play("sfx_eat", { category: "voice", minGap: 0.20 }); },
|
|
poke() { this.play("sfx_poke", { category: "ops", minGap: 0.07 }); },
|
|
birth() { this.play("sfx_birth", { category: "voice", minGap: 0.18 }); },
|
|
death() { this.play("sfx_death", { category: "voice", minGap: 0.20 }); },
|
|
fight() { this.play("sfx_fight_hit", { category: "voice", minGap: 0.09 }); },
|
|
fightFinish() { this.play("sfx_fight_finish", { category: "voice", minGap: 0.40 }); },
|
|
explode() { if (!this.playSample("sfx_firecracker_explode", 0.30, "ops")) this.play("sfx_firecracker_explode", { category: "ops", minGap: 0.30 }); },
|
|
phase() { this.play("sfx_phase", { category: "notify", minGap: 1.0 }); },
|
|
notify() { this.play("sfx_notify", { category: "notify", minGap: 0.12 }); },
|
|
uiClick() { this.play("sfx_ui_click", { category: "ops", minGap: 0.035 }); },
|
|
uiFold() { this.play("sfx_ui_fold", { category: "ops", minGap: 0.08 }); },
|
|
place(type = "") {
|
|
if (type === "genkotsu") return this.play("sfx_genkotsu_fall", { category: "ops", minGap: 0.10 });
|
|
if (type === "firecracker") return this.play("sfx_firecracker_fuse", { category: "ops", minGap: 0.18 });
|
|
if (type === "grass") return this.play("sfx_grass", { category: "ops", minGap: 0.08 });
|
|
if (type === "zunchi") return this.play("sfx_zunchi", { category: "ops", minGap: 0.12 });
|
|
if (type === "ant_nest" || type === "nest_box") return this.play("sfx_ant_nest", { category: "ops", minGap: 0.12 });
|
|
return this.play("sfx_place", { category: "ops", minGap: 0.08 });
|
|
},
|
|
delete() { this.play("sfx_delete", { category: "ops", minGap: 0.08 }); },
|
|
grab() { this.play("sfx_grab", { category: "ops", minGap: 0.08 }); },
|
|
drop() { this.play("sfx_drop", { category: "ops", minGap: 0.08 }); },
|
|
waterHose() { this.play("sfx_water_hose", { category: "ops", minGap: 0.20 }); },
|
|
waterClean() { this.play("sfx_water_clean", { category: "ops", minGap: 0.22 }); },
|
|
ballHit() { this.play("sfx_ball_hit", { category: "ops", minGap: 0.08 }); },
|
|
stoneImpact() { this.play("sfx_stone_impact", { category: "ops", minGap: 0.18 }); },
|
|
genkotsuImpact() { this.play("sfx_genkotsu_impact", { category: "ops", minGap: 0.20 }); setTimeout(() => this.play("sfx_ground_rumble", { category: "ops", minGap: 0.20 }), 80); },
|
|
damage(amount = 0) { this.play(amount >= 18 ? "sfx_damage_heavy" : "sfx_damage_soft", { category: "voice", minGap: amount >= 18 ? 0.16 : 0.09 }); },
|
|
disease() { this.play("sfx_disease", { category: "voice", minGap: 0.55 }); },
|
|
heal() { this.play("sfx_heal", { category: "voice", minGap: 0.40 }); },
|
|
antDrag() { this.play("sfx_ant_drag", { category: "voice", minGap: 0.50 }); },
|
|
antDie() { this.play("sfx_ant_die", { category: "voice", minGap: 0.18 }); },
|
|
antNest() { this.play("sfx_ant_nest", { category: "ops", minGap: 0.28 }); },
|
|
queenAnt() { this.play("sfx_queen_ant", { category: "voice", minGap: 0.80 }); },
|
|
};
|
|
|
|
audio.applySoundPack?.(window.TARINAI_SOUND_PACK);
|
|
audio.loadSettings();
|
|
window.TarinaiEvents?.on?.("audio:test", ev => audio.play(ev.detail?.id || "sfx_notify", ev.detail || {}));
|
|
window.TarinaiAudio = audio;
|