3d8566bc-063f-41e8-8934-68c9ac5b8211
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
// 回响星核 / Echo Nexus — 程序化音频引擎(Web Audio API,零资源文件)
|
||||
//
|
||||
// 设计哲学:深空考古的氛围音效,全部由振荡器 + 噪声 + 包络合成,
|
||||
// 无需任何外部音频文件。音色偏向"谐振/水晶/低频脉冲",契合全息晶体美学。
|
||||
|
||||
type SfxName =
|
||||
| "pulse" // 主动脉冲扫描(短促上升音)
|
||||
| "pulseCombo" // 连击脉冲(更高音 + 泛音)
|
||||
| "decodeClick" // 解码点击节点(按颜色变调)
|
||||
| "decodeFail" // 解码点击错误(低沉短音)
|
||||
| "decodeSuccess" // 解码成功(和弦上扬)
|
||||
| "fragmentUnlock" // 记忆碎片浮现(空灵长音)
|
||||
| "techBuy" // 购买技术(确认音)
|
||||
| "expeditionStart" // 探险出发(引擎启动)
|
||||
| "expeditionNode" // 探险节点结算(中频)
|
||||
| "expeditionVictory" // 探险胜利(凯旋和弦)
|
||||
| "expeditionDefeat" // 探险失败(下行低音)
|
||||
| "prestige" // 飞升(宏大扫频)
|
||||
| "achievement" // 成就解锁(亮丽琶音)
|
||||
| "uiHover" // 界面悬停(极轻)
|
||||
| "uiClick"; // 界面点击(轻确认)
|
||||
|
||||
const COLOR_FREQ: Record<string, number> = {
|
||||
emerald: 523.25, // C5
|
||||
rose: 587.33, // D5
|
||||
amber: 659.25, // E5
|
||||
fuchsia: 698.46, // F5
|
||||
};
|
||||
|
||||
class AudioEngine {
|
||||
private ctx: AudioContext | null = null;
|
||||
private master: GainNode | null = null;
|
||||
private enabled = true;
|
||||
private volume = 0.35;
|
||||
|
||||
/** 首次用户交互后初始化(浏览器自动播放策略) */
|
||||
private ensure() {
|
||||
if (typeof window === "undefined") return null;
|
||||
if (!this.ctx) {
|
||||
const AC =
|
||||
window.AudioContext ||
|
||||
(window as unknown as { webkitAudioContext: typeof AudioContext })
|
||||
.webkitAudioContext;
|
||||
if (!AC) return null;
|
||||
this.ctx = new AC();
|
||||
this.master = this.ctx.createGain();
|
||||
this.master.gain.value = this.volume;
|
||||
this.master.connect(this.ctx.destination);
|
||||
}
|
||||
if (this.ctx.state === "suspended") {
|
||||
void this.ctx.resume();
|
||||
}
|
||||
return this.ctx;
|
||||
}
|
||||
|
||||
setEnabled(v: boolean) {
|
||||
this.enabled = v;
|
||||
}
|
||||
|
||||
setVolume(v: number) {
|
||||
this.volume = Math.max(0, Math.min(1, v));
|
||||
if (this.master) this.master.gain.value = this.volume;
|
||||
}
|
||||
|
||||
/** 简易正弦音 + ADSR 包络 */
|
||||
private tone(
|
||||
freq: number,
|
||||
dur: number,
|
||||
type: OscillatorType = "sine",
|
||||
gain = 0.3,
|
||||
delay = 0,
|
||||
detune = 0,
|
||||
filterFreq?: number
|
||||
) {
|
||||
const ctx = this.ensure();
|
||||
if (!ctx || !this.master) return;
|
||||
const t0 = ctx.currentTime + delay;
|
||||
const osc = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
osc.type = type;
|
||||
osc.frequency.setValueAtTime(freq, t0);
|
||||
if (detune) osc.detune.setValueAtTime(detune, t0);
|
||||
// ADSR
|
||||
g.gain.setValueAtTime(0, t0);
|
||||
g.gain.linearRampToValueAtTime(gain, t0 + 0.008);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||
let node: AudioNode = osc;
|
||||
if (filterFreq) {
|
||||
const f = ctx.createBiquadFilter();
|
||||
f.type = "lowpass";
|
||||
f.frequency.value = filterFreq;
|
||||
osc.connect(f);
|
||||
f.connect(g);
|
||||
} else {
|
||||
osc.connect(g);
|
||||
}
|
||||
g.connect(this.master);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + dur + 0.05);
|
||||
void node;
|
||||
}
|
||||
|
||||
/** 频率扫描音(飞升/出发用) */
|
||||
private sweep(
|
||||
fStart: number,
|
||||
fEnd: number,
|
||||
dur: number,
|
||||
type: OscillatorType = "sine",
|
||||
gain = 0.3,
|
||||
delay = 0
|
||||
) {
|
||||
const ctx = this.ensure();
|
||||
if (!ctx || !this.master) return;
|
||||
const t0 = ctx.currentTime + delay;
|
||||
const osc = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
osc.type = type;
|
||||
osc.frequency.setValueAtTime(fStart, t0);
|
||||
osc.frequency.exponentialRampToValueAtTime(Math.max(1, fEnd), t0 + dur);
|
||||
g.gain.setValueAtTime(0, t0);
|
||||
g.gain.linearRampToValueAtTime(gain, t0 + 0.02);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||
osc.connect(g);
|
||||
g.connect(this.master);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + dur + 0.05);
|
||||
}
|
||||
|
||||
/** 噪声脉冲(解码失败/探险打击) */
|
||||
private noise(dur: number, gain = 0.2, delay = 0, filterFreq = 1200) {
|
||||
const ctx = this.ensure();
|
||||
if (!ctx || !this.master) return;
|
||||
const t0 = ctx.currentTime + delay;
|
||||
const len = Math.floor(ctx.sampleRate * dur);
|
||||
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
|
||||
const data = buf.getChannelData(0);
|
||||
for (let i = 0; i < len; i++) {
|
||||
data[i] = (Math.random() * 2 - 1) * (1 - i / len);
|
||||
}
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = buf;
|
||||
const f = ctx.createBiquadFilter();
|
||||
f.type = "lowpass";
|
||||
f.frequency.value = filterFreq;
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(gain, t0);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||
src.connect(f);
|
||||
f.connect(g);
|
||||
g.connect(this.master);
|
||||
src.start(t0);
|
||||
src.stop(t0 + dur + 0.02);
|
||||
}
|
||||
|
||||
play(name: SfxName, opts?: { color?: string; combo?: number }) {
|
||||
if (!this.enabled) return;
|
||||
const ctx = this.ensure();
|
||||
if (!ctx) return;
|
||||
|
||||
switch (name) {
|
||||
case "pulse": {
|
||||
// 上升短音 + 轻泛音
|
||||
const base = 220 + (opts?.combo ? opts.combo * 18 : 0);
|
||||
this.tone(base, 0.18, "sine", 0.28);
|
||||
this.tone(base * 2, 0.14, "triangle", 0.1, 0.01);
|
||||
break;
|
||||
}
|
||||
case "pulseCombo": {
|
||||
const base = 440 + (opts?.combo ? opts.combo * 24 : 0);
|
||||
this.tone(base, 0.2, "sine", 0.3);
|
||||
this.tone(base * 1.5, 0.18, "triangle", 0.14, 0.01);
|
||||
this.tone(base * 2, 0.16, "sine", 0.08, 0.02);
|
||||
break;
|
||||
}
|
||||
case "decodeClick": {
|
||||
const f = opts?.color ? COLOR_FREQ[opts.color] ?? 523 : 523;
|
||||
this.tone(f, 0.16, "sine", 0.26);
|
||||
this.tone(f * 2, 0.1, "triangle", 0.08, 0.005);
|
||||
break;
|
||||
}
|
||||
case "decodeFail": {
|
||||
this.tone(160, 0.16, "sawtooth", 0.18, 0, 0, 800);
|
||||
this.noise(0.08, 0.1, 0, 600);
|
||||
break;
|
||||
}
|
||||
case "decodeSuccess": {
|
||||
// 上扬大三和弦琶音
|
||||
this.tone(523.25, 0.22, "sine", 0.24, 0);
|
||||
this.tone(659.25, 0.22, "sine", 0.22, 0.08);
|
||||
this.tone(783.99, 0.3, "sine", 0.24, 0.16);
|
||||
this.tone(1046.5, 0.34, "triangle", 0.12, 0.2);
|
||||
break;
|
||||
}
|
||||
case "fragmentUnlock": {
|
||||
// 空灵长音
|
||||
this.tone(880, 0.7, "sine", 0.18, 0);
|
||||
this.tone(1108.73, 0.7, "sine", 0.12, 0.04);
|
||||
this.tone(1318.51, 0.8, "triangle", 0.08, 0.1);
|
||||
this.sweep(440, 880, 0.6, "sine", 0.1, 0.05);
|
||||
break;
|
||||
}
|
||||
case "techBuy": {
|
||||
this.tone(587.33, 0.12, "sine", 0.22);
|
||||
this.tone(880, 0.16, "triangle", 0.16, 0.06);
|
||||
break;
|
||||
}
|
||||
case "expeditionStart": {
|
||||
// 引擎启动:低频上升
|
||||
this.sweep(80, 240, 0.5, "sawtooth", 0.16);
|
||||
this.sweep(120, 360, 0.5, "square", 0.06, 0.02);
|
||||
this.noise(0.4, 0.08, 0, 400);
|
||||
break;
|
||||
}
|
||||
case "expeditionNode": {
|
||||
this.tone(440, 0.14, "triangle", 0.2);
|
||||
this.tone(660, 0.12, "sine", 0.1, 0.04);
|
||||
break;
|
||||
}
|
||||
case "expeditionVictory": {
|
||||
// 凯旋上行
|
||||
this.tone(523.25, 0.18, "sine", 0.24, 0);
|
||||
this.tone(659.25, 0.18, "sine", 0.24, 0.1);
|
||||
this.tone(783.99, 0.18, "sine", 0.24, 0.2);
|
||||
this.tone(1046.5, 0.4, "triangle", 0.2, 0.3);
|
||||
break;
|
||||
}
|
||||
case "expeditionDefeat": {
|
||||
// 下行低音
|
||||
this.tone(330, 0.3, "sawtooth", 0.2, 0, 0, 700);
|
||||
this.tone(220, 0.4, "sine", 0.18, 0.12);
|
||||
this.tone(146.83, 0.5, "sine", 0.16, 0.24);
|
||||
break;
|
||||
}
|
||||
case "prestige": {
|
||||
// 宏大扫频 + 和弦
|
||||
this.sweep(110, 880, 1.2, "sine", 0.2);
|
||||
this.sweep(220, 1760, 1.2, "triangle", 0.1, 0.05);
|
||||
this.tone(523.25, 0.6, "sine", 0.16, 0.3);
|
||||
this.tone(659.25, 0.6, "sine", 0.16, 0.42);
|
||||
this.tone(783.99, 0.8, "sine", 0.16, 0.54);
|
||||
this.tone(1046.5, 1.0, "triangle", 0.12, 0.66);
|
||||
break;
|
||||
}
|
||||
case "achievement": {
|
||||
// 亮丽琶音
|
||||
this.tone(659.25, 0.16, "sine", 0.22, 0);
|
||||
this.tone(880, 0.16, "sine", 0.22, 0.08);
|
||||
this.tone(1046.5, 0.16, "sine", 0.22, 0.16);
|
||||
this.tone(1318.51, 0.4, "triangle", 0.18, 0.24);
|
||||
break;
|
||||
}
|
||||
case "uiHover": {
|
||||
this.tone(880, 0.05, "sine", 0.05);
|
||||
break;
|
||||
}
|
||||
case "uiClick": {
|
||||
this.tone(660, 0.07, "sine", 0.1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局单例(客户端) */
|
||||
let _engine: AudioEngine | null = null;
|
||||
export function getAudio(): AudioEngine {
|
||||
if (!_engine) _engine = new AudioEngine();
|
||||
return _engine;
|
||||
}
|
||||
|
||||
export type { SfxName };
|
||||
Reference in New Issue
Block a user