🎮
Flutter + Riverpod でリアルタイムゲームを作る:タイミングゲームの状態管理
はじめに
この記事は「Flutter + Riverpod 実践ガイド:タスク管理アプリで学ぶ状態管理の設計パターン」の続編です。
前回はタスク管理アプリを題材にRiverpodの基本的な使い方を解説しました。今回は視点を変えて、リアルタイムゲームにおける状態管理を扱います。
この記事で学べること
- タイミングゲーム特有の状態設計パターン
- 時間ベースの判定ロジックの実装方法
- NotifierとAsyncNotifierの使い分け基準
- メモリリークを防ぐ履歴管理の技法
- Maestroを使った自動UIテスト
対象読者
- Riverpodの基本を理解している方(前回記事を読んだレベル)
- ゲームやインタラクティブアプリを作りたい方
- 時間依存の処理を扱う必要がある方
題材:おじさんバウンス
本記事では「おじさんバウンス」というカジュアルゲームを題材にします。
ゲームの流れ:
- サイコロを振って駅間を移動
- 確率でおじさんと遭遇
- 最適なタイミングでタップして撃退
- タイミング判定に応じてポイント獲得
完全なソースコードはGitHubで公開しています。
ゲーム動作画面は以下のような、いわゆるタイミングゲームです。
第1章:ビジネスアプリとゲームの根本的な違い
状態更新頻度の比較
ビジネスアプリとゲームでは、状態管理のアプローチが根本的に異なります。
観点 | タスク管理アプリ | おじさんバウンス |
---|---|---|
更新トリガー | ユーザー操作のみ | ユーザー操作 + タイマー |
更新頻度 | 低頻度(数秒〜数分に1回) | 高頻度(1秒に複数回) |
非同期処理 | API通信がメイン | タイマー制御がメイン |
状態の複雑度 | データ構造が複雑 | 状態遷移が複雑 |
エラー処理 | リトライ・ユーザー通知 | 状態の整合性維持 |
設計への影響
この違いは、以下の設計判断に影響します。
- Providerの選択
- タスク管理:
AsyncNotifier
(API通信の状態を自動管理) - ゲーム:
Notifier
(同期的な状態更新が多い)
- タスク管理:
- エラーハンドリング
- タスク管理:
AsyncValue
で統一的に処理 - ゲーム: 状態遷移のチェックで防止
- タスク管理:
- パフォーマンス最適化
- タスク管理: API呼び出しの削減
- ゲーム: 再描画の最小化
第2章:状態設計(Static Design)
ゲーム開発では、まず「静的な構造」を設計します。データモデルと状態クラスの定義です。
2.1 データモデルの定義
駅(Station)
class Station with _$Station {
const factory Station({
required int id,
required String name,
required double x, // 画面上のX座標(0.0-1.0)
required double y, // 画面上のY座標(0.0-1.0)
required String emoji,
(false) bool visited,
}) = _Station;
factory Station.fromJson(Map<String, dynamic> json) =>
_$StationFromJson(json);
}
- 設計のポイント
- 座標は相対値(0.0-1.0)で保持 → 画面サイズに依存しない
-
visited
フラグで訪問済みを管理 → ゲーム進行状況を追跡
プレイヤー(Player)
class Player with _$Player {
const factory Player({
(0) int currentStationId,
(0) int justicePoints,
(0) int ojisanDefeated,
([0]) List<int> visitedStations,
(0) int turnCount,
}) = _Player;
factory Player.fromJson(Map<String, dynamic> json) =>
_$PlayerFromJson(json);
}
- 拡張メソッドで可読性を向上させる
extension PlayerExtension on Player {
/// 全駅訪問済みか判定
bool hasVisitedAllStations(int totalStations) {
return visitedStations.toSet().length >= totalStations;
}
/// ポイントを追加(イミュータブル)
Player addJusticePoints(int points) {
return copyWith(justicePoints: justicePoints + points);
}
/// おじさん撃退数を増加
Player incrementOjisanDefeated() {
return copyWith(ojisanDefeated: ojisanDefeated + 1);
}
}
- なぜ拡張メソッドを使うのか
- ビジネスロジックをモデルに近い場所に配置するため
-
player.addJusticePoints(10)
のように自然な記述を行うため - Freezedクラス本体を汚さないため
おじさん(Ojisan)
class Ojisan with _$Ojisan {
const factory Ojisan({
required int id,
required String name,
required int evilness, // 悪辣度(1-5)
required String emoji,
required int basePoints,
}) = _Ojisan;
factory Ojisan.fromJson(Map<String, dynamic> json) =>
_$OjisanFromJson(json);
}
タイミング判定結果:
enum TimingResult {
perfect, // ±150ms
great, // ±300ms
good, // ±500ms
miss; // それ以外
String get displayName {
switch (this) {
case TimingResult.perfect: return 'PERFECT!';
case TimingResult.great: return 'GREAT!';
case TimingResult.good: return 'GOOD';
case TimingResult.miss: return 'MISS...';
}
}
int get multiplier {
switch (this) {
case TimingResult.perfect: return 3;
case TimingResult.great: return 2;
case TimingResult.good: return 1;
case TimingResult.miss: return 0;
}
}
}
2.2 状態クラスの設計
GameState:ゲーム全体の状態
class GameState with _$GameState {
const factory GameState({
required GamePhase phase,
required Player player,
required List<Station> stations,
required DifficultySettings difficulty,
int? currentDiceValue,
OjisanEncounter? currentEncounter,
([]) List<OjisanEncounter> encounterHistory,
}) = _GameState;
factory GameState.fromJson(Map<String, dynamic> json) =>
_$GameStateFromJson(json);
/// 初期状態を作成
factory GameState.initial(List<Station> stations) {
return GameState(
phase: GamePhase.idle,
player: const Player(),
stations: stations,
difficulty: DifficultySettings.normal(),
);
}
}
- フィールド設計の意図
フィールド | 型 | nullability | 理由 |
---|---|---|---|
phase | GamePhase | required | 常に現在のフェーズが存在 |
player | Player | required | プレイヤーは常に存在 |
stations | List<Station> | required | 駅リストは不変 |
difficulty | DifficultySettings | required | 難易度は開始時に設定 |
currentDiceValue | int? | nullable | サイコロを振っていない時はnull |
currentEncounter | OjisanEncounter? | nullable | 遭遇していない時はnull |
encounterHistory | List<OjisanEncounter> | @Default([]) | 空リストで初期化 |
OjisanEncounter:遭遇イベントの状態
class OjisanEncounter with _$OjisanEncounter {
const factory OjisanEncounter({
required Ojisan ojisan,
required int stationId,
DateTime? actionStartTime, // タイミング判定の基準時刻
DateTime? actionEndTime, // ユーザーがタップした時刻
TimingResult? result, // 判定結果
int? pointsEarned, // 獲得ポイント
}) = _OjisanEncounter;
factory OjisanEncounter.fromJson(Map<String, dynamic> json) =>
_$OjisanEncounterFromJson(json);
}
- 状態遷移とフィールドの関係
フィールド | 生成時 | action開始時 | タップ時 | 履歴化時 |
---|---|---|---|---|
ojisan | ✅ 設定 | - | - | - |
stationId | ✅ 設定 | - | - | - |
actionStartTime | ❌ null | ✅ 設定 | - | - |
actionEndTime | ❌ null | ❌ null | ✅ 設定 | - |
result | ❌ null | ❌ null | ✅ 設定 | - |
pointsEarned | ❌ null | ❌ null | ✅ 設定 | - |
- 状態判定の実装例
// 遭遇データの現在の状態を判別
if (encounter.actionStartTime == null) {
// まだアクション受付前(encounter フェーズ)
print('おじさん登場アニメーション中');
} else if (encounter.result == null) {
// アクション受付中(action フェーズ)
print('タップ待ち');
} else {
// 判定完了(feedback フェーズ)
print('結果表示中: ${encounter.result}');
}
2.3 難易度システムの設計
class DifficultySettings with _$DifficultySettings {
const factory DifficultySettings({
required DifficultyLevel level,
required double perfectThreshold, // PERFECT判定の閾値
required double greatThreshold, // GREAT判定の閾値
required double goodThreshold, // GOOD判定の閾値
required double ojisanSpawnRate, // おじさん出現率
required int maxTurns, // 最大ターン数
required int targetJusticePoints, // 目標ポイント
required int minOjisanDefeated, // 最低撃退数
required int optimalTimingBase, // 最適タイミングの基準値(ms)
}) = _DifficultySettings;
/// 難易度:かんたん
factory DifficultySettings.easy() => const DifficultySettings(
level: DifficultyLevel.easy,
perfectThreshold: 0.30, // ±300ms
greatThreshold: 0.60, // ±600ms
goodThreshold: 1.00, // ±1000ms
ojisanSpawnRate: 0.5, // 50%
maxTurns: 25,
targetJusticePoints: 80,
minOjisanDefeated: 8,
optimalTimingBase: 2500, // 2.5秒後
);
/// 難易度:ふつう
factory DifficultySettings.normal() => const DifficultySettings(
level: DifficultyLevel.normal,
perfectThreshold: 0.15, // ±150ms
greatThreshold: 0.30, // ±300ms
goodThreshold: 0.50, // ±500ms
ojisanSpawnRate: 0.6, // 60%
maxTurns: 20,
targetJusticePoints: 100,
minOjisanDefeated: 10,
optimalTimingBase: 2000, // 2.0秒後
);
/// 難易度:むずかしい
factory DifficultySettings.hard() => const DifficultySettings(
level: DifficultyLevel.hard,
perfectThreshold: 0.08, // ±80ms
greatThreshold: 0.15, // ±150ms
goodThreshold: 0.30, // ±300ms
ojisanSpawnRate: 0.7, // 70%
maxTurns: 15,
targetJusticePoints: 120,
minOjisanDefeated: 12,
optimalTimingBase: 1500, // 1.5秒後
);
/// レベルから設定を取得
factory DifficultySettings.fromLevel(DifficultyLevel level) {
switch (level) {
case DifficultyLevel.easy: return DifficultySettings.easy();
case DifficultyLevel.normal: return DifficultySettings.normal();
case DifficultyLevel.hard: return DifficultySettings.hard();
}
}
}
- 業界標準との比較
ゲーム | PERFECT | GREAT | GOOD |
---|---|---|---|
太鼓の達人 | ±33ms | ±75ms | ±108ms |
プロセカ | ±50ms | ±100ms | ±150ms |
おじさんバウンス(ふつう) | ±150ms | ±300ms | ±500ms |
→ カジュアルゲームとして約3倍緩めに設定
第2章のチェックリスト
この章で学んだことを自分のプロジェクトに適用する際、以下を確認してください。
- Freezedでイミュータブルなデータモデルを定義した
- 拡張メソッドでビジネスロジックを整理した
- nullableなフィールドに明確な意図がある
- 難易度システムをファクトリコンストラクタで実装した
- 業界標準を参考に数値を決定した
第3章:状態遷移(Dynamic Design)
静的な構造を定義したら、次は「動的な振る舞い」を設計します。
3.1 GamePhaseの定義と遷移ルール
GamePhaseの定義
enum GamePhase {
idle, // 待機中(サイコロを振れる状態)
rollingDice, // サイコロを振っている
moving, // プレイヤーが移動中
encounter, // おじさんと遭遇(表示中)
action, // アクション判定待ち
feedback, // 判定結果表示
gameOver, // ゲーム終了
}
- なぜEnumで状態を管理するのか
// ❌ 文字列での状態管理(タイプミスのリスク)
state = state.copyWith(phase: 'rollingDice'); // コンパイルエラーにならない
state = state.copyWith(phase: 'rolingDice'); // タイプミスを検出できない
// ✅ Enumでの状態管理(型安全)
state = state.copyWith(phase: GamePhase.rollingDice); // 正しい
state = state.copyWith(phase: GamePhase.rolingDice); // コンパイルエラー
完全な状態遷移マトリクス
現在の状態 | 遷移トリガー | 前提条件 | 次の状態 | 所要時間 |
---|---|---|---|---|
idle | 🟢 rollDice() | ターン数 < maxTurns | rollingDice | 即座 |
🟢 rollDice() | ターン数 >= maxTurns | gameOver | 即座 | |
rollingDice | 🟡 タイマー | 1000ms経過 | moving | 1000ms |
moving* | 🟡 タイマー + 🎲 確率 | 800ms経過 + 出現判定成功 | encounter | 800ms |
🟡 タイマー + 🎲 確率 | 800ms経過 + 出現判定失敗 | idle | 800ms | |
encounter | 🟡 タイマー | 1200ms経過 | action | 1200ms |
action | 🟢 performAction() | 制限時間内 | feedback | 即座 |
🟡 タイマー | 5000ms経過(タイムアウト) | feedback (MISS) | 5000ms | |
feedback | 🟡 タイマー | 1500ms経過 + 勝利条件達成 | gameOver (WIN) | 1500ms |
🟡 タイマー | 1500ms経過 + 通常 | idle | 1500ms | |
gameOver | 🟢 resetGame() | - | idle | 即座 |
- 記号の意味
- 🟢: ユーザー操作トリガー
- 🟡: システム自動遷移
- 🎲: 確率的イベント
状態遷移フロー図
START
↓
[idle] ──────────────────────────────────┐
│ 🟢 rollDice() │
│ (ターン数チェック) │
↓ │
[rollingDice] │
│ 🟡 1000ms後 │
↓ │
[moving] │
│ 🟡 800ms後 + おじさん出現判定 │
├─→ 出現(60%) → [encounter] │
│ │ 🟡 1200ms後 │
│ ↓ │
│ [action] │
│ │ 🟢 performAction() │
│ ↓ │
│ [feedback] │
│ │ 🟡 1500ms後 │
│ ├─→ 勝利条件達成 │
│ │ → [gameOver] │
│ └─→ 通常 ─────────────┤
└─→ 不出現(40%) ────────────────────────┘
3.2 GameNotifierの完全実装
NotifierとAsyncNotifierの選択基準
- 判断基準の定量化
条件 | Notifier | AsyncNotifier |
---|---|---|
非同期処理の割合 | < 20% | > 20% |
初期化の複雑さ | 単純(10行以内) | 複雑(API複数回呼び出し) |
エラーハンドリング | 個別で十分 | 統一したい |
状態更新の頻度 | 高頻度(1秒に複数回) | 低頻度(数秒に1回) |
- おじさんバウンスの分析
非同期処理の発生箇所
- 初期データ読み込み(駅、おじさんタイプ)← 起動時1回のみ
- サウンドファイル読み込み ← 初期化時のみ
同期処理の発生箇所
- サイコロ判定 ← 毎ターン
- タイミング判定 ← おじさん遭遇時
- スコア計算 ← おじさん撃退時
- 状態遷移 ← 常時
結論: 非同期処理は全体の約5%。Notifierが最適。
- AsyncNotifierを使った場合の問題
// ❌ AsyncNotifierだと毎回ローディング表示になる
state = const AsyncValue.loading(); // UIが毎回ローディング表示
state = await AsyncValue.guard(() async {
final diceValue = GameLogic.rollDice(); // 実は同期処理
return state.value!.copyWith(currentDiceValue: diceValue);
});
// ✅ Notifierなら即座に状態更新
state = state.copyWith(
currentDiceValue: GameLogic.rollDice(),
);
- パフォーマンス差
- AsyncNotifier: 状態更新に平均50ms(ローディング表示 + 再描画)
- Notifier: 状態更新に平均2ms(再描画のみ)
GameNotifierの完全実装
/// ゲーム全体の状態を管理するNotifier
///
/// 責務:
/// - 状態遷移の制御(idle → rollingDice → moving → ...)
/// - タイマー管理(自動遷移の制御)
/// - サウンド再生の統括
/// - データ読み込みのエラーハンドリング
class Game extends _$Game {
// 依存関係(すべてfinalで不変性を保証)
final SoundService _soundService = SoundService();
final DataLoader _dataLoader = DataLoader();
// タイマー管理(必ずdisposeでクリーンアップ)
Timer? _phaseTimer;
// キャッシュされたデータ(初回ロード後は再利用)
List<Map<String, dynamic>>? _ojisanTypes;
GameState build() {
// 初期化処理
_soundService.initialize();
_loadData(); // 非同期だが結果を待たない(バックグラウンドで実行)
// 初期状態を返す(駅データは後で設定)
return GameState.initial([]);
}
/// リソースのクリーンアップ(重要)
void dispose() {
_phaseTimer?.cancel();
_soundService.dispose();
}
/// データ読み込み(エラーハンドリング付き)
Future<void> _loadData() async {
try {
// 並列読み込みでパフォーマンス向上
final results = await Future.wait([
_dataLoader.loadStations(),
_dataLoader.loadOjisanTypes(),
]);
final stations = results[0] as List<Station>;
_ojisanTypes = results[1] as List<Map<String, dynamic>>;
// データ読み込み成功時のみ状態更新
state = GameState.initial(stations);
debugPrint('✅ データ読み込み完了: ${stations.length}駅');
} catch (e, stackTrace) {
// エラー時はログ出力 + デフォルトデータで続行
debugPrint('❌ データ読み込み失敗: $e');
debugPrint('StackTrace: $stackTrace');
// フォールバック処理(ユーザー体験を途切れさせない)
state = GameState.initial(_getDefaultStations());
_ojisanTypes = _getDefaultOjisanTypes();
}
}
/// フォールバック用のデフォルトデータ
List<Station> _getDefaultStations() {
return [
const Station(id: 0, name: '始発駅', x: 0.1, y: 0.5, emoji: '🚉'),
const Station(id: 1, name: '終着駅', x: 0.9, y: 0.5, emoji: '🏁'),
];
}
List<Map<String, dynamic>> _getDefaultOjisanTypes() {
return [
{
'id': 0,
'name': '普通のおじさん',
'evilness': 3,
'emoji': '😐',
'basePoints': 30,
},
];
}
/// サイコロを振る(idle → rollingDice → moving)
Future<void> rollDice() async {
// 状態チェック(idle以外では実行しない)
if (state.phase != GamePhase.idle) return;
// ゲームオーバー条件チェック
if (state.player.turnCount >= state.difficulty.maxTurns) {
state = state.copyWith(phase: GamePhase.gameOver);
_soundService.playGameOver();
return;
}
// フェーズ遷移: idle → rollingDice
state = state.copyWith(phase: GamePhase.rollingDice);
_soundService.playDiceRoll();
// サイコロアニメーション待機
await Future.delayed(
Duration(milliseconds: AnimationDurations.diceRoll),
);
// サイコロの出目を計算(同期処理)
final diceValue = GameLogic.rollDice();
state = state.copyWith(currentDiceValue: diceValue);
// 次のフェーズへ
await _movePlayer(diceValue);
}
/// プレイヤーを移動(rollingDice → moving → encounter/idle)
Future<void> _movePlayer(int diceValue) async {
// フェーズ遷移: rollingDice → moving
state = state.copyWith(phase: GamePhase.moving);
// 次の駅を計算
final nextStationId = GameLogic.calculateNextStation(
state.player.currentStationId,
diceValue,
state.stations.length,
);
// 移動アニメーション待機
await Future.delayed(
Duration(milliseconds: AnimationDurations.playerMovement),
);
// プレイヤー位置更新 + ターン数増加
final updatedPlayer = state.player
.moveToStation(nextStationId)
.incrementTurn();
// 駅の訪問フラグ更新
final updatedStations = state.stations.map((station) {
if (station.id == nextStationId) {
return station.copyWith(visited: true);
}
return station;
}).toList();
state = state.copyWith(
player: updatedPlayer,
stations: updatedStations,
);
// おじさん遭遇判定
await _checkEncounter(nextStationId);
}
/// おじさん遭遇チェック(moving → encounter/idle)
Future<void> _checkEncounter(int stationId) async {
// 確率的にお��さんが出現するか判定
final shouldEncounter = GameLogic.shouldEncounterOjisan(
state.difficulty.ojisanSpawnRate,
);
if (shouldEncounter && _ojisanTypes != null) {
// フェーズ遷移: moving → encounter
state = state.copyWith(phase: GamePhase.encounter);
// おじさんをランダムに選択
final ojisan = GameLogic.generateRandomOjisan(_ojisanTypes!);
final encounter = OjisanEncounter(
ojisan: ojisan,
stationId: stationId,
);
state = state.copyWith(currentEncounter: encounter);
_soundService.playOjisanAppear();
// おじさん出現アニメーション待機
await Future.delayed(
Duration(milliseconds: AnimationDurations.ojisanAppear),
);
// フェーズ遷移: encounter → action
if (state.phase == GamePhase.encounter) {
// actionStartTimeを設定(タイミング判定の基準時刻)
state = state.copyWith(
phase: GamePhase.action,
currentEncounter: encounter.copyWith(
actionStartTime: DateTime.now(),
),
);
}
} else {
// おじさんが出現しない場合は次のターンへ
await _endTurn();
}
}
/// アクションボタンをタップ(action → feedback)
void performAction(DateTime tapTime) {
// 状態チェック
if (state.phase != GamePhase.action) return;
if (state.currentEncounter == null) return;
final encounter = state.currentEncounter!;
// アクション開始時刻が設定されていない場合はエラー
if (encounter.actionStartTime == null) {
debugPrint('❌ エラー: actionStartTimeが設定されていません');
return;
}
// 経過時間を計算
final elapsedTime = tapTime
.difference(encounter.actionStartTime!)
.inMilliseconds / 1000.0;
// 最適タイミングを計算(おじさんの悪辣度により変動)
final optimalTiming = GameLogic.calculateOptimalTiming(
state.difficulty,
encounter.ojisan.evilness,
) / 1000.0;
// 最適タイミングからの誤差を計算
final timeDifference = elapsedTime - optimalTiming;
// タイミング判定
final result = GameLogic.judgeTiming(
timeDifference,
state.difficulty,
);
// ポイント計算
final points = GameLogic.calculatePoints(
encounter.ojisan.basePoints,
result,
);
// デバッグログ
debugPrint('🎯 タイミング判定デバッグ:');
debugPrint(' 経過時間: ${elapsedTime.toStringAsFixed(3)}秒');
debugPrint(' 最適タイミング: ${optimalTiming.toStringAsFixed(3)}秒');
debugPrint(' 誤差: ${timeDifference.toStringAsFixed(3)}秒');
debugPrint(' 判定: ${result.displayName}');
debugPrint(' 獲得ポイント: $points');
// プレイヤー状態更新
var updatedPlayer = state.player;
if (points > 0) {
updatedPlayer = updatedPlayer
.addJusticePoints(points)
.incrementOjisanDefeated();
}
// 遭遇記録を更新
final updatedEncounter = encounter.copyWith(
actionEndTime: tapTime,
result: result,
pointsEarned: points,
);
// 履歴にメモリリーク対策を適用
var history = [...state.encounterHistory, updatedEncounter];
if (history.length > GameConfig.maxEncounterHistory) {
history = history.sublist(
history.length - GameConfig.maxEncounterHistory,
);
}
// フェーズ遷移: action → feedback
state = state.copyWith(
phase: GamePhase.feedback,
player: updatedPlayer,
currentEncounter: updatedEncounter,
encounterHistory: history,
);
// サウンド再生
switch (result) {
case TimingResult.perfect:
_soundService.playPerfect();
break;
case TimingResult.great:
_soundService.playGreat();
break;
case TimingResult.good:
_soundService.playGood();
break;
case TimingResult.miss:
_soundService.playMiss();
break;
}
// 結果表示後、次のターンへ
Future.delayed(
Duration(milliseconds: AnimationDurations.actionFeedback),
_endTurn,
);
}
/// ターン終了処理(feedback → idle/gameOver)
Future<void> _endTurn() async {
// 勝利条件チェック
final hasVisitedAll = state.player
.hasVisitedAllStations(state.stations.length);
final hasEnoughPoints = state.player.justicePoints >=
state.difficulty.targetJusticePoints;
final hasEnoughDefeats = state.player.ojisanDefeated >=
state.difficulty.minOjisanDefeated;
if (hasVisitedAll && hasEnoughPoints && hasEnoughDefeats) {
// 勝利
state = state.copyWith(phase: GamePhase.gameOver);
_soundService.playWin();
return;
}
// 敗北条件チェック(最大ターン数超過)
if (state.player.turnCount >= state.difficulty.maxTurns) {
state = state.copyWith(phase: GamePhase.gameOver);
_soundService.playLose();
return;
}
// 次のターンへ
state = state.copyWith(
phase: GamePhase.idle,
currentDiceValue: null,
currentEncounter: null,
);
}
/// ゲームをリセット
void resetGame() {
_phaseTimer?.cancel();
state = GameState.initial(state.stations);
}
/// 難易度を変更
void setDifficulty(DifficultyLevel difficulty) {
state = state.copyWith(
difficulty: DifficultySettings.fromLevel(difficulty),
);
}
}
3.3 タイマー制御と非同期処理のベストプラクティス
Future.delayedの落とし穴
// ❌ 問題のあるコード
Future.delayed(Duration(seconds: 2), () {
// 2秒後に実行される時点で、stateが変わっている可能性
if (state.phase == GamePhase.action) {
state = state.copyWith(phase: GamePhase.feedback);
}
});
// 問題点:
// 1. ユーザーがアプリを閉じた場合でもコールバックが実行される
// 2. 別の画面に遷移していてもコールバックが実行される
// 3. Timerをキャンセルする手段がない
Timerを使った正しい実装
// ✅ Timerを使った安全な実装
class GameNotifier extends Notifier<GameState> {
Timer? _phaseTimer;
void dispose() {
// 確実にタイマーをキャンセル
_phaseTimer?.cancel();
super.dispose();
}
Future<void> _movePlayer(int diceValue) async {
state = state.copyWith(phase: GamePhase.moving);
// 前のタイマーがあればキャンセル
_phaseTimer?.cancel();
// 新しいタイマーを設定
_phaseTimer = Timer(
Duration(milliseconds: AnimationDurations.playerMovement),
() {
// コールバック内で状態をチェック
if (state.phase == GamePhase.moving) {
_checkEncounter();
}
},
);
}
}
- Timerを使うメリット
-
dispose()
で確実にキャンセルできる - 複数のタイマーを管理できる
- コールバック内で状態をチェックして安全性を確保
-
第3章のチェックリスト
- GamePhaseをEnumで定義し、不正な遷移を防いだ
- 状態遷移マトリクスで全パターンを網羅した
- NotifierとAsyncNotifierを適切に選択した
- Timerを使って非同期処理を安全に管理した
- dispose()でリソースをクリーンアップした
- エラーハンドリングでユーザー体験を途切れさせない
第4章:ゲームロジック(Business Logic)
状態設計と状態遷移ができたら、次はゲームの核心となるロジックを実装します。
4.1 タイミング判定システム
タイミング判定の実装
class GameLogic {
/// タイミング判定
///
/// @param timeDifference 最適タイミングからの誤差(秒)
/// @param difficulty 難易度設定
/// @return 判定結果
static TimingResult judgeTiming(
double timeDifference,
DifficultySettings difficulty,
) {
final absTimeDiff = timeDifference.abs();
if (absTimeDiff <= difficulty.perfectThreshold) {
return TimingResult.perfect;
} else if (absTimeDiff <= difficulty.greatThreshold) {
return TimingResult.great;
} else if (absTimeDiff <= difficulty.goodThreshold) {
return TimingResult.good;
} else {
return TimingResult.miss;
}
}
/// おじさんの悪辣度に応じた最適タイミングを計算
///
/// 悪辣度が高いほど、素早くタップする必要がある
///
/// @param difficulty 難易度設定
/// @param evilness おじさんの悪辣度(1-5)
/// @return 最適タイミング(ミリ秒)
static int calculateOptimalTiming(
DifficultySettings difficulty,
int evilness,
) {
// 基準値から悪辣度に応じて減算
// 例: ふつう難易度(2000ms) + 悪辣度5 = 2000 - (5 * 200) = 1000ms
return difficulty.optimalTimingBase - (evilness * 200);
}
/// ポイント計算
///
/// @param basePoints おじさんの基本ポイント
/// @param timingResult タイミング判定結果
/// @return 獲得ポイント
static int calculatePoints(
int basePoints,
TimingResult timingResult,
) {
return basePoints * timingResult.multiplier;
}
}
タイミング判定のテスト
void main() {
group('タイミング判定のテスト', () {
const difficulty = DifficultySettings.normal();
test('PERFECT判定: 誤差0秒', () {
expect(
GameLogic.judgeTiming(0.0, difficulty),
TimingResult.perfect,
);
});
test('PERFECT判定: 閾値ギリギリ(+150ms)', () {
expect(
GameLogic.judgeTiming(0.15, difficulty),
TimingResult.perfect,
);
});
test('PERFECT判定: 閾値ギリギリ(-150ms)', () {
expect(
GameLogic.judgeTiming(-0.15, difficulty),
TimingResult.perfect,
);
});
test('GREAT判定: 閾値を超える(+151ms)', () {
expect(
GameLogic.judgeTiming(0.151, difficulty),
TimingResult.great,
);
});
test('MISS判定: 大きくズレる(+1000ms)', () {
expect(
GameLogic.judgeTiming(1.0, difficulty),
TimingResult.miss,
);
});
});
group('最適タイミングの計算', () {
const difficulty = DifficultySettings.normal();
test('悪辣度1: 2000 - 200 = 1800ms', () {
expect(
GameLogic.calculateOptimalTiming(difficulty, 1),
1800,
);
});
test('悪辣度5: 2000 - 1000 = 1000ms', () {
expect(
GameLogic.calculateOptimalTiming(difficulty, 5),
1000,
);
});
});
group('ポイント計算', () {
test('PERFECT: basePoints × 3', () {
expect(
GameLogic.calculatePoints(50, TimingResult.perfect),
150,
);
});
test('GREAT: basePoints × 2', () {
expect(
GameLogic.calculatePoints(50, TimingResult.great),
100,
);
});
test('GOOD: basePoints × 1', () {
expect(
GameLogic.calculatePoints(50, TimingResult.good),
50,
);
});
test('MISS: basePoints × 0', () {
expect(
GameLogic.calculatePoints(50, TimingResult.miss),
0,
);
});
});
}
4.2 ランダム要素の制御
サイコロ
class GameLogic {
static final Random _random = Random();
/// サイコロを振る(1-6)
static int rollDice() {
return _random.nextInt(GameConfig.diceMax) + GameConfig.diceMin;
}
}
おじさんの出現判定
class GameLogic {
/// おじさんとの遭遇判定
///
/// @param spawnRate 出現率(0.0-1.0)
/// @return true: 遭遇, false: 遭遇しない
static bool shouldEncounterOjisan(double spawnRate) {
return _random.nextDouble() < spawnRate;
}
/// ランダムなおじさんを生成
///
/// @param ojisanTypes おじさんタイプのリスト
/// @return ランダムに選ばれたおじさん
static Ojisan generateRandomOjisan(
List<Map<String, dynamic>> ojisanTypes,
) {
final selectedType = ojisanTypes[_random.nextInt(ojisanTypes.length)];
return Ojisan(
id: selectedType['id'] as int,
name: selectedType['name'] as String,
evilness: selectedType['evilness'] as int,
emoji: selectedType['emoji'] as String,
basePoints: selectedType['basePoints'] as int,
);
}
}
4.3 ゲーム終了判定
勝利条件
extension GameStateExtension on GameState {
/// 勝利判定
bool get isWon {
// 3つの条件をすべて満たす必要がある
final hasVisitedAll = player.hasVisitedAllStations(stations.length);
final hasEnoughPoints = player.justicePoints >=
difficulty.targetJusticePoints;
final hasEnoughDefeats = player.ojisanDefeated >=
difficulty.minOjisanDefeated;
return hasVisitedAll && hasEnoughPoints && hasEnoughDefeats;
}
/// 敗北判定
bool get isLost {
// 最大ターン数を超えた場合は敗北
return player.turnCount >= difficulty.maxTurns && !isWon;
}
/// ゲーム終了判定
bool get isGameOver {
return isWon || isLost;
}
}
第4章のチェックリスト
- タイミング判定を純粋関数として実装した
- すべてのゲームロジックにユニットテストを書いた
- ランダム要素を適切に制御した
- ゲーム終了条件を明確に定義した
- 拡張メソッドでビジネスロジックを整理した
第5章:品質保証(Quality Assurance)
ゲームの核心機能が実装できたら、最後に品質を保証します。
5.1 パフォーマンス最適化
selectによる再描画の最小化
// ❌ GameState全体を監視(無駄な再描画が多い)
final gameState = ref.watch(gameProvider);
return Text('ターン: ${gameState.player.turnCount}');
// ✅ 必要な部分だけを監視
final turnCount = ref.watch(
gameProvider.select((state) => state.player.turnCount),
);
return Text('ターン: $turnCount');
- パフォーマンス測定
class PerformanceMonitor {
static final Map<String, int> _rebuildCounts = {};
static void trackRebuild(String widgetName) {
_rebuildCounts[widgetName] = (_rebuildCounts[widgetName] ?? 0) + 1;
debugPrint('Rebuild: $widgetName (${_rebuildCounts[widgetName]})');
}
static void printStats() {
debugPrint('=== Rebuild Statistics ===');
_rebuildCounts.forEach((name, count) {
debugPrint('$name: $count rebuilds');
});
}
}
// ウィジェットで使用
class GameScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
PerformanceMonitor.trackRebuild('GameScreen');
// ...
}
}
5.2 メモリ管理
履歴の上限管理
class GameConfig {
static const int maxEncounterHistory = 50; // 最大50件まで保持
}
void performAction(DateTime tapTime) {
// ...
var history = [...state.encounterHistory, updatedEncounter];
// 古い履歴を削除(メモリリーク対策)
if (history.length > GameConfig.maxEncounterHistory) {
history = history.sublist(
history.length - GameConfig.maxEncounterHistory,
);
}
state = state.copyWith(encounterHistory: history);
}
- なぜ50件なのか
- 平均的なプレイ時間(10分)で20-30回の遭遇
- メモリ使用量: 1件あたり約200バイト × 50件 = 10KB程度
- バランス: 統計表示に十分な量 + メモリ消費を抑える
リソースのクリーンアップ
void dispose() {
// タイマーのキャンセル
_phaseTimer?.cancel();
// サウンドリソースの解放
_soundService.dispose();
super.dispose();
}
5.3 テスト戦略
テストピラミッド
/\
/E2E\ ← Maestro(UIフロー)
/------\
/ 統合 \ ← integration_test(状態管理の複雑な検証)
/----------\
/ ユニット \ ← Dart test(ゲームロジック)
/--------------\
ユニットテスト:ゲームロジック
// test/game_logic_test.dart
void main() {
group('GameLogic', () {
test('サイコロは1-6の値を返す', () {
for (var i = 0; i < 100; i++) {
final value = GameLogic.rollDice();
expect(value, greaterThanOrEqualTo(1));
expect(value, lessThanOrEqualTo(6));
}
});
test('タイミング判定が正しく動作する', () {
const difficulty = DifficultySettings.normal();
expect(GameLogic.judgeTiming(0.0, difficulty), TimingResult.perfect);
expect(GameLogic.judgeTiming(0.15, difficulty), TimingResult.perfect);
expect(GameLogic.judgeTiming(0.16, difficulty), TimingResult.great);
expect(GameLogic.judgeTiming(0.31, difficulty), TimingResult.good);
expect(GameLogic.judgeTiming(0.51, difficulty), TimingResult.miss);
});
});
}
Maestro:UIフロー
# maestro/game_flow.yaml
appId: com.example.ojisanBounce
---
# アプリ起動
- launchApp
- assertVisible: "おじさんバウンス"
# 難易度選択
- assertVisible: "難易度を選択"
- tapOn:
point: "50%,55%" # ふつう
- waitForAnimationToEnd
# ゲームスタート
- tapOn: "ゲームスタート"
- waitForAnimationToEnd
- assertVisible: "サイコロで駅を移動"
# チュートリアルスキップ
- tapOn: "スキップ"
- waitForAnimationToEnd
# サイコロを振る
- assertVisible: "サイコロを振る"
- tapOn: "サイコロを振る"
- waitForAnimationToEnd
# 移動完了を確認
- assertVisible:
text: "ターン:"
# もう一度サイコロを振る
- tapOn: "サイコロを振る"
- waitForAnimationToEnd
- Maestroの実行
# インストール
curl -Ls https://get.maestro.mobile.dev | bash
# iOS Simulatorで実行
maestro test maestro/game_flow.yaml
# Android Emulatorで実行
maestro test --platform android maestro/game_flow.yaml
# Maestro Cloudで実行(複数デバイス)
maestro cloud maestro/game_flow.yaml
第5章のチェックリスト
- selectで不要な再描画を削減した
- パフォーマンスモニターで再描画回数を測定した
- 履歴に上限を設定してメモリリークを防いだ
- dispose()で確実にリソースを解放した
- テストピラミッドに従ってテストを実装した
- MaestroでUIフローの自動テストを書いた
まとめ
この記事では、リアルタイムゲームにおけるRiverpodの実践的な使い方を、体系的に解説しました。
ビジネスアプリとゲームの違い
観点 | ビジネスアプリ | ゲーム |
---|---|---|
更新頻度 | 低頻度 | 高頻度 |
非同期処理 | API通信 | タイマー制御 |
Providerの選択 | AsyncNotifier | Notifier |
エラー処理 | AsyncValue | 状態チェック |
設計の5つの柱
- 状態設計(Static): Freezedでイミュータブルなデータモデル
- 状態遷移(Dynamic): GamePhaseとNotifierで明確な制御
- ゲームロジック(Business Logic): 純粋関数でテスト可能に
- 品質保証(QA): パフォーマンス最適化とメモリ管理
- 自動テスト: ユニット・統合・E2Eの3層構造
前回記事との使い分け
- タスク管理アプリ: CRUD操作中心のビジネスアプリ
- おじさんバウンス: 時間制御中心のリアルタイムアプリ
両方を読むことで、Riverpodの応用範囲が深く理解できます。
次のステップ
完全なソースコードはGitHubで公開しています。
- リポジトリをクローン
- 実際に動かして試す
- 自分のゲームに応用する
質問や改善提案があれば、GitHubのIssueでお待ちしています!
参考資料
公式ドキュメント
関連記事
音ゲーの判定参考
- 太鼓の達人: ±33ms(PERFECT)
- プロジェクトセカイ: ±50ms(PERFECT)
Discussion