React で作る堅牢なゲームセーブシステム:localStorage と Reducer を疎結合に保つ設計

React で作る堅牢なゲームセーブシステム:localStorage と Reducer を疎結合に保つ設計 ゲーム開発において、プレイヤーの進行状況を保存する「セーブ / ロード」は最も重要な機能の一つです。Web ブラウザで動作する React アプリケーションの場合、最も手軽な保存先は localStorage です。 しかし、単純に localStorage.setItem をコードのあちこちに散りばめてしまうと、テスタビリティが低下し、データ構造の変更(スキーマ変更)に弱いシステムになってしまいます。 本記事では、架空の RPG 『React Odyssey』を例に、SavePort インターフェースと Reducer の初期化関数を組み合わせた、クリーンで堅牢なセーブ / ロードの実装方法を解説します。 1. 概要:なぜ「直接 localStorage」を避けるのか React の useReducer を使ったゲーム開発では、ゲームの状態(State)は一つの大きなオブジェクトとして管理されます。これを JSON.stringify して localStorage に保存するのは簡単です。 しかし、以下の理由から、ロジックの中に直接 localStorage を書くべきではありません。 副作用の分離: Reducer は純粋関数であるべきです。セーブ処理(副作用)を Reducer の中に入れることはできません。 環境非依存: 将来的に保存先を IndexedDB やクラウド(Firebase 等)に変更したくなったとき、コードを大幅に書き換える必要が出てきます。 テストのしやすさ: localStorage が存在しない Node.js 環境(Vitest 等)でロジックのテストを行う際、モック化が容易である必要があります。 これらを解決するために、Dependency Inversion Principle(依存性逆転の原則) に基づいた設計を採用します。 2. SavePort 設計:抽象化の定義 まずは、ゲームエンジン側が必要とする「セーブ機能」のインターフェースを定義します。これを SavePort と呼びます。 // domain/save/save-port.ts /** * 保存されるデータの構造(スキーマ) * ゲームの現在の状態に加え、メタ情報を付与する */ export interface SaveData { version: number; // セーブデータのバージョン timestamp: string; // 保存日時 state: GameState; // 実際のゲーム状態 } /** * セーブ/ロードに関する抽象インターフェース */ export interface SavePort { /** データを保存する */ save(data: SaveData): Promise<void>; /** データを読み込む。存在しない場合は null を返す */ load(): Promise<SaveData | null>; /** セーブデータが存在するか確認する */ exists(): Promise<boolean>; /** セーブデータを削除する */ clear(): Promise<void>; } なぜインターフェースにするのか? ゲームのメインロジック(UseCase)は、この SavePort を通じて読み書きを行います。この時点では「どうやって保存するか」は知らなくてよく、「保存できる」という事実だけに依存させます。 ...

March 28, 2026 · 4 min

Reducer パターンだけでゲーム状態を管理する:副作用ゼロのアーキテクチャ

Reducer パターンだけでゲーム状態を管理する:副作用ゼロのアーキテクチャ 概要 現代のフロントエンド開発において、Redux や React の useReducer を通じて「Reducer パターン」は広く浸透しました。しかし、このパターンを本格的なゲーム開発、特に複雑なロジックが絡み合う RPG やシミュレーションゲームに適用しようとすると、多くの開発者が「副作用」の壁にぶつかります。 「ダメージ計算に乱数を使いたい」「ゲーム内の経過時間を管理したい」「マスターデータを参照したい」……。 これらの要素を素直に実装すると、Reducer の外側にある状態や関数に依存してしまい、純粋関数としての美しさとテストのしやすさが失われてしまいます。 本記事では、すべての副作用を引数として注入し、gameReducer(state, action, masters, random) という純粋関数のみでゲームの全ロジックを完結させる「副作用ゼロ」のアーキテクチャについて解説します。 課題:なぜゲーム開発で Reducer は敬遠されるのか 一般的な Web アプリケーションの Reducer は単純です。「ボタンを押したらフラグを反転させる」「入力された文字列を状態に保存する」といった操作がメインだからです。 しかし、ゲームでは以下のような「非決定的な要素」や「巨大な静的データ」が頻繁に登場します。 1. 乱数 (Randomness) の扱い クリティカルヒットの判定、モンスターのドロップアイテム、マップの自動生成など、ゲームは乱数の塊です。Reducer の中で Math.random() を呼んだ瞬間、その関数は「同じ入力に対して同じ出力を返す」という純粋性を失います。 2. 静的データ (Master Data) の参照 モンスターのステータス表、アイテムの定義、スキル効果など、ゲームには膨大な「変わらないデータ」があります。これを Reducer の外にあるグローバル変数から参照すると、テスト時にそのグローバル変数の状態も気にしなければならなくなります。 3. 時刻 (Time) と経過の管理 「3時間経過したらスタミナが回復する」「夜になるとモンスターが強くなる」といった時間依存の処理です。new Date() を Reducer で使うのは、乱数と同様に純粋性を破壊します。 4. 非同期処理 (Async/Fetch) サーバーから最新のイベント情報を取得したり、セーブデータをロードしたりする処理です。Reducer は同期的に実行される必要があるため、非同期処理をそのまま中に書くことはできません。 OOP(オブジェクト指向)との比較 クラスベースの OOP では、player.attack(monster) のようにメソッドを呼び出します。これは直感的ですが、内部で this.hp -= damage のように状態を直接書き換えます(ミュータブル)。 小規模なら良いですが、プロジェクトが大きくなり「攻撃時にスキルが発動し、その効果で回復し、さらにログに記録し……」と連鎖が始まると、どこで何が起きたかを追跡するのが不可能になります。 設計:副作用を「引数」に封じ込める 副作用を排除するための解決策はシンプルです。「必要なものはすべて外から渡す」、つまり依存性の注入 (DI) です。 ...

March 28, 2026 · 4 min