mcpField
P
phaser-saveload

phaser-saveload

github.com/yakoub-ai開発29193 回インストール9 週間前に更新
リポジトリ

phaser-saveload について

phaser-saveloadは、Phaser 4のゲーム状態を永続化するスキルです。ブラウザー保存先の選択、セーブデータの構成、バージョン移行、自動保存、クラウドセーブの設計概要を扱います。

主な機能

  1. 01localStorageとIndexedDBのパターン
  2. 02型付きセーブデータと初期値
  3. 03複数スロットのセーブ管理
  4. 04ゲームイベントに応じた自動保存
  5. 05セーブのバージョン管理と移行
  6. 06クラウドセーブの設計概要

活用シーン

  1. 01プレイヤーの進行状況と設定の保存
  2. 02複数のセーブスロットの管理
  3. 03古いセーブデータとの互換性維持
  4. 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 を提供していません。

パターン:

  1. 保存時: セッショントークン + JSON.stringify(saveData) をボディとして POST /api/save
  2. 読み込み時: セッショントークン付きで GET /api/save → レスポンスボディをパース
  3. オフラインフォールバック: fetch が失敗した場合、localStorage にフォールバック
  4. 競合解決: lastSaved タイムスタンプを比較し、最も新しいものを使用

競合解決の注意: 「最後の書き込みが優先」はシンプルで、ほとんどのシングルプレイヤーゲームでは正しい選択です。同時編集を伴う協力プレイやクロスデバイスプレイでは、マージ戦略が必要です。

追加リソース

参照ファイル
  • references/save-patterns.md — ジェネリクスを使用した完全な TypeScript SaveManager、SettingsManager、HiScoreManager、セーブマイグレーションシステム、基本的な難読化(btoa/atob)、大容量データ向けの IndexedDB パターン

コメント