3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Godot 4.x】2D脱出ゲームを作ってみる⑦〜スプライトシートを使ったダイヤル錠ギミックを実装する〜

3
Last updated at Posted at 2026-07-07

はじめに

この記事は、Godot 4.xで脱出ゲームを作る連載の第7回です。

前回(第6回)ではポップアップコンポーネントを実装しました。今回はそのポップアップ内にダイヤル錠を組み込む謎解きギミックを実装します。

今回の実装は若干の手抜きで、以前Axmol Engineで作成したダイヤル錠(参考記事)をGodotに移植する形で進めます。Claude Codeへの指示もその参考記事のURLを渡す形で始めています。

スプライトシートの作成に興味のあるお方もご覧ください!

この記事で作るもの

  • スプライトシート(spritesheet.png)から AtlasTexture で各数字フレームを切り出す
  • タップするたびに 0→1→…→9→0 とループする単体ダイヤルコンポーネント(dial.tscn
  • base.png の上に3つのダイヤルを並べたダイヤル錠コンポーネント(dial_lock.tscn
  • ポップアップ内に組み込んで謎解きギミックとして動作させる

スクリーンショット 2026-07-08 0.08.24.png

前提条件

  • Claude Code + godot-mcpのセットアップ完了(第2回参照
  • scenes/components/popup.tscn が作成済み(第6回参照
  • 以下の素材が assets/images/dial/ に配置済み
ファイル 内容
spritesheet.png 数字0〜9の画像をまとめたスプライトアトラス
spritesheet.json フレーム定義(TexturePacker JSON Array形式)参考記事
base.png ダイヤル錠の背景画像(640×480px)前回配置済み

1枚の画像のスプライトアトラス
spritesheet.png
spritesheet.png

設計方針

今回も段階的に追加指示を出しながら調整しています。その試行錯誤の過程もそのまま記録しています。


ファイル構成

scenes/components/
  dial.tscn          ← 今回作成(単体ダイヤル、再利用可能)
  dial_lock.tscn     ← 今回作成(base.png + 3ダイヤルのセット)
  popup.tscn         ← 前回作成(ダイヤル錠を組み込む)
scripts/components/
  dial.gd            ← 今回作成
  dial_lock.gd       ← 今回作成
assets/images/dial/
  spritesheet.png    ← 0〜9の数字フレームをまとめたアトラス
  spritesheet.json   ← フレーム定義
  base.png           ← ダイヤル錠の背景

1. スプライトシートの構造を把握する

たまには楽をしましょう!ということで自分の書いた Axmol Engineでのギミック実装を参考に進めることにしました

私が書いたaxmol engine での実装、https://qiita.com/OnuuuumaX/items/2ca44f4be64fbe9d1745 を参考にgodotで実装してください

まずClaude Codeが spritesheet.json を読み込み、フレーム座標を確認しました。

{
  "frames": [
    { "filename": "btn_1_0.png", "frame": { "x": 1,   "y": 1,   "w": 146, "h": 369 } },
    { "filename": "btn_1_1.png", "frame": { "x": 149, "y": 1,   "w": 146, "h": 369 } },
    { "filename": "btn_1_2.png", "frame": { "x": 297, "y": 1,   "w": 146, "h": 369 } },
    { "filename": "btn_1_3.png", "frame": { "x": 445, "y": 1,   "w": 146, "h": 369 } },
    { "filename": "btn_1_4.png", "frame": { "x": 593, "y": 1,   "w": 146, "h": 369 } },
    { "filename": "btn_1_5.png", "frame": { "x": 741, "y": 1,   "w": 146, "h": 369 } },
    { "filename": "btn_1_6.png", "frame": { "x": 1,   "y": 372, "w": 146, "h": 369 } },
    { "filename": "btn_1_7.png", "frame": { "x": 149, "y": 372, "w": 146, "h": 369 } },
    { "filename": "btn_1_8.png", "frame": { "x": 297, "y": 372, "w": 146, "h": 369 } },
    { "filename": "btn_1_9.png", "frame": { "x": 445, "y": 372, "w": 146, "h": 369 } }
  ]
}

各フレームは 146×369px。2段構成で 0〜5 が1行目、6〜9 が2行目に並んでいます。

1-1. AxmolとGodotの対応関係

Axmol ではカスタム JsonArraySpriteSheetLoader を実装してフレームキャッシュに登録し、setSpriteFrame() でUV座標のみを切り替えていました。Godot では AtlasTexture が同じ役割を果たします。

やりたいこと Axmol Godot
フレームの切り替え setSpriteFrame() AtlasTexture.region を差し替え
タップ検出 タッチリスナー + convertToNodeSpace TextureButton.pressed シグナル
数字のループ (_dialDigits[i] + 1) % 10 (_digit + 1) % 10
ダイヤル間隔 手動座標指定 HBoxContainerseparation
描画順(z軸) zOrder プロパティ シーンツリーの並び順で制御

2. 単体ダイヤルコンポーネントの実装(dial.tscn)

2-1. 生成されたシーン(dial.tscn)

[gd_scene format=3]

[ext_resource type="Script" path="res://scripts/components/dial.gd" id="1_dial"]

[node name="Dial" type="TextureButton"]
custom_minimum_size = Vector2(146, 369)
script = ExtResource("1_dial")

ルートを TextureButton にすることで、タップの検出を pressed シグナルに任せられます。custom_minimum_size でスプライトシートのフレームと同サイズを確保します。

2-2. 生成されたスクリプト(dial.gd)

extends TextureButton

signal digit_changed(digit: int)

const _SPRITESHEET := preload("res://assets/images/dial/spritesheet.png")
const _REGIONS: Array[Rect2] = [
    Rect2(1,   1,   146, 369),  # 0
    Rect2(149, 1,   146, 369),  # 1
    Rect2(297, 1,   146, 369),  # 2
    Rect2(445, 1,   146, 369),  # 3
    Rect2(593, 1,   146, 369),  # 4
    Rect2(741, 1,   146, 369),  # 5
    Rect2(1,   372, 146, 369),  # 6
    Rect2(149, 372, 146, 369),  # 7
    Rect2(297, 372, 146, 369),  # 8
    Rect2(445, 372, 146, 369),  # 9
]

var _digit: int = 0
var _textures: Array[AtlasTexture] = []

func _ready() -> void:
    for region in _REGIONS:
        var atlas := AtlasTexture.new()
        atlas.atlas = _SPRITESHEET
        atlas.region = region
        _textures.append(atlas)
    texture_normal = _textures[0]
    pressed.connect(_on_pressed)

func _on_pressed() -> void:
    _digit = (_digit + 1) % 10
    texture_normal = _textures[_digit]
    digit_changed.emit(_digit)

func get_digit() -> int:
    return _digit

func set_digit(value: int) -> void:
    _digit = clampi(value, 0, 9)
    if not _textures.is_empty():
        texture_normal = _textures[_digit]

2-3. ポイント解説

AtlasTexture_ready() でまとめて生成してキャッシュする

10枚の AtlasTexture を起動時に一括生成して _textures 配列に保持します。タップのたびに AtlasTexture を新規生成するのではなく、配列の参照を差し替えるだけなので効率的です。Axmol のフレームキャッシュと同じ考え方です。

(_digit + 1) % 10 で 0→9 のループ

Axmol 実装と全く同じロジックです。%(剰余)演算子で 9 の次が 0 に戻ります。

set_digit() で外部から初期値を設定可能

ゲームのセーブデータから値を復元したい場合など、外部からダイヤルの値を設定できるようにしています。


3. ダイヤル錠コンポーネントの実装(dial_lock.tscn)

3-1. 生成されたシーン(dial_lock.tscn)

[gd_scene format=3]

[ext_resource type="Texture2D" path="res://assets/images/dial/base.png" id="1_base"]
[ext_resource type="Script" path="res://scripts/components/dial_lock.gd" id="2_dial_lock"]
[ext_resource type="PackedScene" path="res://scenes/components/dial.tscn" id="3_dial"]

[node name="DialLock" type="Control"]
script = ExtResource("2_dial_lock")

[node name="Base" type="TextureRect" parent="."]
layout_mode = 0
texture = ExtResource("1_base")

[node name="Dials" type="HBoxContainer" parent="."]
layout_mode = 0
offset_left = 61.0
offset_top = 62.0
offset_right = 580.0
offset_bottom = 433.0
theme_override_constants/separation = 36

[node name="Dial1" parent="Dials" instance=ExtResource("3_dial")]
layout_mode = 2

[node name="Dial2" parent="Dials" instance=ExtResource("3_dial")]
layout_mode = 2

[node name="Dial3" parent="Dials" instance=ExtResource("3_dial")]
layout_mode = 2

3-2. 生成されたスクリプト(dial_lock.gd)

extends Control

signal combination_changed(digits: Array[int])

func _ready() -> void:
    for dial in $Dials.get_children():
        dial.digit_changed.connect(_on_digit_changed)

func _on_digit_changed(_digit: int) -> void:
    combination_changed.emit(get_combination())

func get_combination() -> Array[int]:
    var result: Array[int] = []
    for dial in $Dials.get_children():
        result.append(dial.get_digit())
    return result

3-3. ポイント解説

HBoxContainerseparation でダイヤル間隔を管理する

Dial1Dial3HBoxContainer(Dials)の子ノードなので、個別に offset で位置を指定することはできません(コンテナが子の位置を管理するため)。ダイヤルの間隔は theme_override_constants/separation で制御します。

ダイヤル幅が 146px でダイヤル間の目標間隔が 182px の場合:

separation = 目標間隔 - ダイヤル幅 = 182 - 146 = 36px

combination_changed シグナルで3桁をまとめて通知する

いずれかのダイヤルが変化するたびに、3桁全体を Array[int] でまとめて通知します。呼び出し側は combination_changed を購読するだけで現在の組み合わせを取得できます。


4. ポップアップへの組み込み

ここまでは実装されたのみでしたので、実際に配置するようにお願いしました。

ポップアップ内、frame_popup.pngのz軸の下に組み込んでください

4-1. z軸の管理(ノードツリーの順序)

Godot ではノードリストの後方にあるものが手前(上)に描画されます。DialLockContentframe_popup.png を含む VBoxContainer)より前に置くことで、frame_popup.png の奥にダイヤル錠を配置できます。

Popup (Control)
├── Blocker      ← 最背面(半透明黒・タップブロック)
├── DialLock     ← frame_popup.png の奥に描画
└── Content (VBoxContainer)
    ├── Frame    ← frame_popup.png(最前面)
    └── BtnBack

4-2. 生成された差分(popup.tscn)

[ext_resource type="PackedScene" path="res://scenes/components/dial_lock.tscn" id="4_dial_lock"]

[node name="DialLock" parent="." instance=ExtResource("4_dial_lock")]
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -314.0
offset_top = -352.5
grow_horizontal = 2
grow_vertical = 2

4-3. ポイント解説

中央アンカー(anchors_preset = 8)で位置を調整する

DialLockPopup(plain Control)の子なので、アンカーとオフセットで位置を手動指定できます。anchors_preset = 8(CENTER)を起点に offset_leftoffset_top で微調整しています。


5. ダイヤル位置の微調整

実際に表示確認したところ下記のようにずれてました。

スクリーンショット 2026-07-07 23.44.04.png

godotのエディタで位置調整をしてみました

Dialを移動
スクリーンショット 2026-07-07 23.46.35.png

まだ、ダイヤルボタンがずれてます
スクリーンショット 2026-07-07 23.47.34.png

一旦位置調整の方法をClaude Codeに尋ねてみると、ある程度の位置調整をしてもらえましたがまだずれてましたので細かく指示していきます。

5-1. Claude Codeへの追加指示

Dial1 を x:92, y:64へ

Dial1HBoxContainer の子なので直接位置を変更できません。HBoxContainerDials)自体の offset を変更することで、ダイヤル全体の表示位置が決まります。

Dial2, 3 は 182pxづつ右へ

ダイヤル間の間隔を 182px にするため separation を調整します。

- theme_override_constants/separation = 0
+ theme_override_constants/separation = 36
separation = 182 - 146(ダイヤル幅)= 36px

各ダイヤルの左端x座標(Dialsoffset_left = 61 基準):

ダイヤル x座標
Dial1 61
Dial2 61 + 182 = 243
Dial3 61 + 364 = 425

補足HBoxContainer の子ノードは親がレイアウトを管理するため、子ノード側の offset を直接変更しても反映されません。位置の調整はコンテナ(Dials)の offset を変更することで行います。


6. 動作確認

F5 でゲームを実行し、以下の流れで動作すれば成功です。

  1. 郵便受けをタップしてポップアップを開く
  2. frame_popup.png の枠の奥にダイヤル錠が表示されている
  3. 各ダイヤルをタップするたびに数字が 0→1→…→9→0 とループする
  4. 「戻る」ボタン(btn_back.png)でポップアップを閉じられる

Claude Codeと共に、gotot上のエディタやプロンプト指示で位置調整を行いまして、ポップアップにダイヤルギミックを表示することができました
スクリーンショット 2026-07-08 0.08.24.png

さらにタップでダイヤルを回すことができました!
スクリーンショット 2026-07-08 0.08.05.png

うまく動かない場合

症状 確認ポイント
ダイヤルが表示されない DialLockpopup.tscnContent より前に配置されているか
数字がループしない dial.gdpressed.connect(_on_pressed)_ready() で呼ばれているか
ダイヤルがフレームの手前に出てしまう ノードツリーで DialLockContent より**前(上)**にあるか確認
ダイヤルの間隔がおかしい HBoxContainerseparation が正しく設定されているか

7. 呼び出し側での使い方

combination_changed シグナルを購読することで、ダイヤルの現在値を取得して正解判定ができます。

# 正解の組み合わせを判定する例
$Popup/DialLock.combination_changed.connect(func(digits: Array[int]):
    if digits == [1, 2, 3]:
        print("正解!ロック解除")
)

まとめ

今回はClaude Code + godot-mcpを使って以下を実装しました。

やったこと ポイント
スプライトシートの切り出し AtlasTexture.region にフレーム座標を指定
フレームの効率的な切り替え _ready() でまとめて生成してキャッシュ、タップ時は参照を差し替えるだけ
タップで数字をループ (_digit + 1) % 10 でAxmol実装と同じロジック
ダイヤル間隔の管理 HBoxContainerseparation で宣言的に制御
z軸の管理 ノードツリーの並び順で制御(後方が手前に描画)
組み合わせの通知 combination_changed シグナルで3桁をまとめて通知

Axmol からの移植にあたり、Claude CodeにAxmol実装の参考記事URLを渡すことで、フレームキャッシュのロジックや数字ループの考え方をそのままGodotに適用してもらえました。

3
2
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
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?