Skip to content

Flutter ↔ JS ブリッジ仕様 ​

内部ドキュメント

このドキュメントは内部実装の詳細です。ゲーム開発者向けの情報は docs.code.uzu-app.com を参照してください。

Flutter ホストと WebView 内のゲーム間で双方向通信を行うためのプロトコル。

通信経路 ​

Native (iOS / Android / Desktop) ​

方向技術グローバルオブジェクト
Flutter → JSWebViewController.runJavaScript()window.__ps.onMessage(msg)
JS → FlutterJavaScriptChannelwindow.FlutterHost.postMessage(json)

Web (iframe) ​

方向技術
親 → iframeiframe.contentWindow.postMessage()
iframe → 親window.parent.postMessage()

メッセージ形式 ​

すべてのメッセージは以下の共通構造を持つ JSON オブジェクトである。

json
{
  "channel": "sdk" | "game" | "platform",
  "type": "string",
  "payload": { ... },
  "playerId": "string (optional)"
}
フィールド型説明
channel'sdk' | 'game' | 'platform'メッセージのチャネル。SDK/プラットフォームコマンドか、ゲーム独自メッセージか、アプリとサーバーの伝言かを区別する
typestringメッセージの種別
payloadobjectメッセージのペイロード(データ本体)
playerIdstring(任意)送信元プレイヤーの ID(ゲーム → ホスト方向のみ)。Web エミュレータでは複数プレイヤーの iframe が同一オリジンになり postMessage の送信元 iframe を区別できないため、ホストはこの値で自分宛メッセージかを判定する。Flutter ホスト(QueryParamsBuilder)と dev ハーネスは URL に必ず playerId を付与するため、ゲーム → ホスト方向では常に存在する。受信側は playerId 無し = 旧 SDK ビルドとして従来の送信元判定にフォールバックする

チャネルの概念 ​

チャネル用途説明
sdkSDK/プラットフォームコマンドサウンド再生、マイク制御、通話の状態、ハンドシェイクなど 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
readySDK 初期化完了通知{}
playSound効果音再生リクエスト{ sound: string, volume?: number, slot?: string }
playBgmBGM 再生リクエスト (ループ){ sound: string, volume?: number, fadeMs?: number }
stopBgmBGM 停止{ fadeMs?: number }
setMicMuted本人のマイクのミュート。シナリオの muteMic(){ muted: true }
setListeningRoom観測席が聴く部屋を選ぶ。シナリオの setListeningRoom(){ room: string | null }
connectionChangedSDK が張っている WebSocket の状態{ status: 'connecting' | 'online' | 'reconnecting' | 'offline' }
haptic端末を短く振動させる{ kind: 'light' | 'medium' | 'heavy' }
gameEndedゲームが決着して結果・感想戦に入った報告。画面は閉じない{}
finishGame遊び終えて退出し、この端末のプレイ画面を閉じる{}
pauseState緊急一時停止の確定{ paused: boolean, by: string | null }
stateResponseゲーム state の応答{ state: object | null }
json
{ "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)。指定が無ければ volume 1・fadeMs 0 で、その場で切り替える
  • 範囲外は丸め、数でない値は既定値にする。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 }
insetsChangedsafe area / HUD 回避領域の変化を配る{ safeArea, hud }
json
{
  "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.sincenumber(任意)session が今の値になった端末の時刻 (ms)。connecting / reconnecting / failed で使う
self.roomstring | null今入っている部屋。null は全体の部屋
self.micMic{ status: 'open' } / { status: 'muted', by: 'self' | 'scenario' } / { status: 'locked' } / { status: 'listen-only' }
self.speaking / self.qualityboolean / 'good' | 'poor'発話中か / 音質
heardRecord<string, { mic, speaking, quality, nickname? }>同じ部屋で聞こえている人(キーは席 ID)。観測席も入り、観測席には nickname を付ける
missingRecord<string, { since }>presence の部屋割りでは同じ部屋にいるはずなのに、声が届かない人。ホストが猶予(5 秒)を見てから載せる。since は端末の時刻
leftRecord<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 で渡される
json
{
  "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() から送信される。

json
{ "channel": "sdk", "type": "getState", "payload": {} }

platform チャネルメッセージ (channel: 'platform') ​

アプリとサーバー(play-server)のあいだの伝言。SDK は中身を見ずに、WebSocket の __platform と行き来させる。申告・一時停止の要求の中身は、アプリとサーバーの更新だけで変えられる。

json
// アプリ → 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) で受信する。

json
{ "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 を参照。

typescript
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 を受け取り、双方向通信のチャネル管理を行う。

初期化手順 ​

dart
// 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 を別の用途に使う:

signalscenario 側Flutter 側用途
sdk/ready (内部)init() 完了時に SDK が自動送信_pendingMessages キュー flush、onReady callback
firstFrameReadycreator が firstFrameReady() を明示的に呼ぶ (splash 描画後の requestAnimationFrame 内)onFirstFrameReady callback → splash overlay 非表示
gameReadycreator が 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 の描画は未保証。

メッセージ送信の仕組み ​

dart
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 受信後に自動送信

メッセージ受信の仕組み ​

dart
// 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 メッセージタイプの追加 ​

  1. メッセージ形式を定義: channel: 'sdk'、type、payload のフィールドを決定
  2. 送信側を実装:
    • Flutter → JS: BridgeService に送信メソッド追加
    • JS → Flutter: 対応する SDK 関数を追加
  3. 受信側を実装:
    • Flutter 側: BridgeService._handleMessage() で channel: 'sdk' の case 追加
    • JS 側: SDK 内部でハンドリング

ゲームチャネルのカスタムメッセージ ​

ゲーム固有のメッセージは channel: 'game' で送受信する。SDK の拡張は不要。

typescript
// 送信
send('myCustomEvent', { data: 'value' });

// 受信(payload が直接渡される)
on('myCustomEvent', (payload) => {
  console.log(payload.data);  // 'value'
});

新しいサウンドの追加 ​

  1. UGC ゲームの public/sounds/ に MP3 ファイルを配置 (例: newSound.mp3)
  2. ゲーム側から相対パスで呼び出し:
    typescript
    playSound('sounds/newSound.mp3');
  3. ZIP にビルドすれば Flutter 側で自動的に利用可能になる