// 音效:WebAudio 合成,不需要资源文件。GDD §6:落子短音(音高随步号上升,12 后循环)、冲突闷音、过关和弦。 // Cocos 版换成 AudioSource 播放几段短音即可,接口保持 step/conflict/cleared/reject。 export interface Sfx { unlock(): void; step(n: number): void; conflict(): void; cleared(): void; reject(): void; enabled: boolean; } export class WebAudioSfx implements Sfx { enabled = true; private ctx: AudioContext | null = null; /** 必须在用户手势里调用一次,iOS 才放行 */ unlock(): void { if (!this.ctx) { const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (!AC) return; this.ctx = new AC(); } if (this.ctx.state === 'suspended') void this.ctx.resume(); } private tone(freq: number, dur: number, type: OscillatorType, gain: number, when = 0): void { if (!this.enabled || !this.ctx) return; const ctx = this.ctx; const t0 = ctx.currentTime + when; const osc = ctx.createOscillator(); const g = ctx.createGain(); osc.type = type; osc.frequency.setValueAtTime(freq, t0); g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(gain, t0 + 0.008); g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); osc.connect(g).connect(ctx.destination); osc.start(t0); osc.stop(t0 + dur + 0.02); } step(n: number): void { const semi = (n - 1) % 12; const f = 523.25 * Math.pow(2, semi / 12); this.tone(f, 0.09, 'sine', 0.18); } conflict(): void { this.tone(130, 0.16, 'square', 0.08); } cleared(): void { const base = 523.25; [0, 4, 7, 12].forEach((semi, i) => this.tone(base * Math.pow(2, semi / 12), 0.22, 'triangle', 0.16, i * 0.07)); } reject(): void { this.tone(220, 0.06, 'triangle', 0.06); } }