phaser-saveload について
phaser-saveloadは、Phaser 4のゲーム状態を永続化するスキルです。ブラウザー保存先の選択、セーブデータの構成、バージョン移行、自動保存、クラウドセーブの設計概要を扱います。
主な機能
- 01localStorageとIndexedDBのパターン
- 02型付きセーブデータと初期値
- 03複数スロットのセーブ管理
- 04ゲームイベントに応じた自動保存
- 05セーブのバージョン管理と移行
- 06クラウドセーブの設計概要
活用シーン
- 01プレイヤーの進行状況と設定の保存
- 02複数のセーブスロットの管理
- 03古いセーブデータとの互換性維持
- 04バックエンドを使うクラウドセーブの計画
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ yakoub-ai/ phaser4-gamedev --skill phaser-saveloadインストール後、Claude Code で/phaser-saveloadを入力して読み込まれたか確認します。
同梱ファイルreferences/save-patterns.md
詳細
Phaser 4 セーブ&ロード
Phaser には組み込みのセーブシステムがありません。ゲームの状態を永続化するには、localStorage(シンプル、同期、常に利用可能)または IndexedDB(非同期、より大きなペイロード)を使用します。このガイドでは、その両方に加えて、マイグレーション、マルチスロットセーブ、自動セーブのパターンについて説明します。
保存すべきものと再構築すべきもの
これを保存します:
- プレイヤーの進行状況: 現在のレベル、スコア、インベントリ、通貨
- フラグ: 閲覧済みのチュートリアル、解放済みのエリア、獲得済みの実績
- 設定: 音量レベル、全画面表示の設定、アクセシビリティオプション
- メタ情報: 最終保存日時、セーブバージョン番号、総プレイ時間
保存データから再構築します(直接シリアライズしないでください):
- シーンの状態 — シーンを再起動し、
create()で保存データを再適用します - エンティティの位置 — 生の座標ではなく、意味のある状態(例:
bossDefeated: true)だけを保存します - 物理ボディ — シリアライズは決してできません。保存された設定から再作成します
これらは決して保存しないでください:
Phaser.GameObjects.*のインスタンス(Sprite、Image、Text など)Phaser.Physics.*のボディまたはワールド- シーンの参照、または循環参照を持つもの
localStorage — シンプルなセーブ
localStorage は同期的で、文字列のみを扱い、約 5 MB に制限されています。ゲームのセーブにはこれが適切な選択です。
// Save
localStorage.setItem('game-save', JSON.stringify(saveData));
// Load
const raw = localStorage.getItem('game-save');
const saveData: SaveData | null = raw ? JSON.parse(raw) : null;
// Delete
localStorage.removeItem('game-save');
// Check if a save exists
const hasSave = localStorage.getItem('game-save') !== null;
必ず try/catch の中で JSON.parse してください。破損した、または古い保存データは例外をスローするため、ゲームは安全に復帰できる必要があります。
SaveData インターフェース
保存データ用に型付きのインターフェースを定義します。すべてのフィールドにはデフォルト値が必要です。
interface SaveData {
version: number; // increment when save schema changes
level: number; // current level index (1-based)
score: number; // current run score
hiScore: number; // all-time high score
totalPlayTime: number; // total seconds played
settings: {
musicVolume: number; // 0–1
sfxVolume: number; // 0–1
fullscreen: boolean;
};
unlockedLevels: number[]; // array of unlocked level indices
flags: Record<string, boolean>; // flexible flags: tutorials, events, etc.
lastSaved: number; // Date.now() timestamp
}
const DEFAULT_SAVE: SaveData = {
version: 1,
level: 1,
score: 0,
hiScore: 0,
totalPlayTime: 0,
settings: { musicVolume: 0.7, sfxVolume: 1.0, fullscreen: false },
unlockedLevels: [1],
flags: {},
lastSaved: 0,
};
SaveManager クラス
静的な SaveManager は、すべてのセーブ/ロードロジックを一元化し、バージョン管理を処理します。
const SAVE_KEY = 'my-game-v1';
class SaveManager {
/** Load save data. Returns DEFAULT_SAVE if none exists or data is corrupt. */
static load(): SaveData {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return { ...DEFAULT_SAVE };
const data = JSON.parse(raw) as SaveData;
return SaveManager.migrate(data);
} catch {
console.warn('SaveManager: failed to parse save data, using defaults');
return { ...DEFAULT_SAVE };
}
}
/** Merge partial update into existing save and write to localStorage. */
static save(partial: Partial<SaveData>): void {
const current = SaveManager.load();
const updated: SaveData = { ...current, ...partial, lastSaved: Date.now() };
// Always update hi-score
if ((partial.score ?? 0) > current.hiScore) {
updated.hiScore = partial.score!;
}
localStorage.setItem(SAVE_KEY, JSON.stringify(updated));
}
/** Delete all saved data. */
static delete(): void {
localStorage.removeItem(SAVE_KEY);
}
/** Check whether a save exists. */
static exists(): boolean {
return localStorage.getItem(SAVE_KEY) !== null;
}
/** Migrate old save formats to the current version. */
static migrate(data: SaveData): SaveData {
let d = { ...data };
// v1 → v2: added `flags` field
if (d.version < 2) {
d.flags = {};
d.version = 2;
}
// v2 → v3: added `totalPlayTime`
if (d.version < 3) {
d.totalPlayTime = 0;
d.version = 3;
}
// Always ensure all required fields have defaults (defensive merge)
return { ...DEFAULT_SAVE, ...d };
}
}
マルチスロットセーブ
type SlotId = 1 | 2 | 3;
class SlotSaveManager {
private static key(slot: SlotId): string {
return `my-game-slot-${slot}`;
}
static load(slot: SlotId): SaveData {
try {
const raw = localStorage.getItem(SlotSaveManager.key(slot));
if (!raw) return { ...DEFAULT_SAVE };
return SaveManager.migrate(JSON.parse(raw) as SaveData);
} catch {
return { ...DEFAULT_SAVE };
}
}
static save(slot: SlotId, partial: Partial<SaveData>): void {
const current = SlotSaveManager.load(slot);
const updated = { ...current, ...partial, lastSaved: Date.now() };
localStorage.setItem(SlotSaveManager.key(slot), JSON.stringify(updated));
}
static delete(slot: SlotId): void {
localStorage.removeItem(SlotSaveManager.key(slot));
}
/** Get metadata for all slots (for save select screen). */
static listSlots(): Array<{ slot: SlotId; data: SaveData | null }> {
return ([1, 2, 3] as SlotId[]).map(slot => ({
slot,
data: localStorage.getItem(SlotSaveManager.key(slot))
? SlotSaveManager.load(slot)
: null,
}));
}
}
自動セーブパターン
意味のあるゲームイベントで自動セーブします: レベルクリア、チェックポイント到達、シーンシャットダウン、およびタイマーによる定期実行です。
class GameScene extends Phaser.Scene {
private currentLevel = 1;
private lastAutoSave = 0;
create(): void {
// Auto-save on scene shutdown (handles Alt+F4, tab close, etc.)
this.events.on(Phaser.Scenes.Events.SHUTDOWN, () => this.autoSave());
this.events.on(Phaser.Scenes.Events.PAUSE, () => this.autoSave());
// Periodic auto-save every 5 minutes
this.time.addEvent({
delay: 300_000,
callback: this.autoSave,
callbackScope: this,
loop: true,
});
}
private autoSave(): void {
SaveManager.save({
level: this.currentLevel,
score: this.registry.get('score') as number,
totalPlayTime: (this.registry.get('totalPlayTime') as number) +
Math.floor((Date.now() - this.lastAutoSave) / 1000),
});
this.lastAutoSave = Date.now();
}
private onLevelComplete(): void {
this.currentLevel++;
SaveManager.save({
level: this.currentLevel,
unlockedLevels: [...(SaveManager.load().unlockedLevels), this.currentLevel],
});
// Also save immediately on meaningful event
this.autoSave();
}
}
Registry + SaveManager の統合
Phaser の this.registry をメモリ上の実行時状態として、SaveManager を永続ストアとして使用します。起動時とシャットダウン時に同期します。
// --- In BootScene or GameScene.create() ---
// Load save → populate registry
const save = SaveManager.load();
this.registry.set('score', save.score);
this.registry.set('hiScore', save.hiScore);
this.registry.set('level', save.level);
this.registry.set('musicVolume', save.settings.musicVolume);
this.registry.set('sfxVolume', save.settings.sfxVolume);
// Apply settings immediately
this.sound.volume = save.settings.musicVolume;
// --- On game over / scene end ---
// Read registry → save
SaveManager.save({
score: this.registry.get('score') as number,
level: this.registry.get('level') as number,
});
なぜ registry を中間層として使うのですか?
Phaser の registry はシーン間で共有されています(どこからでも this.registry でアクセスできます)。これにより、毎フレーム localStorage を読み込むことを避け、すべてのシーンが同じライブ状態を参照できるようになります。
設定: ゲームの進行状況とは分離する
設定(音量、全画面表示)は独自の localStorage キーに保持し、設定を失うことなくゲームの進行状況をリセットできるようにします。
const SETTINGS_KEY = 'my-game-settings';
interface GameSettings {
musicVolume: number;
sfxVolume: number;
fullscreen: boolean;
}
const DEFAULT_SETTINGS: GameSettings = {
musicVolume: 0.7,
sfxVolume: 1.0,
fullscreen: false,
};
class SettingsManager {
static load(): GameSettings {
try {
const raw = localStorage.getItem(SETTINGS_KEY);
return raw ? { ...DEFAULT_SETTINGS, ...JSON.parse(raw) } : { ...DEFAULT_SETTINGS };
} catch {
return { ...DEFAULT_SETTINGS };
}
}
static save(settings: Partial<GameSettings>): void {
const current = SettingsManager.load();
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ ...current, ...settings }));
}
}
ハイスコアテーブル
スコアエントリの配列を保存します。上位 N 件のみを降順で保持します。
interface ScoreEntry {
name: string;
score: number;
date: string; // ISO string
}
const HISCORE_KEY = 'my-game-hiscores';
const MAX_ENTRIES = 10;
class HiScoreManager {
static load(): ScoreEntry[] {
try {
const raw = localStorage.getItem(HISCORE_KEY);
return raw ? JSON.parse(raw) as ScoreEntry[] : [];
} catch {
return [];
}
}
static submit(name: string, score: number): ScoreEntry[] {
const entries = HiScoreManager.load();
entries.push({ name, score, date: new Date().toISOString() });
const sorted = entries
.sort((a, b) => b.score - a.score)
.slice(0, MAX_ENTRIES);
localStorage.setItem(HISCORE_KEY, JSON.stringify(sorted));
return sorted;
}
static isHighScore(score: number): boolean {
const entries = HiScoreManager.load();
if (entries.length < MAX_ENTRIES) return true;
return score > (entries[MAX_ENTRIES - 1]?.score ?? 0);
}
}
保存データのバージョン管理
常に version フィールドと migrate() 関数を含めてください。これにより、新しいリリースで古いセーブデータが壊れるのを防ぎます。
- セーブからフィールドを決して削除しない — 非推奨としてマークし、使用をやめます
- フィールドの型を決して変更しない — 代わりに新しいフィールドを追加します
- 新しい必須フィールドを追加するときは、
migrate()でデフォルト値を提供します versionは整数として保存し、スキーマが変更されたらインクリメントします
クラウドセーブのアーキテクチャ(概要)
クラウドセーブにはバックエンドが必要です。Phaser はクラウドセーブ API を提供していません。
パターン:
- 保存時: セッショントークン +
JSON.stringify(saveData)をボディとしてPOST /api/save - 読み込み時: セッショントークン付きで
GET /api/save→ レスポンスボディをパース - オフラインフォールバック: fetch が失敗した場合、
localStorageにフォールバック - 競合解決:
lastSavedタイムスタンプを比較し、最も新しいものを使用
競合解決の注意: 「最後の書き込みが優先」はシンプルで、ほとんどのシングルプレイヤーゲームでは正しい選択です。同時編集を伴う協力プレイやクロスデバイスプレイでは、マージ戦略が必要です。
追加リソース
参照ファイル
references/save-patterns.md— ジェネリクスを使用した完全な TypeScript SaveManager、SettingsManager、HiScoreManager、セーブマイグレーションシステム、基本的な難読化(btoa/atob)、大容量データ向けの IndexedDB パターン