feat(game): 添加全套音效系统(背景BGM+7种攻击音+波次/Boss/死亡/建塔音效),纯Web Audio API合成
This commit is contained in:
289
game/AudioEngine.ts
Normal file
289
game/AudioEngine.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* AudioEngine — 纯 Web Audio API 程序化合成,零音频文件依赖
|
||||
*
|
||||
* 使用单例模式,所有音效通过 AudioEngine.getInstance().play('soundName') 调用
|
||||
* 背景音乐通过 startBGM() / stopBGM() 控制
|
||||
*/
|
||||
export class AudioEngine {
|
||||
private static _instance: AudioEngine | null = null
|
||||
private ctx: AudioContext | null = null
|
||||
private bgmNodes: AudioNode[] = []
|
||||
private bgmRunning = false
|
||||
private muted = false
|
||||
private masterGain!: GainNode
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): AudioEngine {
|
||||
if (!AudioEngine._instance) AudioEngine._instance = new AudioEngine()
|
||||
return AudioEngine._instance
|
||||
}
|
||||
|
||||
/** 首次用户交互后调用,初始化 AudioContext */
|
||||
init(): void {
|
||||
if (this.ctx) return
|
||||
try {
|
||||
this.ctx = new AudioContext()
|
||||
this.masterGain = this.ctx.createGain()
|
||||
this.masterGain.gain.value = 0.55
|
||||
this.masterGain.connect(this.ctx.destination)
|
||||
} catch {
|
||||
// 浏览器不支持 Web Audio,静默失败
|
||||
}
|
||||
}
|
||||
|
||||
setMuted(v: boolean): void {
|
||||
this.muted = v
|
||||
if (this.masterGain) this.masterGain.gain.value = v ? 0 : 0.55
|
||||
}
|
||||
isMuted(): boolean { return this.muted }
|
||||
|
||||
private get ac(): AudioContext | null { return this.ctx }
|
||||
|
||||
// ─── 工具方法 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private osc(
|
||||
type: OscillatorType,
|
||||
freq: number,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
gainStart = 0.3,
|
||||
gainEnd = 0,
|
||||
destination: AudioNode | null = null
|
||||
): void {
|
||||
if (!this.ac) return
|
||||
const g = this.ac.createGain()
|
||||
g.gain.setValueAtTime(gainStart, startTime)
|
||||
g.gain.exponentialRampToValueAtTime(Math.max(gainEnd, 0.001), endTime)
|
||||
g.connect(destination ?? this.masterGain)
|
||||
const o = this.ac.createOscillator()
|
||||
o.type = type
|
||||
o.frequency.setValueAtTime(freq, startTime)
|
||||
o.connect(g)
|
||||
o.start(startTime)
|
||||
o.stop(endTime)
|
||||
}
|
||||
|
||||
private noise(
|
||||
startTime: number,
|
||||
duration: number,
|
||||
gainVal = 0.15,
|
||||
hipass = 0,
|
||||
destination: AudioNode | null = null
|
||||
): void {
|
||||
if (!this.ac) return
|
||||
const bufLen = this.ac.sampleRate * duration
|
||||
const buf = this.ac.createBuffer(1, bufLen, this.ac.sampleRate)
|
||||
const data = buf.getChannelData(0)
|
||||
for (let i = 0; i < bufLen; i++) data[i] = Math.random() * 2 - 1
|
||||
const src = this.ac.createBufferSource()
|
||||
src.buffer = buf
|
||||
const g = this.ac.createGain()
|
||||
g.gain.setValueAtTime(gainVal, startTime)
|
||||
g.gain.exponentialRampToValueAtTime(0.001, startTime + duration)
|
||||
if (hipass > 0) {
|
||||
const f = this.ac.createBiquadFilter()
|
||||
f.type = 'highpass'
|
||||
f.frequency.value = hipass
|
||||
src.connect(f)
|
||||
f.connect(g)
|
||||
} else {
|
||||
src.connect(g)
|
||||
}
|
||||
g.connect(destination ?? this.masterGain)
|
||||
src.start(startTime)
|
||||
src.stop(startTime + duration)
|
||||
}
|
||||
|
||||
// ─── 音效库 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 钉钉消息通知音:建塔 / 波次开始 */
|
||||
playDingTalk(): void {
|
||||
if (!this.ac) return
|
||||
const t = this.ac.currentTime
|
||||
// 叮 — 高频正弦
|
||||
this.osc('sine', 1047, t, t + 0.18, 0.4, 0.001)
|
||||
// 咚 — 低频跟随
|
||||
this.osc('sine', 523, t + 0.1, t + 0.35, 0.25, 0.001)
|
||||
}
|
||||
|
||||
/** 钉钉消息发送音(更轻):波次开始 */
|
||||
playNotify(): void {
|
||||
if (!this.ac) return
|
||||
const t = this.ac.currentTime
|
||||
this.osc('sine', 880, t, t + 0.12, 0.3, 0.001)
|
||||
this.osc('sine', 1320, t + 0.07, t + 0.2, 0.2, 0.001)
|
||||
}
|
||||
|
||||
/** 拳击音:实习生近战 */
|
||||
playPunch(): void {
|
||||
if (!this.ac) return
|
||||
const t = this.ac.currentTime
|
||||
this.noise(t, 0.06, 0.3, 200)
|
||||
this.osc('sine', 120, t, t + 0.06, 0.35, 0.001)
|
||||
}
|
||||
|
||||
/** 键盘敲击音:资深开发射击 */
|
||||
playKeyboard(): void {
|
||||
if (!this.ac) return
|
||||
const t = this.ac.currentTime
|
||||
this.noise(t, 0.04, 0.18, 1200)
|
||||
this.osc('square', 440, t, t + 0.03, 0.12, 0.001)
|
||||
}
|
||||
|
||||
/** PPT 大师 AOE — 空洞回声 */
|
||||
playPPTBlast(): void {
|
||||
if (!this.ac) return
|
||||
const t = this.ac.currentTime
|
||||
this.osc('sawtooth', 180, t, t + 0.4, 0.2, 0.001)
|
||||
this.osc('sine', 90, t, t + 0.5, 0.15, 0.001)
|
||||
this.noise(t, 0.3, 0.08, 100)
|
||||
}
|
||||
|
||||
/** 产品经理 — 需求变更铃 */
|
||||
playPMAttack(): void {
|
||||
if (!this.ac) return
|
||||
const t = this.ac.currentTime
|
||||
this.osc('triangle', 660, t, t + 0.15, 0.25, 0.001)
|
||||
this.osc('triangle', 440, t + 0.08, t + 0.25, 0.2, 0.001)
|
||||
}
|
||||
|
||||
/** 运营专员 — 数据上涨 */
|
||||
playOpsAttack(): void {
|
||||
if (!this.ac) return
|
||||
const t = this.ac.currentTime
|
||||
this.osc('sine', 523, t, t + 0.08, 0.2, 0.001)
|
||||
this.osc('sine', 659, t + 0.05, t + 0.15, 0.2, 0.001)
|
||||
this.osc('sine', 784, t + 0.1, t + 0.22, 0.2, 0.001)
|
||||
}
|
||||
|
||||
/** 外包程序员 — 闷声丢出 */
|
||||
playOutsourceAttack(): void {
|
||||
if (!this.ac) return
|
||||
const t = this.ac.currentTime
|
||||
this.noise(t, 0.08, 0.2, 300)
|
||||
this.osc('sawtooth', 200, t, t + 0.08, 0.15, 0.001)
|
||||
}
|
||||
|
||||
/** 怪物死亡 — 碎纸机 */
|
||||
playEnemyDeath(): void {
|
||||
if (!this.ac) return
|
||||
const t = this.ac.currentTime
|
||||
this.noise(t, 0.12, 0.22, 800)
|
||||
this.noise(t + 0.05, 0.08, 0.15, 2000)
|
||||
}
|
||||
|
||||
/** Boss 登场 — 警报 + 低沉冲击 */
|
||||
playBossAppear(): void {
|
||||
if (!this.ac) return
|
||||
const t = this.ac.currentTime
|
||||
// 低沉轰鸣
|
||||
this.osc('sawtooth', 55, t, t + 1.2, 0.5, 0.001)
|
||||
this.osc('sawtooth', 60, t + 0.1, t + 1.0, 0.3, 0.001)
|
||||
// 警报扫频
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const st = t + i * 0.25
|
||||
this.osc('square', 440, st, st + 0.1, 0.25, 0.001)
|
||||
this.osc('square', 660, st + 0.12, st + 0.22, 0.2, 0.001)
|
||||
}
|
||||
}
|
||||
|
||||
/** Boss 技能触发 — 电话挂断 */
|
||||
playBossSkill(): void {
|
||||
if (!this.ac) return
|
||||
const t = this.ac.currentTime
|
||||
this.osc('square', 1480, t, t + 0.06, 0.3, 0.001)
|
||||
this.osc('square', 1109, t + 0.07, t + 0.13, 0.3, 0.001)
|
||||
this.osc('square', 740, t + 0.14, t + 0.22, 0.3, 0.001)
|
||||
this.noise(t, 0.25, 0.1, 1000)
|
||||
}
|
||||
|
||||
/** 塔被摧毁 — 电话挂断 */
|
||||
playTowerDestroyed(): void {
|
||||
if (!this.ac) return
|
||||
const t = this.ac.currentTime
|
||||
this.osc('square', 440, t, t + 0.1, 0.35, 0.001)
|
||||
this.osc('square', 350, t + 0.1, t + 0.25, 0.35, 0.001)
|
||||
this.osc('square', 220, t + 0.2, t + 0.4, 0.35, 0.001)
|
||||
this.noise(t, 0.4, 0.12, 500)
|
||||
}
|
||||
|
||||
/** 新波次开始 — 钉钉来电铃声 */
|
||||
playWaveStart(): void {
|
||||
if (!this.ac) return
|
||||
const t = this.ac.currentTime
|
||||
const pattern = [0, 0.18, 0.36, 0.64, 0.82, 1.0]
|
||||
const freqs = [784, 784, 784, 659, 784, 880]
|
||||
pattern.forEach((delay, i) => {
|
||||
this.osc('sine', freqs[i], t + delay, t + delay + 0.14, 0.3, 0.001)
|
||||
})
|
||||
}
|
||||
|
||||
/** KPI 告警(KPI < 30%)— 紧张低音 */
|
||||
playKPIWarning(): void {
|
||||
if (!this.ac) return
|
||||
const t = this.ac.currentTime
|
||||
this.osc('sawtooth', 110, t, t + 0.3, 0.4, 0.001)
|
||||
this.osc('sawtooth', 146, t + 0.1, t + 0.3, 0.3, 0.001)
|
||||
}
|
||||
|
||||
// ─── 背景音乐 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 启动背景音乐(办公室 ambient:低频嗡鸣 + 轻微节奏脉冲)
|
||||
* 完全程序化合成,循环播放
|
||||
*/
|
||||
startBGM(): void {
|
||||
if (!this.ac || this.bgmRunning) return
|
||||
this.bgmRunning = true
|
||||
this._scheduleBGM()
|
||||
}
|
||||
|
||||
stopBGM(): void {
|
||||
this.bgmRunning = false
|
||||
this.bgmNodes.forEach(n => {
|
||||
try { (n as OscillatorNode).stop?.() } catch { /* ignore */ }
|
||||
})
|
||||
this.bgmNodes = []
|
||||
}
|
||||
|
||||
private _scheduleBGM(): void {
|
||||
if (!this.ac || !this.bgmRunning) return
|
||||
const t = this.ac.currentTime
|
||||
|
||||
// 底层办公室嗡鸣(电脑风扇感)
|
||||
const droneGain = this.ac.createGain()
|
||||
droneGain.gain.value = 0.04
|
||||
droneGain.connect(this.masterGain)
|
||||
const drone = this.ac.createOscillator()
|
||||
drone.type = 'sawtooth'
|
||||
drone.frequency.setValueAtTime(55, t)
|
||||
drone.frequency.linearRampToValueAtTime(58, t + 8)
|
||||
drone.frequency.linearRampToValueAtTime(55, t + 16)
|
||||
drone.connect(droneGain)
|
||||
drone.start(t)
|
||||
drone.stop(t + 16)
|
||||
this.bgmNodes.push(drone)
|
||||
|
||||
// 低频键盘噪声节拍(每 0.8s 一拍)
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const beat = t + i * 0.8
|
||||
const ng = this.ac.createGain()
|
||||
ng.gain.setValueAtTime(0.03, beat)
|
||||
ng.gain.exponentialRampToValueAtTime(0.001, beat + 0.15)
|
||||
ng.connect(this.masterGain)
|
||||
const ns = this.ac.createOscillator()
|
||||
ns.type = 'square'
|
||||
ns.frequency.value = i % 4 === 0 ? 130 : i % 2 === 0 ? 110 : 87
|
||||
ns.connect(ng)
|
||||
ns.start(beat)
|
||||
ns.stop(beat + 0.12)
|
||||
this.bgmNodes.push(ns)
|
||||
}
|
||||
|
||||
// 16s 后循环
|
||||
setTimeout(() => {
|
||||
if (this.bgmRunning) this._scheduleBGM()
|
||||
}, 15500)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user