【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 / StringdefaultNumber: numberdefaultBoolean: booleandefaultString: 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つのカスタムセーブ項目」を表すクラス。
keyとvalueType、および型ごとのデフォルト値を持ちます。
SaveDataSynconLoad():entryIdが空ならノード名で補完(防御的)。_initCustomValuesFromConfig()でカスタム値を初期化。autoRegisterが true なら_registerToGlobalMap()で static レジストリに登録。
onDestroy():- ノード破棄時に
_unregisterFromGlobalMap()でレジストリから削除。
- ノード破棄時に
getSaveData():- groupId / entryId を含むルートオブジェクトを作成。
autoTrackNodeTransformが true なら position / rotation / scale をtransformに詰める。- カスタム値辞書
_customValuesをcustomとしてコピー。
applySaveData(data):- 渡されたオブジェクトから transform と custom を読み取り、ノードに反映。
- transform が存在すれば
setPosition,setRotation,setScaleで復元。 - custom は
entriesに従って型変換しつつ_customValuesに格納。
getCustomValue / setCustomValue:- スクリプトから直接カスタム値を読み書きするための API。
- static レジストリ:
_groupMapにgroupId -> (entryId -> SaveDataSync)を保持。- 外部から
SaveDataSync.getGroupMap()またはSaveDataSync.getEntry()で参照可能。 - これにより、外部セーブシステムが「シーン内の全 SaveDataSync を簡単に列挙」できます。
使用手順と動作確認
1. スクリプトファイルの作成
- Assets パネルで右クリックします。
Create → TypeScriptを選択し、ファイル名をSaveDataSync.tsにします。- 作成された
SaveDataSync.tsをダブルクリックして開き、上記コードをそのまま貼り付けて保存します。
2. テスト用ノードの作成
- Hierarchy パネルで右クリック →
Create → 3D Object → Cubeなど、適当なノードを作成します。- ここでは例として
Playerという名前に変更しておきます。
- ここでは例として
3. SaveDataSync コンポーネントをアタッチ
- Hierarchy で先ほど作成した
Playerノードを選択します。 - Inspector の下部で
Add Componentボタンをクリックします。 CustomカテゴリからSaveDataSyncを選択してアタッチします。
4. インスペクタでプロパティを設定
Player ノードの Inspector に表示される SaveDataSync のプロパティを次のように設定してみます。
- groupId:
Player - entryId:
PlayerCore - autoRegister: チェック(true のまま)
- autoTrackNodeTransform: チェック(true のまま)
- entries:
- 右側の
+ボタンを押して要素を 3 つ追加します。 - 1つ目:
key:hpvalueType:NumberdefaultNumber:10
- 2つ目:
key:goldvalueType:NumberdefaultNumber:100
- 3つ目:
key:playerNamevalueType:StringdefaultString: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);
}
}
}
このテストスクリプトの使い方:
- Assets パネルで
SaveDataSyncTester.tsを作成し、上記コードを貼り付けて保存します。 - Hierarchy で
Canvasなどのノードを選択し、Add Component → Custom → SaveDataSyncTesterを追加します。 SaveDataSyncTesterのtargetプロパティに、先ほどSaveDataSyncをアタッチしたPlayerノードをドラッグ&ドロップします。- エディタ右上の
▶︎でゲームを再生し、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 をベースに、プロジェクトのセーブ仕様に合わせて少しずつカスタマイズしてみてください。
「セーブ対象化」をノード単位で簡単に付け外しできるようになることで、ゲーム全体の設計と保守が大きく楽になります。




