第8章

付録:もりアプリにおけるFlutterとFirebaseの関係

認証・プレー画面・終了後・ルーム管理に分け、Authentication・Realtime Database・Cloud Functionsの役割を図で学びます。

この章で学ぶこと

この付録では、Flutterコースで学んだ認証・データ保存・変更の購読が、実際のアプリでどう組み合わされているかを確認します。もりアプリの実装を題材に、FlutterとFirebaseの役割分担を見ていきましょう。

もりアプリの通信を、登録・ログインと、その後の3つの処理に分けて説明します。それぞれの図で、誰が、どのデータを変え、それを受けてどの処理が動くかを追いましょう。

場面 主な目的 中心になるデータ
登録・ログイン 利用者を認証し、データと関連付ける id、password、uid、playerName
1. プレー画面の処理 カード操作を各端末で共有する field、playerCards、currentTurnIndex
2. 終了後の処理 次の試合へ進む、またはレートを確定する completedMatches、settlementRequested、ratings
3. ルーム管理 接続状態を管理し、不要になったルームを削除する presence、spectators、createdAt

この章は2026年9月29日に確認したmori_gameの実装に基づきます。Flutterは端末で動くDartのコード、Cloud Functionsはサーバーで動くTypeScriptのコードです。

図を読むための前提

Realtime Databaseはデータの保存・変更通知、Cloud Functionsはサーバー側の処理を担当します。Flutterは画面表示に加え、操作の判定とデータの読み書きも行います。

  • rooms/{roomId}は1つのルームの保存先です。roomIdは部屋IDを表します。
  • myId・playerId・uidはプレイヤーを識別するIDです。人間のプレイヤーにはAuthenticationのuidを使います。
  • onValueはFlutter側で値を購読するAPI、onValueWrittenはDBの変更を受けてFunctionsを起動するトリガーです。
  • 図中で繰り返し登場するrooms/{roomId}は、別のデータベースではなく、同じルームのデータです。

認証はAuthentication、Web版の配信はHostingが担当します。まず認証を確認し、その後にRealtime DatabaseとCloud Functionsの処理を追います。対戦データにはCloud Firestoreを使っていません。

登録・ログイン(Authentication)

入力したIDとパスワードで認証する → uidを取得する → ロビーへ進む → uidに対応したデータを使う流れです。Authenticationが利用者の認証を担当し、プレイヤー名やレートはRealtime Databaseに保存します。

Flutterが入力IDをメール形式に変換してAuthenticationに登録・ログインを依頼する。AppGateは認証状態で画面を切り替え、ログイン後はuidでDBのデータを関連付ける。

図を大きく開く

入力したIDが、そのままuidになるわけではない

もりアプリは、画面では「ユーザーID・パスワード」を入力させますが、内部ではFirebaseのメールアドレス・パスワード認証を使っています。

名前 例 役割
入力ID:id Mori_01 ログイン画面で利用者が入力するID
内部のメール形式:email mori_01@mori-game.local idToEmail()で前後の空白を取り、小文字化して変換した認証用の値
認証UID:uid Authenticationが割り当てる文字列 アカウントを識別するキー。同じアカウントでログインすれば同じUIDを使う
表示名:playerName もり好き 対戦画面などに表示する名前。変更してもUIDは変わらない

mori-game.localは、このアプリが内部の認証IDを組み立てるために使う固定ドメインです。利用者の実際の連絡先メールアドレスを入力させる設計ではありません。

登録・ログイン時の処理

  1. AuthPage._submit()がidとpasswordを読み取ります。入力IDは3〜24文字の半角英数字と_、パスワードは6文字以上かを画面側で確認します。
  2. 登録ならregisterWithIdAndPassword()、ログインならsignInWithIdAndPassword()を呼びます。
  3. FirebaseAuthServiceがidToEmail(id)でメール形式に変換し、Firebase Auth SDKへ渡します。
  4. Authenticationが登録または認証を行い、成功時にUserCredentialを返します。FlutterはFirebaseAuth.instance.currentUser?.uidでも利用者のUIDを取得できます。
  5. 失敗した場合、AuthPageの_errorにエラー表示用の内容を設定します。処理中は_busyで状態を管理します。
Flutterのサービスメソッド 呼び出すFirebase Auth API
registerWithIdAndPassword() createUserWithEmailAndPassword()
signInWithIdAndPassword() signInWithEmailAndPassword()

このログイン処理はFlutterからAuthenticationへ直接依頼します。パスワードをroomsやusersに保存して照合したり、独自のCloud Functionへ送って判定したりする実装ではありません。

認証状態で画面を切り替える

AppGateはauthStateChanges()をStreamBuilder<User?>で購読しています。

状態 表示する画面
認証状態の確認中 読み込み表示
snapshot.data != null EntrancePage(ロビー)
snapshot.data == null AuthPage(ログイン画面)

ログイン成功時に限らず、起動時に認証状態が復元された場合も、この状態に応じて画面が決まります。authStateChanges()は認証状態を、対戦画面のonValueはDBの値を購読します。監視している対象が違います。Firebase公式:Flutterの認証状態の監視

ログアウトはlogoutAndReturnToLogin()でsignOut()を呼び、認証状態を未ログインに戻します。画面のナビゲーションも先頭へ戻し、AppGateがログイン画面を表示します。ログアウトはアカウント削除やDBデータの削除とは別の操作です。

uidがDBとCloud Functionsにつながるところ

ログイン後のロビーでは、取得したUIDをプロフィールの読み書きやゲーム画面への引数に使います。

  • users/{uid}/playerName:UserProfileServiceがプレイヤー名を保存・取得する。
  • ratings/{uid}:レートや成績を、その利用者に関連付ける。
  • GameRoomPage(userId: uid, ...):通常の人間プレイヤーのUIDをゲーム画面へ渡し、myIdとしてplayerCards/{myId}などで使う。

DBへのアクセス時にはFirebase SDKが認証情報を扱い、Security Rulesではauth.uidとして認証された利用者を確認できます。パスへUIDを書くだけで認証されるわけではありません。Firebase公式:ルールで認証情報を使う

たとえば現在のusers/{userId}の読み取り条件は、次のとおりです。

auth != null && auth.uid == $userId

「ログインしていること」と「読もうとしているプロフィールの所有者であること」を確認します。すべてのDBパスが本人限定という意味ではなく、roomsやratingsなどはそれぞれ別のルールを持ちます。

Callable関数のsubmitContactやdeleteAccountも、request.auth?.uidで呼び出し元を確認し、未認証なら拒否します。一方、DB変更で起動するonRoomWrittenは起動方法が異なります。AuthenticationのUIDを基準に、画面・保存データ・直接呼び出す関数が利用者を関連付けています。

1. プレー画面の処理

カードを出す → DBへ更新を書く → 自分と相手が変更を受け取る → 画面を描き直す流れです。

プレー画面の処理。Flutterがroomsを更新し、各端末がonValueで購読する。onRoomWrittenは一覧同期とBot進行を準備する。

図を大きく開く

カード操作から画面更新まで

  1. game_room_page.dartで、順番やGameRules.canPlayNormal()などの条件を確認します。
  2. _executePlay(cards)がmyHandから出したカードを取り除き、更新する状態を作ります。
  3. FirebaseDB.updateGameStatus()がrooms/{roomId}へupdate()で書き込みます。
  4. 各端末のroomStream.listen(_onData)が変更を受け取り、手札・場・順番を画面へ反映します。
保存するフィールド 内容 Flutter側の対応例
field 場のカードのnumberとsuit playedCardから作る
playerCards/{myId} 自分の手札の配列 myHandをシリアライズする
playerHands/{myId} 自分の手札枚数 myHand.length
currentTurnIndex 次に操作する人の順番 参加者の並びから計算する
lastPlayerId 最後にカードを出した人 myId
fieldHistory 場に出たカードの履歴 updatedHistory

端末間で画面の画像を送るのではなく、同じルームの状態データを共有します。通常のカード操作はFlutterからDBへ直接書き込みます。

// lib/services/firebase_db.dartの通信の入口
_roomRef = FirebaseDatabase.instance.ref('rooms/$roomId');

Stream<DatabaseEvent> get roomStream => _roomRef.onValue;

Future<void> updateGameStatus(Map<String, dynamic> updates) =>
    _updateRoomAndSummary(updates);

onValueでは購読開始時と変更時に値を受け取ります。送信元の画面にはローカル更新が先に反映されることもあるため、画面が変わったこととサーバーへの保存成功は区別します。Firebase公式の読み書きガイド

この場面のCloud Functions

onRoomWrittenは/rooms/{roomId}の変更を受け、主に次の処理を行います。

  • syncRoomSummary():roomSummaries/{roomId}へルーム一覧用の軽量な情報を同期します。
  • prepareBotProgress():Bot操作が必要ならタスクを予約し、botActionTaskで実行します。Botの操作結果もDBに書かれ、端末はその変更を受け取ります。

ロビーはroomSummariesを購読するため、一覧表示に不要な手札・山札を読み込まずに済みます。Flutter側にもサマリーを更新する処理があり、Functionsが同期を補います。ルーム本体とサマリーは別々に更新されます。

現在はFlutter側にもタイマー・Bot関連処理・もり確定の補完処理があります。ゲームの判定すべてをFunctionsだけで行う構成ではありません。

2. 終了後の処理

1試合の終了と、設定した試合数すべての終了を分けて考えます。たとえば3試合の設定なら、1試合目の終了後は次の試合へ進み、規定試合の終了後にレートを確定します。

終了後の処理。settlementRequestedをtrueにするとonRoomSettlementRequestedが起動し、settleRoomSeriesがレートと確定結果を書き戻す。

図を大きく開く

1試合が終わったとき

moriPhase == 'finished'、またはburstPlayerIdがあることが試合終了の判定に使われます。completedMatchesは終了した試合数、totalMatchesは設定した試合数です。

フィールド 終了後に表すもの
playerPoints プレイヤーごとの得点
completedMatches / totalMatches 終了済み試合数/規定試合数
postGameActive 試合後の状態に入っているか
seriesNextMatchAt 次の試合を開始する予定時刻
seriesRestarting 次の試合へ切り替える処理中か

FunctionsのonRoomMatchEndedPhaseやonRoomBurstPlayerからも進行管理が呼ばれます。processRoomSteward()は接続中の人間がいないなどの条件を確認し、得点処理や次試合への進行、精算要求を補います。端末側とサーバー側が無条件に同じ処理を重ねて実行するわけではありません。

規定試合が終わったときのレート確定

  1. 精算を担当するFlutterがrequestSeriesSettlement()を呼び、ルームにsettlementRequested: trueを書きます。不在時にはサーバー側の進行管理も精算を要求します。
  2. /rooms/{roomId}/settlementRequestedを監視するonRoomSettlementRequestedが、値がtrueになったことを確認します。
  3. settleRoomSeries(db, roomId)が、試合終了・規定試合数・適用済みフラグを確認し、playerPointsなどからレートを計算します。
  4. ratings/{playerId}を更新し、ルームへ確定結果を書き戻します。
  5. Flutterは結果を読み取り、レートや順位を表示します。
保存先 主な更新値
ratings/{playerId} rating、mu、sigma、gamesPlayed、totalPoints、averagePoints
rooms/{roomId}/seriesRatingApplied true:レート適用済み
rooms/{roomId}/seriesRatingSummary 表示用の結果概要
rooms/{roomId}/seriesRatingDetails プレイヤー別の順位・レート差分など
rooms/{roomId}/settlementCompletedAt 確定した時刻
rooms/{roomId}/settlementRequested 完了時はnullを書き、要求を削除
rooms/{roomId}/settlementError エラー情報。正常完了時は削除

waitForSeriesSettlement()はget()を繰り返し、seriesRatingAppliedまたはsettlementErrorを確認します。現在の既定値は確認間隔400ミリ秒、待機上限45秒です。対戦状態のonValue購読も別に続きます。

これはDBを介した要求と結果の受け渡しです。httpsCallable()で関数の戻り値を受け取る方式とは異なります。

3. ルーム管理

接続情報を更新する処理と、不要なルームを削除する処理を分けます。「誰もいなくなった」だけで直ちにルームを削除する実装ではありません。

ルーム管理。onDisconnectで接続情報を更新し、scheduledRoomCleanupが60分ごとに削除条件を確認してroomsとroomSummariesを削除する。

図を大きく開く

接続中・切断・再接続を記録する

registerPlayerPresence()は、切断時に実行する処理をonDisconnect()でDB側へ事前登録します。

パス(rooms/{roomId}の下) 意味・更新
presence/{uid} 接続中の印。切断検知時に削除する
afkPlayerIds/{uid} 離脱の印。切断時刻を保存する。意図的な退室ではtrueを使う処理もある
spectators/{uid} 観戦者の情報。参加プレイヤーのpresenceとは別に扱う
host ホストのプレイヤーID

Flutterは.info/connectedを監視し、再接続時に接続情報とonDisconnect()を登録し直します。閉じた端末のFlutterが実行を続ける必要はありません。

onRoomPresenceChangedはpresenceの変更を検知してrunRoomSteward()を呼び、不在時の進行を管理します。このトリガーで即座にルームを削除するわけではありません。

定期処理は何をする?

関数 間隔 役割
scheduledRoomMaintenanceSweep 5分ごと sweepRoomStewards()で進行を補完し、Botの予定なども点検する。試合記録の保持期限チェックも呼び出す
scheduledRoomCleanup 60分ごと sweepRoomCleanup()でルームを点検し、sweepOrphanRoomSummaries()で本体のないサマリーを整理する

ルーム削除では、tryDeleteRoomIfNeeded()が現在のルームを取得し、shouldDeleteRoom()で判定します。対象ならrooms/{roomId}をremove()し、対応するroomSummaries/{roomId}も削除します。

どんなルームが削除される?

以下はfunctions/src/room_cleanup.tsの条件です。

削除対象 必要な条件・注意点
終了・精算済みでホストがいない gameStarted == true、isGameFullyConcluded()、seriesRatingApplied == trueを満たし、hostがpresenceにいない。ゲストや観戦者の不在までは条件にしていない
無人で活動の止まった開始済みルーム presenceもspectatorsも空、gameStarted == true、最後の活動から5分以上
作成から24時間を超えたルーム createdAtから24時間超。現在の実装では接続者・観戦者の有無にかかわらず対象
createdAtのない古い空ルーム 参加者・観戦者がおらず、活動時刻が一切ない、または最後の活動から24時間超
本体のない一覧用サマリー 対応するrooms/{roomId}が存在しないroomSummaries/{roomId}

「最後の活動」はupdatedAtだけではなく、createdAt・deckResetAt・moriDeclaredAt・postGameEndedAt・rematchStartedAt・seriesNextMatchAt・presence内の時刻の最大値を使います。

isGameFullyConcluded()は、試合終了に加え、再戦要求や次試合への切り替え状態も確認します。postGameEndedAtがある場合の60秒の再戦判断猶予や、途中試合の継続予定時刻に対する30秒の猶予もここで扱います。

5分は「削除対象になるための非活動時間」、60分は「削除を点検する間隔」です。 最後の活動からちょうど5分で削除されるとは限りません。また、開始前の空ルームは無人5分の条件では消えず、24時間の期限などで整理されます。観戦者だけがいる場合も無人5分の条件には当たりません。

補足:読み書きと関数呼び出しの違い

API・起動方法 用途
get() その時点の値を一度読む。精算完了待ちでは繰り返し呼ぶ
onValue 最初の値と、その後の変更を購読する
set() / update() 値を置き換える/指定項目を更新する
runTransaction() 現在値に基づき、競合時に再評価して更新する。ルーム作成・誤もり確定などで使う
onDisconnect() 切断を検知したときのDB更新を事前登録する
FunctionsのonValueWritten DBの変更を契機に関数を起動する
FunctionsのonSchedule 定期的に関数を起動する
httpsCallable() Flutterから関数を直接呼ぶ。submitContact・deleteAccountなどで使う

複数項目を一回のupdate()で更新することと、他の端末の同時操作との競合を防ぐことは別です。通常のカード更新とrunTransaction()を使う更新を区別して読みましょう。

認証・ルール・ゲーム判定を分けて考える

database.rules.jsonは、クライアントからのデータベースアクセスを許可する条件や、保存値の検証条件を定義します。たとえばusers/{uid}では本人だけが読み取れ、プレイヤー名には文字数の検証があります。ratingsのレート本体はクライアントからの書き込みを禁止し、本人の表示名などだけを更新可能にしています。

一方、現在のroomsは読み取りが公開され、ルームへの書き込みも認証済み利用者に広く許可されています。手札を画面に表示しないことは、データへのアクセスを制限することと同じではありません。

また、Realtime Databaseの.read・.writeは親の許可が子にも及びます。現在のルールでは、ルームの親で書き込みを許可しているため、子のseriesRatingAppliedなどにある.write: falseだけでは書き込みを禁止できません。この性質はFirebase公式のルール構文ガイドで確認できます。

再現時は、「Flutterで操作を制限する」「データベースのルールでアクセスを制限する」「サーバーで操作の正当性を検証する」を分けて考えましょう。この章で説明しているのは現状の役割分担であり、すべての操作がサーバーで検証されているという意味ではありません。

エミュレーターで通信を観察しよう

環境構築を済ませたmori_gameのディレクトリで、ターミナルを二つ使います。初回の準備はリポジトリのREADMEを参照してください。

ターミナル1でFirebaseのローカル環境を起動します。

npm run emulators

ターミナル2でFlutterを起動します。

flutter run -d chrome

この実装では、明示的な指定がなければデバッグビルドはエミュレーターに接続します。コンソールの「Firebase Local Emulator Suite に接続しました」を確認し、Emulator UIを開きましょう。

  1. アカウントを作り、Authenticationに利用者が追加されることを確認する。入力したID、内部のメール形式、UIDを見比べ、ログアウト・再ログインでロビーとログイン画面が切り替わることも確かめる。
  2. ルームを作り、DatabaseのroomsとroomSummariesを見る。
  3. 別のブラウザーセッションで別アカウントを使って参加し、両方の画面を並べる。
  4. カードを出し、field・playerCards・currentTurnIndexと相手の画面が変わることを確認する。
  5. 一方を閉じ、切断検知後にpresenceとafkPlayerIdsが変わる様子を見る。
  6. 規定の試合を終え、settlementRequested、Functionsのログ、ratingsの変化を追う。
  7. 切断後のルームについて、presence・spectators・時刻を読み、上の表で削除対象か判定する。ローカルでエミュレーターを起動するだけでは、本番の定期スケジュールによる実行は再現されないため、5分・60分待つだけの確認にはしない。

簡易版を作るなら、この順番で

  1. Flutterだけで画面を作る。 固定の手札と場を表示し、ボタンで場が変わるところまで作ります。
  2. Authenticationをつなぐ。 ログインしてuidを取得します。
  3. 一つのルームを共有する。 場のカードをupdate()で保存し、もう一方の画面でonValueを購読します。
  4. 対戦状態を増やす。 参加者、手札、順番を追加し、同時操作と書き込み権限を考えます。
  5. サーバー側の処理を追加する。 まず終了後の集計をCloud Functionsで行い、その後に切断対応やBotを追加します。

最初の目標は「片方の画面で操作すると、共有データが変わり、もう片方にも反映される」ことです。この往復を理解すると、対戦・観戦・ランキングにも同じ仕組みが使われていることが見えてきます。

実装を読むための案内

以下のパスはmori_gameリポジトリのルートからの相対パスです。

知りたいこと 読むファイル
Firebaseの初期化 lib/main.dart
登録・ログイン・ログアウト lib/features/auth/auth_page.dart、lib/services/firebase_auth_service.dart、lib/features/auth/app_gate.dart、lib/features/auth/logout.dart
UIDとプロフィールの関連付け lib/services/user_profile_service.dart、lib/features/entrance/entrance_page.dart
ルーム一覧の購読 lib/features/entrance/entrance_page.dart
カード操作・状態の受信 lib/features/game/game_room_page.dart、lib/logic/game_rules.dart
ルームの読み書き・切断処理 lib/services/firebase_db.dart
サーバー処理の入口 functions/src/index.ts
レート確定・進行管理 functions/src/settle_room.ts、functions/src/room_steward.ts
ルーム削除の条件・終了判定 functions/src/room_cleanup.ts、functions/src/room_lifecycle.ts
アクセス権限 database.rules.json
ローカル接続・配信の設定 lib/services/firebase_emulator_config.dart、firebase.json

理解を確認しよう

  • 入力ID・uid・表示名は、それぞれ何に使われるでしょうか?

  • authStateChanges()とonValueは、それぞれ何の変化を受け取るでしょうか?

  • 相手の端末に送るのは画面の画像でしょうか、それとも状態データでしょうか?

  • onValueで購読する方法と、get()で取得する方法はどう違うでしょうか?

  • レート確定の要求とお問い合わせ送信では、Cloud Functionsの起動方法がどう違うでしょうか?

  • 無人ルームの「非活動5分」と「60分ごとの点検」は、どう違うでしょうか?

  • 開始前の空ルームや観戦者だけのルームは、無人5分の条件で消えるでしょうか?

  • Flutterでボタンを押せなくするだけでは、なぜデータの書き換えを防げないのでしょうか?