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.* 实例(精灵、图像、文本等)
  • 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. 保存时:发送 POST /api/save,请求体包含会话令牌和 JSON.stringify(saveData)
  2. 加载时:使用会话令牌请求 GET /api/save → 解析响应体
  3. 离线回退:如果 fetch 失败,则回退到 localStorage
  4. 冲突解决:比较 lastSaved 时间戳;使用最新的存档

冲突解决说明:“最后写入者胜出”简单明了,适用于大多数单人游戏。对于合作游戏或跨设备同时编辑的情况,则需要合并策略。

更多资源

参考文件
  • references/save-patterns.md — 完整的 TypeScript SaveManager(含泛型)、SettingsManager、HiScoreManager、存档迁移系统、基础混淆(btoa/atob),以及用于大型数据的 IndexedDB 模式

评论

支持 Markdown