2
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脱出ゲームを作ってみる⑫〜左右移動・ギミック2点・アイテム合成・パーティクルを実装する〜

2
Posted at

はじめに

Claude Code + MCP + Godot 4.xで脱出ゲームを作る連載の第12回です。

前回までにBGMと効果音が入り、脱出ゲームとしての手触りが整いました。今回は扉の先に新しいエリア「ホール」を作り、ホールでの左右移動ボタン、ギミック設計の考え方(順番押し系・絵合わせ系)、絵合わせ系+別ボタンギミック(時計)、アイテム合成(ライター+ライターオイル)、パーティクルを使った演出(暖炉に火を入れる)という項目を一気に実装します。

これらはAxmol Engine版の連載で先に扱った内容の移植です。参考にした記事は以下の通りです。

  • 第12回 左右移動ボタンの実装
  • 番外編1 ギミックについて(順番押し系・絵合わせ系の設計論)
  • 第13回 順番押し系ギミックの実装
  • 第14回 絵合わせ系+別ボタンギミック(時計)の実装
  • 第15回 アイテム合成の実装
  • 第16回 パーティクルを使う(暖炉に火を入れる)

時短のため、これらの記事を仕様書としてclaudeに渡して、godotでの実装を行なってもらいます!


この記事で作るもの

扉を解錠した先の「ホール」エリアと左右移動ボタンを作り、額縁のタップギミックや、部品を直接いじらせず専用ボタンで進める時計ギミックを実装します。相互に組み合わせ可能なアイテム合成(ライター+ライターオイル)と、CPUParticles2D を使った暖炉の炎演出も扱います。

前提条件

Claude Code + godot-mcpのセットアップ完了(第2回参照)、scripts/global.gd(オートロード)にインベントリ・使用済み管理が実装済み(第9・10回参照)、scenes/door.tscn が実装済み(第10回参照)という前提で進めます。以下の画像・リソース素材も配置済みです。

ファイル 用途
bg_stage_004.png ホールの背景
bg_stage_006_3.png 時計ギミック画面の背景
bg_stage_006_4.png / bg_stage_006_5.png キャビネットの開閉差分
btn_hour.png / btn_minute.png 時計の針を進めるボタン
hourhand.png / minutehand.png 時計の針画像(450×450px)
item_02_1 (ライター) / item_03_1 (ライターオイル) / item_04_1 (火の点くライター) アイテムリソース

設計方針

Axmol版は stages.json / gimmicks.json によるデータ駆動のステージ管理でしたが、本プロジェクトは「1シーン=1画面」の既存方針(post.tscn/door.tscnなど)を踏襲し、シーンファイルを直接増やす形で移植します。Axmol版のstate配列で「未解決時はギミック全画面表示、解決後は通常ナビゲーション」を1つのステージ定義で表現していたのに対し、本プロジェクトでは素直に「ギミック用シーン」と「通常閲覧用シーン」を別ファイルに分け、Globalの真偽値フラグで表示を出し分けます。シーン数は増えますが、既存の「1シーン=1画面、フェードで遷移」という規約を崩さずに済みます。


ファイル構成

scenes/
  hall.tscn                        ← 追加(左右移動ボタン)
  room_fireplace.tscn              ← 追加
  room_fireplace_closeup.tscn      ← 追加(パーティクル)
  room_clock.tscn                  ← 追加
  room_clock_zoom.tscn             ← 追加
  room_clock_gimmick.tscn          ← 追加(絵合わせ+別ボタン)
  room_clock_cabinet.tscn          ← 追加
  components/
    item_popup.tscn                ← 変更(ItemImageのmouse_filter)
scripts/
  hall.gd                          ← 追加
  door.gd                          ← 変更(解錠後にhallへ遷移)
  room_fireplace.gd                ← 追加
  room_fireplace_closeup.gd        ← 追加
  room_clock.gd                    ← 追加
  room_clock_zoom.gd               ← 追加
  room_clock_gimmick.gd            ← 追加
  room_clock_cabinet.gd            ← 追加
  global.gd                        ← 変更(FIRE_LIT・CLOCK_SOLVED追加)
  components/
    item_popup.gd                  ← 変更(アイテム合成)
resources/items/
  item_02_1.tres(ライター)         ← 追加
  item_03_1.tres(ライターオイル)    ← 追加
  item_04_1.tres(火の点くライター)  ← 追加

1. 左右移動ボタンで扉の先のホールへ

参考:Axmol版第12回「左右移動ボタンの実装」

扉(door.tscn)を解錠した後、hall.tscn(ホール、bg_stage_004.png)へ遷移するようにします。既に解錠済みなら鍵穴タップで直接ホールへ、未解錠なら鍵使用→演出ポップアップの closed シグナルでホールへ遷移します。

# scripts/door.gd(抜粋)
func _on_keyhole_area_gui_input(event: InputEvent) -> void:
    if not (event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT):
        return
    if Global.DOOR_UNLOCKED:
        _go_to_hall()
        return
    if Global.selected_item == null or Global.selected_item.id != _KEY_ITEM_ID:
        return
    $AttractionPopup.show_for(3.0)
    Global.use_item(_KEY_ITEM_ID)
    Global.DOOR_UNLOCKED = true

func _on_attraction_popup_closed() -> void:
    _go_to_hall()

ホールは btn_back.png(下向き三角)を回転させて左右矢印として使い回します。Axmol版の setRotation(90)(時計回りで下→左)と同じ考え方で、Godotでも rotation_degrees は時計回りが正なので、左ボタンは 90、右ボタンは -90 をそのまま使えます。回転の中心をボタン中央に合わせるため pivot_offset をボタンサイズの半分(75, 50)に設定します。

# scenes/hall.tscn(抜粋)
[node name="BtnLeft" type="TextureButton" parent="."]
offset_left = 65.0
offset_top = 491.0
offset_right = 215.0
offset_bottom = 591.0
pivot_offset = Vector2(75, 50)
rotation_degrees = 90.0
texture_normal = ExtResource("3_btn_back")

[node name="BtnRight" type="TextureButton" parent="."]
offset_left = 1705.0
...
pivot_offset = Vector2(75, 50)
rotation_degrees = -90.0
texture_normal = ExtResource("3_btn_back")
# scripts/hall.gd
func _on_btn_left_pressed() -> void:
    _go_to("res://scenes/room_fireplace.tscn")

func _on_btn_right_pressed() -> void:
    _go_to("res://scenes/room_clock.tscn")

左の翼(暖炉部屋)・右の翼(時計部屋)ともに末端は既存パターン通りの BtnBack(回転なし)でホールへ戻る構成にし、「移動ボタン」は分岐点であるホールだけに置きます。

新しく矢印画像を用意せず、既存の btn_back.pngrotation_degrees で回転させるだけで左右矢印として流用しているのがポイントです。pivot_offset を正しく設定しないと画像の左上隅を軸に回転してしまい位置がずれるため、ボタンサイズの半分を指定する必要があります。

スクリーンショット 2026-08-10 23.45.44.png
左のボタンで暖炉のある広間に、右のボタンで時計台のある広間に遷移します
戻るボタンで建物の外に戻ります


2. ギミック設計の考え方

参考:Axmol版番外編1「ギミックについて」・第13回「順番押し系ギミックの実装」

Axmol版の番外編記事では、ギミックの部品そのものをトリガにすると複数箇所への同時タップで誤爆する問題を指摘し、「専用の操作ボタンを別に置く」「自動判定ではなく確認ボタンで判定する」という設計方針が示されています。この方針は次のセクションの時計ギミックにそのまま活かされています。


3. 絵合わせ系+別ボタンギミック(時計)

参考:Axmol版第14回「絵合わせ系+別ボタンギミック(時計)の実装」

時計部屋(room_clock.tscnroom_clock_zoom.tscn)から時計盤をタップすると room_clock_gimmick.tscnbg_stage_006_3.png)へ入ります。短針・長針をタップで直接回転させるのではなく、btn_hour.png/btn_minute.png という別ボタンで1回タップにつき30度ずつ回転させ、btn_ok.png で正誤判定、btn_reload.png でリセットします。これがセクション2の設計方針の実践です。

# scripts/room_clock_gimmick.gd(抜粋)
const _TARGET_HOUR := 8
const _TARGET_MINUTE := 4

func _on_btn_hour_pressed() -> void:
    _hour_count = (_hour_count + 1) % 12
    _update_hands()

func _on_btn_minute_pressed() -> void:
    _minute_count = (_minute_count + 1) % 12
    _update_hands()

func _update_hands() -> void:
    $HourHand.rotation_degrees = _hour_count * 30.0
    $MinuteHand.rotation_degrees = _minute_count * 30.0

func _on_btn_ok_pressed() -> void:
    if _hour_count == _TARGET_HOUR and _minute_count == _TARGET_MINUTE:
        Global.CLOCK_SOLVED = true
        $SeSuccess.play()
        _disable_controls()

正解は「8時20分」です。長針は5分刻み(30度=5分)なので _TARGET_MINUTE = 4(4回×5分=20分)になります。針の画像(hourhand.png/minutehand.png、450×450)は針の付け根がおおよそ画像中心にあるため、pivot_offset = Vector2(225, 225) を回転の中心にして時計盤の中心(922, 410)に重ねています。

解錠済みかどうかは Global.CLOCK_SOLVED(第8回の POST_OPEN/DOOR_UNLOCKED と同じgetter/setter + _save() パターン)で永続化し、_ready() で既に解けていれば針を正解位置に固定してボタンを disabled = true にします。

正解すると、時計盤下部のキャビネット(room_clock_cabinet.tscnbg_stage_006_4.pngbg_stage_006_5.png)が開き、ライターオイル(item_03_1)を item_006.png のタップで取得できるようになります。

# scripts/room_clock_cabinet.gd(抜粋)
func _update_item_button_visibility() -> void:
    $ItemButton.visible = Global.CLOCK_SOLVED and not Global.has_item(_ITEM.id) and not Global.is_used(_ITEM.id)

HourHandMinuteHand を直接タップ判定するのではなく btn_hour/btn_minute という別ボタンで進めることで、「隣の針を誤ってタップしてしまう」ような誤爆を避けています。room_clock_cabinet.gd の可視性判定は「時計が解けている」「未所持」「未使用」の3条件を and で連結しており、第10回で確立した「所持数と進行フラグは別の関心事」という設計がここでも活きています。

スクリーンショット 2026-08-10 23.46.34.png
8時20分に合わせると・・・

スクリーンショット 2026-08-10 23.49.03.png
振り子の扉が開き、中に何かが・・・

スクリーンショット 2026-08-10 23.49.13.png
「ライターオイル」を取得しました!


もう一つの額縁のギミックも無事移植されました
スクリーンショット 2026-08-10 23.50.39.png
額縁の4隅を正しい順番でタップすると・・・

スクリーンショット 2026-08-10 23.51.36.png
額縁が外れて・・・

スクリーンショット 2026-08-10 23.52.05.png
「ライター」を取得しました!


4. アイテム合成(ライター+ライターオイル)

参考:Axmol版第15回「アイテム合成の実装」

暖炉部屋のマントルでライター(item_02_1item_007.png)、時計部屋のキャビネットでライターオイル(item_03_1)を取得できます。

ItemDatacombinable_with/combine_result は互いに参照させ、どちらを長押しして開いても合成できるようにします。

# resources/items/item_02_1.tres(抜粋)
combinable_with = Array[String](["item_03_1"])
combine_result = "item_04_1"

# resources/items/item_03_1.tres(抜粋)
combinable_with = Array[String](["item_02_1"])
combine_result = "item_04_1"

操作は「合成したい片方を長押しして詳細ポップアップを表示→もう片方をスロットでタップ選択→ポップアップ内のアイテム画像をタップ」という流れです。item_popup.gd にアイテム画像のタップ処理を追加し、第9回で実装済みの Global.try_combine() を呼びます。

# scripts/components/item_popup.gd(抜粋)
func _on_item_image_gui_input(event: InputEvent) -> void:
    if not (event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT):
        return
    var other := Global.selected_item
    if other == null or other.id == _item.id:
        return
    var result := Global.try_combine(_item, other)
    if result != null:
        show_item(result)

ItemImageTextureRect)は元々 mouse_filter = 2(IGNORE)で gui_input が発火しなかったため、mouse_filter = 0(STOP)に変更します。合成成功時は show_item(result) をそのまま呼ぶことで、同じポップアップに合成結果(item_04_1=火の点くライター)を連続表示します。

combinable_with を両方のアイテムに相互設定することで、「ライターを長押ししてオイルを選ぶ」でも「オイルを長押ししてライターを選ぶ」でも同じ結果に到達します。プレイヤーがどちらから操作しても迷わない設計です。

スクリーンショット 2026-08-10 23.52.47.png
「ライター」を表示して、「ライターオイル」を選択状態にして、「ライター」をタップ・・・

スクリーンショット 2026-08-10 23.52.56.png
「火の点くライター」になりました!


5. パーティクルで暖炉に火を入れる

参考:Axmol版第16回「パーティクルを使う(暖炉に火を入れる)」

火の点くライター(item_04_1)を選択した状態で暖炉の焚き口(HearthArea)をタップすると着火します。Axmolの ParticleFire 相当として CPUParticles2D を使用し、Gradient で白→オレンジ→透明に減衰する炎らしい色変化をつけます。

# scenes/room_fireplace_closeup.tscn(抜粋)
[sub_resource type="Gradient" id="Gradient_fire"]
offsets = PackedFloat32Array(0, 0.5, 1)
colors = PackedColorArray(1, 0.95, 0.6, 1, 1, 0.45, 0.1, 0.9, 0.6, 0.1, 0.05, 0)

[node name="Fire" type="CPUParticles2D" parent="."]
position = Vector2(980, 560)
emitting = false
direction = Vector2(0, -1)
gravity = Vector2(0, -60)
color_ramp = SubResource("Gradient_fire")
# scripts/room_fireplace_closeup.gd(抜粋)
func _on_hearth_area_gui_input(event: InputEvent) -> void:
    if not (event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT):
        return
    if Global.FIRE_LIT:
        return
    if Global.selected_item == null or Global.selected_item.id != _LIT_LIGHTER_ID:
        return
    Global.use_item(_LIT_LIGHTER_ID)
    Global.FIRE_LIT = true
    $Fire.emitting = true
    $SeUseKey.play()

着火状態は Global.FIRE_LIT で永続化し、_ready()$Fire.emitting = Global.FIRE_LIT として再訪問時も燃えたままにします。ライターは door.gd の鍵と同じく use_item() で消費し、item_04_1 は再利用不可の単発アイテムとして扱います。

CPUParticles2Dcolor_rampGradient リソースを設定することで、パーティクルが生成されてから消えるまでの色変化を細かく制御できます。今回は「白(高温の中心)→オレンジ(炎の色)→透明(消える)」という3点のグラデーションで炎らしさを出しており、単色の color プロパティだけよりも自然な見た目になります。

スクリーンショット 2026-08-10 23.54.51.png
暖炉のある広間・・・

スクリーンショット 2026-08-10 23.55.00.png
暖炉に近寄ってみます
ライターを使ってみましょう

スクリーンショット 2026-08-10 23.55.18.png
暖炉に火が入りました!

スクリーンショット 2026-08-10 23.55.26.png


動作確認

F5 でゲームを実行し、扉を解錠すると hall.tscn に遷移し、ホールの左右ボタンで暖炉部屋・時計部屋にそれぞれ移動できれば最初のステップは成功です。時計ギミックで btn_hour/btn_minute を押して8時20分に合わせ、btn_ok を押すと正解になり、キャビネットが開いてライターオイルを取得できます。ライターとライターオイルをポップアップ内でタップして合成すると「火の点くライター」になり、それを選択した状態で暖炉をタップすると炎のパーティクルが再生されます。一度着火した暖炉は再訪しても燃えたままになっていれば、一通りの動作確認は完了です。

左右ボタンが正しい方向を向かない場合は pivot_offset がボタンサイズの半分になっているか、rotation_degrees の符号(左は90、右は-90)を確認してください。時計の針がずれた位置で回転する場合は pivot_offset が針画像のサイズの半分(225, 225)になっているかを見ます。正解しても CLOCK_SOLVED が保存されない場合は Global.CLOCK_SOLVED のsetterで _save() が呼ばれているか(getter/setterパターンの実装漏れ)を確認してください。アイテム合成ができない場合は ItemImagemouse_filter0(STOP)になっているか、combinable_with が両方向に設定されているかを見ます。炎が表示されない場合は $Fire.emitting = true が呼ばれているか、CPUParticles2Ddirection/gravity の設定を確認してください。


成果物


まとめ

今回は時短のため、仕様書として以前書いた自身のQiita記事を渡して、Godotで実装してもらいました。
実は想定通りには移植されませんでしたので、10回程度プロンプトで修正指示を出しましたが、最終的にしっかりと整った形になりました。

やりたいこと 実装
分岐点だけに左右移動ボタン btn_back.pngrotation_degrees = ±90 + pivot_offset で使い回し。末端の部屋は通常の BtnBack で戻る
部品タップの誤爆を避けるギミック 針や部品自体ではなく専用ボタン(btn_hour/btn_minute)で状態を進め、btn_ok で確認判定
ギミックの解答状態を永続化 POST_OPEN/DOOR_UNLOCKED と同じgetter/setter + _save() パターンで CLOCK_SOLVED を追加
どちらから長押ししても合成できる combinable_with をアイテム同士で相互に設定
ポップアップ内タップで合成→結果を連続表示 ItemImagemouse_filter をSTOPに変更し、gui_input から try_combine() →成功時は show_item(result)
炎の演出 CPUParticles2D + Gradientcolor_ramp)で色を減衰させ、着火状態を FIRE_LIT で永続化

Axmol版は stages.json のデータ駆動でステージ状態を表現していましたが、本プロジェクトでは「ギミック用シーン」と「通常閲覧用シーン」を別ファイルに分け、Global の真偽値フラグで出し分ける方針を貫きました。シーン数は増える分エディタ上での見通しは良く、これまでの連載を通して確立してきたパターン(フェード遷移・getter/setter永続化・所持/使用済みの分離)がそのまま新しいギミックに応用できることも確認できました。

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