【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 スクリプトの作成
- Assets パネルで右クリック → Create → TypeScript を選択します。
- ファイル名を SaveSystem.ts に変更します。
- 作成した
SaveSystem.tsをダブルクリックして開き、上記のコードを丸ごと貼り付けて保存します。
2. テスト用コンポーネントの作成(例:PlayerStatus)
SaveSystem は「任意のコンポーネント」のプロパティを保存できますが、動作確認用に簡単なコンポーネントを作っておきます。
- Assets パネルで右クリック → Create → TypeScript を選択します。
- ファイル名を PlayerStatus.ts に変更します。
- 以下のような簡易スクリプトを貼り付けます:
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. シーンにノードを配置してコンポーネントをアタッチ
- Hierarchy パネルで右クリック → Create → 3D Object → Node(または 2D Object → Sprite など)を選択し、ノード名を Player に変更します。
- Player ノードを選択し、Inspector の Add Component → Custom → PlayerStatus を選択してアタッチします。
- 同様に、セーブ管理用のノードを作成します:
- 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 からセーブ/ロードを試したい場合の設定手順です。
- Hierarchy パネルで右クリック → Create → UI → Button を 2 つ作成し、それぞれ SaveButton, LoadButton と名前を付けます。
- SaveButton を選択し、Inspector の Button コンポーネントの「Click Events」を展開します。
- 「+」ボタンを押して新しいイベントを追加します。
- 追加されたイベントの Target 欄に、SaveManager ノード をドラッグ&ドロップします。
- その下のドロップダウンから SaveSystem → onClickSaveButton を選択します。
- 同様に、LoadButton の Click Events に onClickLoadButton を設定します。
6. 実行して動作確認
- エディタ上部の「Play」ボタンを押してゲームを実行します。
- 初期状態:
- Player ノードの
PlayerStatusコンポーネントがhp=100, mp=50, level=1になっていることを確認します。
- Player ノードの
- 任意の値に変更:
- ゲーム中にスクリプトなどで値を変更してもいいですし、テスト目的なら一旦停止して Inspector から直接値を変えて再生し直しても構いません。
- 例:
hp=12, mp=7, level=3に変更した状態をセーブしたいとします。
- セーブ:
- ゲーム実行中に SaveButton をクリックします。
- Console に
[SaveSystem] ローカルストレージに保存しました (key: "game_save_slot_1")。などのログが出力されていれば成功です。
- 値をリセット:
- ゲームを停止し、PlayerStatus の値を別の値に変更します(例:
hp=999, mp=999, level=99)。
- ゲームを停止し、PlayerStatus の値を別の値に変更します(例:
- ロード:
- 再度ゲームを実行し、LoadButton をクリックします。
- Console に
[SaveSystem] ローカルストレージから読み込みました ...のログが出て、PlayerStatus の値がセーブ時のhp=12, mp=7, level=3に戻れば成功です。
7. 自動ロード/自動セーブの活用
一度動作確認ができたら、以下のように自動化設定を行うと便利です。
- 起動時に自動ロード:
- SaveSystem の autoLoadOnStart を
trueにすると、シーン開始時に自動でロードされます。
- SaveSystem の autoLoadOnStart を
- シーン破棄時に自動セーブ:
- autoSaveOnDestroy を
trueにすると、シーンを抜ける際などに自動でセーブされます。
- autoSaveOnDestroy を
まとめ
この SaveSystem コンポーネントは、「対象ノード」「対象コンポーネント名」「対象プロパティ名」 をインスペクタで指定するだけで、
任意のデータを JSON 化してローカルストレージに保存・読み込みできる汎用セーブ機構です。
- 外部の GameManager や Singleton に依存せず、SaveSystem.ts 単体で完結しているため、どのプロジェクトにも簡単に持ち込めます。
- 保存対象のコンポーネントやプロパティ名を変えるだけで、プレイヤー情報、敵の状態、設定値など、さまざまなデータを一括管理できます。
- JSON 形式なので、デバッグ時に中身を確認しやすく、将来的な拡張(ファイル出力、サーバー送信など)も行いやすい構造になっています。
応用としては、以下のような使い方が考えられます:
- 複数のセーブスロット用に
saveKeyを切り替え、セーブ/ロード画面を実装する。 - プレイヤーだけでなく、ステージ進行度、開放済みアイテムなどのフラグもまとめて保存する。
- デバッグ用に
exportToJson()の結果をログに出し、ゲームバランス調整に活用する。
このコンポーネントをベースに、UUID ベースのキー管理 や 暗号化、クラウド保存 などを追加していけば、より本格的なセーブシステムに発展させることも可能です。
まずは本記事の内容をそのまま実装し、シンプルなセーブ&ロードから試してみてください。




