🎮

Flutter + Riverpod でリアルタイムゲームを作る:タイミングゲームの状態管理

に公開

はじめに

この記事は「Flutter + Riverpod 実践ガイド:タスク管理アプリで学ぶ状態管理の設計パターン」の続編です。

前回はタスク管理アプリを題材にRiverpodの基本的な使い方を解説しました。今回は視点を変えて、リアルタイムゲームにおける状態管理を扱います。

この記事で学べること

  • タイミングゲーム特有の状態設計パターン
  • 時間ベースの判定ロジックの実装方法
  • NotifierとAsyncNotifierの使い分け基準
  • メモリリークを防ぐ履歴管理の技法
  • Maestroを使った自動UIテスト

対象読者

  • Riverpodの基本を理解している方(前回記事を読んだレベル)
  • ゲームやインタラクティブアプリを作りたい方
  • 時間依存の処理を扱う必要がある方

題材:おじさんバウンス

本記事では「おじさんバウンス」というカジュアルゲームを題材にします。

ゲームの流れ:

  1. サイコロを振って駅間を移動
  2. 確率でおじさんと遭遇
  3. 最適なタイミングでタップして撃退
  4. タイミング判定に応じてポイント獲得

完全なソースコードは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つの柱

  1. 状態設計(Static): Freezedでイミュータブルなデータモデル
  2. 状態遷移(Dynamic): GamePhaseとNotifierで明確な制御
  3. ゲームロジック(Business Logic): 純粋関数でテスト可能に
  4. 品質保証(QA): パフォーマンス最適化とメモリ管理
  5. 自動テスト: ユニット・統合・E2Eの3層構造

前回記事との使い分け

  • タスク管理アプリ: CRUD操作中心のビジネスアプリ
  • おじさんバウンス: 時間制御中心のリアルタイムアプリ

両方を読むことで、Riverpodの応用範囲が深く理解できます。

次のステップ

完全なソースコードはGitHubで公開しています。

  1. リポジトリをクローン
  2. 実際に動かして試す
  3. 自分のゲームに応用する

質問や改善提案があれば、GitHubのIssueでお待ちしています!


参考資料

公式ドキュメント

関連記事

音ゲーの判定参考

  • 太鼓の達人: ±33ms(PERFECT)
  • プロジェクトセカイ: ±50ms(PERFECT)

Discussion