Skip to content

Emulator アプリ仕様 ​

内部ドキュメント

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

ソースコード: dart/apps/uzu_code/emulator/

uzu_code ライブラリを使ったマルチプレイヤーテスト用アプリケーション。 複数プレイヤーの画面を同時表示し、ローカル環境でマルチプレイをシミュレートする。

技術スタック ​

パッケージバージョン用途
Flutter / DartSDK ^3.11.0フレームワーク
uzu_codepath: ../uzu_codeゲーム表示ライブラリ
archive^4.0.0ZIP 展開
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 = false
  • Release.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)
OFFoff(this-device)null
SPK / LSN / MUTE / ERRconnected(部屋はルーム表示の部屋)。発話・ミュート・音質は voiceChanged だけに載る。SPK は発話中、MUTE はミュート、ERR は音質 poorspeaking / listening / muted / unstable
RECONreconnecting。その画面には「通話に再接続しています…」がすぐ出て、他の画面では聞こえなくなり、5 秒続くと「(名前)との通話が切れています」が出るunstable
FAILfailed(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 はサンドボックスディレクトリになるため、以下の順序でプロジェクトルートを探索する:

  1. Platform.resolvedExecutable から親ディレクトリを辿る
  2. Directory.current.path から親ディレクトリを辿る

各パスから上位に辿り、games.json が存在するディレクトリをプロジェクトルートとする。

API ​

メソッド説明
fetchGames()games.json を読み込み List<Game> を返す
ensureGameAvailable(gameId)ゲームのプレイ可能なディレクトリパスを返す
clearAllCache()展開済み ZIP のキャッシュを全削除

ゲーム解決の優先順位 ​

ensureGameAvailable() は以下の順序でゲームファイルを探索する:

優先度パス条件用途
1ugc/{id}/dist/index.html が存在開発中の npm run build 出力
2r2/{id}.zipZIP ファイルが存在uzu publish で配布された ZIP
3ugc/{id}/__zip__ZIP ファイルが存在レガシー

ZIP 展開とキャッシュ ​

ZIP ファイルは path_provider の getApplicationSupportDirectory() 配下に展開する。

~/Library/Application Support/com.uzu.emulator/games/{gameId}/
  • キャッシュ済みの場合は ZIP の更新日時を比較して最新なら使い回す
  • clearAllCache() で全キャッシュを削除

起動 ​

パラメータ指定起動(Desktop) ​

bash
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(省略時は自動生成)
orientationportrait(デフォルト)/ landscape
player_id単独表示するプレイヤー ID(単独プレイヤー表示モード)
admin1 で admin 席(進行管理席)を追加(admin 席)
monitor1 で監視として開く。UZU ADMIN のイベント詳細と Studio のテストプレイ履歴が付ける。各画面はプレイヤーと同じ席で、本物のプレイヤーが遊ぶ部屋につながる。エミュレータは止める操作と「アプリで開く」の QR を出さない。wire 3 の SDK の作品では、SDK が印を WebSocket の接続 URL まで運ぶので、サーバーもこの接続を数えず、止める要求も受けない

endpoint のモード自動判定 ​

endpoint のホストに応じて ZIP ダウンロードの要否を自動判定する。url_mode パラメータは不要。

ホスト動作
localhost / 127.0.0.1URL モード: endpoint を直接ロード
それ以外ZIP モード: {endpoint}/__zip__ から ZIP をダウンロード・展開

パラメータ未指定起動 ​

パラメータなしで起動すると ParameterBuilderScreen が表示され、GUI でパラメータを入力できる。

bash
# 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 コマンドをクリップボードにコピーする(別ウインドウで貼って実行する想定)
  • 元のグリッドタブと併走させると同じ席に接続が 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 時間キャッシュするので、変更後すぐには効かない