Flutter ↔ JS ブリッジ仕様
内部ドキュメント
このドキュメントは内部実装の詳細です。ゲーム開発者向けの情報は docs.code.uzu-app.com を参照してください。
Flutter ホストと WebView 内のゲーム間で双方向通信を行うためのプロトコル。
通信経路
Native (iOS / Android / Desktop)
| 方向 | 技術 | グローバルオブジェクト |
|---|---|---|
| Flutter → JS | WebViewController.runJavaScript() | window.__ps.onMessage(msg) |
| JS → Flutter | JavaScriptChannel | window.FlutterHost.postMessage(json) |
Web (iframe)
| 方向 | 技術 |
|---|---|
| 親 → iframe | iframe.contentWindow.postMessage() |
| iframe → 親 | window.parent.postMessage() |
メッセージ形式
すべてのメッセージは以下の共通構造を持つ JSON オブジェクトである。
{
"channel": "sdk" | "game" | "platform",
"type": "string",
"payload": { ... },
"playerId": "string (optional)"
}| フィールド | 型 | 説明 |
|---|---|---|
channel | 'sdk' | 'game' | 'platform' | メッセージのチャネル。SDK/プラットフォームコマンドか、ゲーム独自メッセージか、アプリとサーバーの伝言かを区別する |
type | string | メッセージの種別 |
payload | object | メッセージのペイロード(データ本体) |
playerId | string(任意) | 送信元プレイヤーの ID(ゲーム → ホスト方向のみ)。Web エミュレータでは複数プレイヤーの iframe が同一オリジンになり postMessage の送信元 iframe を区別できないため、ホストはこの値で自分宛メッセージかを判定する。Flutter ホスト(QueryParamsBuilder)と dev ハーネスは URL に必ず playerId を付与するため、ゲーム → ホスト方向では常に存在する。受信側は playerId 無し = 旧 SDK ビルドとして従来の送信元判定にフォールバックする |
チャネルの概念
| チャネル | 用途 | 説明 |
|---|---|---|
sdk | SDK/プラットフォームコマンド | サウンド再生、マイク制御、通話の状態、ハンドシェイクなど SDK が内部的に使用するメッセージ |
game | ゲーム独自メッセージ | send(type, payload) / on(type, handler) で送受信するゲーム固有のカスタムメッセージ |
platform | アプリとサーバーの伝言 | SDK は中身を見ずに WebSocket の __platform と行き来させる。通話の申告・一時停止の要求・presence(接続と通話の状態) |
SDK の send() API は channel: 'game' のメッセージのみを送信する。on() API は channel: 'game' のメッセージのみを受信し、handler には payload が直接渡される。
SDK チャネルのメッセージ(サウンド、マイク制御等)は対応する SDK 関数(playSound(), muteMic() 等)から自動送信され、ゲーム開発者が直接扱う必要はない。
新旧の SDK が同じアプリで動く
シナリオの SDK は publish した時点の版のまま動き続ける。bridge 2 で changeRoom / setMicEnabled / playersChanged / requestPause / requestResume を消したが、ホストは bridge 1 の作品のためにこれらを送り続け、受け続ける。新しいメッセージと両方を送っても、旧 SDK は platform チャネルと知らない type を捨て、新 SDK は旧いメッセージを知らない type として捨てるので壊れない。ホストが名乗る bridge の範囲は 0〜2(バージョンと互換性)。
SDK チャネルメッセージ (channel: 'sdk')
JS → Flutter (SDK コマンド)
| type | 説明 | payload |
|---|---|---|
ready | SDK 初期化完了通知 | {} |
playSound | 効果音再生リクエスト | { sound: string, volume?: number, slot?: string } |
playBgm | BGM 再生リクエスト (ループ) | { sound: string, volume?: number, fadeMs?: number } |
stopBgm | BGM 停止 | { fadeMs?: number } |
setMicMuted | 本人のマイクのミュート。シナリオの muteMic() | { muted: true } |
setListeningRoom | 観測席が聴く部屋を選ぶ。シナリオの setListeningRoom() | { room: string | null } |
connectionChanged | SDK が張っている WebSocket の状態 | { status: 'connecting' | 'online' | 'reconnecting' | 'offline' } |
haptic | 端末を短く振動させる | { kind: 'light' | 'medium' | 'heavy' } |
gameEnded | ゲームが決着して結果・感想戦に入った報告。画面は閉じない | {} |
finishGame | 遊び終えて退出し、この端末のプレイ画面を閉じる | {} |
pauseState | 緊急一時停止の確定 | { paused: boolean, by: string | null } |
stateResponse | ゲーム state の応答 | { state: object | null } |
{ "channel": "sdk", "type": "ready", "payload": {} }
{ "channel": "sdk", "type": "playSound", "payload": { "sound": "sounds/clear.mp3" } }
{ "channel": "sdk", "type": "playSound", "payload": { "sound": "voice/line-012.mp3", "slot": "voice" } }
{ "channel": "sdk", "type": "playBgm", "payload": { "sound": "bgm.mp3", "volume": 0.5, "fadeMs": 2000 } }
{ "channel": "sdk", "type": "stopBgm", "payload": { "fadeMs": 1500 } }
{ "channel": "sdk", "type": "setMicMuted", "payload": { "muted": true } }
{ "channel": "sdk", "type": "setListeningRoom", "payload": { "room": "room_a" } }
{ "channel": "sdk", "type": "connectionChanged", "payload": { "status": "reconnecting" } }
{ "channel": "sdk", "type": "haptic", "payload": { "kind": "light" } }
{ "channel": "sdk", "type": "gameEnded", "payload": {} }
{ "channel": "sdk", "type": "finishGame", "payload": {} }
{ "channel": "sdk", "type": "stateResponse", "payload": { "state": { "board": [], "turn": 1 } } }playSound / playBgm / stopBgm:
volumeはプレイヤーの BGM / SE の音量設定にかける倍率 (0〜1)。fadeMsは BGM の音量を寄せる時間 (0〜10000 ms)。指定が無ければvolume1・fadeMs0 で、その場で切り替える- 範囲外は丸め、数でない値は既定値にする。SDK も丸めて送るが、ホストでも丸める
slotはplaySoundだけが持つ。同じslotで鳴る効果音は常に 1 本で、ホストは前の音を止めてから鳴らす。空文字や文字列でない値は、SDK もホストも付いていないものとして扱う- BGM の切り替え・同じ曲の送り直し・フェードの規則は Flutter ライブラリ仕様のサウンド再生 にある。ホストと
uzu devは同じ規則で鳴らす volume/fadeMs/slotを読まないホストでは、プレイヤーの音量のまま、その場で切り替わり、slot付きの効果音も重なって鳴る。足しただけなので bridge の版は上げていない
setMicMuted:
- SDK は
muted: trueだけを送る。作品はマイクをミュートすることしかできず、オンにできるのは本人だけ(本人の同意なく声を流さない) - ホストは
muted: falseを捨てる。古い SDK や改造された作品から届いても、ホストが最後の守りになる - 形が崩れていたらミュート側に倒す
setListeningRoom:
- 観測席(観戦・進行管理)が聴く通話の部屋。
nullは全体の部屋 - プレイヤーの部屋は presence の plan で決まるので、プレイヤー席から来たらホストは無視する
connectionChanged:
- ホストは自分のゲームの接続が切れている帯を出し、
onlineに戻ったら通話の申告(platformのvoice.report)を送り直す。サーバーは切れた端末の申告を捨てるため
pauseState:
- SDK が wire の
frozen(時計が止まっているか)から送る。止まった理由はplatformのpresenceのpauseで届く
bridge 1 の SDK だけが送るもの:
| type | 説明 | payload |
|---|---|---|
setMicEnabled | マイクのオンオフ切り替え。ホストは enabled: false(ミュート)だけを受け、true は捨てる | { enabled: boolean } |
changeRoom | ボイスチャットルームの移動。null はデフォルトルーム(全体ルーム) | { roomId: string | null } |
bridge 2 ではシナリオが通話の部屋とマイクを直接変える手段は無く、ロジックの voicePlan が state から返す宣言だけで決まる。ホストは bridge 1 の作品のためにこの 2 つを受け続け、presence が届かない作品ではこちらで部屋とマイクを動かす。
haptic payload:
| フィールド | 型 | 説明 |
|---|---|---|
kind | 'light' | 'medium' | 'heavy' | 振動の強さ。ホストは HapticFeedback.lightImpact() / mediumImpact() / heavyImpact() に対応させる |
- WebView 内では
navigator.vibrateが効かない(iOS には Web から振動させる手段が無い)ため、ホストが代わりに振動させる - 知らない
kindは無視する - ホストの外(単独ページ)では SDK が
navigator.vibrateにフォールバックし、メッセージは送らない
gameEnded:
- 卓を終了扱いにする報告。v2 で感想戦へ移るときと同じく、卓の
finishedAtを立てて参加費を確定し、卓の status を RESULT にする。画面は閉じない - シナリオは全端末から state の再描画のたびに送りうるので、ホストは処理中の 2 回目以降を無視し、失敗したときだけ次の受信でやり直す
- 受けたホストがすること:
| ホスト | 処理 |
|---|---|
| mobile(イベント) | 参加者の端末だけが finishPlayingEvent を呼ぶ。観戦者・リプレイ中・手元のイベントの finishedAt が立っているときは呼ばない。backend は finishedAt が未設定の卓でだけ立てるので、全員がほぼ同時に呼んでも最初の 1 人の時刻が残る |
| エミュレータ | 卓を持たないので、ライフサイクル表示の ended を点けるだけ |
mobile(一人用) / UZU TOKYO / uzu dev harness | 何もしない |
finishGame:
- 閉じるのは送信した端末だけ。全員を閉じるには、シナリオが終了を表す state を全員へ配り、各端末から送る
- シナリオは state の再描画のたびに送りうるので、ホストは処理中の 2 回目以降を無視する
- 受けたホストがすること:
| ホスト | 処理 |
|---|---|
| mobile(イベント) | v2 の結果画面を閉じたときと同じ participantFinishEvent で退出し、同じ評価へ進める。gameEnded が届かなかった卓の保険として、backend はイベントの finishedAt が未設定なら立てる。立つのは最初の 1 人だけ |
| mobile(一人用) | finishSoloEvent を呼んで画面を閉じ、未評価ならシナリオ評価へ進める |
| エミュレータ | 閉じる画面を持たないので、ライフサイクル表示の finish を点けるだけ |
UZU TOKYO / uzu dev harness | 何もしない |
Flutter → JS (SDK メッセージ)
| type | 説明 | payload |
|---|---|---|
voiceChanged | 自分の端末で見たままの通話の状態 | { self, heard, missing, left } |
insetsChanged | safe area / HUD 回避領域の変化を配る | { safeArea, hud } |
{
"channel": "sdk",
"type": "voiceChanged",
"payload": {
"self": {
"session": "connected",
"room": "library",
"mic": { "status": "muted", "by": "scenario" },
"speaking": false,
"quality": "good"
},
"heard": {
"p1": { "mic": { "status": "open" }, "speaking": true, "quality": "good" },
"spec_3": { "mic": { "status": "open" }, "speaking": false, "quality": "poor", "nickname": "ミカ" }
},
"missing": {
"p2": { "since": 1790000000000 }
},
"left": {
"p4": { "since": 1790000003000 }
}
}
}voiceChanged payload:
| フィールド | 型 | 説明 |
|---|---|---|
self.session | 'off' | 'connecting' | 'connected' | 'reconnecting' | 'failed' | 自分の通話の状態 |
self.reason | 'table' | 'this-device' | 'mic-permission' | 'network'(任意) | off なら卓が通話を使わない / この端末は通話に入らない、failed ならマイクの権限なし / つながらない |
self.since | number(任意) | session が今の値になった端末の時刻 (ms)。connecting / reconnecting / failed で使う |
self.room | string | null | 今入っている部屋。null は全体の部屋 |
self.mic | Mic | { status: 'open' } / { status: 'muted', by: 'self' | 'scenario' } / { status: 'locked' } / { status: 'listen-only' } |
self.speaking / self.quality | boolean / 'good' | 'poor' | 発話中か / 音質 |
heard | Record<string, { mic, speaking, quality, nickname? }> | 同じ部屋で聞こえている人(キーは席 ID)。観測席も入り、観測席には nickname を付ける |
missing | Record<string, { since }> | presence の部屋割りでは同じ部屋にいるはずなのに、声が届かない人。ホストが猶予(5 秒)を見てから載せる。since は端末の時刻 |
left | Record<string, { since }>(任意) | 退出した人。卓の参加者の状態(backend の isPlaying)で決める。since はこの端末で退出に気付いた時刻。対応していないホストは送らない |
- 見たままを送り、切れた・別の部屋といった卓としての判断は載せない(それはサーバーが決める)
- 何秒待つかはホストが決める。SDK には時間の判断を持たせないので、
missingに載るまでは声が届いていない人もhereとして出る - 中身が変わるたびに送る(発話やミュートの変化も含む)。同じ中身なら送らない
- 相手のマイクが本人のミュートか場面のミュートかは、ホストが presence の plan と一時停止を合わせて決める。組み立ての表は 接続と通話の状態
bridge 1 の SDK にだけ送るもの:
| type | 説明 | payload |
|---|---|---|
playersChanged | プレイヤーのリアルタイム状態更新 | { players: Record<string, { audioStatus: 'speaking' | 'listening' | 'muted' | 'unstable' | null }> } |
requestPause | 緊急一時停止を要求する | {} |
requestResume | 緊急一時停止の解除を要求する | {} |
ホストは bridge 1 の作品のためにこれらを送り続ける。bridge 2 の SDK は知らない type として捨て、緊急一時停止の要求は platform の pause.request / pause.resume で受ける。
補足:
- プレイヤーの身元情報(ID、ニックネーム、アイコン URL、キャラクター ID)は URL query parameter (
?players=[...]) で渡される。bridge のメッセージはリアルタイムに変化する状態のみを伝達する - プレイヤー一覧はゲーム開始時に確定し、途中で増減しない
roomId、playerIdも URL query parameter で渡される
{
"channel": "sdk",
"type": "insetsChanged",
"payload": {
"safeArea": { "top": 0, "right": 59, "bottom": 21, "left": 59 },
"hud": { "x": 138, "y": 52 }
}
}insetsChanged 補足:
- 初期値はこのメッセージでは配らない。 起動 URL の
uzuSafeAreaInset{Top,Right,Bottom,Left}/uzuHudInsetX/uzuHudInsetYで渡し、初回ペイント前に確定させる。これは回転 / PiP / HUD の枚数変化で値が動いたときの差分通知を担う - 起動後に値が動いていれば、
readyのたびに最後の値を渡し直す。ページを読み込み直すと起動 URL が そのまま使われ、SDK は起動時の値に戻るため - ゲーム側 SDK はこれを
--uzu-safe-area-inset-*/--uzu-hud-inset-*/--uzu-content-inset-*の 3 層とgetInsets()/onInsetsChange()へ展開する。詳細は Flutter ライブラリ仕様
Flutter → JS 内部メッセージ (SDK チャネル)
| type | 説明 | payload |
|---|---|---|
getState | 現在のゲーム state をリクエスト | {} |
PlayScreenController の requestState() から送信される。
{ "channel": "sdk", "type": "getState", "payload": {} }platform チャネルメッセージ (channel: 'platform')
アプリとサーバー(play-server)のあいだの伝言。SDK は中身を見ずに、WebSocket の __platform と行き来させる。申告・一時停止の要求の中身は、アプリとサーバーの更新だけで変えられる。
// アプリ → SDK → サーバー
{ "channel": "platform", "type": "voice.report", "payload": { "session": "connected", "room": null } }
{ "channel": "platform", "type": "pause.request", "payload": {} }
// サーバー → SDK → アプリ
{ "channel": "platform", "type": "presence", "payload": { "serverNow": 1790000000000, "seats": { … }, "observers": { "count": 0 }, "pause": null } }| type | 向き | 説明 |
|---|---|---|
voice.report | アプリ → サーバー | 自分の通話の申告(session / reason / room)。session / reason / room が変わったとき、SDK の ready のとき、WebSocket がつながり直したときに送る |
pause.request | アプリ → サーバー | 緊急一時停止 |
pause.resume | アプリ → サーバー | 緊急一時停止の解除 |
presence | サーバー → アプリ | 判断済みの接続と通話の状態。SDK も読んでシナリオの Presence を組み立てる |
- 形と意味は 接続と通話の状態
- サーバーは足すだけで版を上げないので、ホストは知らない type を捨てる
roomIdなしのローカル実行ではサーバーが無いので、SDK がpause.request/pause.resumeを自分で処理し、voice.reportは捨てる
ゲームチャネルメッセージ (channel: 'game')
ゲーム固有のカスタムメッセージ。SDK の send(type, payload) で送信し、on(type, handler) で受信する。
{ "channel": "game", "type": "attack", "payload": { "lines": 2 } }
{ "channel": "game", "type": "chat", "payload": { "text": "hello" } }ゲームチャネルのメッセージは SDK/Flutter 側では特別な処理を行わず、そのまま中継される。
JS 側: PlayScreen SDK (@uzuhq/code-sdk)
共有パッケージ javascript/uzu_code/sdk/ として提供。詳細は play-screen-sdk.md を参照。
import { init, playSound, muteMic, on, onPresenceChange } from '@uzuhq/code-sdk';
init(); // 初期化 + ready メッセージ送信
playSound('sounds/clear.mp3');
muteMic(); // 本人のミュート。作品からオンにする手段は無い
// 通話の部屋とマイクの方針はロジックの voicePlan が state から返す。画面からは変えない
onPresenceChange((presence, changes) => {
for (const [id, other] of Object.entries(presence.others)) {
updatePlayerAvatar(id, other.voice.status);
}
});
// ゲーム独自メッセージの送受信
import { send } from '@uzuhq/code-sdk';
send('attack', { lines: 2 }); // channel: 'game' で送信
on('attack', (payload) => {
// payload = { lines: 2 }(payload が直接渡される)
console.log(`Received attack with ${payload.lines} lines`);
});Flutter 側: BridgeService
WebViewController を受け取り、双方向通信のチャネル管理を行う。
初期化手順
// 1. WebViewController と共にインスタンス化
final bridge = BridgeService(controller);
// 2. コールバック設定
bridge.onPlaySound = (sound) => audioService.play(sound);
// 3. チャネル登録 (loadRequest の前に呼ぶ)
bridge.registerChannels();API
| メソッド / プロパティ | 説明 |
|---|---|
registerChannels() | FlutterHost JavaScriptChannel を登録 |
sendToGame(BridgeMessage) | Flutter → JS メッセージ送信 (ready 前はキューイング) |
onPlaySound (void Function(String, UzuCodeSoundOptions)?) | 効果音再生リクエスト受信時のコールバック |
onPlayBgm (void Function(String, UzuCodeSoundOptions)?) | BGM 再生リクエスト受信時のコールバック |
onStopBgm (void Function(Duration)?) | BGM 停止コールバック。引数はフェードアウトの長さ |
onSetMicMuted (void Function(bool)?) | setMicMuted 受信時のコールバック。形が崩れていたら true。PlayScreenController.handleSetMicMuted が false を捨てる |
onSetListeningRoom (void Function(String?)?) | setListeningRoom 受信時のコールバック |
onConnectionChanged (void Function(String?)?) | connectionChanged 受信時のコールバック |
onPlatformMessage (void Function(String?, Map)?) | platform チャネルの伝言を受信したときのコールバック |
onSetMicEnabled (void Function(bool)?) | bridge 1 の setMicEnabled 受信時のコールバック。PlayScreenController.handleSetMicEnabled が true を捨てる |
onChangeRoom (void Function(String?)?) | bridge 1 の changeRoom 受信時のコールバック |
onGameEnded (void Function()?) | gameEnded 受信時のコールバック。卓を終了扱いにする。画面は閉じない |
onFinishGame (void Function()?) | finishGame 受信時のコールバック。この端末のプレイ画面を閉じる |
onReady (void Function()?) | SDK の bridge 受け口が立った時のコールバック (= init() 完了)。Flutter → JS が送信可能になった signal |
onFirstFrameReady (void Function()?) | scenario が最初の絵 (splash) を描画した瞬間のコールバック。splash overlay 非表示 / WebView 可視化の trigger (UZU-5524) |
onGameReady (void Function()?) | scenario が操作受付可能になった瞬間のコールバック。入力受付 / TTI 計測終了の trigger (UZU-5524) |
onStateResponse (void Function(Map?)?) | stateResponse 受信時のコールバック |
ライフサイクル 3 signal
scenario は init → firstFrameReady → gameReady の順に signal を発火し、Flutter は各 signal を別の用途に使う:
| signal | scenario 側 | Flutter 側用途 |
|---|---|---|
sdk/ready (内部) | init() 完了時に SDK が自動送信 | _pendingMessages キュー flush、onReady callback |
firstFrameReady | creator が firstFrameReady() を明示的に呼ぶ (splash 描画後の requestAnimationFrame 内) | onFirstFrameReady callback → splash overlay 非表示 |
gameReady | creator が gameReady() を明示的に呼ぶ (asset load + setup 完了後) | onGameReady callback → 入力受付開始 / TTI 計測終了 |
ready ハンドシェイク
SDK 初期化前に Flutter がメッセージを送信するとロストする問題を防ぐ仕組み。
1. BridgeService は _sdkReady = false で開始
2. sendToGame() は _sdkReady が false の間、メッセージを _pendingMessages にキュー
3. SDK が init() を呼ぶと { channel: 'sdk', type: 'ready', payload: {} } が送信される
4. BridgeService が受信 → _sdkReady = true → キュー内メッセージを一括送信
5. PlayScreenController が自分の通話の状態 (voiceChanged) と申告 (platform の voice.report)、起動後に動いた inset (insetsChanged) を渡し直すページを読み込み直すと SDK は何も覚えていないので、ready のたびに通話の状態と inset を渡し直す。通話を持たないホストは、ここで既定の off が届く。
splash 非表示は ready ではなく firstFrameReady で行う (UZU-5524)。ready は bridge 受け口が立っただけで scenario の描画は未保証。
メッセージ送信の仕組み
bridge.sendToGame({'channel': 'platform', 'type': 'voice.report', 'payload': {'session': 'connected', 'room': null}});
// _sdkReady == true の場合:
// controller.runJavaScript('window.__ps && window.__ps.onMessage({...})')
// _sdkReady == false の場合:
// _pendingMessages にキューイング → ready 受信後に自動送信メッセージ受信の仕組み
// JavaScriptChannel 'FlutterHost' で受信
// → JSON デコード → channel と type に応じて処理
// channel: 'sdk' のメッセージ:
// - 'ready': _sdkReady = true にし、保留メッセージをフラッシュ、onReady を呼び出し
// - 'firstFrameReady': onFirstFrameReady を呼び出し (splash 非表示) (UZU-5524)
// - 'gameReady': onGameReady を呼び出し (入力受付開始 / TTI 計測終了) (UZU-5524)
// - 'playSound': onPlaySound コールバックを呼び出し
// - 'playBgm' / 'stopBgm': BGM 制御
// - 'setMicMuted': 本人のミュート (muted: false は捨てる)
// - 'setListeningRoom': 観測席が聴く部屋の選択
// - 'connectionChanged': SDK の WebSocket の状態 (online に戻ったら申告を送り直す)
// - 'setMicEnabled' / 'changeRoom': bridge 1 の作品のマイクと部屋 (setMicEnabled の true は捨てる)
// - 'haptic': 端末を振動させる (ホスト側での結線は不要)
// - 'gameEnded': onGameEnded を呼び出し (卓を終了扱いにする。画面は閉じない)
// - 'finishGame': onFinishGame を呼び出し (この端末のプレイ画面を閉じる)
// - 'stateResponse': onStateResponse コールバックを呼び出し
// channel: 'platform' のメッセージ:
// - onPlatformMessage を呼び出し ('presence' を PlayScreenController が読む。知らない type は捨てる)
// channel: 'game' のメッセージ:
// - Flutter 側では処理しない(WebView 内で完結)シーケンス図
通話の状態の更新 (Flutter → JS)
音声再生 (JS → Flutter)
拡張ガイド
新しい SDK メッセージタイプの追加
- メッセージ形式を定義:
channel: 'sdk'、type、payloadのフィールドを決定 - 送信側を実装:
- Flutter → JS:
BridgeServiceに送信メソッド追加 - JS → Flutter: 対応する SDK 関数を追加
- Flutter → JS:
- 受信側を実装:
- Flutter 側:
BridgeService._handleMessage()でchannel: 'sdk'の case 追加 - JS 側: SDK 内部でハンドリング
- Flutter 側:
ゲームチャネルのカスタムメッセージ
ゲーム固有のメッセージは channel: 'game' で送受信する。SDK の拡張は不要。
// 送信
send('myCustomEvent', { data: 'value' });
// 受信(payload が直接渡される)
on('myCustomEvent', (payload) => {
console.log(payload.data); // 'value'
});新しいサウンドの追加
- UGC ゲームの
public/sounds/に MP3 ファイルを配置 (例:newSound.mp3) - ゲーム側から相対パスで呼び出し:typescript
playSound('sounds/newSound.mp3'); - ZIP にビルドすれば Flutter 側で自動的に利用可能になる