Axmol Engine で脱出ゲームを作る ── 額縁ギミック(4隅タップ)とライター取得
はじめに
今回は room1-1-1 に配置した肖像画の額縁をタップし、4隅を正しい順番でタップする割と古典的な謎解きギミックを実装します。
順番押し系のギミックに属します。
ついでに、解いた先には隠し扉(壁の窪み)とアイテム「ライター」を配置します。
この記事の、順番押し系(重複有り)+部品自身がトリガ+自動判定で、額縁タップギミックを実装します
- 第1回【Axmol Engine】 スプラッシュ画面シーンを実装する
- 第2回【Axmol Engine】 タイトルシーンを作って画像を表示する
- 第3回【Axmol Engine】 タップした領域への移動
- 第4回【Axmol Engine】 ノードを使用してポップアップを実装する
- 第5回【Axmol Engine】 テクスチャアトラスでダイヤル錠UIを実装する
- 第6回【Axmol Engine】 リファクタリング(メモリ消費対応、ステージをJSON管理、処理修正)+戻るボタン設置
- 第7回【Axmol Engine】 ローカルストレージでステージ進捗管理・ギミックをJSONで管理
- 第8回【Axmol Engine】 アイテムシステムの実装1 - アイテムの取得 -
- 第9回【Axmol Engine】 アイテムシステムの実装2 - アイテムの選択・表示 -
- 第10回【Axmol Engine】 アイテムシステムの実装3 - アイテムの使用 -
- 第11回【Axmol Engine】 BGM・効果音の実装 -
- 第12回【Axmol Engine】 左右移動ボタンの実装 -
- 番外編1【Axmol Engine】 ギミックについて -
- 第13回【Axmol Engine】 順番押し系ギミックの実装 -(今回)
用意した画像(Chat-GPT)
ライターが置いてある画像(上記の画像に被せるように表示)[item_007.png]

ライターの詳細表示用[item_02_1_large.png]

1. 額縁へ近接
このステージの左の壁にある額縁をタップすると、額縁に近接するようにします。
プロンプト:
"room1-1"のbg_stage_004.pngの左の額縁付近をタップしたら、room1-1-1のステージへ遷移。bgはbg_stage_007.png, backは room1-1。
ステージ構成を決めるjsonの構造をclaudeが掌握しているため、このような簡潔なプロンプトでも汲み取って、ステージの作成や遷移をコーディングしてくれます。
2. 額縁タップギミック(type "2")の実装
プロンプト:
room1-1-1 でbg_stage_007.png内の額縁をタップしたら、gimmickに遷移。ただしポップアップ形式ではなく、room1-1-1での表示そのままで。ギミックはgimmicks.jsonにpictureFrameとして定義を追加。bg_stage_007.pngをbgとして、そのZ軸上にframe/base.pngを配置。base.pngの4隅に透過の四角領域のボタンをそれぞれ配置。左上→右下→左下→右上→左上→左下と順番にタップしたら正解。正解後はroom1-1-1のステップを進める。room1-1-1のステップ2では、bgはbg_stage_007_2.pngを表示。
既存のダイヤル錠ギミック(type: "1")は暗転オーバーレイ+frame_popup.pngのポップアップ形式でしたが、今回は「今の画面表示のまま」という要件のため type: "2" という新しいギミック種別を追加します。ダーク背景・ポップアップ枠・reload/okボタンは使わず、ギミック自身の bg をステージ背景と同じスケール・位置でフル表示し、その上に frame/base.png を重ねる構成です。
"pictureFrame": {
"type": "2",
"bg": "bg_stage_007.png",
"frame": "frame/base.png",
"frameRect": [717, 141, 426, 580],
"touches": [
{ "rect": [717, 641, 80, 80] },
{ "rect": [1063, 641, 80, 80] },
{ "rect": [717, 141, 80, 80] },
{ "rect": [1063, 141, 80, 80] }
]
"sequence": [0, 3, 2, 1, 0, 2],
"stage": "room1-1-1",
"stageStatus": 1,
"goStage": "room1-1-1"
}
touches はTL・TR・BL・BRの4隅(それぞれ0〜3)、sequence は「左上→右下→左下→右上→左上→左下」を添字で表したものです。
struct GimmickDef {
// ...
std::string frame;
ax::Rect frameRect;
std::vector<int> sequence;
};
// showGimmick() 内
if (def.type == "2")
{
auto overlay = Node::create();
overlay->setContentSize(visibleSize);
overlay->setAnchorPoint(Vec2::ANCHOR_BOTTOM_LEFT);
overlay->setTag(200);
this->addChild(overlay, 10);
auto base = Sprite::create(def.bg);
base->setAnchorPoint(Vec2::ANCHOR_MIDDLE);
float scale = visibleSize.height / base->getContentSize().height;
base->setScale(scale);
base->setPosition(Vec2(visibleSize.width / 2, visibleSize.height / 2));
base->setTag(250);
overlay->addChild(base, -1);
auto frameSp = Sprite::create(def.frame);
frameSp->setAnchorPoint(Vec2::ANCHOR_MIDDLE);
frameSp->setPosition(Vec2(def.frameRect.origin.x + def.frameRect.size.width / 2,
def.frameRect.origin.y + def.frameRect.size.height / 2));
frameSp->setTag(260);
base->addChild(frameSp, 1);
// 4隅のtouches判定、sequenceとの一致でgoStageへ
}
3. ガイドメッセージ帯の追加
プロンプト:
gimmicksのpictureFrameにmessageを追加して「額縁の4隅を順番通りにタップしましょう」を定義。ギミック表示時に、画面一番上に幅100%、高さは表示フォントの1.5倍で、黒い透過50%の帯を表示して、その上に白いフォントでこのメッセージを表示。
"message": "額縁の4隅を順番通りにタップしましょう"
if (!def.message.empty())
{
constexpr float fontSize = 28.0f;
const float bannerHeight = fontSize * 1.5f;
auto banner = LayerColor::create(Color32(0, 0, 0, 128), visibleSize.width, bannerHeight);
banner->setPosition(Vec2(0, visibleSize.height - bannerHeight));
overlay->addChild(banner, 2);
auto label = Label::createWithSystemFont(def.message, "Arial", fontSize);
label->setTextColor(Color32(255, 255, 255, 255));
label->setAlignment(TextHAlignment::CENTER);
label->setAnchorPoint(Vec2::ANCHOR_MIDDLE);
label->setPosition(Vec2(visibleSize.width / 2, visibleSize.height - bannerHeight / 2));
overlay->addChild(label, 3);
}
脱出ゲームではユーザーさんが何をしていいか迷わないように、このようにある程度ナビゲーションをしてあげると喜ばれます。
4. タップ時の額縁シェイク演出
プロンプト:
額縁をタップしたとき、base.pngを軽く左右に揺らしてください
プロンプト:
左右に軽く回転するように
最初は MoveBy による左右移動で実装しましたが、回転の要望を受けて RotateBy に変更しました。角のタップ判定に使うため frameSp に tag(260) を付与し、タップのたびに取得して再生します。
frameSp->setTag(260);
auto* frameNode = baseNode->getChildByTag(260);
if (frameNode)
{
constexpr int kShakeTag = 999;
constexpr float shakeAngle = 5.0f;
constexpr float shakeDur = 0.05f;
frameNode->stopActionByTag(kShakeTag);
frameNode->setRotation(0.0f);
auto shake = Sequence::create(
RotateBy::create(shakeDur, -shakeAngle),
RotateBy::create(shakeDur, shakeAngle * 2),
RotateBy::create(shakeDur, -shakeAngle * 2),
RotateBy::create(shakeDur, shakeAngle),
nullptr);
shake->setTag(kShakeTag);
frameNode->runAction(shake);
}
連続タップで揺れが重なっても位置がずれないよう、再生前に必ず stopActionByTag と setRotation(0.0f) でリセットします。
5. 失敗時の挙動
プロンプト:
sequencesサイズ分、タップして失敗したら、完全にroom1-1-1に戻ってください(もしくは再度遷移)
sequence の長さ分(6回)タップし終えるまでは間違えても継続し、6回使い切って不一致なら失敗という仕様にしました。失敗時は単に removeChildByTag(200) でギミックを閉じるだけでなく、_stage と _stageStatus を明示的に再設定して showStageBg() を呼び、正式に room1-1-1 へ再遷移します。
auto tapHistory = std::make_shared<std::vector<int>>();
// ...
tapHistory->push_back(i);
if (!def.effect.empty())
AudioEngine::play2d(def.effect, false);
if (!def.sequence.empty() && tapHistory->size() >= def.sequence.size())
{
if (*tapHistory == def.sequence)
{
auto* ud = ax::UserDefault::getInstance();
if (!def.saveStage.empty())
ud->setIntegerForKey(("stageData_" + def.saveStage).c_str(), def.saveStatus);
ud->flush();
if (!def.effectSuccess.empty())
AudioEngine::play2d(def.effectSuccess, false);
this->removeChildByTag(200);
_stage = def.goStage;
ax::UserDefault::getInstance()->setStringForKey("stage", _stage);
ax::UserDefault::getInstance()->flush();
showStageBg();
}
else
{
if (!def.effectReset.empty())
AudioEngine::play2d(def.effectReset, false);
this->removeChildByTag(200);
_stage = !def.saveStage.empty() ? def.saveStage : def.goStage;
_stageStatus = 0;
ax::UserDefault::getInstance()->setStringForKey("stage", _stage);
ax::UserDefault::getInstance()->flush();
showStageBg();
}
}
入力に失敗したら、失敗したと分かりやすいようにしておくと良いです。
6. アイテム「ライター」の追加
プロンプト:
items.jsonに"2"を追加して、「ライター」という名前で、items/item_02_* の画像を設定してください
"2": {
"name": "ライター",
"image": "items/item_02_1.png",
"image_large": "items/item_02_1_large.png"
}
プロンプト:
room1-1-1のステップ2で、アイテム"2"を設定。未取得時の画像はitem_007.png。
item_007.png はステージ背景と同じ1870×841のフル透過画像で、窪みの中の実際の位置にライターが描かれています。既存の item_002.png(郵便受けの鍵)と同じ設計方針で、rect は表示位置ではなくタップ判定専用です。
{
"bg": "bg_stage_007_2.png",
"touches": [],
"back": "room1-1",
"items": [
{ "image": "item_007.png", "itemNo": "2", "rect": [905, 211, 95, 110] }
]
}
オイルが無いので、火が点かないようです。。。
成果物
claudeが画像解析に使用したツール
今回、画像解析のために下記をclaudeがインストールして利用していました。
額縁やアイテムの座標をコードに落とし込む際、目視確認だけでなくピクセル単位の解析も行いました。Python 3とPillow(PIL)で Image.crop() を使い、ステージ背景の一部を切り出して ImageDraw で座標グリッドを重ねて描画することで、額縁やアイテムの正確なピクセル範囲を特定しています。Image.alpha_composite() では透過PNG(frame/base.png や item_007.png)を背景画像に実際に合成し、算出した座標が視覚的にズレていないかを確認しました。macOS標準コマンドの sips は画像ファイルのピクセル幅・高さを素早く取得するのに使っています。
座標系の変換にも注意が必要でした。Axmol(cocos2d系)の Rect は左下原点・Y軸上向きなのに対し、Pillowで読み込んだ画像は左上原点・Y軸下向きです。bottom_left_y = 画像高さ - top_left_y - height で相互変換しながら検証しています。
まとめ
| 実装内容 | 対応ファイル |
|---|---|
| 額縁4隅タップギミック(type "2") |
gimmicks.json / MainScene.cpp
|
| 額縁画像差し替えに伴う座標再計算 | gimmicks.json |
| ガイドメッセージ帯 |
gimmicks.json / MainScene.cpp
|
| 額縁の回転シェイク演出 | MainScene.cpp |
| 失敗時のroom1-1-1再遷移 | MainScene.cpp |
| ライターアイテムの追加 |
items.json / stages.json
|







