Emulator アプリ仕様
内部ドキュメント
このドキュメントは内部実装の詳細です。ゲーム開発者向けの情報は docs.code.uzu-app.com を参照してください。
ソースコード: dart/apps/uzu_code/emulator/
uzu_code ライブラリを使ったマルチプレイヤーテスト用アプリケーション。 複数プレイヤーの画面を同時表示し、ローカル環境でマルチプレイをシミュレートする。
技術スタック
| パッケージ | バージョン | 用途 |
|---|---|---|
| Flutter / Dart | SDK ^3.11.0 | フレームワーク |
| uzu_code | path: ../uzu_code | ゲーム表示ライブラリ |
| archive | ^4.0.0 | ZIP 展開 |
| path_provider | ^2.1.0 | アプリサポートディレクトリ取得 |
| path | ^1.8.0 | パスユーティリティ |
ディレクトリ構造
dart/apps/uzu_code/emulator/
├── pubspec.yaml
├── lib/
│ ├── main.dart # エントリーポイント (config 判定 → 画面振り分け)
│ ├── config.dart # URL query / dart-define から EmulatorConfig を読む
│ ├── pane_layout.dart # グリッド 1 ペイン分の寸法計算 (実機相当の論理サイズ + 縮小倍率)
│ ├── screens/
│ │ ├── parameter_builder_screen.dart # パラメータ入力画面 (endpoint 未指定時)
│ │ ├── desktop_emulator_loader.dart # Desktop ZIP ダウンロード → EmulatorScreen
│ │ └── emulator_screen.dart # マルチプレイヤー画面
│ ├── services/
│ │ └── game_service.dart # ZIP ダウンロード・キャッシュ管理
│ └── utils/
│ └── open_url.dart # URL ユーティリティ
├── macos/ # macOS プラットフォーム設定
└── test/EmulatorConfig(パラメータの解釈)・EmulatorPlayer(席 id と roster の既定値)・ZIP 展開は、mobile の dev プレビュー画面と共有するため dart/packages/uzu_code/lib/emulator/ と lib/services/game_archive.dart に置いている。
macOS サンドボックス
開発ツールのため、macOS サンドボックスは無効化している。 プロジェクトファイル (games.json, ugc/, r2/) にアクセスするために必要。
DebugProfile.entitlements:com.apple.security.app-sandbox=falseRelease.entitlements:com.apple.security.app-sandbox=false
画面
GameListScreen (ゲーム一覧)
games.json からゲーム一覧を取得し、2列グリッドで表示する。
フロー:
initState() → GameService.fetchGames() → GridView 表示
↓ タップ
EmulatorScreen へ遷移games.json読み込み失敗時はエラー UI + 再試行ボタンを表示- リフレッシュボタンでキャッシュクリア + 再読み込み
EmulatorScreen (マルチプレイヤー)
起動パラメータで決まった人数ぶんのプレイヤー画面を同時表示する。
グリッドレイアウト:
ペイン数とウィンドウの縦横比から、ゲーム画面の面積が最大になる列数 × 行数を選ぶ。 ペインは「ラベル行 + ゲーム画面」で、枠線ごと中央に寄せて並べる。
実機相当の viewport で縮小表示:
各ペインは実機相当の論理サイズで組み、収まらないぶんだけ Transform.scale で縮小する。 画面上の実 px をそのまま viewport にすると、ペイン数が多い構成 (5P+A landscape 等) で 短辺が 200px 台まで落ち、vh や短辺基準で組まれたシナリオが実機の倍近くに肥大して見える。
- ゲーム画面の短辺は
defaultMinGameShortEdge(360 論理 px) を下回らない。 dev harness (uzu dev) のdevMinIframeShortEdgeと同じ既定値で、ここがずれると 同じシナリオが harness と emulator で違うレイアウトになる - ウィンドウが広ければ倍率は 1 のままで、ペインは下限より大きく組まれる。 縮小が掛かるのはウィンドウが狭いときだけ
- 縮小中は見た目の大きさが実機と違うので、文字が読めるか・指で押せるかは 単独プレイヤー表示モードか実機で判断する
- いま何を見ているかは AppBar のバッジが言い切る。
937×432なら等倍、807×372 ×0.70なら「シナリオは 807×372 でレイアウトし、画面には 0.70 倍で出ている」。 倍率は等倍のとき出さないので、出ていること自体が縮小中の合図になる (harness も画面右上に同じバッジを出す) - ラベル行と HUD (UZU ボタン) も同じ論理空間に置く。ゲーム画面だけ論理サイズにすると、 固定 px の HUD だけが相対的に巨大になって実機比率が崩れる
寸法計算は lib/pane_layout.dart の computePaneLayout。短辺の下限と縦横比の契約は test/pane_layout_test.dart が検証する。
各プレイヤー:
- 独立した
GamePlayScreenインスタンス (uzu_code ライブラリ) - 独立した WebView + LocalServerService + BridgeService
- 各画面はボーダーで区切り表示
プレイヤーコントロールバー: 各プレイヤー画面の上部に表示されるバーで、以下の情報を表示・操作できる:
- ニックネーム / プレイヤー ID の表示
- ルーム表示: 通話の部屋。bridge 2 の作品は presence の plan(観測席は
setListeningRoom)、bridge 1 の作品はchangeRoomで変わる。nullの場合は "default" と表示 - 通話の操作盤(audioStatus セレクタ): タップでプルダウンメニューを表示し、各画面の通話の様子 (OFF / SPK / LSN / MUTE / ERR / RECON / FAIL) を切り替える。実機なしで切断の帯・別の部屋・話している人を試すためのもの。既定はプレイヤーの画面が LSN、観測席が MUTE
| 値 | bridge 2 の作品へ(voiceChanged / voice.report) | bridge 1 の作品へ(playersChanged) |
|---|---|---|
| OFF | off(this-device) | null |
| SPK / LSN / MUTE / ERR | connected(部屋はルーム表示の部屋)。発話・ミュート・音質は voiceChanged だけに載る。SPK は発話中、MUTE はミュート、ERR は音質 poor | speaking / listening / muted / unstable |
| RECON | reconnecting。その画面には「通話に再接続しています…」がすぐ出て、他の画面では聞こえなくなり、5 秒続くと「(名前)との通話が切れています」が出る | unstable |
| FAIL | failed(network)。他の画面では RECON と同じく、5 秒続くと帯が出る | null |
- 同じ部屋で通話に入っている他の画面を、聞こえている相手(
heard)として渡す。一時停止の間は全員が全体の部屋に集まる - 部屋割りで同じ部屋にいるはずで、一度は聞こえた画面が聞こえなくなったら、本番と同じ規則(
expectedVoicesOfとMissingVoiceTracker、猶予 5 秒)でmissingに載せる。帯とシナリオのlostはここから出る - マイクの方針(plan の
mic)と一時停止にも本番と同じ規則(MicPolicyTracker)で従う。ゲームからのミュート(setMicMutedのmuted: true、bridge 1 はsetMicEnabled(false))は muted に切り替える。ゲームからのオンの要求(muted: false/setMicEnabled(true))は捨てる - 場面が明けても切ったマイクは戻さない。オンに戻すのは操作盤のマイクから
- 一度も聞こえていない画面でも、部屋割りで同じ部屋にいて、同じ部屋の通話に入っている(LSN 等)なら、15 秒で
missingに載せる
HUD (UZU ボタン + マイク): 各ゲーム画面の左上に本番アプリと同じ UZU ボタンとマイクボタンを出す。シナリオに配られる HUD の避け領域 (hud) も本番と同じ値になる。
- マイク: MUTE / OFF / RECON / FAIL なら LSN に、それ以外なら MUTE に切り替える。自分の plan が
lockedで一時停止中でなければ鍵のアイコンになり、押すと本番と同じダイアログが出る - UZU ボタン: 緊急一時停止 / State JSON / この画面だけ開く (グリッド表示時のみ)。緊急一時停止は roster の席で、
monitor=1でないときだけ出す
ルーム ID:
- エミュレータ起動ごとに
DateTime.now().millisecondsSinceEpochで生成 - 全 WebView で共通の roomId を使用
PlayScreenController(roomId: _roomId)でコントローラに渡す- GamePlayScreen が URL の
?roomId=xxxとして WebView に伝達
GameService
games.json + ファイルシステムからゲームを読み込む。サーバー不要。
プロジェクトルート探索
ゲームファイルは games.json と同じディレクトリ (プロジェクトルート) を基準にアクセスする。 macOS アプリの cwd はサンドボックスディレクトリになるため、以下の順序でプロジェクトルートを探索する:
Platform.resolvedExecutableから親ディレクトリを辿るDirectory.current.pathから親ディレクトリを辿る
各パスから上位に辿り、games.json が存在するディレクトリをプロジェクトルートとする。
API
| メソッド | 説明 |
|---|---|
fetchGames() | games.json を読み込み List<Game> を返す |
ensureGameAvailable(gameId) | ゲームのプレイ可能なディレクトリパスを返す |
clearAllCache() | 展開済み ZIP のキャッシュを全削除 |
ゲーム解決の優先順位
ensureGameAvailable() は以下の順序でゲームファイルを探索する:
| 優先度 | パス | 条件 | 用途 |
|---|---|---|---|
| 1 | ugc/{id}/dist/ | index.html が存在 | 開発中の npm run build 出力 |
| 2 | r2/{id}.zip | ZIP ファイルが存在 | uzu publish で配布された ZIP |
| 3 | ugc/{id}/__zip__ | ZIP ファイルが存在 | レガシー |
ZIP 展開とキャッシュ
ZIP ファイルは path_provider の getApplicationSupportDirectory() 配下に展開する。
~/Library/Application Support/com.uzu.emulator/games/{gameId}/- キャッシュ済みの場合は ZIP の更新日時を比較して最新なら使い回す
clearAllCache()で全キャッシュを削除
起動
パラメータ指定起動(Desktop)
flutter run -d macos \
--dart-define=endpoint=http://localhost:5173 \
--dart-define=server_url=ws://localhost:8787 \
--dart-define=player_count=2| パラメータ | 必須 | 説明 |
|---|---|---|
endpoint | ✅ | ゲーム配信エンドポイント URL |
server_url | ✅ | WebSocket サーバー URL |
player_count | △ | プレイヤー数(players 未指定時) |
players | △ | プレイヤー設定 JSON 配列 |
room_id | ルーム ID(省略時は自動生成) | |
orientation | portrait(デフォルト)/ landscape | |
player_id | 単独表示するプレイヤー ID(単独プレイヤー表示モード) | |
admin | 1 で admin 席(進行管理席)を追加(admin 席) | |
monitor | 1 で監視として開く。UZU ADMIN のイベント詳細と Studio のテストプレイ履歴が付ける。各画面はプレイヤーと同じ席で、本物のプレイヤーが遊ぶ部屋につながる。エミュレータは止める操作と「アプリで開く」の QR を出さない。wire 3 の SDK の作品では、SDK が印を WebSocket の接続 URL まで運ぶので、サーバーもこの接続を数えず、止める要求も受けない |
endpoint のモード自動判定
endpoint のホストに応じて ZIP ダウンロードの要否を自動判定する。url_mode パラメータは不要。
| ホスト | 動作 |
|---|---|
localhost / 127.0.0.1 | URL モード: endpoint を直接ロード |
| それ以外 | ZIP モード: {endpoint}/__zip__ から ZIP をダウンロード・展開 |
パラメータ未指定起動
パラメータなしで起動すると ParameterBuilderScreen が表示され、GUI でパラメータを入力できる。
# Taskfile 経由
task dev
# 直接起動
cd dart/apps/uzu_code/emulator && flutter run -d macosコマンドコピー機能
EmulatorScreen の AppBar にあるターミナルアイコンで、現在の接続パラメータを含む flutter run コマンドをクリップボードにコピーできる。同じ room_id で再接続したい場合に便利。
admin 席(進行管理席)
admin=1 を付けて起動する(または EmulatorScreen の AppBar にある ⚙ トグルを押す)と、グリッドに admin 席(admin_0)の pane が加わる。この席は roster(players)には載せず、?seatKind=admin を付けた roster 外の接続として開く。ゲーム側が観測者ビュー(state.players に自席が居ない場合の view)に対応している場合、この pane から GM 操作ができる。onState の第 3 引数で admin が渡るので、観戦ビューとの出し分けもできる。未対応ゲームでは pane の表示はゲーム依存。
manifest が admin: true を宣言している作品は、admin 管理画面(シナリオ詳細・イベント詳細・一人用イベント詳細)が emulator リンクを組むときに admin=1 を自動付与する(backend が revision の uzu_code_manifest から ScenarioRevision.uzuCodeAdminEnabled として返す)。uzu preview が返すリンクも同様に自動付与する。手動で URL を組む場合だけ自分で付ける。
- admin 席は roster に載らないので、トグルしても roster は変わらない。ただし player 席の構成は最初の WebSocket 接続で room に固定されるため、人数を変えるときは新しい room_id で開き直す
- admin 席は
playersChanged(音声状態)の対象外。bridge 2 の作品では観測席として、聴く部屋をsetListeningRoomで選ぶ player_id=admin_0で admin 席の単独表示も可能
単独プレイヤー表示モード
player_id パラメータを付けて起動すると、グリッド表示をやめて該当プレイヤー 1 人ぶんの画面だけを全画面表示する。配信や 1 画面ぶんのデバッグで個別画面を切り出したい時に使う。
- Web の例:
?endpoint=...&server_url=...&player_count=4&room_id=xxx&player_id=player_2 - Desktop は
--dart-define=player_id=player_2を追加して起動する - SDK に渡す
players(部屋の参加者一覧)はメインタブと同じ全員ぶんが渡るため、ゲーム側から見た参加者構成は変わらない - グリッド表示中はプレイヤーコントロールバーに「open_in_new」アイコンが出る
- Web: 現在 URL に
player_idを付与した URL を新しいタブで開く(room_idも自動付与) - Desktop: 単独表示モードで起動する
flutter runコマンドをクリップボードにコピーする(別ウインドウで貼って実行する想定)
- Web: 現在 URL に
- 元のグリッドタブと併走させると同じ席に接続が 2 本貼られる。play-server は両方に state を配り、action は playerId で処理するので進行は壊れない。ただし片方が切れるとその席の連続入力(スティック等)がリセットされ、端末内だけで持つ UI 状態は 2 画面の間で同期されない
アプリで席を開く
プレイヤーコントロールバーの QR アイコンから、その席を UZU アプリで開く QR を出せる。実機で遊びながら、エミュレータで全席の進行を見る使い方を想定している。
- リンクは単独表示と同じ query(
player_id・room_idを補完)で、ホストをhttps://uzu-app.com/uzu-code-previewに置き換えたもの。卓の環境によらず prd アプリで開き、繋ぐ play-server はserver_urlで決まる - アプリはこの query だけで卓に繋ぐ(
views/pages/uzu_code_preview/)。イベント・ボイス・配役解決は持たず、roster はエミュレータと同じ規則で組む - リンクは誰でも作れるので、アプリは endpoint が
*.{dev,stg,prd}.uzu-code-content.uzu-asset.com、server_url がwss://{dev,stg,prd}.uzu-code-play.uzu.oneのリンクしか開かない。uzu devのローカル卓や独自ホストの endpoint は開けない - endpoint か server_url が localhost の卓では実機から届かないので QR アイコンを出さない
monitor=1の卓でも出さない。アプリで開くとプレイヤーの席として数えられ、止められもするため- iOS は AASA に
/uzu-code-preview*が載っていないとアプリが起動しない。AASA は Apple の CDN(app-site-association.cdn-apple.com)経由で端末に届き、CDN は最大 6 時間キャッシュするので、変更後すぐには効かない