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 を通じて読み書きを行います。この時点では「どうやって保存するか」は知らなくてよく、「保存できる」という事実だけに依存させます。
3. LocalStorageSaveAdapter 実装:具体化の責務
次に、SavePort を localStorage を使って具体的に実装した「アダプター」を作成します。
// adapters/localstorage-save-adapter.ts
import { SavePort, SaveData } from '../domain/save/save-port';
export class LocalStorageSaveAdapter implements SavePort {
private readonly STORAGE_KEY = 'react_odyssey_save_v1';
async save(data: SaveData): Promise<void> {
try {
const serialized = JSON.stringify(data);
localStorage.setItem(this.STORAGE_KEY, serialized);
} catch (error) {
console.error('Failed to save data to localStorage:', error);
throw new Error('セーブに失敗しました。ディスク容量を確認してください。');
}
}
async load(): Promise<SaveData | null> {
const serialized = localStorage.getItem(this.STORAGE_KEY);
if (!serialized) return null;
try {
return JSON.parse(serialized) as SaveData;
} catch (error) {
console.error('Failed to parse save data:', error);
return null; // 破損している場合は null を返して初期状態にする
}
}
async exists(): Promise<boolean> {
return localStorage.getItem(this.STORAGE_KEY) !== null;
}
async clear(): Promise<void> {
localStorage.removeItem(this.STORAGE_KEY);
}
}
設計のポイント:
STORAGE_KEYを一箇所に定義し、他から参照させないことで、キーの重複やミスを防ぎます。try-catchをアダプター内で完結させ、呼び出し側(React コンポーネント)にはクリーンな結果(または意味のあるエラーメッセージ)を返します。
4. Reducer 初期値へのロード:非同期と初期化の壁
React の useReducer にロードしたデータを反映させるには、少し工夫が必要です。useReducer の第3引数である initializer 関数を利用します。
まずは、ゲームの状態と Reducer の定義を見てみましょう。
// domain/game/game-reducer.ts
export interface GameState {
player: { hp: number; mp: number; gold: number };
location: string;
}
export const INITIAL_STATE: GameState = {
player: { hp: 100, mp: 50, gold: 0 },
location: '始まりの町'
};
export type GameAction =
| { type: 'RECOVER_HP'; amount: number }
| { type: 'MOVE'; to: string }
| { type: 'LOAD_GAME'; state: GameState }; // ロード専用のアクション
export function gameReducer(state: GameState, action: GameAction): GameState {
switch (action.type) {
case 'RECOVER_HP':
return { ...state, player: { ...state.player, hp: state.player.hp + action.amount } };
case 'MOVE':
return { ...state, location: action.to };
case 'LOAD_GAME':
return action.state; // 保存された状態で上書き
default:
return state;
}
}
コンポーネントでのロード処理
localStorage.getItem は同期処理ですが、将来の IndexedDB 移行を見据えて async に対応させる必要があります。React の useEffect でロードを実行し、成功したらアクションをディスパッチします。
// components/GameProvider.tsx
import React, { useReducer, useEffect, useMemo } from 'react';
import { gameReducer, INITIAL_STATE, GameState } from '../domain/game/game-reducer';
import { LocalStorageSaveAdapter } from '../adapters/localstorage-save-adapter';
export const GameContext = React.createContext<{
state: GameState;
dispatch: React.Dispatch<any>;
saveGame: () => void;
} | null>(null);
export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, dispatch] = useReducer(gameReducer, INITIAL_STATE);
// アダプターのインスタンスをメモ化
const saveAdapter = useMemo(() => new LocalStorageSaveAdapter(), []);
// アプリ起動時にロードを試みる
useEffect(() => {
const initLoad = async () => {
const savedData = await saveAdapter.load();
if (savedData) {
dispatch({ type: 'LOAD_GAME', state: savedData.state });
}
};
initLoad();
}, [saveAdapter]);
// 手動セーブ用の関数
const saveGame = async () => {
const data = {
version: 1,
timestamp: new Date().toISOString(),
state: state
};
await saveAdapter.save(data);
alert('セーブが完了しました!');
};
return (
<GameContext.Provider value={{ state, dispatch, saveGame }}>
{children}
</GameContext.Provider>
);
};
5. セーブデータの破損・バージョン不一致への対策
ここまでの実装では、データ構造が変わったときにクラッシュする危険があります。例えば、player オブジェクトに level フィールドを追加した後に古いセーブデータを読み込むと、level が undefined になり、計算で NaN が発生するかもしれません。
対策1:バージョンチェックと移行(Migration)
SaveData に含めた version をチェックします。
function migrate(data: any): GameData {
let currentData = data;
// v1 から v2 への移行
if (currentData.version === 1) {
currentData = {
...currentData,
version: 2,
state: {
...currentData.state,
player: { ...currentData.state.player, level: 1 } // 新しいフィールドを追加
}
};
}
return currentData;
}
対策2:スキーマバリデーションとフォールバック
ロードしたデータが正しい形式か、実行時にチェックします。簡易的な方法としては、スプレッド構文を用いた「デフォルト値の流し込み」が有効です。
// アダプターの load 内で、構造の不一致を最小限に抑える
async load(): Promise<SaveData | null> {
const serialized = localStorage.getItem(this.STORAGE_KEY);
if (!serialized) return null;
try {
const rawData = JSON.parse(serialized);
// 最小限の構造チェック
if (!rawData.state || !rawData.version) {
throw new Error('Invalid save data format');
}
// デフォルト値をマージして、新しく追加されたフィールドの欠落を防ぐ
const sanitizedState = {
...INITIAL_STATE,
...rawData.state,
player: {
...INITIAL_STATE.player,
...rawData.state.player
}
};
return { ...rawData, state: sanitizedState };
} catch (error) {
console.warn('Save data is corrupted. Starting a new game.', error);
return null;
}
}
6. まとめ
本記事では、React でゲームのセーブ / ロードを実装する際の「疎結合」な設計について解説しました。
- SavePort (Interface) で「何をすべきか」を定義する。
- LocalStorageSaveAdapter (Class) で「どう保存するか」を実装する。
- Reducer は副作用を持たず、専用のアクション(
LOAD_GAME)で状態を受け取る。 - サニタイズ処理 を挟むことで、コードの進化によるデータの破損から守る。
この設計の最大の利点は、テストコードが書きやすくなることです。テスト時には LocalStorageSaveAdapter の代わりに MemorySaveAdapter を渡すだけで、ブラウザ環境なしでセーブ機能の検証が可能になります。
// テスト用のモックアダプター
export class MemorySaveAdapter implements SavePort {
private data: SaveData | null = null;
async save(d: SaveData) { this.data = d; }
async load() { return this.data; }
// ...
}
ゲームが複雑になるにつれ、保存すべきデータの量は増えていきます。初期の段階で「保存の責務」を明確に分けておくことで、将来の機能追加やプラットフォーム変更に強いコードベースを維持できるでしょう。
あなたの React Odyssey に、安全な「冒険の記録」を実装してみてください。