// 纯合成音效(对应 web 版 audio.js 的 WebAudio 合成),无外部资源。 // 在 Dart 侧生成 16-bit PCM WAV 字节,通过 audioplayers 播放(自带播放池以支持重叠)。 import 'dart:math'; import 'dart:typed_data'; import 'package:audioplayers/audioplayers.dart'; class _Voice { final Float32List samples; // 已含包络、相对自身 0 起点 final int delaySamples; _Voice(this.samples, this.delaySamples); } class Sfx { static final Sfx instance = Sfx._(); factory Sfx() => instance; Sfx._(); static bool enabled = true; static double volume = 0.5; /// 音频不可用时永久静默。 /// /// 音效纯属装饰,但 audioplayers 的异常会在**别的 Zone 上下文**里冒出来, /// 现有的 try/catch 抓不到;一旦冒泡到全局错误钩子,整个界面会被切换成错误详情。 /// 因此只要音频子系统出过一次错,就彻底关掉,保证「音频坏掉 ≠ 游戏坏掉」。 static bool broken = false; final int rate = 44100; final List _pool = []; final Map _cache = {}; bool _init = false; void init() { if (_init || broken) return; _init = true; try { for (int i = 0; i < 12; i++) { final p = AudioPlayer(); p.setVolume(volume); _pool.add(p); } } catch (_) { broken = true; } } void setVolume(double v) { volume = v; for (final p in _pool) p.setVolume(v); } /// 首次用户手势时解锁音频(移动端要求用户手势后才能播放)。 /// 音效被关闭时不创建播放器,省资源也避免插件噪声。 void unlock() { if (!enabled || broken) return; init(); } Uint8List _render(List<_Voice> voices) { int maxLen = 0; for (final v in voices) { maxLen = max(maxLen, v.delaySamples + v.samples.length); } final mix = Float32List(maxLen); for (final v in voices) { for (int i = 0; i < v.samples.length; i++) { mix[v.delaySamples + i] += v.samples[i]; } } // 限幅 for (int i = 0; i < mix.length; i++) { if (mix[i] > 1) mix[i] = 1; if (mix[i] < -1) mix[i] = -1; } return _encodeWav(mix); } Float32List _genTone({required double f, double f2 = 0, required double d, String type = 'square', double vol = 0.25, double delay = 0, double decay = 0}) { final total = d + decay + 0.02; final n = (total * rate).ceil(); final out = Float32List(n); final t0 = (delay * rate).round(); final len = (d * rate).round(); final end = ((d + decay) * rate).round(); final k = decay > 0 ? -log(0.0001) / max(1e-4, (d + decay - 0.008)) : 0.0; double phase = 0; for (int i = 0; i < n; i++) { final t = i / rate; final fr = f2 > 0 ? f * pow(f2 / f, t / max(1e-6, d)) : f; phase += 2 * pi * fr / rate; if (phase > 1e9) phase = phase % (2 * pi); double e; if (t < 0.008) { e = t / 0.008; } else { e = exp(-k * (t - 0.008)); } final w = _wave(type, phase); out[i] = w * vol * e; } return out; } Float32List _genNoise({required double d, double vol = 0.25, double delay = 0, double hp = 400}) { final total = d + 0.02; final n = (total * rate).ceil(); final out = Float32List(n); final t0 = (delay * rate).round(); final end = (d * rate).round(); final a = hp / (hp + rate); double hpState = 0, prevX = 0, prevOut = 0; for (int i = 0; i < n; i++) { final t = i / rate; final x = (Random().nextDouble() * 2 - 1) * (1 - i / max(1, n)); final e = (i < end) ? 1.0 : 0.0; hpState = 0.99 * hpState + x - prevX; prevX = x; prevOut = a * (prevOut + hpState); out[i] = prevOut * vol * e; } return out; } static double _wave(String type, double phase) { switch (type) { case 'sine': return sin(phase); case 'triangle': final f = (phase / (2 * pi)) % 1; return 4 * (f - 0.5).abs() - 1; case 'sawtooth': final f = (phase / (2 * pi)) % 1; return 2 * f - 1; case 'square': default: return sin(phase) >= 0 ? 1 : -1; } } Uint8List _encodeWav(Float32List samples) { final n = samples.length; final bytes = ByteData(44 + n * 2); final bc = bytes.buffer.asUint8List(); // RIFF bc.setRange(0, 4, [0x52, 0x49, 0x46, 0x46]); // RIFF bytes.setUint32(4, 36 + n * 2, Endian.little); bc.setRange(8, 12, [0x57, 0x41, 0x56, 0x45]); // WAVE bc.setRange(12, 16, [0x66, 0x6d, 0x74, 0x20]); // fmt bytes.setUint32(16, 16, Endian.little); bytes.setUint16(20, 1, Endian.little); // PCM bytes.setUint16(22, 1, Endian.little); // mono bytes.setUint32(24, rate, Endian.little); bytes.setUint32(28, rate * 2, Endian.little); bytes.setUint16(32, 2, Endian.little); bytes.setUint16(34, 16, Endian.little); bc.setRange(36, 40, [0x64, 0x61, 0x74, 0x61]); // data bytes.setUint32(40, n * 2, Endian.little); for (int i = 0; i < n; i++) { int s = (samples[i] * 32767).round().clamp(-32768, 32767); bytes.setInt16(44 + i * 2, s, Endian.little); } return bc; } Uint8List _build(String name) { late List<_Voice> voices; switch (name) { case 'jump': voices = [_v(_genTone(f: 380, f2: 700, d: 0.09, type: 'square', vol: 0.16))]; break; case 'djump': voices = [_v(_genTone(f: 520, f2: 900, d: 0.09, type: 'square', vol: 0.14))]; break; case 'shoot': voices = [ _v(_genTone(f: 900, f2: 260, d: 0.07, type: 'square', vol: 0.14)), _v(_genNoise(d: 0.05, vol: 0.06, hp: 2000), 0), ]; break; case 'death': voices = [ _v(_genTone(f: 620, f2: 60, d: 0.55, type: 'sawtooth', vol: 0.26)), _v(_genNoise(d: 0.35, vol: 0.16, hp: 300), (0.02 * rate).round()), ]; break; case 'save': voices = [ _v(_genTone(f: 700, d: 0.08, type: 'triangle', vol: 0.2)), _v(_genTone(f: 1050, d: 0.13, type: 'triangle', vol: 0.18), (0.07 * rate).round()), ]; break; case 'goal': voices = [ for (int i = 0; i < 4; i++) _v(_genTone(f: [523.0, 659.0, 784.0, 1047.0][i], d: 0.16, type: 'triangle', vol: 0.2), (i * 0.1 * rate).round()), ]; break; case 'warp': voices = [_v(_genTone(f: 300, f2: 1400, d: 0.2, type: 'sine', vol: 0.18))]; break; case 'hit': voices = [_v(_genTone(f: 200, f2: 90, d: 0.08, type: 'square', vol: 0.16))]; break; case 'bossHit': voices = [_v(_genTone(f: 260, f2: 140, d: 0.1, type: 'sawtooth', vol: 0.14))]; break; case 'bossDie': voices = [ for (int i = 0; i < 5; i++) _v(_genTone(f: [400.0, 330.0, 260.0, 180.0, 110.0][i], d: 0.22, type: 'sawtooth', vol: 0.22), (i * 0.11 * rate).round()), _v(_genNoise(d: 0.8, vol: 0.2, hp: 150), (0.1 * rate).round()), ]; break; case 'menu': voices = [_v(_genTone(f: 620, d: 0.05, type: 'square', vol: 0.12))]; break; case 'vanish': voices = [_v(_genTone(f: 700, f2: 300, d: 0.12, type: 'sine', vol: 0.1))]; break; case 'troll': voices = [_v(_genTone(f: 180, f2: 900, d: 0.3, type: 'sine', vol: 0.18))]; break; case 'splash': voices = [_v(_genNoise(d: 0.18, vol: 0.12, hp: 800))]; break; default: voices = [_v(_genTone(f: 440, d: 0.05, type: 'square', vol: 0.1))]; } return _render(voices); } _Voice _v(Float32List s, [int delaySamples = 0]) => _Voice(s, delaySamples); void _play(String name) { if (!enabled || broken) return; try { init(); final bytes = _cache.putIfAbsent(name, () => _build(name)); // 找一个空闲播放器 AudioPlayer? free; for (final p in _pool) { if (p.state == PlayerState.stopped || p.state == PlayerState.completed) { free = p; break; } } free ??= _pool[_lastIdx = (_lastIdx + 1) % _pool.length]; free.stop().catchError((Object _) { broken = true; }); free .play(BytesSource(bytes, mimeType: 'audio/wav')) .catchError((Object _) { broken = true; }); } catch (_) { // 任何失败都只静音,不影响玩法。 broken = true; } } int _lastIdx = 0; void jump() => _play('jump'); void djump() => _play('djump'); void shoot() => _play('shoot'); void death() => _play('death'); void save() => _play('save'); void goal() => _play('goal'); void warp() => _play('warp'); void hit() => _play('hit'); void bossHit() => _play('bossHit'); void bossDie() => _play('bossDie'); void menu() => _play('menu'); void vanish() => _play('vanish'); void troll() => _play('troll'); void splash() => _play('splash'); }