// 回响星核 / Echo Nexus — 程序化音频引擎(Web Audio API,零资源文件) // // 设计哲学:深空考古的氛围音效,全部由振荡器 + 噪声 + 包络合成, // 无需任何外部音频文件。音色偏向"谐振/水晶/低频脉冲",契合全息晶体美学。 type SfxName = | "pulse" // 主动脉冲扫描(短促上升音) | "pulseCombo" // 连击脉冲(更高音 + 泛音) | "decodeClick" // 解码点击节点(按颜色变调) | "decodeFail" // 解码点击错误(低沉短音) | "decodeSuccess" // 解码成功(和弦上扬) | "fragmentUnlock" // 记忆碎片浮现(空灵长音) | "techBuy" // 购买技术(确认音) | "expeditionStart" // 探险出发(引擎启动) | "expeditionNode" // 探险节点结算(中频) | "expeditionVictory" // 探险胜利(凯旋和弦) | "expeditionDefeat" // 探险失败(下行低音) | "prestige" // 飞升(宏大扫频) | "achievement" // 成就解锁(亮丽琶音) | "tideStart" // 星潮降临(神秘扫频) | "tideEnd" // 星潮结束(柔和消退) | "constellation" // 星图觉醒(空灵琶音 + 高频闪光) | "uiHover" // 界面悬停(极轻) | "uiClick"; // 界面点击(轻确认) const COLOR_FREQ: Record = { 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 "tideStart": { // 神秘扫频 + 泛音列 this.sweep(330, 660, 0.8, "sine", 0.16); this.sweep(440, 880, 0.8, "triangle", 0.08, 0.04); this.tone(523.25, 0.5, "sine", 0.1, 0.2); this.tone(783.99, 0.6, "sine", 0.08, 0.3); break; } case "tideEnd": { // 柔和下行消退 this.tone(659.25, 0.3, "sine", 0.14, 0); this.tone(523.25, 0.35, "sine", 0.12, 0.1); this.tone(392, 0.5, "triangle", 0.1, 0.22); break; } case "constellation": { // 星图觉醒:上升琶音 + 高频闪光 this.tone(523.25, 0.18, "sine", 0.16, 0); this.tone(659.25, 0.2, "sine", 0.16, 0.08); this.tone(783.99, 0.22, "sine", 0.18, 0.16); this.tone(1046.5, 0.35, "triangle", 0.16, 0.24); // 高频闪光 this.tone(2093, 0.15, "sine", 0.08, 0.3); 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 };