4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Axmol Engine】 バイブコーディングで脱出ゲームを作る[ 7 ]:ローカルストレージでステージ進捗管理・ギミックをJSONで管理

4
Last updated at Posted at 2026-06-29

はじめに

こちらの記事は、Axmol Engine なるものを使ってバイブコーディングでどこまでできるかを、検証と勉学を兼ねた試験的な目論見となります!

Axmol Engine(Cocos2d-x派生)で脱出ゲームを作る過程で、ダイヤルロックのギミックやステージ管理をどのように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
bg_stage_002_2.png (引き続きChatGPTに作ってもらいました)

Step 1. ポップアップにリロードボタンを追加する

ポップアップのダイヤルの左側に btn_reload.png をボタンとして表示

bg_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.jsondefault を全要素同一指定から、各ボタンごとのデフォルト値設定にしましたので関連箇所を修正

"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 の値を照らし合わせ、同値であれば、ローカルストレージの stageDatastage の値をキーとして stageStatus の値を代入して保存。その後、goStage のステージに遷移。

btn_ok.png

この時点で gimmicks.jsonstagestageStatusgoStage フィールドを追加します。

{
  "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);
}

スクリーンショット 2026-06-29 23.58.08.png


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 ── _stagestageData_* の2種類のキーで進捗を永続化

新しいステージやギミックを追加する際はJSONを編集するだけで済みます。
※但し、ギミックはまだ1パターンしか作成していないので、その他のパターンを作成する場合はコーディングは必要になります。

成果物

ポストの前に立つ!
スクリーンショット 2026-06-30 0.13.50.png

ダイヤルがある・・・どうしよう?
スクリーンショット 2026-06-30 0.13.59.png

本来はどこかにヒントが・・・という流れですが、今回は「678」が閃きました!
スクリーンショット 2026-06-30 0.14.11.png

OKを押してポストを確認すると・・・
スクリーンショット 2026-06-30 0.14.19.png

開きました!
スクリーンショット 2026-06-30 0.14.27.png

4
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?