【Cocos Creator】アタッチするだけ!SaveDataSync (セーブ対象化)の実装方法【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】SaveDataSyncの実装:アタッチするだけで「任意ノードの状態を辞書化してセーブ/ロードできる」汎用スクリプト

このガイドでは、任意のノードにアタッチするだけで、そのノードに紐づく「セーブ対象の値」を辞書(JSON 互換オブジェクト)としてまとめて扱える汎用コンポーネント SaveDataSync を実装します。
外部の GameManager やシングルトンに依存せず、このコンポーネント単体で「セーブ用データの集約・同期」機能を提供します。

想定される用途の例:

  • プレイヤーの位置・HP・所持金などをひとまとめにしてセーブしたい
  • UI の開閉状態や選択中タブなどをシーン復帰時に復元したい
  • ゲーム内の任意のノードごとに「ローカルなセーブデータ」を持たせたい

このコンポーネントは、インスペクタで「同期したい値」を列挙するだけで使えます。外部のセーブシステムは、このコンポーネントから getSaveData() で辞書を取得し、applySaveData() で復元するだけで済みます。


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

1. 機能要件の整理

  • このコンポーネント自体はセーブファイルの書き込み/読み込みは行わない
  • 代わりに、「このノードに関するセーブ対象の値」を辞書(plain object)として集約・管理する。
  • 外部のセーブシステムからは、以下のように扱えるようにする:
    • getSaveData():現在の値を辞書にして返す
    • applySaveData(data: any):辞書から値をノードに反映する
  • 外部依存禁止のため、「グローバルなレジストリ」も内部的に static で保持し、必要であれば他スクリプトから SaveDataSync.getGroupMap() 経由で参照できるようにする。
  • どの値を同期するかは、インスペクタから柔軟に指定可能にする。

2. 同期対象の設計

Cocos Creator 3.8 では、インスペクタから配列の要素を編集できるため、「同期したいエントリ」を配列で列挙する方式にします。

1つのエントリは次の情報を持ちます:

  • key: セーブ辞書でのキー名(例: "hp", "gold"
  • type: 値の型(Number / Boolean / String / Vec3(位置) / Quat(回転) / Scale など)
  • mode: 値の同期モード
    • NODE_BUILTIN: ノードのビルトイン情報(position, rotation, scale など)
    • CUSTOM_NUMBER, CUSTOM_BOOLEAN, CUSTOM_STRING など:コンポーネント内部のカスタム値
  • targetBuiltin: ビルトイン対象種別(Position / Rotation / Scale など)
  • default 値: ロードデータに存在しない場合に使う初期値

このガイドでは、「ノードの位置・回転・スケール」+「カスタムの数値/真偽値/文字列」を扱えるようにしつつ、設計が拡張しやすい形に留めます。

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

今回の SaveDataSync でインスペクタから編集できるプロパティは以下の通りです。

  • groupId: string
    • ツールチップ: 同一セーブグループを識別するID。セーブシステム側がこのID単位でデータをまとめて扱えます。
    • 用途: 外部のセーブシステムが「どのグループのデータか」を区別するためのラベル。
    • 例: "Player", "UI_MainMenu", "Stage1_Objects"
  • entryId: string
    • ツールチップ: このノード固有のエントリID。同一groupId内で一意になるように設定してください。
    • 用途: 同じ groupId 内で複数の SaveDataSync を区別するための ID。
    • 例: "PlayerCore", "DoorA", "Panel_Left"
  • autoRegister: boolean
    • ツールチップ: 有効にすると、onLoad時にグローバルレジストリへ自動登録されます。
    • 用途: 外部スクリプトが SaveDataSync.getGroupMap() で一覧取得できるようにするかどうか。
    • デフォルト: true
  • autoTrackNodeTransform: boolean
    • ツールチップ: 有効にすると、このノードのPosition/Rotation/Scaleを自動でセーブ対象に含めます。
    • 用途: 位置や回転などを毎回手動でエントリ登録しなくてもよくする。
    • デフォルト: true
  • entries: SaveEntryConfig[]
    • ツールチップ: カスタムでセーブしたい値のリスト。キー名と型、初期値を設定します。
    • 用途: 任意のカスタム値(数値/真偽値/文字列)をセーブ対象に追加できる。
    • 各要素の構成(後述のクラス SaveEntryConfig):
      • key: string — セーブ辞書のキー名
      • valueType: SaveValueType — Number / Boolean / String
      • defaultNumber: number
      • defaultBoolean: boolean
      • defaultString: string

この設計により、最低限の設定だけで「ノードの位置・回転・スケール」+「自由なカスタム値」を辞書化して扱えるようになります。


TypeScriptコードの実装

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


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

/**
 * セーブ対象の値の型
 */
enum SaveValueType {
    Number = 0,
    Boolean = 1,
    String = 2,
}

/**
 * インスペクタで個別に設定するカスタムエントリ
 * 例: hp, gold, isOpened, currentTab など
 */
@ccclass('SaveEntryConfig')
class SaveEntryConfig {
    @property({
        tooltip: 'セーブ辞書で使用するキー名。groupId + entryId + key で一意にする想定です。',
    })
    public key: string = '';

    @property({
        tooltip: 'このエントリの値の型を指定します(Number / Boolean / String)。',
        type: Number,
    })
    public valueType: SaveValueType = SaveValueType.Number;

    @property({
        tooltip: 'valueType が Number のときの初期値/デフォルト値。',
    })
    public defaultNumber: number = 0;

    @property({
        tooltip: 'valueType が Boolean のときの初期値/デフォルト値。',
    })
    public defaultBoolean: boolean = false;

    @property({
        tooltip: 'valueType が String のときの初期値/デフォルト値。',
    })
    public defaultString: string = '';
}

/**
 * SaveDataSync
 * 任意ノードにアタッチして、そのノードに紐づくセーブ対象データを
 * 「辞書」として集約・同期するための汎用コンポーネント。
 */
@ccclass('SaveDataSync')
export class SaveDataSync extends Component {

    // ==========================
    // 静的レジストリ(任意で利用)
    // ==========================

    /**
     * グローバルレジストリ:
     * groupId => (entryId => SaveDataSyncインスタンス)
     * 外部のセーブシステムから一覧取得したい場合に使用します。
     */
    private static _groupMap: Map<string, Map<string, SaveDataSync>> = new Map();

    /**
     * 現在のグループマップを取得します(読み取り専用想定)。
     * 例: SaveDataSync.getGroupMap().get('Player')?.get('PlayerCore')
     */
    public static getGroupMap(): Map<string, Map<string, SaveDataSync>> {
        return this._groupMap;
    }

    /**
     * 指定された groupId, entryId に対応する SaveDataSync を取得します。
     */
    public static getEntry(groupId: string, entryId: string): SaveDataSync | undefined {
        const group = this._groupMap.get(groupId);
        if (!group) return undefined;
        return group.get(entryId);
    }

    // ==========================
    // インスペクタ設定
    // ==========================

    @property({
        tooltip: '同一セーブグループを識別するID。セーブシステム側がこのID単位でデータをまとめて扱えます。\n例: "Player", "UI_MainMenu", "Stage1_Objects" など。',
    })
    public groupId: string = 'DefaultGroup';

    @property({
        tooltip: 'このノード固有のエントリID。同一groupId内で一意になるように設定してください。\n例: "PlayerCore", "DoorA", "Panel_Left" など。',
    })
    public entryId: string = '';

    @property({
        tooltip: '有効にすると、onLoad時にグローバルレジストリへ自動登録されます。\n外部スクリプトから SaveDataSync.getGroupMap() で参照できます。',
    })
    public autoRegister: boolean = true;

    @property({
        tooltip: '有効にすると、このノードの Position / Rotation / Scale を自動でセーブ対象に含めます。',
    })
    public autoTrackNodeTransform: boolean = true;

    @property({
        type: [SaveEntryConfig],
        tooltip: 'カスタムでセーブしたい値のリスト。キー名と型、初期値を設定します。\n例: hp, gold, isOpened, currentTab など。',
    })
    public entries: SaveEntryConfig[] = [];

    // ==========================
    // 内部状態
    // ==========================

    /**
     * カスタム値の実体を保持する辞書。
     * キー = SaveEntryConfig.key
     * 値 = number | boolean | string
     */
    private _customValues: Record<string, number | boolean | string> = {};

    onLoad() {
        // entryId が未設定なら、Node名をデフォルトにする(防御的実装)
        if (!this.entryId || this.entryId.trim().length === 0) {
            this.entryId = this.node.name;
            console.warn(
                `[SaveDataSync] entryId が未設定だったため、Node名 "${this.node.name}" を entryId として使用します。`,
            );
        }

        // カスタム値辞書を初期化
        this._initCustomValuesFromConfig();

        // グローバルレジストリへの自動登録
        if (this.autoRegister) {
            this._registerToGlobalMap();
        }
    }

    onDestroy() {
        // グローバルレジストリからの削除
        this._unregisterFromGlobalMap();
    }

    // ==========================
    // 公開API:セーブ/ロード
    // ==========================

    /**
     * 現在の状態を辞書として返します。
     * 外部のセーブシステムは、この戻り値を JSON.stringify して保存するなどして利用します。
     *
     * 返却フォーマット例:
     * {
     *   groupId: "Player",
     *   entryId: "PlayerCore",
     *   transform: {
     *     position: { x: 0, y: 1, z: 0 },
     *     rotation: { x: 0, y: 0, z: 0, w: 1 },
     *     scale:    { x: 1, y: 1, z: 1 },
     *   },
     *   custom: {
     *     hp: 10,
     *     gold: 100,
     *     isOpened: false,
     *     playerName: "Hero"
     *   }
     * }
     */
    public getSaveData(): any {
        const node = this.node;
        const data: any = {
            groupId: this.groupId,
            entryId: this.entryId,
        };

        if (this.autoTrackNodeTransform && node) {
            const pos = node.position;
            const rot = node.rotation;
            const scale = node.scale;

            data.transform = {
                position: { x: pos.x, y: pos.y, z: pos.z },
                rotation: { x: rot.x, y: rot.y, z: rot.z, w: rot.w },
                scale: { x: scale.x, y: scale.y, z: scale.z },
            };
        }

        // カスタム値
        data.custom = { ...this._customValues };

        return data;
    }

    /**
     * セーブデータを元に、このノードの状態を復元します。
     * data は getSaveData() で取得したオブジェクト、または同等の構造を持つオブジェクトを想定します。
     */
    public applySaveData(data: any): void {
        if (!data || typeof data !== 'object') {
            console.error('[SaveDataSync] applySaveData: data が不正です。', data);
            return;
        }

        // groupId, entryId は基本的に読み取り専用扱いだが、
        // 必要に応じてチェックすることも可能
        if (data.groupId !== undefined && data.groupId !== this.groupId) {
            console.warn(
                `[SaveDataSync] applySaveData: data.groupId (${data.groupId}) がこのコンポーネントの groupId (${this.groupId}) と異なります。`,
            );
        }
        if (data.entryId !== undefined && data.entryId !== this.entryId) {
            console.warn(
                `[SaveDataSync] applySaveData: data.entryId (${data.entryId}) がこのコンポーネントの entryId (${this.entryId}) と異なります。`,
            );
        }

        // Transform の復元
        if (this.autoTrackNodeTransform && data.transform && this.node) {
            const t = data.transform;

            if (t.position) {
                const p = t.position;
                this.node.setPosition(new Vec3(p.x ?? 0, p.y ?? 0, p.z ?? 0));
            }

            if (t.rotation) {
                const r = t.rotation;
                this.node.setRotation(
                    new Quat(
                        r.x ?? 0,
                        r.y ?? 0,
                        r.z ?? 0,
                        r.w ?? 1,
                    ),
                );
            }

            if (t.scale) {
                const s = t.scale;
                this.node.setScale(new Vec3(s.x ?? 1, s.y ?? 1, s.z ?? 1));
            }
        }

        // カスタム値の復元
        if (data.custom && typeof data.custom === 'object') {
            const customData = data.custom;
            for (const cfg of this.entries) {
                const key = cfg.key;
                if (!key) continue;

                if (Object.prototype.hasOwnProperty.call(customData, key)) {
                    const raw = customData[key];

                    switch (cfg.valueType) {
                        case SaveValueType.Number:
                            this._customValues[key] = Number(raw);
                            break;
                        case SaveValueType.Boolean:
                            this._customValues[key] = Boolean(raw);
                            break;
                        case SaveValueType.String:
                            this._customValues[key] = String(raw);
                            break;
                    }
                } else {
                    // データに存在しない場合は default 値を再適用
                    this._applyDefaultToCustomValue(cfg);
                }
            }
        }
    }

    // ==========================
    // 公開API:カスタム値の操作
    // ==========================

    /**
     * カスタム値を取得します。
     * key は entries 配列で設定したキー名を指定してください。
     */
    public getCustomValue(key: string): number | boolean | string | undefined {
        return this._customValues[key];
    }

    /**
     * カスタム値を設定します。
     * 型チェックは緩やかに行い、Number/Boolean/String に変換します。
     */
    public setCustomValue(key: string, value: any): void {
        const cfg = this.entries.find((c) => c.key === key);
        if (!cfg) {
            console.warn(
                `[SaveDataSync] setCustomValue: key="${key}" に対応する SaveEntryConfig が見つかりません。`,
            );
            return;
        }

        switch (cfg.valueType) {
            case SaveValueType.Number:
                this._customValues[key] = Number(value);
                break;
            case SaveValueType.Boolean:
                this._customValues[key] = Boolean(value);
                break;
            case SaveValueType.String:
                this._customValues[key] = String(value);
                break;
        }
    }

    // ==========================
    // 内部処理
    // ==========================

    /**
     * entries 設定を元に、_customValues を初期化します。
     */
    private _initCustomValuesFromConfig(): void {
        this._customValues = {};

        for (const cfg of this.entries) {
            if (!cfg.key) {
                console.warn(
                    '[SaveDataSync] entries 内に key が空の要素があります。無視されます。',
                    cfg,
                );
                continue;
            }
            this._applyDefaultToCustomValue(cfg);
        }
    }

    /**
     * 指定されたエントリ設定に基づき、_customValues に default 値を適用します。
     */
    private _applyDefaultToCustomValue(cfg: SaveEntryConfig): void {
        switch (cfg.valueType) {
            case SaveValueType.Number:
                this._customValues[cfg.key] = cfg.defaultNumber;
                break;
            case SaveValueType.Boolean:
                this._customValues[cfg.key] = cfg.defaultBoolean;
                break;
            case SaveValueType.String:
                this._customValues[cfg.key] = cfg.defaultString;
                break;
        }
    }

    /**
     * グローバルレジストリへ自身を登録します。
     */
    private _registerToGlobalMap(): void {
        const groupId = this.groupId || 'DefaultGroup';
        const entryId = this.entryId || this.node.name;

        let group = SaveDataSync._groupMap.get(groupId);
        if (!group) {
            group = new Map<string, SaveDataSync>();
            SaveDataSync._groupMap.set(groupId, group);
        }

        if (group.has(entryId)) {
            console.warn(
                `[SaveDataSync] 同一 groupId="${groupId}" & entryId="${entryId}" のエントリが既に存在します。上書き登録します。`,
            );
        }

        group.set(entryId, this);
    }

    /**
     * グローバルレジストリから自身を削除します。
     */
    private _unregisterFromGlobalMap(): void {
        const groupId = this.groupId;
        const entryId = this.entryId;

        const group = SaveDataSync._groupMap.get(groupId);
        if (!group) return;

        if (group.get(entryId) === this) {
            group.delete(entryId);
        }

        if (group.size === 0) {
            SaveDataSync._groupMap.delete(groupId);
        }
    }
}

コードの要点解説

  • SaveEntryConfig
    • インスペクタから編集する「1つのカスタムセーブ項目」を表すクラス。
    • keyvalueType、および型ごとのデフォルト値を持ちます。
  • SaveDataSync
    • onLoad()
      • entryId が空ならノード名で補完(防御的)。
      • _initCustomValuesFromConfig() でカスタム値を初期化。
      • autoRegister が true なら _registerToGlobalMap() で static レジストリに登録。
    • onDestroy()
      • ノード破棄時に _unregisterFromGlobalMap() でレジストリから削除。
    • getSaveData()
      • groupId / entryId を含むルートオブジェクトを作成。
      • autoTrackNodeTransform が true なら position / rotation / scale を transform に詰める。
      • カスタム値辞書 _customValuescustom としてコピー。
    • applySaveData(data)
      • 渡されたオブジェクトから transform と custom を読み取り、ノードに反映。
      • transform が存在すれば setPosition, setRotation, setScale で復元。
      • custom は entries に従って型変換しつつ _customValues に格納。
    • getCustomValue / setCustomValue
      • スクリプトから直接カスタム値を読み書きするための API。
    • static レジストリ:
      • _groupMapgroupId -> (entryId -> SaveDataSync) を保持。
      • 外部から SaveDataSync.getGroupMap() または SaveDataSync.getEntry() で参照可能。
      • これにより、外部セーブシステムが「シーン内の全 SaveDataSync を簡単に列挙」できます。

使用手順と動作確認

1. スクリプトファイルの作成

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

2. テスト用ノードの作成

  1. Hierarchy パネルで右クリック → Create → 3D Object → Cube など、適当なノードを作成します。
    • ここでは例として Player という名前に変更しておきます。

3. SaveDataSync コンポーネントをアタッチ

  1. Hierarchy で先ほど作成した Player ノードを選択します。
  2. Inspector の下部で Add Component ボタンをクリックします。
  3. Custom カテゴリから SaveDataSync を選択してアタッチします。

4. インスペクタでプロパティを設定

Player ノードの Inspector に表示される SaveDataSync のプロパティを次のように設定してみます。

  • groupId: Player
  • entryId: PlayerCore
  • autoRegister: チェック(true のまま)
  • autoTrackNodeTransform: チェック(true のまま)
  • entries:
    1. 右側の + ボタンを押して要素を 3 つ追加します。
    2. 1つ目:
      • key: hp
      • valueType: Number
      • defaultNumber: 10
    3. 2つ目:
      • key: gold
      • valueType: Number
      • defaultNumber: 100
    4. 3つ目:
      • key: playerName
      • valueType: String
      • defaultString: Hero

5. 動作確認用の簡単なスクリプト例

実際に getSaveData()applySaveData() が動いているか確認するために、簡単なテストスクリプトを追加してみます。
このスクリプトは 任意のノードにアタッチして構いません(例えば Canvas)。


import { _decorator, Component, sys } from 'cc';
import { SaveDataSync } from './SaveDataSync';
const { ccclass, property } = _decorator;

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

    @property({
        type: SaveDataSync,
        tooltip: 'テスト対象の SaveDataSync コンポーネントをドラッグ&ドロップで指定します。',
    })
    public target: SaveDataSync | null = null;

    start() {
        if (!this.target) {
            console.error('[SaveDataSyncTester] target が指定されていません。');
            return;
        }

        // 1. 現在のセーブデータを取得してログ出力
        const data = this.target.getSaveData();
        console.log('[SaveDataSyncTester] 初期セーブデータ:', JSON.stringify(data));

        // 2. カスタム値を書き換えてから、もう一度セーブデータを取得
        this.target.setCustomValue('hp', 50);
        this.target.setCustomValue('gold', 999);
        this.target.setCustomValue('playerName', 'Legend');

        const modified = this.target.getSaveData();
        console.log('[SaveDataSyncTester] 変更後セーブデータ:', JSON.stringify(modified));

        // 3. JSON 経由で保存されたものを復元するイメージ
        const json = JSON.stringify(modified);
        const loaded = JSON.parse(json);

        // 位置などを変えてから apply すると、Transform が戻ることも確認できます
        // this.target.node.setPosition(0, 10, 0);

        this.target.applySaveData(loaded);
        console.log(
            '[SaveDataSyncTester] applySaveData 実行後の hp:',
            this.target.getCustomValue('hp'),
        );

        // 実際のゲームでは、ここで sys.localStorage やファイルIOと組み合わせます。
        // 例:
        sys.localStorage.setItem('TEST_SAVE', json);
        const restoredJson = sys.localStorage.getItem('TEST_SAVE');
        if (restoredJson) {
            const restoredData = JSON.parse(restoredJson);
            this.target.applySaveData(restoredData);
        }
    }
}

このテストスクリプトの使い方:

  1. Assets パネルで SaveDataSyncTester.ts を作成し、上記コードを貼り付けて保存します。
  2. Hierarchy で Canvas などのノードを選択し、Add Component → Custom → SaveDataSyncTester を追加します。
  3. SaveDataSyncTestertarget プロパティに、先ほど SaveDataSync をアタッチした Player ノードをドラッグ&ドロップします。
  4. エディタ右上の ▶︎ でゲームを再生し、Console ログを確認します。

ログに 初期セーブデータ変更後セーブデータ が JSON 形式で出力され、applySaveData 実行後の値が反映されていることが確認できれば成功です。

6. グローバルレジストリ経由でのアクセス確認

autoRegister を有効にしている場合、SaveDataSync は自動的に static レジストリに登録されます。
例えば、シーン内のすべての Player グループのセーブデータをまとめて取得したいときは、次のようにします。


import { _decorator, Component } from 'cc';
import { SaveDataSync } from './SaveDataSync';
const { ccclass } = _decorator;

@ccclass('SaveDataCollector')
export class SaveDataCollector extends Component {
    start() {
        const groupMap = SaveDataSync.getGroupMap();
        const playerGroup = groupMap.get('Player');
        if (!playerGroup) {
            console.warn('[SaveDataCollector] Player グループが見つかりません。');
            return;
        }

        const allData: any[] = [];
        playerGroup.forEach((sync, entryId) => {
            allData.push(sync.getSaveData());
        });

        console.log('[SaveDataCollector] Player グループの全セーブデータ:', allData);
    }
}

このように、シーン内の SaveDataSync を「groupId / entryId」ベースで一覧取得できるため、外部のセーブシステムは簡単に全データを収集・保存できます。


まとめ

このガイドでは、Cocos Creator 3.8 向けに完全に独立した汎用コンポーネントとして SaveDataSync を実装しました。

  • アタッチするだけで
    • ノードの位置・回転・スケール(任意)
    • 任意のカスタム値(数値/真偽値/文字列)

    を辞書化して扱えるようになりました。

  • 外部のセーブシステムは:
    • getSaveData() で辞書を取得し、JSON 化して保存
    • applySaveData() で辞書から復元
    • SaveDataSync.getGroupMap() でシーン内のすべてのセーブ対象を一括列挙

    するだけで済みます。

  • 外部の GameManager やシングルトンに依存せず、このスクリプト単体で完結しているため、プロジェクト間の再利用性も高いです。

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

  • 値の型に Vec2 / Vec3 / Color / Enum などを追加する
  • 特定のコンポーネント(Sprite, Label など)のプロパティをセーブ対象にする
  • セーブデータのバージョン管理やマイグレーション処理を追加する

まずは本記事の SaveDataSync をベースに、プロジェクトのセーブ仕様に合わせて少しずつカスタマイズしてみてください。
「セーブ対象化」をノード単位で簡単に付け外しできるようになることで、ゲーム全体の設計と保守が大きく楽になります。

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をコピーしました!