はじめに
脱出ゲームを Axmol Engine(cocos2d-x の後継 C++ フレームワーク)で開発しています。
今回はダイヤル錠ギミックを作るにあたり、テクスチャアトラス(スプライトシート) を使ってダイヤルボタンを表示し、タップで数字が変わるインタラクションを実装しました。
実装を通じて分かった「Axmol の JSON アトラスへの対応方法」も合わせて紹介します。
成果物
なぜテクスチャアトラスを使うのか
個別画像とアトラスを比べると、モバイルゲームでは特にドローコール削減の恩恵が大きいです。
複数のUIの画像を一つの画像ファイルにまとめておいて使用するのが一般的かと思われます。
| 個別画像(10枚) | テクスチャアトラス(1枚) | |
|---|---|---|
| ファイル数 | 10個 | 1個 |
| ドローコール | 最大10回 | 1回(バッチ処理) |
| GPUメモリ | 約5倍の無駄 | 効率的 |
GPUテクスチャは 2の累乗サイズ で確保されます。146×369px の画像を個別に渡すと GPU 上では 512×512 として確保されますが、まとめた 888×742px のアトラスなら 1024×1024 の1枚で10フレーム分をカバーできます。
素材の準備
ダイヤル画像(0〜9)の作成
ダイヤル錠の数字部分を1桁ずつ画像として用意します(今回は btn_1_0.png 〜 btn_1_9.png、各 146×369px)。
のようにダイヤルを回した結果画像を0から9が中央に来るように作成しました
3つのダイヤルにそれぞれ同じ画像を使用することで制作コストや画像リソースコストを削減しています
スプライトシートの生成
TexturePacker Online(無料) に10枚をアップロードし、Format: JSON Array を選択してエクスポートします。
注意
JSON Hash 形式は Axmol の SpriteFrameCache が標準では読めません。
必ず JSON Array 形式を選んでください(後述のカスタムローダーが Array 前提です)。
生成された2ファイルを Content/dial/ に配置します。
Content/dial/
├── base.png # ダイヤル錠の背景画像
├── spritesheet.json # フレーム座標情報(JSON Array 形式)
└── spritesheet.png # テクスチャアトラス本体
spritesheet.json の構造はこうなっています。
{
"frames": [
{
"filename": "btn_1_0.png",
"frame": { "x": 1, "y": 1, "w": 146, "h": 369 },
"rotated": false,
"trimmed": false,
"spriteSourceSize": { "x": 0, "y": 0, "w": 146, "h": 369 },
"sourceSize": { "w": 146, "h": 369 }
},
{
"filename": "btn_1_1.png",
"frame": { "x": 149, "y": 1, "w": 146, "h": 369 },
...
}
],
"meta": {
"image": "spritesheet.png",
"size": { "w": 888, "h": 742 }
}
}
claudeに記憶させておく
プロンプト:
https://www.codeandweb.com/free-sprite-sheet-packer
にダイヤル錠のダイヤル部分の0から9の画像をアップロードして、
spritesheet.json
spritesheet.png
を作成してダウンロードして、Content/dial/以下に配置しました
覚えておいてください
カスタム SpriteSheetLoader の実装
下調べで、axmolではテクスチャアトラスのjsonを扱う場合は、配列形式でないと利用できない、かつカスタムが必要とのことでしたので、下記プロンプトを作成して投げました。
プロンプト:
補足:JSONのままAxmolで使う方法
AxmolはPLIST以外のスプライトアトラス形式も利用可能で、SpriteSheetLoaderインターフェースを実装してカスタムローダーを作り、SpriteFrameCache::registerSpriteSheetLoader()で登録することができます。またcpp-testsにはJSON形式のローダー実装例(GenericJsonArraySpriteSheetLoader)も含まれています。
Axmol の SpriteFrameCache::addSpriteFramesWithFile() は標準では .plist 形式しか読みません。
ただし SpriteSheetLoader インターフェースを実装して登録することで、任意のフォーマットに対応できます(cpp-tests に GenericJsonArraySpriteSheetLoader という実装例があります)。
これを参考に、TexturePacker Online の JSON Array 形式に対応したローダーを作ります。
Source/JsonArraySpriteSheetLoader.h
#pragma once
#include "axmol/axmol.h"
#include "rapidjson/document.h"
// TexturePacker "JSON Array" 形式のスプライトシートローダー
class JsonArraySpriteSheetLoader : public ax::SpriteSheetLoader
{
public:
static constexpr uint32_t FORMAT = ax::SpriteSheetFormat::CUSTOM + 1;
uint32_t getFormat() override { return FORMAT; }
void load(std::string_view filePath, ax::SpriteFrameCache& cache) override
{
const auto fullPath = ax::FileUtils::getInstance()->fullPathForFilename(filePath);
if (fullPath.empty())
return;
rapidjson::Document doc;
doc.Parse(ax::FileUtils::getInstance()->getStringFromFile(fullPath).c_str());
std::string texturePath;
auto&& metaItr = doc.FindMember("meta");
if (metaItr != doc.MemberEnd())
texturePath = ax::FileUtils::getInstance()->fullPathFromRelativeFile(
metaItr->value["image"].GetString(), filePath);
if (texturePath.empty())
{
texturePath = filePath.substr(0, filePath.rfind('.'));
texturePath += ".png";
}
loadFrames(doc, texturePath, filePath, cache);
}
void load(std::string_view filePath, ax::Texture2D* texture,
ax::SpriteFrameCache& cache) override
{
rapidjson::Document doc;
doc.Parse(ax::FileUtils::getInstance()
->getStringFromFile(
ax::FileUtils::getInstance()->fullPathForFilename(filePath))
.c_str());
loadFrames(doc, texture, filePath, cache);
}
void load(std::string_view filePath, std::string_view textureFileName,
ax::SpriteFrameCache& cache) override
{
rapidjson::Document doc;
doc.Parse(ax::FileUtils::getInstance()
->getStringFromFile(
ax::FileUtils::getInstance()->fullPathForFilename(filePath))
.c_str());
loadFrames(doc, textureFileName, filePath, cache);
}
void load(const ax::Data& content, ax::Texture2D* texture,
ax::SpriteFrameCache& cache) override
{
if (content.isNull())
return;
rapidjson::Document doc;
doc.Parse(reinterpret_cast<const char*>(content.getBytes()));
loadFrames(doc, texture, "", cache);
}
void reload(std::string_view filePath, ax::SpriteFrameCache& cache) override
{
load(filePath, cache);
}
private:
void loadFrames(const rapidjson::Document& doc, std::string_view texturePath,
std::string_view atlasPath, ax::SpriteFrameCache& cache)
{
auto* texture = ax::Director::getInstance()
->getTextureCache()->addImage(texturePath);
if (texture)
loadFrames(doc, texture, atlasPath, cache);
}
void loadFrames(const rapidjson::Document& doc, ax::Texture2D* texture,
std::string_view atlasPath, ax::SpriteFrameCache& cache)
{
auto&& framesItr = doc.FindMember("frames");
if (framesItr == doc.MemberEnd() || !framesItr->value.IsArray())
return;
auto spriteSheet = std::make_shared<ax::SpriteSheet>();
spriteSheet->format = FORMAT;
spriteSheet->path = atlasPath;
for (auto&& f : framesItr->value.GetArray())
{
if (!f.IsObject())
continue;
const char* name = f["filename"].GetString();
if (cache.findFrame(name))
continue;
const float fx = f["frame"]["x"].GetFloat();
const float fy = f["frame"]["y"].GetFloat();
const float fw = f["spriteSourceSize"]["w"].GetFloat();
const float fh = f["spriteSourceSize"]["h"].GetFloat();
const int sw = f["sourceSize"]["w"].GetInt();
const int sh = f["sourceSize"]["h"].GetInt();
const bool rot = f["rotated"].GetBool();
auto* frame = ax::SpriteFrame::createWithTexture(
texture, ax::Rect(fx, fy, fw, fh),
rot, ax::Vec2(), ax::Size(sw, sh));
cache.insertFrame(spriteSheet, name, frame);
}
spriteSheet->full = true;
}
};
ポイントは frames を GetArray() でイテレートし、filename フィールドをフレーム名として cache.insertFrame() に渡すことです。
rapidjson は Axmol が内部で使っており、ビルドシステムが自動的に HEADER_SEARCH_PATHS に axmol/3rdparty を追加しているので #include "rapidjson/document.h" がそのまま使えます。
ローダーをアプリ起動時に登録する
AppDelegate.cpp の applicationDidFinishLaunching() でローダーを1度だけ登録します。
// AppDelegate.cpp
#include "JsonArraySpriteSheetLoader.h"
bool AppDelegate::applicationDidFinishLaunching()
{
// ... Director / RenderView の初期化 ...
// JSON Array 形式のスプライトシートローダーを登録
ax::SpriteFrameCache::getInstance()->registerSpriteSheetLoader(
std::make_unique<JsonArraySpriteSheetLoader>());
auto scene = utils::createInstance<SplashScene>();
director->runWithScene(scene);
return true;
}
ダイヤル錠UIの実装
プロンプト:
dial/spritesheet.json、dial/spritesheet.png のテクスチャアトラスから抜き取ったbtn_1_0.pngをdial/base.png上に、3つのボタンとして配置してください
微妙に希望した位置とズレてましたので、ここは手動で調整しました
更にダイヤルを回す処理を実装してもらいます
プロンプト:
3つのそれぞれのダイヤルボタンがタップされたら、btn_1_0.png であれば btn_1_1.pngに、btn_1_1.pngであればbtn_1_2.pngに差し替えてください
btn_1_9.pngの場合は btn_1_0.pngに差し替えてください
ヘッダー(MainScene.h)
3つのダイヤルそれぞれの現在の数字を保持するメンバを追加します。
// MainScene.h
private:
int _dialDigits[3] = {0, 0, 0}; // 各ダイヤルの現在値
void showPopup();
ポップアップ表示(MainScene.cpp)
// MainScene.cpp
#include "JsonArraySpriteSheetLoader.h"
void MainScene::showPopup()
{
auto visibleSize = _director->getVisibleSize();
auto origin = _director->getVisibleOrigin();
// ① オーバーレイ(画面全体を覆うノード)
_dialDigits[0] = _dialDigits[1] = _dialDigits[2] = 0;
auto overlay = Node::create();
overlay->setContentSize(visibleSize);
overlay->setAnchorPoint(Vec2::ANCHOR_BOTTOM_LEFT);
overlay->setPosition(origin);
overlay->setTag(200);
this->addChild(overlay, 10);
// 半透明の黒背景
auto bgLayer = LayerColor::create(Color32(0, 0, 0, 128),
visibleSize.width, visibleSize.height);
overlay->addChild(bgLayer, -2);
// ② ダイヤル錠の背景画像(base.png)
auto base = Sprite::create("dial/base.png");
if (base)
{
base->setAnchorPoint(Vec2::ANCHOR_MIDDLE);
base->setPosition(Vec2(visibleSize.width / 2, visibleSize.height / 2 + 50));
base->setTag(250); // タップ判定で後から参照するためタグを付ける
overlay->addChild(base, -1);
// ③ スプライトシートをキャッシュに登録
SpriteFrameCache::getInstance()->addSpriteFramesWithFile(
"dial/spritesheet.json", JsonArraySpriteSheetLoader::FORMAT);
// ④ 3つのダイヤルボタンを base の子として配置
const auto baseSize = base->getContentSize(); // 640x480
const float colX = 138.0f; // 左端の列中心X
const float colWidth = 180.0f; // 列の幅
const float centerY = baseSize.height / 2.0f;
for (int i = 0; i < 3; i++)
{
auto dial = Sprite::createWithSpriteFrameName("btn_1_0.png");
if (dial)
{
dial->setAnchorPoint(Vec2::ANCHOR_MIDDLE);
dial->setPosition(Vec2(colX + colWidth * i, centerY));
dial->setTag(301 + i); // 301, 302, 303
base->addChild(dial, 1);
}
}
}
// ⑤ 枠画像を最前面に
auto popup = Sprite::create("frame_popup.png");
if (popup)
{
popup->setAnchorPoint(Vec2::ANCHOR_MIDDLE);
popup->setPosition(Vec2(visibleSize.width / 2, visibleSize.height / 2 + 50));
overlay->addChild(popup, 0);
}
// ⑥ 戻るボタン
auto backItem = MenuItemImage::create(
"btn_back.png", "btn_back.png",
[this](Object*) { this->removeChildByTag(200); });
if (backItem)
{
float margin = 20.0f;
backItem->setPosition(Vec2(visibleSize.width / 2,
margin + backItem->getContentSize().height / 2));
auto backMenu = Menu::create(backItem, nullptr);
backMenu->setPosition(Vec2::ZERO);
overlay->addChild(backMenu, 1);
}
// ⑦ タッチリスナー:ダイヤルのタップ判定 + 下層へのタッチをスワロー
auto listener = EventListenerTouchOneByOne::create();
listener->setSwallowTouches(true);
listener->onTouchBegan = [this, overlay](Touch* touch, Event*) -> bool {
auto* base = overlay->getChildByTag(250);
if (base)
{
for (int i = 0; i < 3; i++)
{
auto* dial = dynamic_cast<Sprite*>(base->getChildByTag(301 + i));
if (!dial)
continue;
// タッチ座標をダイヤルのローカル座標に変換して当たり判定
auto local = dial->convertToNodeSpace(touch->getLocation());
auto cs = dial->getContentSize();
if (Rect(0, 0, cs.width, cs.height).containsPoint(local))
{
// 0→1→...→9→0 とローテーション
_dialDigits[i] = (_dialDigits[i] + 1) % 10;
dial->setSpriteFrame(
SpriteFrameCache::getInstance()->getSpriteFrameByName(
"btn_1_" + std::to_string(_dialDigits[i]) + ".png"));
return true;
}
}
}
return true; // ダイヤル以外のタップもスワロー
};
_eventDispatcher->addEventListenerWithSceneGraphPriority(listener, overlay);
}
実装のポイント解説
① ノード階層とタグ設計
this(MainScene)
└── overlay(tag=200)
├── bgLayer(半透明黒)
├── base(tag=250) ← ダイヤル錠の背景
│ ├── dial[0](tag=301) ← ダイヤルボタン(btn_1_0.png から開始)
│ ├── dial[1](tag=302)
│ └── dial[2](tag=303)
├── popup(frame_popup.png)
└── backMenu
タグ番号で後から子ノードを検索できるようにしておくと、タッチリスナーのラムダ内でノード間を安全にナビゲートできます。
② ダイヤルの座標系
ダイヤルボタンを base->addChild(dial) で追加しているため、ダイヤルの位置は base のローカル座標系 で指定します。
タッチ判定の dial->convertToNodeSpace() も同様にダイヤルのローカル座標に変換します。
③ スプライトフレームの切り替え
dial->setSpriteFrame(
SpriteFrameCache::getInstance()->getSpriteFrameByName("btn_1_3.png"));
setSpriteFrame() はテクスチャ本体を差し替えずに UV 座標だけを変えるので、ドローコールのバッチを壊さずに表示を更新できます。
④ ポップアップを閉じても安全な理由
overlay を removeChildByTag(200) で削除すると、その子ノード(ダイヤルを含む)もすべて解放されます。
タッチリスナーは overlay にアタッチしているので、overlay が削除されると同時に自動的にリスナーも解除されます。ラムダが this(MainScene)を捕捉していても dangling pointer にはなりません。
それぞれのダイヤルをタップすると0 → 1 → 2 → 3 ... 9 とさもダイヤルが回されているかのようにダイヤル画像が切り替わっていきます
まとめ
| ステップ | 内容 |
|---|---|
| 素材作成 | TexturePacker Online で JSON Array 形式のアトラスを生成 |
| ローダー実装 |
SpriteSheetLoader を継承した JsonArraySpriteSheetLoader を作成 |
| ローダー登録 |
AppDelegate で registerSpriteSheetLoader() を1度呼ぶ |
| フレーム読み込み |
addSpriteFramesWithFile("...", FORMAT) でキャッシュに登録 |
| 描画 |
createWithSpriteFrameName() でスプライト生成、子ノードとして配置 |
| インタラクション | タッチリスナーで当たり判定 → setSpriteFrame() でフレーム切り替え |
Axmol は標準で PLIST 形式のアトラスしか読めませんが、SpriteSheetLoader インターフェースを使えば任意のフォーマットに対応できます。JSON Array 形式は TexturePacker Online の無料版でも生成できるため、アセットパイプラインをシンプルに保てます。
作成した画像
テクスチャアトラス化したものです







