はじめに
Axmol Engine(Cocos2d-x 派生のオープンソース2Dゲームエンジン)で脱出ゲームを開発しています。
claudeにどんどん処理を書いてもらっていますが、メモリリークや処理の煩雑化が目立ってきましたのでバイブコーディングと言いつつ、ここは自分の目視で問題点を洗い出して、修正はclaudeにお任せするスタイルにしてみました
最初は「ステージが変わるたびに背景画像を差し替える」という処理を、ステージごとに if/else if で分岐してベタ書きしていました。この記事では、その実装を段階的にリファクタリングし、最終的にJSONで定義できるデータドリブンな設計に変えていく過程を紹介します。
アピールポイント
- 背景画像をステージ切り替わりで破棄&再読み込みすることで、メモリにやさしい作りに
- ステージ定義を設定化し、描画処理を共通化するリファクタリング
- 各ステージに「戻る」ボタンを JSON で設置し、ステージ間を自由に移動できるように
最初の実装(リファクタリング前)
init() で全ステージの背景スプライトをまとめて生成し、不要なものは opacity = 0 で隠すという実装でした。
// init() 内 — 全ステージ分を起動時にロード
auto bg = Sprite::create("bg_title.png");
bg->setTag(100);
this->addChild(bg, 0);
auto bgStage = Sprite::create("bg_stage_001.png");
bgStage->setOpacity(0);
bgStage->setTag(101);
this->addChild(bgStage, 1);
auto bgStage2 = Sprite::create("bg_stage_002.png");
bgStage2->setOpacity(0);
bgStage2->setTag(102);
this->addChild(bgStage2, 2);
タップ処理もステージごとに条件分岐:
void MainScene::onTouchesBegan(...) {
constexpr float duration = 0.6f;
if (_stage == 0) {
auto bgTitle = this->getChildByTag(100);
// ヒット判定...
_stage = 1;
bgTitle->runAction(FadeOut::create(duration));
getChildByTag(101)->runAction(FadeIn::create(duration));
}
else if (_stage == 1) {
auto bgStage = this->getChildByTag(101);
// ...
_stage = 2;
bgStage->runAction(FadeOut::create(duration));
getChildByTag(102)->runAction(FadeIn::create(duration));
}
else if (_stage == 2) {
// ポップアップ表示...
}
}
問題点:
- 全ステージ分の画像を起動時に一括ロード → メモリを無駄に消費
- ステージを追加するたびに複数箇所を修正する必要がある
Step 1:データ定義と共通描画メソッドへ
プロンプト:
現在、_stage の値ごとに条件で分けて bgStage、bgStage2 などに背景画像を個別に表示する処理を実装していますが、
[
{ path: "bg_title.png" },
{ path: "bg_stage_001.png" },
]
といった配列の添字 == _stage のような定義に変更して、一つのメソッドで描画するように。
メモリ使用量を抑えるため、上記メソッドが呼び出された時に背景画像を読み込みで描画してください。タグは不要で、Sprite* _bg を共通で使用する。
直前にすでに表示されている画像があればその画像を0.25秒でフェイドアウトした後にメモリから破棄してください。その後、今回の画像を0.25秒でフェイドインで表示してください。
_stage の値が切り替わるたびに上記を呼び出すように。
パス配列を定義し、showStageBg() という単一メソッドに描画処理を集約しました。
static const char* kStageBgPaths[] = {
"bg_title.png",
"bg_stage_001.png",
"bg_stage_002.png",
};
void MainScene::showStageBg()
{
constexpr float duration = 0.25f;
auto visibleSize = _director->getVisibleSize();
auto origin = _director->getVisibleOrigin();
int stage = _stage;
auto loadAndFadeIn = [this, stage, visibleSize, origin, duration]() {
auto sp = Sprite::create(kStageBgPaths[stage]);
sp->setAnchorPoint(Vec2::ANCHOR_MIDDLE);
float scale = visibleSize.height / sp->getContentSize().height;
sp->setScale(scale);
sp->setPosition(Vec2(origin.x + visibleSize.width / 2,
origin.y + visibleSize.height / 2));
sp->setOpacity(0);
this->addChild(sp, 0);
_bg = sp;
sp->runAction(FadeIn::create(duration));
};
if (_bg)
{
auto oldBg = _bg;
_bg = nullptr;
oldBg->runAction(Sequence::create(
FadeOut::create(duration),
CallFunc::create([this, oldBg, loadAndFadeIn]() {
oldBg->removeFromParent(); // 参照カウント0でテクスチャも解放
loadAndFadeIn();
}),
nullptr));
}
else
{
loadAndFadeIn();
}
}
ポイント: removeFromParent() で古いスプライトの参照カウントを0にし、テクスチャもメモリから解放されます。常に1枚だけ保持するため、メモリ効率が大幅に改善しました。
また _bg = nullptr にしてからフェードアウトを開始するため、遷移中に if (!_bg) return; が効いて二重遷移を防げます。
onTouchesBegan は _bg を直接参照し、遷移時に _stage を更新して showStageBg() を呼ぶだけになりました:
void MainScene::onTouchesBegan(...) {
if (!_bg) return; // 遷移中(フェードアウト中)はタップ無視
if (_stage == 0) {
// ヒット判定...
_stage = 1;
showStageBg();
}
else if (_stage == 1) { ... }
}
Step 2:タップ判定もデータに含める
プロンプト:
タップ判定で、各 _stage ごとに範囲を指定して判定していますが、これを kStageBgPaths に移行してください。
{
"bg": "bg_title.png",
"touches": [
{ "rect": ax::Rect(654, 0, 935, 841), "stage": 1 }
]
}
TouchZone 構造体と StageDef 構造体を定義し、タップ矩形もデータに含めました。
C++20 の指示付き初期化子(designated initializers)を使うと定義が読みやすくなります:
struct TouchZone {
ax::Rect rect;
int stage; // -1 = showPopup
};
struct StageDef {
const char* bg;
std::vector<TouchZone> touches;
};
static const StageDef kStageDefs[] = {
{
.bg = "bg_title.png",
.touches = { {.rect = ax::Rect(654, 0, 935, 841), .stage = 1} },
},
{
.bg = "bg_stage_001.png",
.touches = { {.rect = ax::Rect(250, 41, 350, 510), .stage = 2} },
},
{
.bg = "bg_stage_002.png",
.touches = { {.rect = ax::Rect(550, 51, 570, 760), .stage = -1} },
},
};
onTouchesBegan の if/else if の塊が、シンプルなループに変わりました:
auto local = _bg->convertToNodeSpace(touches[0]->getLocation());
const auto& def = kStageDefs[_stage];
for (const auto& zone : def.touches)
{
if (!zone.rect.containsPoint(local))
continue;
if (zone.stage >= 0) {
_stage = zone.stage;
showStageBg();
} else {
showPopup();
}
return;
}
Step 3:ステージ定義をJSONファイルへ
プロンプト:
kStageDefs は JSON で定義できますか?
(→ 可能です、と回答後)
JSON 化でお願いします
ステージを追加・修正するたびにリコンパイルが必要なのは不便です。Axmol に同梱の rapidjson と FileUtils を使って JSON ファイルから読み込む方式に変更しました。
rapidjson は既存の JsonArraySpriteSheetLoader.h 経由でインクルード済みなので、追加の #include は不要でした。
Content/stages.json(この時点)
[
{
"bg": "bg_title.png",
"touches": [{ "rect": [654, 0, 935, 841], "stage": 1 }]
},
{
"bg": "bg_stage_001.png",
"touches": [{ "rect": [250, 41, 350, 510], "stage": 2 }]
},
{
"bg": "bg_stage_002.png",
"touches": [{ "rect": [550, 51, 570, 760], "stage": -1 }]
}
]
static std::vector<StageDef> s_stageDefs;
static void loadStageDefs()
{
auto json = ax::FileUtils::getInstance()->getStringFromFile("stages.json");
rapidjson::Document doc;
doc.Parse(json.c_str());
if (doc.HasParseError() || !doc.IsArray())
return;
s_stageDefs.clear();
for (const auto& entry : doc.GetArray())
{
StageDef def;
def.bg = entry["bg"].GetString();
for (const auto& t : entry["touches"].GetArray())
{
const auto& r = t["rect"].GetArray();
def.touches.push_back({
.rect = ax::Rect(r[0].GetFloat(), r[1].GetFloat(),
r[2].GetFloat(), r[3].GetFloat()),
.stage = t["stage"].GetInt(),
});
}
s_stageDefs.push_back(std::move(def));
}
}
init() で loadStageDefs() → showStageBg() の順に呼ぶだけで準備完了です。
Step 4:JSONフォーマット改善と戻るボタンの追加
プロンプト:
stages.json を以下のように変更したいです。
- 各ステージを文字列のキー指定で設定
-
"stage"を数値から文字列に変更し、_stageも文字列型に -
"back"を追加。空文字でなければ、セーフゾーンより上に戻るボタンを表示して_stageに指定ステージをセットし、背景画像を再描画
配列インデックスでのステージ管理を改め、文字列キーで意味のある名前を付けられるようにしました。
Content/stages.json(最終形)
{
"start": {
"bg": "bg_title.png",
"touches": [
{ "rect": [654, 0, 935, 841], "stage": "infront" }
],
"back": ""
},
"infront": {
"bg": "bg_stage_001.png",
"touches": [
{ "rect": [250, 41, 350, 510], "stage": "post" }
],
"back": "start"
},
"post": {
"bg": "bg_stage_002.png",
"touches": [
{ "rect": [550, 51, 570, 760], "stage": "" }
],
"back": "infront"
}
}
構造体と型の変更
// MainScene.h
std::string _stage = "start";
// MainScene.cpp
struct TouchZone {
ax::Rect rect;
std::string stage; // empty = showPopup
};
struct StageDef {
std::string bg;
std::vector<TouchZone> touches;
std::string back; // empty = no back button
};
static std::unordered_map<std::string, StageDef> s_stageDefs;
JSONパース(オブジェクト形式)
for (auto it = doc.MemberBegin(); it != doc.MemberEnd(); ++it)
{
std::string key = it->name.GetString();
const auto& entry = it->value;
StageDef def;
def.bg = entry["bg"].GetString();
def.back = entry["back"].GetString();
for (const auto& t : entry["touches"].GetArray())
{
const auto& r = t["rect"].GetArray();
def.touches.push_back({
.rect = ax::Rect(r[0].GetFloat(), r[1].GetFloat(),
r[2].GetFloat(), r[3].GetFloat()),
.stage = t["stage"].GetString(),
});
}
s_stageDefs.emplace(key, std::move(def));
}
showStageBg() に戻るボタン管理を追加
void MainScene::showStageBg()
{
this->removeChildByTag(300); // 戻るボタンを即時撤去
constexpr float duration = 0.25f;
auto visibleSize = _director->getVisibleSize();
auto origin = _director->getVisibleOrigin();
auto safeArea = _director->getSafeAreaRect();
std::string stage = _stage;
auto loadAndFadeIn = [this, stage, visibleSize, origin, safeArea, duration]() {
auto it = s_stageDefs.find(stage);
if (it == s_stageDefs.end()) return;
const auto& def = it->second;
auto sp = Sprite::create(def.bg);
// ...配置・スケール設定...
_bg = sp;
// back が空でなければ戻るボタンを表示
if (!def.back.empty())
{
auto backItem = MenuItemImage::create(
"btn_back.png", "btn_back.png",
[this, backStage = def.back](Object*) {
_stage = backStage;
showStageBg();
});
constexpr float margin = 20.0f;
backItem->setPosition(Vec2(
visibleSize.width / 2,
safeArea.origin.y + margin + backItem->getContentSize().height / 2));
auto backMenu = Menu::create(backItem, nullptr);
backMenu->setPosition(Vec2::ZERO);
backMenu->setTag(300);
this->addChild(backMenu, 5);
}
sp->runAction(FadeIn::create(duration));
};
// ...フェードアウト → removeFromParent → loadAndFadeIn の流れは同じ
}
onTouchesBegan の遷移判定は文字列の空チェックに変わりました:
auto it = s_stageDefs.find(_stage);
if (it == s_stageDefs.end()) return;
auto local = _bg->convertToNodeSpace(touches[0]->getLocation());
for (const auto& zone : it->second.touches)
{
if (!zone.rect.containsPoint(local))
continue;
if (!zone.stage.empty()) {
_stage = zone.stage;
showStageBg();
} else {
showPopup();
}
return;
}
まとめ
| リファクタリング前 | リファクタリング後 | |
|---|---|---|
| メモリ | 全背景を起動時にロード | 表示中の1枚のみ保持 |
| ステージ追加 | C++ の複数箇所を修正 |
stages.json に1エントリ追加 |
| 戻る遷移 | 実装なし |
"back" フィールドで自動表示 |
| ステージID |
int(連番のみ) |
std::string(任意のキー) |
最終的な stages.json は場所の名前でステージを管理できるシンプルな構造になりました。ステージの追加や遷移の変更はこのファイルだけ編集すればよく、C++ のリコンパイルは不要です:
{
"start": { "bg": "bg_title.png", "touches": [...], "back": "" },
"infront": { "bg": "bg_stage_001.png", "touches": [...], "back": "start" },
"post": { "bg": "bg_stage_002.png", "touches": [...], "back": "infront" }
}
Axmol Engine で2Dゲームを作る際の参考になれば幸いです。
成果物
↕️
↕️


