mcpField
S
save-systems

save-systems

github.com/gamedev-skills開発5.4k8.1k 回インストール今日更新
リポジトリ

save-systems について

セッションやアップデートをまたぐセーブ設計を支援する、ゲーム開発向けスキルです。状態のシリアライズ、クラッシュに備えた書き込み、セーブ枠、旧データの移行を扱います。

主な機能

  1. 01ゲーム状態データのシリアライズ
  2. 02バージョン付きスキーマと移行
  3. 03バックアップ付きのアトミック書き込み
  4. 04セーブ枠と独立したオートセーブ
  5. 05防御的な読み込みと検証

活用シーン

  1. 01プレイヤーの進行状況をセッション間で保存
  2. 02クラッシュに備えたセーブやクイックセーブの実装
  3. 03アップデート後も旧セーブを維持
  4. 04破損または不正なセーブからの復旧

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/gamedev-skills/awesome-gamedev-agent-skills --skill save-systems

インストール後、Claude Code で/save-systemsを入力して読み込まれたか確認します。

同梱ファイルreferences/versioning-and-migration.md

詳細

セーブシステム

セーブファイルとは、再起動後も保持されるゲーム状態のシリアライズ済みスナップショットです。 難しいのはバイトを書き込むことではなく、何を保存するかを選び、セーブ中にクラッシュしても破損しないように書き込み、パッチをリリースした後も古いセーブを読み込めるようにすることです。この3点を正しく行えば、あとは定型処理です。

使用する場面

  • プレイヤーのステータス、インベントリ、ワールドフラグ、設定、位置などの進行状況を、セッションやゲームのアップデートをまたいで保持するときに使用します。
  • セーブスロット、クイックセーブ/オートセーブ、クラッシュに強い書き込みを設計するときに使用します。
  • コンテンツやコードの変更後に古いセーブファイルが使えなくなったときに使用します(バージョン管理と移行)。

使用しない場面: Roblox のクラウド永続化の詳細については、roblox-datastores を使用してください。セーブをシリアライズするデータモデル(リソース/SO)については、godot-resources / unity-scriptableobjects を使用してください。Godot の FileAccess/ResourceSaver と user:// パスについては、ここに記載のパターンを適用しつつ、Godot エンジンのスキルを参照してください。

基本的なワークフロー

  1. どの状態を正とするかを決めます。 エンジンオブジェクトやシーンノードではなく、データ(HP、位置、シード、アンロック済みフラグ)を保存します。ロード時にはデータからオブジェクトを再構築します。実行中のノード参照は絶対にシリアライズしないでください。
  2. バージョン付きのスキーマを定義します。 すべてのセーブに version 整数を含めます。パッチを適用する予定があるゲームでは、これが最も重要なフィールドです。
  3. フォーマットを選びます。 読みやすさとデバッグのしやすさを重視するなら JSON/テキスト、サイズや速度、またはある程度の改ざん耐性を重視するならバイナリ形式を選びます。まずは JSON から始めてください。
  4. アトミックに書き込みます。 一時ファイルにシリアライズしてフラッシュした後、実際のファイルを置き換えるようにリネームします。クラッシュしても古いセーブか新しいセーブのどちらかが残り、書き込み途中の状態にはなりません。
  5. 安全にロードします。 バージョンを読み取る → 現行バージョンまで移行する → 検証する → インスタンス化する、という順で行います。最後に正常だったセーブのバックアップを保持し、解析エラー時にはそちらにフォールバックします。
  6. 安全な区切り(レベル変更、チェックポイント)で、間隔を制限してオートセーブします。また、手動セーブを上書きしないよう、別のスロットに保存します。
  7. 検証します。 セーブして完全に終了し、再起動してロードし、状態が一致することを確認します。前のバージョンのセーブをロードするテストも行います。

パターン

1. 状態をプレーンデータとしてシリアライズする(エンジンオブジェクトではなく)
# Build a dictionary of pure data. Each savable object reports its own state.
func capture_state() -> Dictionary:
    return {
        "version": SAVE_VERSION,                 # ALWAYS stamp the schema version
        "player": { "hp": player.hp, "pos": [player.position.x, player.position.y] },
        "inventory": player.inventory.to_array(),  # ids + counts, not Item nodes
        "flags": world.flags,                    # e.g. {"met_guard": true}
        "seed": world.seed,                      # regenerate procedural content
    }

# On load, RECONSTRUCT objects from the data — do not expect live references back.
func apply_state(data: Dictionary) -> void:
    player.hp = data["player"]["hp"]
    player.position = Vector2(data["player"]["pos"][0], data["player"]["pos"][1])
    player.inventory.from_array(data["inventory"])
    world.flags = data["flags"]
2. アトミックでクラッシュに強い書き込み(一時ファイル+リネーム)
# RIGHT: write to a temp file, then atomically rename over the target.
func save_atomic(path: String, data: Dictionary) -> void:
    var tmp := path + ".tmp"
    var f := FileAccess.open(tmp, FileAccess.WRITE)
    f.store_string(JSON.stringify(data))
    f.flush()                                    # ensure bytes hit disk
    f.close()
    DirAccess.rename_absolute(tmp, path)         # replaces the target; atomic on POSIX
# WRONG: opening `path` directly and writing in place — a crash mid-write leaves a
# truncated, unloadable save and destroys the player's progress.

POSIX では、同じボリューム内の既存ファイルへのリネームはアトミックです。Windows では、リネームによる置き換えがアトミックである保証はありません。そのため、リネーム前に前のファイルを path + ".bak" として保持してください。このバックアップがあれば、不正な書き込みから確実に復旧できます。

3. バージョン管理されたロードと移行
SAVE_VERSION = 3

def load_save(raw_bytes):
    data = parse(raw_bytes)                  # JSON/binary -> dict
    v = data.get("version", 0)
    if v > SAVE_VERSION:
        raise NewerSaveError(v)              # save is from a newer build; refuse
    while v < SAVE_VERSION:                   # apply migrations in order, v -> v+1
        data = MIGRATIONS[v](data)
        v += 1
        data["version"] = v
    validate(data)                            # check required keys / ranges
    return data

# Each migration is a pure function from one version's shape to the next.
def migrate_1_to_2(d):
    d["flags"] = {k: True for k in d.pop("completed_quests", [])}  # list -> set-map
    return d
MIGRATIONS = {1: migrate_1_to_2, 2: migrate_2_to_3}
4. セーブスロット+間隔を制限したオートセーブ
const SLOT_PATH := "user://save_%d.json"      # manual slots 0..N
const AUTOSAVE_PATH := "user://autosave.json"  # separate file: never clobbers a slot
var _autosave_cooldown := 0.0

func autosave_if_due(dt: float) -> void:
    _autosave_cooldown -= dt
    if _autosave_cooldown <= 0.0:
        save_atomic(AUTOSAVE_PATH, capture_state())
        _autosave_cooldown = 60.0             # throttle: at most once a minute
# Trigger an immediate autosave on checkpoints/level transitions, not mid-combat.

落とし穴

  • エンジンオブジェクト/ノードパスをシリアライズすると、セーブがシーン構造に依存します。ノード名を変更すると、古いセーブがすべて使えなくなります。データを保存し、ロード時にオブジェクトを再構築してください。
  • バージョンフィールドがない。 パッチをリリースした日から、既存のセーブはすべて推測頼みになります。バージョン1から version を記録してください。
  • ファイルを直接書き込むと、クラッシュや電源障害でセーブが破損します。必ず一時ファイルに書き込んでからリネームし、.bak を保持してください。
  • ファイルを無条件に信頼する。 セーブは途中で切り詰められたり、手動で編集されたり、古いデータがクラウド同期されたりすることがあります。ロード時に検証し、失敗した場合はバックアップにフォールバックしてください。
  • 浮動小数点数とロケール。 テキストシリアライザーは精度を落としたり、一部のロケールでは小数点にカンマを使用したりすることがあります。ロケールに依存しないシリアライザーを使用してください。
  • オートセーブが手動セーブを上書きする、または操作の途中で実行されて一貫性のない状態を保存する。専用のオートセーブスロットを使い、安全な区切りで保存してください。
  • マルチプレイヤーで秘密情報を保存したり、クライアントのセーブを信頼したりする。 ローカルセーブはプレイヤーが操作できるため、オンライン状態の正しい情報源として扱わないでください。クラウドを使用する場合は、デバイスのデータ制限と競合を考慮してください(roblox-datastores)。

参考資料

  • references/versioning-and-migration.md — スキーマの進化戦略、移行チェーン、バックアップ/ロールバック、フォーマットのトレードオフ(JSON とバイナリ)、ロード時の検証チェックリスト。

関連スキル

  • roblox-datastores — クラウドでの永続化、リクエスト制限、セッションロック。
  • godot-resources、unity-scriptableobjects — シリアライズするデータモデル。
  • procedural-gen — ワールドを保存する代わりに、シードを保存して再生成します。
  • rpg、survival-crafting、visual-novel — このスキルを組み合わせて構成するジャンル。

コメント