【Cocos Creator】アタッチするだけ!SaveSystem (セーブ機能)の実装方法【TypeScript】

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

【Cocos Creator 3.8】SaveSystemの実装:アタッチするだけで「任意ノード群の状態をJSONで保存・読み込み」できる汎用スクリプト

このガイドでは、Cocos Creator 3.8.7 + TypeScript 用に、SaveSystem という汎用セーブコンポーネントを実装します。
特定のグループに属するノードにアタッチされたコンポーネントから、指定したプロパティだけを辞書化し、JSON 文字列として保存・読み込みする仕組みを作ります。

このコンポーネントは、シーン上の任意の 1 ノードにアタッチするだけで動作し、他のカスタムスクリプトには一切依存しません。
インスペクタから「どのノードを保存対象にするか」「どのプロパティ名を保存するか」「PlayerPrefs 風にローカルストレージへ保存するか」などを設定できます。


コンポーネントの設計方針

1. 要件整理

  • 特定グループに属するノードの「変数(プロパティ)」を辞書化し、JSON 文字列として保存する。
  • 保存対象は以下の 3 要素で指定する:
    • 対象ノード群:インスペクタから Node[] として指定。
    • 対象コンポーネント名:例: "PlayerStatus" のように文字列で指定。
    • 対象プロパティ名リスト:例: ["hp", "mp", "level"] のように文字列配列で指定。
  • 保存形式は JSON 文字列。
    { [nodeName: string]: { [propertyName: string]: any } } のような辞書構造にする。
  • PC / Web / モバイルなどを考慮し、FileSystem などのネイティブ API には依存せずローカルストレージ(sys.localStorage) に保存する設計とする。
    • ファイル名相当は saveKey プロパティ(文字列)として指定。
  • 「完全な独立性」を満たすため、GameManager や Singleton など外部スクリプトは一切使用しない。
  • 防御的実装:
    • 対象ノードに指定コンポーネントが存在しない場合は、エラーログを出す。
    • プロパティが存在しない場合も警告を出す。
    • JSON パースエラー時やローカルストレージ未対応時もログで通知する。

2. データ構造のイメージ

保存される JSON の構造は、例えば次のようになります:

{
  "Player": {
    "hp": 80,
    "mp": 20,
    "level": 5
  },
  "Enemy_1": {
    "hp": 30,
    "isAlive": true
  }
}

キーは「ノード名」、値は「プロパティ名 → 値」の辞書です。
同名ノードが複数ある場合は、オプションで UUID を使うこともできますが、本ガイドではシンプルに ノード名ベース にします(必要であれば簡単に拡張できます)。

3. インスペクタで設定可能なプロパティ設計

インスペクタから調整できるプロパティをまとめます。

  • saveKey: string
    • ローカルストレージに保存する際のキー名。
    • 例: "game_save_slot_1"
    • 空文字の場合は保存・読み込みボタンを押しても警告を出して何もしない。
  • targetNodes: Node[]
    • 保存対象とするノード群。
    • 例: Player ノード、敵管理ノードなど。
    • ドラッグ&ドロップで登録。
  • componentName: string
    • 保存対象のコンポーネントクラス名(Cocos のクラス名 / ccclass 名)。
    • 例: "PlayerStatus"
    • 空文字の場合は警告を出して処理しない。
  • propertyNames: string[]
    • 保存対象のプロパティ名リスト。
    • 例: ["hp", "mp", "level"]
    • 存在しないプロパティ名はスキップし、警告ログを出す。
  • autoLoadOnStart: boolean
    • true の場合、start() 時に自動的に loadFromStorage() を実行する。
    • 例: タイトルから遷移した時に自動でロードしたい場合に便利。
  • autoSaveOnDestroy: boolean
    • true の場合、onDestroy() 時に自動的に saveToStorage() を実行する。
    • 例: シーン遷移前に自動セーブしたい場合など。
  • logVerbose: boolean
    • 詳細ログを出すかどうか。
    • デバッグ時は true、リリース時は false を推奨。

この他に、エディタから直接呼べるテストボタン(セーブ・ロード)を用意します。

  • Editor ボタン(@property({editorOnly: true}))
    • エディタ上で「今の状態を保存」「保存データを読み込み」を手動実行できるようにします。
    • 実際には onClickSaveButton(), onClickLoadButton() などの public メソッドを用意し、UI Button からも呼べるようにします。

TypeScriptコードの実装

以下が完成した SaveSystem.ts の全コードです。


import { _decorator, Component, Node, sys, JsonAsset } from 'cc';
const { ccclass, property } = _decorator;

/**
 * SaveSystem
 * - 指定したノード群に対して、指定コンポーネントの指定プロパティを
 *   JSON 形式でローカルストレージに保存・読み込みする汎用コンポーネント。
 *
 * 想定 JSON 構造:
 * {
 *   "NodeNameA": { "hp": 10, "mp": 5 },
 *   "NodeNameB": { "level": 3 }
 * }
 */
@ccclass('SaveSystem')
export class SaveSystem extends Component {

    @property({
        tooltip: 'ローカルストレージに保存する際のキー名。\n例: "game_save_slot_1"',
    })
    public saveKey: string = 'game_save_slot_1';

    @property({
        type: [Node],
        tooltip: '保存対象とするノード群。\nここに登録された各ノードから、指定コンポーネントのプロパティを保存・読み込みします。',
    })
    public targetNodes: Node[] = [];

    @property({
        tooltip: '保存対象コンポーネントのクラス名 (ccclass 名)。\n例: "PlayerStatus" や "EnemyStatus" など。\n空のままだと処理されません。',
    })
    public componentName: string = '';

    @property({
        tooltip: '保存対象プロパティ名のリスト。\n例: ["hp", "mp", "level"] など。\n存在しないプロパティ名はスキップされます。',
    })
    public propertyNames: string[] = [];

    @property({
        tooltip: 'true の場合、start() 時に自動でローカルストレージからロードします。',
    })
    public autoLoadOnStart: boolean = false;

    @property({
        tooltip: 'true の場合、onDestroy() 時に自動でローカルストレージへセーブします。',
    })
    public autoSaveOnDestroy: boolean = false;

    @property({
        tooltip: 'true にするとセーブ/ロード時に詳細ログを出力します。',
    })
    public logVerbose: boolean = true;

    // 内部で使用する型定義
    private static readonly STORAGE_UNSUPPORTED_MSG =
        '[SaveSystem] このプラットフォームでは sys.localStorage が利用できません。';

    onLoad() {
        // 防御的なチェック
        if (!sys || !sys.localStorage) {
            console.error(SaveSystem.STORAGE_UNSUPPORTED_MSG);
        }

        if (this.logVerbose) {
            console.log('[SaveSystem] onLoad: saveKey =', this.saveKey);
        }
    }

    start() {
        if (this.autoLoadOnStart) {
            if (this.logVerbose) {
                console.log('[SaveSystem] autoLoadOnStart が有効のため、起動時にロードを試みます。');
            }
            this.loadFromStorage();
        }
    }

    onDestroy() {
        if (this.autoSaveOnDestroy) {
            if (this.logVerbose) {
                console.log('[SaveSystem] autoSaveOnDestroy が有効のため、破棄時にセーブを試みます。');
            }
            this.saveToStorage();
        }
    }

    /**
     * 現在の targetNodes の状態を JSON 文字列に変換して返します。
     */
    public exportToJson(): string {
        const data: Record<string, any> = {};

        if (!this.componentName) {
            console.warn('[SaveSystem] componentName が空です。何もエクスポートしません。');
            return JSON.stringify(data);
        }

        if (!this.propertyNames || this.propertyNames.length === 0) {
            console.warn('[SaveSystem] propertyNames が空です。何もエクスポートしません。');
            return JSON.stringify(data);
        }

        for (const node of this.targetNodes) {
            if (!node) {
                console.warn('[SaveSystem] targetNodes に null が含まれています。スキップします。');
                continue;
            }

            const comp = this.getTargetComponent(node);
            if (!comp) {
                console.warn(`[SaveSystem] ノード "${node.name}" にコンポーネント "${this.componentName}" が見つかりません。`);
                continue;
            }

            const nodeEntry: Record<string, any> = {};
            for (const propName of this.propertyNames) {
                if (!propName) {
                    continue;
                }

                // @ts-ignore - 動的アクセスを許容
                if (comp[propName] === undefined) {
                    console.warn(
                        `[SaveSystem] コンポーネント "${this.componentName}" にプロパティ "${propName}" が存在しません (ノード: "${node.name}")。`
                    );
                    continue;
                }

                // @ts-ignore
                const value = comp[propName];
                nodeEntry[propName] = value;
            }

            data[node.name] = nodeEntry;
        }

        const json = JSON.stringify(data);
        if (this.logVerbose) {
            console.log('[SaveSystem] exportToJson: ', json);
        }
        return json;
    }

    /**
     * JSON 文字列から targetNodes に値を適用します。
     * @param json JSON 文字列
     */
    public importFromJson(json: string): void {
        if (!json) {
            console.warn('[SaveSystem] importFromJson: 空の JSON 文字列です。');
            return;
        }

        let parsed: any;
        try {
            parsed = JSON.parse(json);
        } catch (e) {
            console.error('[SaveSystem] JSON のパースに失敗しました:', e);
            return;
        }

        if (typeof parsed !== 'object' || parsed === null) {
            console.error('[SaveSystem] パース結果がオブジェクトではありません。');
            return;
        }

        // ノード名 → データ のマップを適用
        for (const node of this.targetNodes) {
            if (!node) {
                console.warn('[SaveSystem] targetNodes に null が含まれています。スキップします。');
                continue;
            }

            const nodeData = parsed[node.name];
            if (!nodeData) {
                if (this.logVerbose) {
                    console.log(`[SaveSystem] JSON 内にノード "${node.name}" のデータがありません。スキップします。`);
                }
                continue;
            }

            const comp = this.getTargetComponent(node);
            if (!comp) {
                console.warn(`[SaveSystem] ノード "${node.name}" にコンポーネント "${this.componentName}" が見つかりません (import 時)。`);
                continue;
            }

            // nodeData は { propName: value } の形式を想定
            for (const propName of Object.keys(nodeData)) {
                // 指定された propertyNames に含まれていないキーは無視する
                if (this.propertyNames.length > 0 && !this.propertyNames.includes(propName)) {
                    if (this.logVerbose) {
                        console.log(
                            `[SaveSystem] propertyNames に含まれていないプロパティ "${propName}" は無視します (ノード: "${node.name}")。`
                        );
                    }
                    continue;
                }

                // @ts-ignore
                if (comp[propName] === undefined) {
                    console.warn(
                        `[SaveSystem] コンポーネント "${this.componentName}" にプロパティ "${propName}" が存在しません (ノード: "${node.name}")。`
                    );
                    continue;
                }

                // @ts-ignore
                comp[propName] = nodeData[propName];
            }
        }

        if (this.logVerbose) {
            console.log('[SaveSystem] importFromJson: ロード処理が完了しました。');
        }
    }

    /**
     * sys.localStorage に現在の状態を保存します。
     */
    public saveToStorage(): void {
        if (!sys || !sys.localStorage) {
            console.error(SaveSystem.STORAGE_UNSUPPORTED_MSG);
            return;
        }

        if (!this.saveKey) {
            console.warn('[SaveSystem] saveKey が空です。ローカルストレージに保存できません。');
            return;
        }

        const json = this.exportToJson();
        try {
            sys.localStorage.setItem(this.saveKey, json);
            if (this.logVerbose) {
                console.log(`[SaveSystem] ローカルストレージに保存しました (key: "${this.saveKey}")。`);
            }
        } catch (e) {
            console.error('[SaveSystem] ローカルストレージへの保存に失敗しました:', e);
        }
    }

    /**
     * sys.localStorage から状態を読み込み、targetNodes に適用します。
     */
    public loadFromStorage(): void {
        if (!sys || !sys.localStorage) {
            console.error(SaveSystem.STORAGE_UNSUPPORTED_MSG);
            return;
        }

        if (!this.saveKey) {
            console.warn('[SaveSystem] saveKey が空です。ローカルストレージから読み込めません。');
            return;
        }

        const json = sys.localStorage.getItem(this.saveKey);
        if (!json) {
            console.warn(`[SaveSystem] ローカルストレージにキー "${this.saveKey}" のデータが存在しません。`);
            return;
        }

        if (this.logVerbose) {
            console.log(`[SaveSystem] ローカルストレージから読み込みました (key: "${this.saveKey}")。json =`, json);
        }

        this.importFromJson(json);
    }

    /**
     * UI Button などから呼び出すための公開メソッド。
     * 現在の状態を saveKey でローカルストレージへ保存します。
     */
    public onClickSaveButton(): void {
        if (this.logVerbose) {
            console.log('[SaveSystem] onClickSaveButton が呼ばれました。');
        }
        this.saveToStorage();
    }

    /**
     * UI Button などから呼び出すための公開メソッド。
     * saveKey からローカルストレージのデータを読み込みます。
     */
    public onClickLoadButton(): void {
        if (this.logVerbose) {
            console.log('[SaveSystem] onClickLoadButton が呼ばれました。');
        }
        this.loadFromStorage();
    }

    /**
     * 指定ノードから target コンポーネントを取得するヘルパー。
     */
    private getTargetComponent(node: Node): Component | null {
        if (!this.componentName) {
            console.warn('[SaveSystem] componentName が設定されていません。');
            return null;
        }

        // getComponent のシグネチャ的にはコンストラクタ or クラスを渡すのが正式だが、
        // ccclass 名の文字列からの取得は直接サポートされていないため、
        // ここでは any キャストを用いて動的に取得を試みる。
        // 実際には "PlayerStatus" などのクラスをこの SaveSystem と同じプロジェクト内に
        // 定義しておくことで、コンポーネントの取得が可能になる。
        const comp = node.getComponent(this.componentName as any) as Component | null;
        return comp;
    }
}

コードの要点解説

  • onLoad()
    • sys.localStorage が利用可能かどうかをチェック。
    • logVerbose が true の場合、初期情報をログ出力。
  • start()
    • autoLoadOnStart が true なら、起動時に loadFromStorage() を自動実行。
  • onDestroy()
    • autoSaveOnDestroy が true なら、破棄時に saveToStorage() を自動実行。
  • exportToJson()
    • targetNodes をループし、各ノードから componentName で指定されたコンポーネントを取得。
    • propertyNames に含まれる各プロパティを動的に読み出して辞書化。
    • 最終的に JSON.stringify() して文字列を返す。
  • importFromJson(json)
    • JSON 文字列をパースしてオブジェクト化。
    • ノード名をキーとして、各ノードのコンポーネントに値を代入。
    • 存在しないノード / コンポーネント / プロパティは警告としてログ出力。
  • saveToStorage(), loadFromStorage()
    • sys.localStorage を使って JSON 文字列を保存/読み込み。
    • saveKey が空の場合は警告して処理しない。
  • onClickSaveButton(), onClickLoadButton()
    • UI ボタンや他コンポーネントから呼び出しやすい公開メソッド。
    • インスペクタでイベントとして紐づけることで、ワンクリックセーブ/ロードが可能。

使用手順と動作確認

1. SaveSystem.ts スクリプトの作成

  1. Assets パネルで右クリック → Create → TypeScript を選択します。
  2. ファイル名を SaveSystem.ts に変更します。
  3. 作成した SaveSystem.ts をダブルクリックして開き、上記のコードを丸ごと貼り付けて保存します。

2. テスト用コンポーネントの作成(例:PlayerStatus)

SaveSystem は「任意のコンポーネント」のプロパティを保存できますが、動作確認用に簡単なコンポーネントを作っておきます。

  1. Assets パネルで右クリック → Create → TypeScript を選択します。
  2. ファイル名を PlayerStatus.ts に変更します。
  3. 以下のような簡易スクリプトを貼り付けます:

import { _decorator, Component } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('PlayerStatus')
export class PlayerStatus extends Component {

    @property({ tooltip: 'プレイヤーの HP' })
    public hp: number = 100;

    @property({ tooltip: 'プレイヤーの MP' })
    public mp: number = 50;

    @property({ tooltip: 'プレイヤーのレベル' })
    public level: number = 1;
}

この PlayerStatus のプロパティを SaveSystem で保存・読み込みします。

3. シーンにノードを配置してコンポーネントをアタッチ

  1. Hierarchy パネルで右クリック → Create → 3D Object → Node(または 2D Object → Sprite など)を選択し、ノード名を Player に変更します。
  2. Player ノードを選択し、Inspector の Add Component → Custom → PlayerStatus を選択してアタッチします。
  3. 同様に、セーブ管理用のノードを作成します:
    • Hierarchy パネルで右クリック → Create → 3D Object → Node
    • ノード名を SaveManager などに変更。
    • SaveManager ノードを選択し、Inspector の Add Component → Custom → SaveSystem を選択してアタッチします。

4. SaveSystem のプロパティ設定

SaveManager ノードを選択し、Inspector で SaveSystem のプロパティを次のように設定します。

  • saveKey
    • game_save_slot_1 など任意の文字列。
  • targetNodes
    • 右側の「+」ボタンをクリックして要素を 1 つ追加。
    • Hierarchy から Player ノード をドラッグ&ドロップして割り当てます。
  • componentName
    • PlayerStatus と入力(ccclass 名と一致させる)。
  • propertyNames
    • 「+」ボタンで 3 つ要素を追加。
    • それぞれ hp, mp, level と入力。
  • autoLoadOnStart
    • テストしやすいように、最初は false にしておきます。
  • autoSaveOnDestroy
    • 最初は false のままで OK です。
  • logVerbose
    • デバッグのため true にしてログを確認しやすくします。

5. ボタンからセーブ/ロードを呼び出す設定(任意)

UI からセーブ/ロードを試したい場合の設定手順です。

  1. Hierarchy パネルで右クリック → Create → UI → Button を 2 つ作成し、それぞれ SaveButton, LoadButton と名前を付けます。
  2. SaveButton を選択し、Inspector の Button コンポーネントの「Click Events」を展開します。
  3. 「+」ボタンを押して新しいイベントを追加します。
  4. 追加されたイベントの Target 欄に、SaveManager ノード をドラッグ&ドロップします。
  5. その下のドロップダウンから SaveSystem → onClickSaveButton を選択します。
  6. 同様に、LoadButton の Click Events に onClickLoadButton を設定します。

6. 実行して動作確認

  1. エディタ上部の「Play」ボタンを押してゲームを実行します。
  2. 初期状態
    • Player ノードの PlayerStatus コンポーネントが hp=100, mp=50, level=1 になっていることを確認します。
  3. 任意の値に変更
    • ゲーム中にスクリプトなどで値を変更してもいいですし、テスト目的なら一旦停止して Inspector から直接値を変えて再生し直しても構いません。
    • 例: hp=12, mp=7, level=3 に変更した状態をセーブしたいとします。
  4. セーブ
    • ゲーム実行中に SaveButton をクリックします。
    • Console に [SaveSystem] ローカルストレージに保存しました (key: "game_save_slot_1")。 などのログが出力されていれば成功です。
  5. 値をリセット
    • ゲームを停止し、PlayerStatus の値を別の値に変更します(例: hp=999, mp=999, level=99)。
  6. ロード
    • 再度ゲームを実行し、LoadButton をクリックします。
    • Console に [SaveSystem] ローカルストレージから読み込みました ... のログが出て、PlayerStatus の値がセーブ時の hp=12, mp=7, level=3 に戻れば成功です。

7. 自動ロード/自動セーブの活用

一度動作確認ができたら、以下のように自動化設定を行うと便利です。

  • 起動時に自動ロード
    • SaveSystem の autoLoadOnStarttrue にすると、シーン開始時に自動でロードされます。
  • シーン破棄時に自動セーブ
    • autoSaveOnDestroytrue にすると、シーンを抜ける際などに自動でセーブされます。

まとめ

この SaveSystem コンポーネントは、「対象ノード」「対象コンポーネント名」「対象プロパティ名」 をインスペクタで指定するだけで、
任意のデータを JSON 化してローカルストレージに保存・読み込みできる汎用セーブ機構です。

  • 外部の GameManager や Singleton に依存せず、SaveSystem.ts 単体で完結しているため、どのプロジェクトにも簡単に持ち込めます。
  • 保存対象のコンポーネントやプロパティ名を変えるだけで、プレイヤー情報、敵の状態、設定値など、さまざまなデータを一括管理できます。
  • JSON 形式なので、デバッグ時に中身を確認しやすく、将来的な拡張(ファイル出力、サーバー送信など)も行いやすい構造になっています。

応用としては、以下のような使い方が考えられます:

  • 複数のセーブスロット用に saveKey を切り替え、セーブ/ロード画面を実装する。
  • プレイヤーだけでなく、ステージ進行度、開放済みアイテムなどのフラグもまとめて保存する。
  • デバッグ用に exportToJson() の結果をログに出し、ゲームバランス調整に活用する。

このコンポーネントをベースに、UUID ベースのキー管理暗号化クラウド保存 などを追加していけば、より本格的なセーブシステムに発展させることも可能です。
まずは本記事の内容をそのまま実装し、シンプルなセーブ&ロードから試してみてください。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

URLをコピーしました!