DODGE MASTER 整款遊戲只有 921 行 HTML,包含撞擊、金幣、連擊、雷射、引擎、UI click 等十幾種音效。神奇的是它沒有引用任何 .mp3 或 .wav 檔案。所有聲音都是用 Web Audio API 即時用程式碼合成出來的。這篇拆解整套合成式音效系統 — 為什麼這樣做、怎麼寫每一個音效、瀏覽器相容性怎麼處理。
為什麼不用音檔
看起來「不用音檔」是反直覺的選擇。實際的決策理由:
- 檔案大小:一般遊戲 10 種音效,每個 10–50KB,總共 100–500KB。對單檔遊戲(dodge-master-v5.html 只有 ~50KB)這個比例難以接受。
- 授權:免費音效庫的「商業使用」條款各家不同,有的要 attribution、有的禁轉售。寫程式合成完全沒這個問題。
- 動態變化:合成音效可以根據遊戲狀態調整 — 連擊越高音調越高、敵人類型不同音色不同。音檔做不到這個。
- 啟動延遲:音檔要等 download + decode,在弱網路下首次播放會卡。合成音效零延遲。
缺點:合成音效聽起來「電子感」、「8-bit」風格,做不出寫實樂器音色。但對賽博龐克風的閃避遊戲,這個風格反而加分。
Web Audio API 基礎
Web Audio API 的核心概念是「節點圖(node graph)」。每個聲音由:
OscillatorNode(振盪器)— 產生原始波形GainNode(音量)— 控制音量與包絡(envelope)AudioContext.destination— 喇叭輸出
連起來就是:oscillator.connect(gain).connect(ctx.destination)。
第一個音效:金幣聲
最簡單的音效是「pickup coin」— 一個快速上升的方波音調。
const ctx = new (window.AudioContext || window.webkitAudioContext)();
function coinSfx() {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(800, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(1600, ctx.currentTime + 0.05);
gain.gain.setValueAtTime(0.15, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.1);
osc.connect(gain).connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.1);
}
這 12 行程式碼產生的聲音聽起來像 SNES 時代的金幣音效。重點:
type: 'square'— 方波,8-bit 質感- 頻率從 800Hz 滑到 1600Hz — 上升的「叮」感
- 音量從 0.15 急速降到 0.001 — 包絡(attack-release)
- 持續時間 0.1 秒 — 短促,不打擾遊戲節奏
第二個音效:撞擊聲
撞擊音(hit)需要「噪音 + 急速衰減」的特性。用噪音 buffer 替代 oscillator:
function hitSfx() {
// 1. 產生 0.1 秒的白噪音
const bufferSize = ctx.sampleRate * 0.1;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
// 2. 連接 source → filter → gain → output
const source = ctx.createBufferSource();
source.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(1500, ctx.currentTime);
filter.frequency.exponentialRampToValueAtTime(100, ctx.currentTime + 0.08);
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.4, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.1);
source.connect(filter).connect(gain).connect(ctx.destination);
source.start();
}
差異:
- 音源從 oscillator 換成 noise buffer
- 多了 lowpass filter 把高頻砍掉,讓聲音變「悶」(像撞擊)
- filter 頻率從 1500Hz 滑到 100Hz — 製造「砰」的衰減感
第三個音效:連擊上升音
每連擊一次都播一個「越來越高」的音 — 給玩家持續的正向回饋。
let comboCount = 0;
function comboSfx() {
comboCount++;
const baseFreq = 400;
const pitch = baseFreq * Math.pow(1.05, Math.min(comboCount, 20));
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(pitch, ctx.currentTime);
gain.gain.setValueAtTime(0.12, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.15);
osc.connect(gain).connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.15);
}
關鍵:每次 combo +1,頻率上升 5%。連擊到 20 之後封頂,不然會超出可聽範圍。
音效調色盤(10 種音)
整套 DODGE MASTER 用的音效設計:
| 音效 | 用途 | 波形 | 頻率特徵 |
|---|---|---|---|
| coin | 撿金幣 | square | 800→1600 Hz 上升 |
| hit | 玩家撞擊 | noise | 1500→100 Hz lowpass |
| combo | 連擊 | triangle | 動態頻率(依連擊數) |
| powerup | 道具獲得 | square + sine 疊加 | 和弦 |
| laser | 射擊 | sawtooth | 2000→200 Hz 急降 |
| death | 死亡 | noise + sine | 下降爆炸 |
| levelup | 等級提升 | triangle 三連音 | 琶音上行 |
| click | UI 按鍵 | square | 1200 Hz 短點 |
| warning | 警告 | sawtooth | 低音持續 |
| spin | 轉盤 | square + LFO | 顫音效果 |
瀏覽器相容性踩坑
1. iOS Safari 必須使用者互動才能播
iPhone Safari 在使用者第一次 click/tap 之前不准任何 AudioContext 播放。解法:
document.addEventListener('click', () => {
if (ctx.state === 'suspended') {
ctx.resume();
}
}, { once: true });
第一次 click 喚醒 AudioContext,之後就可以正常播放。
2. 舊版 Safari 沒有 AudioContext
舊版 iOS 用 webkitAudioContext。前面建構式裡的 fallback 處理:
const ctx = new (window.AudioContext || window.webkitAudioContext)();
3. Chrome 自動播放政策
背景音樂(不是音效)必須等 user gesture 才能 start。對於遊戲音效,因為都是 click 觸發的,沒問題。但如果想做 BGM,要等到使用者「按開始」之後再 ctx.resume()。
4. Android 上的 AudioWorklet 延遲
低階 Android 在播放 oscillator 時會有 30–80ms 延遲。這個無解,只能接受。但 Web Audio API 的延遲已經比 HTML5 audio 元素低很多(後者在 Android 上可達 200ms)。
音量管控
遊戲中同一秒可能觸發 5–10 個音效。如果每個都 0.4 音量,會爆音(clipping)。我用一個 master gain 統一管控:
const masterGain = ctx.createGain(); masterGain.gain.value = 0.6; masterGain.connect(ctx.destination); // 所有音效都連到 masterGain 而不是 ctx.destination osc.connect(individualGain).connect(masterGain);
玩家還可以從設定改 masterGain.gain.value(0–1)做整體音量控制。
實際省下多少
| 項目 | 音檔方案 | 合成方案 |
|---|---|---|
| 10 種音效大小 | ~ 250 KB | ~ 4 KB(程式碼) |
| 初次載入延遲 | ~ 300 ms | 0 ms |
| 授權成本 / 風險 | 需追蹤 | 無 |
| 動態變化能力 | 困難 | 容易 |
| 音色多樣性 | 高 | 低(限 8-bit 風格) |
結語
合成式音效不是萬靈丹 — 寫實風遊戲還是要用真實音源。但對瀏覽器小遊戲、街機風、復古風、低延遲需求的場景,Web Audio API 是被低估的工具。整套寫熟了之後,新遊戲的音效從「找音源、買授權、調 mix」變成「20 行 code 搞定」。
完整的 sfx 模組(約 200 行)我有計畫整理成 npm package。如果你做相關專案有興趣,歡迎寄信 lo246179268@gmail.com。