Earshot
壁や部屋を反映するサウンドと、マイクの音量を使うゲーム入力を、2D Web ゲームに加えるライブラリです。壁の向こうの声はこもり、洞窟では響き、叫び声は届く距離にいるモンスターだけを起こします。ランタイム依存はなく、圧縮前のコードを gzip して約 3.7 KB です。Web Audio のコンテキストを渡せるエンジンなら、どれでも使えます。
GitHub · 0.2 のリリース記事 · 壁は音に何をするのか
壁越しのサウンド
import { createListener, wallsOnGrid } from "@debuffjp/earshot";
const ctx = new AudioContext(); // タップやキー入力で resume します。
const ear = createListener(ctx, {
tile: 16,
pos: () => player,
world: {
wallsBetween: (a, b) => wallsOnGrid(tiles, a, b), // tiles[y][x] は "thin"、"solid"、null のいずれか
reverbAt: (p) => (p.y > 20 ? "cave" : null),
},
});
const radio = ear.source(radioNode, { pos: () => radioPosition });
// ゲームループの中で。1 秒に 10〜20 回で十分です。
ear.update();
radio.state; // "audible" | "out-of-range" | "muted"音源には任意の AudioNode を使えます。最初の update() までは無音です。位置はワールド座標で渡し、tile でタイル単位に変換します。wallsBetween と reverbAt が受け取る座標は、すでにタイル単位です。
ear.params、ear.materials、ear.reverbs はゲームの実行中に調整できます。コンボルバーは負荷が高いので、同じリバーブの音源は 1 つのコンボルバーを共有します。
デフォルト値
1 つのデモで耳を頼りに調整した出発点です。計測にもとづく基準ではありません。
| 設定 | デフォルト |
|---|---|
| 最大音量 | 2 タイルまで |
| 無音 | 12 タイルから |
| パン | 最大 ±0.6(8 タイルで到達、水平位置のみ) |
| 薄い壁 | 2000 Hz、-6 dB |
| 厚い壁 | 1200 Hz、-12 dB |
| 2 枚以上の壁 | 700 Hz、-18 dB |
| 部屋のリバーブ | 0.4 秒、ウェット -18 dB |
| 洞窟のリバーブ | 1.8 秒、ウェット -12 dB |
音量を入力に使う
import { createLoudness } from "@debuffjp/earshot";
const loud = createLoudness(ctx);
loud.onLevel((level) => { if (level >= 2) monster.hear(player); }); // 0〜3
// ボタンから呼びます。部屋の音を測る 3 秒間、プレイヤーには静かにしてもらいます。
const { ok, error } = await loud.enableMic();
// キーはマイクの有無にかかわらず使えます。
loud.press(1);
loud.press(0);音声はアナライザーまでしか届きません。Earshot は音声を録音、送信、再生しません。レベルは測定したノイズフロアより 10、20、30 dB 大きいかで決まるので、部屋の騒がしさによらず同じように動きます。
press() を使うキーかボタンを必ず用意してください。言葉や長時間の発声を必須にせず、マイクの用途をプレイヤーに伝えてから許可を求めてください。
壁越しの物音
壁と距離をゲームのルールに反映するには、モンスターのいる場所での音量を求めます。しきい値はゲームに合わせて調整してください。私たちのデモでは 6 dB がちょうどよく感じられました。
import { computeMix, heardDb, wallsOnGrid, DEFAULT_MATERIALS, DEFAULT_PARAMS } from "@debuffjp/earshot";
const walls = wallsOnGrid(tiles, player, monster); // どちらもタイル単位。
const mix = computeMix(player, monster, walls, DEFAULT_PARAMS, DEFAULT_MATERIALS);
if (loud.level >= 2 && heardDb(loud.level, mix) >= 6) monster.wake();ボイスチャット
0.2 で追加しました。Earshot は、声がゲームの世界の中にあるように聞こえるようにします。既存の RTCPeerConnection か、後述のボイスルームを使えます。私たちがサーバーをホストすることはありません。
import { createListener, createVoiceMic } from "@debuffjp/earshot";
const mic = createVoiceMic({ canUseVoice: () => settings.voiceAllowed });
const { ok, error } = await mic.start(); // タップやキー入力から呼びます。
if (ok) mic.stream.getTracks().forEach((track) => connection.addTrack(track, mic.stream));
connection.ontrack = (event) => {
const voice = ear.voice(event.streams[0], { pos: () => friend });
// voice.state、voice.speaking、voice.levelDb は UI に使えます。
};
mic.muted = true;
mic.pushToTalk = true;
mic.talk(true);声は他の音源と同じように、壁、部屋、パンを通ります。voice.muted は特定のプレイヤーを手元でミュートします。mic.live は音声を送信中かどうかを示すので、プレイヤーに表示してください。canUseVoice は必須で、false の間はマイクを開きません。同意の確認や、ポータルの disableChat などのフラグに使ってください。
ボイスルーム
接続の仕組みがまだなければ、joinVoiceRoom() を使えます。自分のアカウントにデプロイする小さな Cloudflare Worker でプレイヤーをつなぎます。私たちはホストしません。招待制のルーム、人数の上限、短命な TURN 認証情報を備えていて、ログインや BAN の確認は canJoin 関数に書きます。
import { createRoomCode, joinVoiceRoom } from "@debuffjp/earshot";
const code = createRoomCode(); // フレンドに共有します。
const joined = await joinVoiceRoom({
url: "wss://earshot-voice.you.workers.dev",
code,
mic,
listener: ear,
ipPolicy: "relay", // または "direct"(後述)。
pos: (peer) => players.get(peer) ?? spawn,
onMessage: (peer, position) => players.set(peer, position),
});
if (joined.ok) setInterval(() => joined.room.send(player), 66);ipPolicy はプライバシーに関わる判断なので必須です。"relay" は他のプレイヤーから IP アドレスを隠し、中継できないときは接続に失敗します。すべての音声は TURN のトラフィックとして課金されます。"direct" は安く済みますが、互いの IP アドレスが見えます。
全員が全員に接続するので、人数に応じてアップロードが増えます。十分に離れたプレイヤーへの送信は止まります。エラーは joined.error に room-full、no-turn-server、mic-not-started、connection-failed のいずれかで返ります。切断された接続は自動では再接続しないので、room.connection(peer) で状態を表示してください。
これらの機能だけでは、法令への準拠にはなりません。 プレイヤー間のライブ音声には、米国の COPPA や英国の Online Safety Act などの義務を伴う場合があります。子ども向けのゲームでは特に注意してください。年齢確認、BAN の強制、モデレーション、通報の受付はブラウザのライブラリでは扱えません。ゲーム側で計画してください。ボイスはデフォルトでオフにし、フレンドに限定してください。これは法的助言ではありません。
テストしたのは、同じネットワーク上の PC と iPhone、およびデスクトップの Chromium と Firefox の間のリレー接続です。より大きなグループは未テストです。
debuff で使う
debuff のオーディオコンテキストとバスを渡すと、ゲームの音量とミュートに従います。
const ear = createListener(audio.ctx, { output: audio.bus("sfx"), pos: () => player });制限
- マイクには HTTPS か localhost が必要です。iframe には
allow="microphone"も必要です。 - マイクを拒否されると
{ ok: false, error }が返るので、ゲームはそのまま続けられます。 - ブラウザは制約を無視することがあります。実際の設定は
loud.appliedで確認してください。 - iPhone ではマイクを開くと、ゲームの音質に影響する場合があります。WebKit のバグ 311451 は、iOS 26.4 以降で音質の低下とモノラル化を報告しています。実機で確認してください。
- テストしたのはデスクトップのブラウザと iPhone 1 台です。Android は未テストです。
- 私たちは何もホストしません。ボイスルームは自分の Worker で動きます。0.2 より前の Earshot にボイスチャットとネットワーク機能はありません。