はじめに
こちらの記事は、Axmol Engine なるものを使ってバイブコーディングでどこまでできるかを、検証と勉学を兼ねた試験的な目論見となります!
Axmol Engine(Cocos2d-x派生)で脱出ゲームを作る過程で、ダイヤルロックのギミックやステージ管理をどのようにJSONで設定化していったかを、実装の流れに沿って紹介します。
- 第1回【Axmol Engine】 バイブコーディングで脱出ゲームを作る[ 1 ]:スプラッシュ画面シーンを実装する
- 第2回【Axmol Engine】 バイブコーディングで脱出ゲームを作る[ 2 ]:タイトルシーンを作って画像を表示する(iPhone/Android対応)
- 第3回【Axmol Engine】 バイブコーディングで脱出ゲームを作る[ 3 ]:タップした領域への移動
- 第4回【Axmol Engine】 バイブコーディングで脱出ゲームを作る[ 4 ]:ノードを使用してポップアップを実装する
- 第5回【Axmol Engine】 バイブコーディングで脱出ゲームを作る[ 5 ]:テクスチャアトラスでダイヤル錠UIを実装する
- 第6回【Axmol Engine】 バイブコーディングで脱出ゲームを作る[ 6 ]:リファクタリング(メモリ消費対応、ステージをJSON管理、処理修正)+戻るボタン設置
- 第7回【Axmol Engine】 バイブコーディングで脱出ゲームを作る[ 7 ]:ローカルストレージでステージ進捗管理・ギミックをJSONで管理] (今回)
やったこと
- ギミック情報のJSON化
- ステージ情報のJSON構造修正(ステージ進捗管理のため)
- ギミックをリセットするボタンの配置
- ギミックの答え合わせボタンの配置
- ギミックの正解時にステージ進捗を進める
- ステージの進捗情報をローカルストレージに保存
- 現在いるステージをローカルストレージに保存(ゲームを終了して再開時に、最後にいたステージから始まる)
ファイル構成
EscapeGame/
├── Content/
│ ├── bg_stage_002_2.png // 扉を開いたポストの背景画像
│ ├── btn_reload.png // ギミックのリセットボタン
│ ├── btn_ok.png // ギミックの決定ボタン
│ ├── stages.json // ステージ情報定義JSON
│ └── gimmicks.json // ギミック情報定義JSON
└── Source/
├── MainScene.h
└── MainScene.cpp
ダイヤルのギミックを解くと、ポストが開くようにするパターンの実装です

bg_stage_002_2.png (引き続きChatGPTに作ってもらいました)
Step 1. ポップアップにリロードボタンを追加する
ポップアップのダイヤルの左側に btn_reload.png をボタンとして表示
まず showPopup() 内で、frame_popup.png の左端を基準にボタンを配置します。
auto reloadItem = MenuItemImage::create(
"btn_reload.png", "btn_reload.png",
[this, overlay](Object*) {
_dialDigits[0] = _dialDigits[1] = _dialDigits[2] = 0;
auto* baseNode = overlay->getChildByTag(250);
if (baseNode) {
for (int i = 0; i < 3; i++) {
auto* dial = dynamic_cast<Sprite*>(baseNode->getChildByTag(301 + i));
if (dial)
dial->setSpriteFrame(SpriteFrameCache::getInstance()
->getSpriteFrameByName("btn_1_0.png"));
}
}
});
if (reloadItem)
{
constexpr float margin = 20.0f;
float reloadX = visibleSize.width / 2
- popup->getContentSize().width / 2
- margin
- reloadItem->getContentSize().width / 2;
reloadItem->setPosition(Vec2(reloadX, visibleSize.height / 2 + 50));
auto reloadMenu = Menu::create(reloadItem, nullptr);
reloadMenu->setPosition(Vec2::ZERO);
overlay->addChild(reloadMenu, 1);
}
Step 2. ダイヤルギミックをJSONで設定化する
ダイヤルのギミックを設定化したい
{
"dial1": {
"type": "1",
"bg": "dial/base.png",
"buttons": "dial/spritesheet.json",
"file": "btn_1_%d.png",
"map": ["0","1","2","3","4","5","6","7","8","9"],
"num": 3,
"default": "000",
"collect": "678",
"touches": [
{"rect": [48, 40, 180, 400]},
{"rect": [228, 40, 180, 400]},
{"rect": [408, 40, 180, 400]}
]
}
}
ハードコードされていた値をすべて gimmicks.json に移し、C++ 側に GimmickDef 構造体を定義します。
gimmicks.json フィールド定義
| フィールド | 説明 |
|---|---|
type |
ギミック種別("1" = ダイヤルロック) |
bg |
ベース画像パス |
buttons |
スプライトシートJSONパス |
file |
フレーム名パターン(%d = インデックス) |
map |
インデックスと表示値の対応表 |
num |
ダイヤルの桁数 |
default |
各桁の初期値 |
collect |
正解の文字列 |
touches |
各桁のタッチ矩形(ベース画像座標系) |
C++ 構造体
struct GimmickDef {
std::string type;
std::string bg;
std::string buttons;
std::string filePattern;
std::vector<std::string> map;
int num = 1;
std::vector<int> defaultIndices;
std::string collect;
std::vector<ax::Rect> touches;
};
static std::unordered_map<std::string, GimmickDef> s_gimmickDefs;
loadGimmickDefs()
static void loadGimmickDefs()
{
auto json = ax::FileUtils::getInstance()->getStringFromFile("gimmicks.json");
rapidjson::Document doc;
doc.Parse(json.c_str());
if (doc.HasParseError() || !doc.IsObject())
return;
s_gimmickDefs.clear();
for (auto it = doc.MemberBegin(); it != doc.MemberEnd(); ++it)
{
std::string key = it->name.GetString();
const auto& entry = it->value;
GimmickDef def;
def.type = entry["type"].GetString();
def.bg = entry["bg"].GetString();
def.buttons = entry["buttons"].GetString();
def.filePattern = entry["file"].GetString();
def.num = entry["num"].GetInt();
def.collect = entry["collect"].GetString();
for (const auto& v : entry["map"].GetArray())
def.map.push_back(v.GetString());
// default文字列の各文字をmapのインデックスに変換
std::string defVal = entry["default"].GetString();
for (char c : defVal)
{
std::string ch(1, c);
int idx = 0;
for (int j = 0; j < (int)def.map.size(); j++)
if (def.map[j] == ch) { idx = j; break; }
def.defaultIndices.push_back(idx);
}
for (const auto& t : entry["touches"].GetArray())
{
const auto& r = t["rect"].GetArray();
def.touches.push_back(ax::Rect(r[0].GetFloat(), r[1].GetFloat(),
r[2].GetFloat(), r[3].GetFloat()));
}
s_gimmickDefs.emplace(key, std::move(def));
}
}
Step 3. type による条件分岐
type: "1" であれば、という条件を追加しておいてください
showGimmick() の先頭で type チェックを行い、将来の拡張に備えます。
void MainScene::showGimmick(const std::string& id)
{
auto it = s_gimmickDefs.find(id);
if (it == s_gimmickDefs.end())
return;
const GimmickDef& def = it->second;
if (def.type != "1") // type "1" = ダイヤルロックのみ処理
return;
// ... 以降の処理
}
Step 4. default を桁ごとに設定できるようにする
gimmicks.json の default を全要素同一指定から、各ボタンごとのデフォルト値設定にしましたので関連箇所を修正
"default": "000" の各文字が対応する桁のデフォルト値になります。"default": "357" のように書けば桁ごとに異なる初期値を設定できます。
defaultIdx: int(単一値)から defaultIndices: vector<int>(桁ごとの値)に変更します。
// Before
int defaultIdx = 0;
// After
std::vector<int> defaultIndices;
ロード時:
std::string defVal = entry["default"].GetString();
for (char c : defVal)
{
std::string ch(1, c);
int idx = 0;
for (int j = 0; j < (int)def.map.size(); j++)
if (def.map[j] == ch) { idx = j; break; }
def.defaultIndices.push_back(idx);
}
showGimmick() での初期化:
_dialDigits = def.defaultIndices; // assign(num, 単一値) から変更
Step 5. リロードボタン・OKボタンと正解判定
frame_popup.png の右に btn_ok.png のボタンを配置。押されたら、現在のギミックの選択中の値と collect の値を照らし合わせ、同値であれば、ローカルストレージの stageData に stage の値をキーとして stageStatus の値を代入して保存。その後、goStage のステージに遷移。
この時点で gimmicks.json に stage・stageStatus・goStage フィールドを追加します。
{
"dial1": {
"type": "1",
"bg": "dial/base.png",
"buttons": "dial/spritesheet.json",
"file": "btn_1_%d.png",
"map": ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"],
"num": 3,
"default": "000",
"collect": "678",
"stage": "post",
"stageStatus": 1,
"goStage": "post",
"touches": [
{"rect": [48, 40, 180, 400]},
{"rect": [228, 40, 180, 400]},
{"rect": [408, 40, 180, 400]}
]
}
}
GimmickDef に追加:
std::string saveStage; // JSON: "stage"
int saveStatus = 0; // JSON: "stageStatus"
std::string goStage;
リロードボタン(左)
auto reloadItem = MenuItemImage::create(
"btn_reload.png", "btn_reload.png",
[this, overlay, def](Object*) {
_dialDigits = def.defaultIndices;
auto* baseNode = overlay->getChildByTag(250);
if (!baseNode) return;
char frameName[128];
for (int i = 0; i < def.num; i++)
{
auto* dial = dynamic_cast<Sprite*>(baseNode->getChildByTag(301 + i));
if (!dial) continue;
snprintf(frameName, sizeof(frameName),
def.filePattern.c_str(), def.defaultIndices[i]);
dial->setSpriteFrame(SpriteFrameCache::getInstance()
->getSpriteFrameByName(frameName));
}
});
if (reloadItem)
{
constexpr float margin = 20.0f;
float x = visibleSize.width / 2
- popup->getContentSize().width / 2
- margin
- reloadItem->getContentSize().width / 2;
reloadItem->setPosition(Vec2(x, visibleSize.height / 2 + 50));
auto m = Menu::create(reloadItem, nullptr);
m->setPosition(Vec2::ZERO);
overlay->addChild(m, 1);
}
OKボタン(右)
auto okItem = MenuItemImage::create(
"btn_ok.png", "btn_ok.png",
[this, overlay, def](Object*) {
// collect と照合
bool correct = true;
for (int i = 0; i < def.num && i < (int)def.collect.size(); i++)
{
std::string expected(1, def.collect[i]);
int expectedIdx = 0;
for (int j = 0; j < (int)def.map.size(); j++)
if (def.map[j] == expected) { expectedIdx = j; break; }
if (_dialDigits[i] != expectedIdx) { correct = false; break; }
}
if (!correct) return;
// 正解:stageData を保存して goStage へ遷移
auto* ud = ax::UserDefault::getInstance();
if (!def.saveStage.empty())
ud->setIntegerForKey(("stageData_" + def.saveStage).c_str(), def.saveStatus);
ud->flush();
this->removeChildByTag(200);
_stage = def.goStage;
ud->setStringForKey("stage", _stage);
ud->flush();
showStageBg();
});
if (okItem)
{
constexpr float margin = 20.0f;
float x = visibleSize.width / 2
+ popup->getContentSize().width / 2
+ margin
+ okItem->getContentSize().width / 2;
okItem->setPosition(Vec2(x, visibleSize.height / 2 + 50));
auto m = Menu::create(okItem, nullptr);
m->setPosition(Vec2::ZERO);
overlay->addChild(m, 1);
}
Step 6. ステージを配列化して stageStatus で状態管理
stages.json の各オブジェクトを配列にしました。_stage で現在いるステージを管理していますが、_stage と新たに _stageStatus(何もなければ0で初期のオブジェクトを参照)で管理してください
各ステージ値をオブジェクトからオブジェクトの配列に変更します。_stageStatus が配列インデックスになります。
{
"post": [
{
"bg": "bg_stage_002.png",
"touches": [
{ "rect": [550, 51, 570, 760], "gimmick": "dial1" }
],
"back": "infront"
},
{
"bg": "bg_stage_002_unlocked.png",
"touches": [],
"back": "infront"
}
]
}
s_stageDefs の型を変更:
// Before
static std::unordered_map<std::string, StageDef> s_stageDefs;
// After
static std::unordered_map<std::string, std::vector<StageDef>> s_stageDefs;
loadStageDefs() でネストしてパース:
for (auto it = doc.MemberBegin(); it != doc.MemberEnd(); ++it)
{
std::string key = it->name.GetString();
std::vector<StageDef> defs;
for (const auto& entry : it->value.GetArray()) // ← 配列としてイテレート
{
StageDef def;
def.bg = entry["bg"].GetString();
def.back = entry.HasMember("back") ? entry["back"].GetString() : "";
// touches のパース...
defs.push_back(std::move(def));
}
s_stageDefs.emplace(key, std::move(defs));
}
onTouchesBegan() では _stageStatus をインデックスとして使用、ステージ遷移時に 0 にリセット:
auto it = s_stageDefs.find(_stage);
if (it == s_stageDefs.end() || _stageStatus >= (int)it->second.size())
return;
for (const auto& zone : it->second[_stageStatus].touches)
{
if (!zone.rect.containsPoint(local)) continue;
if (!zone.gimmick.empty())
showGimmick(zone.gimmick);
else if (!zone.stage.empty())
{
_stage = zone.stage;
_stageStatus = 0; // 新ステージは0から
showStageBg();
}
return;
}
Step 7. showStageBg() でローカルストレージから stageStatus を復元
showStageBg() 実行時に、_stageStatus = ud->getIntegerForKey(("stageData_" + _stage).c_str(), 0); を実行してください
showStageBg() の先頭でローカルストレージから _stageStatus をロードします。これにより、戻るボタンや任意のステージ遷移でも常に正しい状態が参照されます。
void MainScene::showStageBg()
{
this->removeChildByTag(300);
// ローカルストレージからステータスをロード
_stageStatus = ax::UserDefault::getInstance()
->getIntegerForKey(("stageData_" + _stage).c_str(), 0);
std::string stage = _stage;
int stageStatus = _stageStatus; // ラムダにコピーキャプチャ
auto loadAndFadeIn = [this, stage, stageStatus, visibleSize, origin, safeArea, duration]() {
auto it = s_stageDefs.find(stage);
if (it == s_stageDefs.end() || stageStatus >= (int)it->second.size())
return;
const auto& def = it->second[stageStatus];
// ... 背景スプライト生成
};
// ...
}
Step 8. _stage の変更をローカルストレージに永続化
_stage が変更されるたびにローカルストレージの "stage" を更新してください。メインシーン初期化の時点で、_stage にそのローカルストレージの値をセットしてください
_stage を更新するすべての箇所でローカルストレージに保存します。
// ① init() での復元
loadStageDefs();
loadGimmickDefs();
_stage = ax::UserDefault::getInstance()->getStringForKey("stage", "start");
showStageBg();
// ② タッチによるステージ遷移
_stage = zone.stage;
ax::UserDefault::getInstance()->setStringForKey("stage", _stage);
ax::UserDefault::getInstance()->flush();
// ③ 戻るボタン
_stage = backStage;
ax::UserDefault::getInstance()->setStringForKey("stage", _stage);
ax::UserDefault::getInstance()->flush();
// ④ ギミック正解後の遷移
_stage = def.goStage;
ax::UserDefault::getInstance()->setStringForKey("stage", _stage);
ax::UserDefault::getInstance()->flush();
これにより一度ゲームを終了しても、再び始めた時に終了時にいたステージからゲームを開始できます
最終的なデータフロー
起動
└─ UserDefault["stage"] → _stage
└─ showStageBg()
└─ UserDefault["stageData_<stage>"] → _stageStatus
└─ s_stageDefs[_stage][_stageStatus] → 背景・タッチゾーン
タッチ
├─ stage あり → _stage 更新 → UserDefault 保存 → showStageBg()
└─ gimmick あり → showGimmick()
├─ ダイヤル操作(タップで数字を進める)
├─ リロードボタン → defaultIndices に戻す
└─ OKボタン
├─ collect と照合
└─ 正解なら UserDefault["stageData_<stage>"] = stageStatus を保存
→ goStage へ遷移
保存キー一覧(UserDefault)
| キー | 型 | 内容 |
|---|---|---|
"stage" |
string | 現在の _stage
|
"stageData_<ステージ名>" |
int | そのステージの _stageStatus
|
まとめ
段階的にリファクタリングすることで、最終的に以下の構成になりました。
-
stages.json ── ステージごとにオブジェクト配列を持ち、
_stageStatusでパズル前後の状態を切り替え -
gimmicks.json ── ダイヤルロックの全パラメータを外部化。
typeフィールドで将来の拡張に対応 -
UserDefault ──
_stageとstageData_*の2種類のキーで進捗を永続化
新しいステージやギミックを追加する際はJSONを編集するだけで済みます。
※但し、ギミックはまだ1パターンしか作成していないので、その他のパターンを作成する場合はコーディングは必要になります。







