第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に保存します。
入力した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を組み立てるために使う固定ドメインです。利用者の実際の連絡先メールアドレスを入力させる設計ではありません。
登録・ログイン時の処理
AuthPage._submit()がidとpasswordを読み取ります。入力IDは3〜24文字の半角英数字と_、パスワードは6文字以上かを画面側で確認します。- 登録なら
registerWithIdAndPassword()、ログインならsignInWithIdAndPassword()を呼びます。 FirebaseAuthServiceがidToEmail(id)でメール形式に変換し、Firebase Auth SDKへ渡します。- Authenticationが登録または認証を行い、成功時に
UserCredentialを返します。FlutterはFirebaseAuth.instance.currentUser?.uidでも利用者のUIDを取得できます。 - 失敗した場合、
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へ更新を書く → 自分と相手が変更を受け取る → 画面を描き直す流れです。
カード操作から画面更新まで
game_room_page.dartで、順番やGameRules.canPlayNormal()などの条件を確認します。_executePlay(cards)がmyHandから出したカードを取り除き、更新する状態を作ります。FirebaseDB.updateGameStatus()がrooms/{roomId}へupdate()で書き込みます。- 各端末の
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試合目の終了後は次の試合へ進み、規定試合の終了後にレートを確定します。
1試合が終わったとき
moriPhase == 'finished'、またはburstPlayerIdがあることが試合終了の判定に使われます。completedMatchesは終了した試合数、totalMatchesは設定した試合数です。
| フィールド | 終了後に表すもの |
|---|---|
playerPoints |
プレイヤーごとの得点 |
completedMatches / totalMatches |
終了済み試合数/規定試合数 |
postGameActive |
試合後の状態に入っているか |
seriesNextMatchAt |
次の試合を開始する予定時刻 |
seriesRestarting |
次の試合へ切り替える処理中か |
FunctionsのonRoomMatchEndedPhaseやonRoomBurstPlayerからも進行管理が呼ばれます。processRoomSteward()は接続中の人間がいないなどの条件を確認し、得点処理や次試合への進行、精算要求を補います。端末側とサーバー側が無条件に同じ処理を重ねて実行するわけではありません。
規定試合が終わったときのレート確定
- 精算を担当するFlutterが
requestSeriesSettlement()を呼び、ルームにsettlementRequested: trueを書きます。不在時にはサーバー側の進行管理も精算を要求します。 /rooms/{roomId}/settlementRequestedを監視するonRoomSettlementRequestedが、値がtrueになったことを確認します。settleRoomSeries(db, roomId)が、試合終了・規定試合数・適用済みフラグを確認し、playerPointsなどからレートを計算します。ratings/{playerId}を更新し、ルームへ確定結果を書き戻します。- 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. ルーム管理
接続情報を更新する処理と、不要なルームを削除する処理を分けます。「誰もいなくなった」だけで直ちにルームを削除する実装ではありません。
接続中・切断・再接続を記録する
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を開きましょう。
- アカウントを作り、Authenticationに利用者が追加されることを確認する。入力したID、内部のメール形式、UIDを見比べ、ログアウト・再ログインでロビーとログイン画面が切り替わることも確かめる。
- ルームを作り、Databaseの
roomsとroomSummariesを見る。 - 別のブラウザーセッションで別アカウントを使って参加し、両方の画面を並べる。
- カードを出し、
field・playerCards・currentTurnIndexと相手の画面が変わることを確認する。 - 一方を閉じ、切断検知後に
presenceとafkPlayerIdsが変わる様子を見る。 - 規定の試合を終え、
settlementRequested、Functionsのログ、ratingsの変化を追う。 - 切断後のルームについて、
presence・spectators・時刻を読み、上の表で削除対象か判定する。ローカルでエミュレーターを起動するだけでは、本番の定期スケジュールによる実行は再現されないため、5分・60分待つだけの確認にはしない。
簡易版を作るなら、この順番で
- Flutterだけで画面を作る。 固定の手札と場を表示し、ボタンで場が変わるところまで作ります。
- Authenticationをつなぐ。 ログインして
uidを取得します。 - 一つのルームを共有する。 場のカードを
update()で保存し、もう一方の画面でonValueを購読します。 - 対戦状態を増やす。 参加者、手札、順番を追加し、同時操作と書き込み権限を考えます。
- サーバー側の処理を追加する。 まず終了後の集計を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でボタンを押せなくするだけでは、なぜデータの書き換えを防げないのでしょうか?