はじめに
Claude Code + MCP + Godot 4.xで脱出ゲームを作る連載の第10回です。
- 第1回:環境構築とプロジェクト作成
- 第2回:Claude Code + godot-mcpのセットアップ
- 第3回:Claude CodeとMCPでスプラッシュ画面を実装する
- 第4回:Claude CodeとMCPでタイトル画面を実装してシーンを遷移する
- 第5回:背景の特定領域をタップしてフェード遷移する
- 第6回:再利用可能なモーダルポップアップを作る
- 第7回:スプライトシートを使ったダイヤル錠ギミックを実装する
- 第8回:戻るボタン・正解判定・開封演出・ローカル保存を実装する
- 第9回:アイテム管理:シーン常駐のインベントリシステムを実装する
- 第10回:アイテムで扉を開ける(今回)
前回までにインベントリシステムが完成し、アイテムの取得・選択・長押し詳細表示ができるようになりました。今回は新しい背景画像3枚をきっかけに、「扉シーン」「鍵穴に鍵を使うギミック」「デバッグ用リセットボタン」「アイテムの使用済み状態」までを実装します。
今回も試行錯誤の過程は省き、各指示に対する最終的な実装だけをまとめています。
この記事で作るもの
背景の扉領域をタップして遷移する door.tscn を作り、選択中のアイテム(金の鍵)を鍵穴に使うと解錠演出が流れるギミックを組み込みます。一定時間表示して自動で閉じる汎用の演出ポップアップ、シーンをまたいだ解錠フラグと背景の出し分け、タイトル画面のデバッグ用リセットボタンも作ります。あわせて、「未所持」と「使用済み」を区別するアイテム状態管理も行います。
前提条件
Claude Code + godot-mcpのセットアップ完了(第2回参照)、scripts/global.gd(オートロード)にインベントリ管理が実装済み(第9回参照)、scenes/main.tscn・scenes/post_open.tscn が作成済み(第5・8回参照)、scenes/components/popup.tscn の構成パターンが確立済み(第6回参照)という前提で進めます。
設計方針
今回は新しい画像アセットが配置されたところから始まります。画像のサイズや命名パターンから用途を推測しつつ、実際の仕様は指示のたびに確認しながら進めていく、という現場のフローをそのまま記録しています。
また、「アイテムを使い切る」という操作を素朴に remove_item() だけで実装すると、「一度も持っていない」状態と区別がつかなくなる落とし穴があります。この気づきも含めて記録します。
ファイル構成
scenes/
door.tscn ← 今回作成
main.tscn ← 変更(DoorArea追加)
title.tscn ← 変更(BtnDebugReset追加)
components/
attraction_popup.tscn ← 今回作成
scripts/
door.gd ← 今回作成
main.gd ← 変更
title.gd ← 変更
global.gd ← 変更(DOOR_UNLOCKED・使用済み管理を追加)
post_open.gd ← 変更
components/
attraction_popup.gd ← 今回作成
1. 追加された画像を確認する
claudeに新しく配置した画像を知らせておくと話が早くなりそうな気がします
プロンプト:
assets/images/bg_stage_003.png、assets/images/attractions/attraction_003.png、assets/images/bg_stage_001_2.pngを配置しました
3枚の画像のサイズと配置場所を確認しました。
| ファイル | メモ | 画像 |
|---|---|---|
bg_stage_003.png |
既存の bg_stage_001/002 と同じフルスクリーン背景サイズ |
|
bg_stage_001_2.png |
bg_stage_001.png と同サイズの差分背景 |
![]() |
attractions/attraction_003.png |
鍵を開けた時の演出用 | ![]() |
2. doorシーンを新設し、扉から遷移できるようにする
プロンプト:
doorシーンを作成して、bg_stage_003.pngを背景表示。mainシーンのbg_stage_001.pngの扉の領域をタップするとdoorシーンに遷移。doorシーンからは画面下の戻るボタンでmainシーンに戻る。
bg_stage_001.png を確認し、画像中央の両開き扉の範囲を当たり判定として main.tscn に追加します。既存の MailboxArea(第5回)と同じ「見た目を持たない Control + gui_input」パターンを踏襲します。
# scenes/main.tscn(抜粋)
[node name="DoorArea" type="Control" parent="."]
anchors_preset = 0
offset_left = 650.0
offset_top = 20.0
offset_right = 1230.0
offset_bottom = 790.0
mouse_default_cursor_shape = 2
post.tscn/post_open.tscnと同じ構成(フェードイン付き Control + 全画面背景 + BtnBack)で door.tscn を新設します。
# scenes/door.tscn(この時点)
[gd_scene format=3]
[ext_resource type="Texture2D" path="res://assets/images/bg_stage_003.png" id="1_bg"]
[ext_resource type="Script" path="res://scripts/door.gd" id="2_door"]
[ext_resource type="Texture2D" path="res://assets/images/btn_back.png" id="3_btn_back"]
[node name="Door" type="Control"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("2_door")
[node name="BgStage003" type="TextureRect" parent="."]
layout_mode = 0
offset_right = 1920.0
offset_bottom = 1083.0
texture = ExtResource("1_bg")
expand_mode = 1
stretch_mode = 1
[node name="BtnBack" type="TextureButton" parent="."]
layout_mode = 0
offset_left = 860.0
offset_top = 641.0
offset_right = 1010.0
offset_bottom = 741.0
texture_normal = ExtResource("3_btn_back")
# scripts/door.gd(この時点)
extends Control
@export var fade_in_duration: float = 0.4
@export var fade_out_duration: float = 0.4
func _ready() -> void:
modulate.a = 0.0
var tween := create_tween()
tween.tween_property(self, "modulate:a", 1.0, fade_in_duration)
$BtnBack.pressed.connect(_on_btn_back_pressed)
func _on_btn_back_pressed() -> void:
var tween := create_tween()
tween.tween_property(self, "modulate:a", 0.0, fade_out_duration)
tween.tween_callback(_on_fade_out_complete)
func _on_fade_out_complete() -> void:
get_tree().change_scene_to_file("res://scenes/main.tscn")
main.gd は元々 MailboxArea 専用の1本道だったため、DoorArea 用の遷移を並列で追加し、遷移先ごとにコールバック名を分けます(_on_fade_out_to_post_complete / _on_fade_out_to_door_complete)。
# scripts/main.gd(この時点)
extends Control
@export var fade_out_duration: float = 0.4
func _ready() -> void:
$MailboxArea.gui_input.connect(_on_mailbox_area_gui_input)
$DoorArea.gui_input.connect(_on_door_area_gui_input)
func _on_mailbox_area_gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
_go_to_post()
func _go_to_post() -> void:
var tween := create_tween()
tween.tween_property(self, "modulate:a", 0.0, fade_out_duration)
tween.tween_callback(_on_fade_out_to_post_complete)
func _on_fade_out_to_post_complete() -> void:
get_tree().change_scene_to_file("res://scenes/post.tscn")
func _on_door_area_gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
_go_to_door()
func _go_to_door() -> void:
var tween := create_tween()
tween.tween_property(self, "modulate:a", 0.0, fade_out_duration)
tween.tween_callback(_on_fade_out_to_door_complete)
func _on_fade_out_to_door_complete() -> void:
get_tree().change_scene_to_file("res://scenes/door.tscn")
1つのシーンから複数の遷移先がある場合、tween_callback に渡すコールバックを共通化せず _on_fade_out_to_post_complete / _on_fade_out_to_door_complete のように分けています。これにより、どのフェードがどの遷移に紐づいているかがコードを見ただけで分かります。
3. 鍵穴に鍵を使うと演出ポップアップを表示し、扉を解錠する
ある程度長文でも意図通りにやってくれることを期待して
プロンプト:
doorシーンのbg_stage_003.pngの鍵穴の領域は、金の鍵を選択状態でタップすると、一度frame_popup.pngでポップアップを表示して、frame_popup.pngのz軸の下にattractions/attraction_003.pngを3秒表示して自動で閉じてください。鍵穴の領域をタップした時、金の鍵を使用済みのステータスにして画面右側のアイテムスロットから非表示にしてください。それと同時に、mainシーンの背景がstage_001_2.pngになるようにしてください。
bg_stage_003.png を確認し、金色の鍵穴プレートの範囲を KeyholeArea として追加します。演出用ポップアップは第3回の splash.gd と同じ「フェードイン→interval→フェードアウト→コールバック」のTweenチェーンを使い、show_for(duration) で好きな表示時間を渡せるようにします。
# scripts/components/attraction_popup.gd
extends Control
signal closed
func _ready() -> void:
hide()
func show_for(duration: float) -> void:
show()
modulate.a = 0.0
var tween := create_tween()
tween.tween_property(self, "modulate:a", 1.0, 0.2)
tween.tween_interval(duration)
tween.tween_property(self, "modulate:a", 0.0, 0.2)
tween.tween_callback(func(): hide(); closed.emit())
「frame_popup.png のz軸の下に attraction_003.png を表示」という指定なので、ノードツリーの並びを Blocker → AttractionImage → Frame の順にします(Godotは後方のノードほど手前に描画されるため、Frame を最後に置くことで attraction_003.png より前面になります)。
# scenes/components/attraction_popup.tscn(抜粋、ノード順)
[node name="Blocker" type="ColorRect" parent="."]
...
[node name="AttractionImage" type="TextureRect" parent="."]
...
texture = ExtResource("2_attraction")
[node name="Frame" type="TextureRect" parent="."]
...
texture = ExtResource("1_frame")
door.tscn に KeyholeArea と AttractionPopup インスタンスを追加します。
# scenes/door.tscn(抜粋)
[node name="KeyholeArea" type="Control" parent="."]
layout_mode = 0
offset_left = 880.0
offset_top = 260.0
offset_right = 1060.0
offset_bottom = 560.0
mouse_default_cursor_shape = 2
[node name="AttractionPopup" parent="." instance=ExtResource("4_attraction_popup")]
door.gd 側では、鍵穴タップ時に「金の鍵が選択中かどうか」を判定してから演出・アイテム消費・扉解錠フラグの3つを行います。
# scripts/door.gd(抜粋)
const _KEY_ITEM_ID := "item_01_1"
func _ready() -> void:
...
$KeyholeArea.gui_input.connect(_on_keyhole_area_gui_input)
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.selected_item == null or Global.selected_item.id != _KEY_ITEM_ID:
return
$AttractionPopup.show_for(3.0)
Global.remove_item(_KEY_ITEM_ID)
Global.DOOR_UNLOCKED = true
「使用済みのステータスにして画面右側のアイテムスロットから非表示に」という要件は、この時点では既存の Global.remove_item() で満たしました(item_removed シグナルでインベントリバーからスロットが消えます)。ただしこれは後述のセクション5で「未所持」と「使用済み」を区別する形に見直すことになります。
「mainシーンの背景がstage_001_2.pngになるように」は、第8回の POST_OPEN と同じgetter/setter + _save() のパターンで DOOR_UNLOCKED を Global に追加して実現します。
# scripts/global.gd(抜粋)
var _door_unlocked: bool = false
var DOOR_UNLOCKED: bool:
get:
return _door_unlocked
set(value):
_door_unlocked = value
_save()
# scripts/global.gd(_load・_save、抜粋)
func _load() -> void:
...
_door_unlocked = parsed.get("DOOR_UNLOCKED", false)
func _save() -> void:
...
file.store_string(JSON.stringify({"POST_OPEN": _post_open, "DOOR_UNLOCKED": _door_unlocked, "ITEMS": item_ids}))
main.gd は _ready() 時点で Global.DOOR_UNLOCKED を見て、背景テクスチャを差し替えます。
# scripts/main.gd(抜粋)
const _BG_STAGE_001 := preload("res://assets/images/bg_stage_001.png")
const _BG_STAGE_001_2 := preload("res://assets/images/bg_stage_001_2.png")
func _ready() -> void:
$BgStage001.texture = _BG_STAGE_001_2 if Global.DOOR_UNLOCKED else _BG_STAGE_001
...
第9回で実装した Global.selected_item をそのまま使えば、「特定のアイテムを選択した状態でオブジェクトをタップする」というインタラクションはシンプルな if 文で実現できます。専用の判定ロジックを新設する必要はありません。show_for(duration) は第3回のスプラッシュ演出、DOOR_UNLOCKED は第8回の POST_OPEN と全く同じ構造で、連載を通して確立してきたパターンが新しいギミックにもそのまま流用できています。
4. タイトル画面にデバッグ用リセットボタンを設置する
ゲーム進行をリセットしてデバッグしたいため、専用のデバッグボタンを設置してもらいます
プロンプト:
タイトルシーンの右上に、btn_reload.pngのボタンを設置して、ローカルストレージを空にするデバッグ用ボタンを設置
title.tscn 右上に btn_reload.png の TextureButton を追加します。
# scenes/title.tscn(抜粋)
[node name="BtnDebugReset" type="TextureButton" parent="."]
layout_mode = 0
offset_left = 1750.0
offset_top = 20.0
offset_right = 1900.0
offset_bottom = 120.0
texture_normal = ExtResource("4_btn_reload")
「ローカルストレージを空に」を Global 側の責務として一元化するため、reset_save_data() を追加します。所持アイテムは remove_item() を1つずつ呼ぶことでインベントリバーの表示も連動して更新されます。
# scripts/global.gd(抜粋)
func reset_save_data() -> void:
for item in _items.duplicate():
remove_item(item.id)
POST_OPEN = false
DOOR_UNLOCKED = false
# scripts/title.gd(抜粋)
func _ready() -> void:
$BtnTapToStart.pressed.connect(_on_btn_tap_to_start_pressed)
$BtnDebugReset.pressed.connect(_on_btn_debug_reset_pressed)
_start_blink()
func _on_btn_debug_reset_pressed() -> void:
Global.reset_save_data()
「ローカルストレージを空にする」処理をボタン側に直接書かず、Global.reset_save_data() として一元化しています。以前確認した「user:// のファイルを直接削除する」方法と違い、この方法なら _items 配列やシグナルの発火も含めて正しく状態がリセットされ、インベントリバーの表示も自動的に更新されます。
5. 「未所持」と「使用済み」を区別する
使用したアイテムが未所持状態で、また同じところから取得できるようになってましたので、使用済みというステータスになるようにお願いしました
プロンプト:
金の鍵を使うと、未所持状態になってしまうので、使用済みというステータスを追加してください
セクション3では鍵の消費に remove_item() を使っていたため、「一度も取得していない」状態と「取得済みだが使い切った」状態が has_item() == false という同じ結果になってしまっていました。これにより post_open.tscn で鍵を再取得できてしまう不具合につながります。
Global に使用済みIDを保持する _used_item_ids を追加し、use_item()(消費+使用済み記録)と is_used()(使用済み判定)を用意します。
# scripts/global.gd(抜粋)
var _used_item_ids: Array[String] = []
func use_item(item_id: String) -> void:
remove_item(item_id)
if not is_used(item_id):
_used_item_ids.append(item_id)
_save()
func is_used(item_id: String) -> bool:
return _used_item_ids.has(item_id)
_load()/_save() にも USED_ITEMS キーを追加して永続化し、reset_save_data() でもクリアします。
# scripts/global.gd(抜粋)
func reset_save_data() -> void:
for item in _items.duplicate():
remove_item(item.id)
_used_item_ids.clear()
POST_OPEN = false
DOOR_UNLOCKED = false
func _load() -> void:
...
for item_id in parsed.get("USED_ITEMS", []):
_used_item_ids.append(item_id)
func _save() -> void:
...
file.store_string(JSON.stringify({
"POST_OPEN": _post_open,
"DOOR_UNLOCKED": _door_unlocked,
"ITEMS": item_ids,
"USED_ITEMS": _used_item_ids,
}))
door.gd は鍵の消費を use_item() に切り替えます。
$AttractionPopup.show_for(3.0)
- Global.remove_item(_KEY_ITEM_ID)
+ Global.use_item(_KEY_ITEM_ID)
Global.DOOR_UNLOCKED = true
post_open.gd の取得ボタン表示条件にも「使用済みでない」を追加し、使用済みの鍵が郵便受けで再表示されないようにします。
func _update_item_button_visibility() -> void:
- $ItemButton.visible = not Global.has_item(_ITEM.id)
+ $ItemButton.visible = not Global.has_item(_ITEM.id) and not Global.is_used(_ITEM.id)
remove_item() だけで「使用済み」を表現しようとすると、「一度も持っていない」状態と区別がつかなくなり再取得できてしまいます。_items(所持中)とは別に _used_item_ids(使用済み)を持ち、取得可否の判定は両方を見るようにすることで、この落とし穴を回避しています。同じような「一度きりのイベントを記録したい」場面(例:あるセリフを一度だけ表示する、ある演出を初回のみ流す)でも、このパターンがそのまま使えます。
動作確認
ゲームを実行し、main.tscn の扉部分をタップすると door.tscn に遷移し、戻るボタンで main.tscn に戻れれば最初のステップは成功です。インベントリで金の鍵を選択した状態で鍵穴をタップすると、演出ポップアップが3秒間表示されて自動で閉じ、金の鍵はインベントリバーから消えます。main.tscn に戻ると背景が bg_stage_001_2.png に変わっており、post_open.tscn を再訪しても使用済みの鍵は再取得できません。タイトル画面右上のリセットボタンでセーブデータが初期化されれば、一通り動作確認は完了です。
扉をタップしても遷移しない場合は DoorArea の座標が扉の見た目に合っているか、main.gd で gui_input が接続されているかを確認してください。鍵穴をタップしても反応しない場合は Global.selected_item が正しく設定されているか(インベントリバーでアイテムをタップ選択したか)を見ます。演出画像が枠の手前に出てしまう場合は attraction_popup.tscn のノード順が Blocker → AttractionImage → Frame になっているか、使用済みアイテムが再取得できてしまう場合は use_item() を使っているか(remove_item() のままになっていないか)を確認してください。リセットボタンを押してもデータが残る場合は Global.reset_save_data() が _used_item_ids もクリアしているかを見ます。
まとめ
今回はClaude Code + godot-mcpを使って以下を実装しました。
| やりたいこと | 実装 |
|---|---|
| 背景画像の特定領域から新シーンへ遷移 |
MailboxArea と同じ「見た目なし Control + gui_input」パターンを扉にも適用 |
| シーンごとの行き来 | 遷移先ごとにフェード用コールバックを分けて命名(_on_fade_out_to_post_completeなど) |
| 選択中アイテムを使うギミック |
Global.selected_item が対象アイテムのidと一致するかで判定 |
| frame_popup.pngの奥に別画像を表示 | ノードツリーの並び順(Blocker→中身→Frame)でz軸を制御 |
| 一定時間表示して自動で閉じる演出 |
splash.gd と同じ「フェードイン→interval→フェードアウト→callback」のTweenチェーンを show_for(duration) として再利用可能にする |
| シーンをまたいだ解錠フラグ |
POST_OPEN と同じgetter/setter + _save() パターンで DOOR_UNLOCKED を追加 |
| ローカルストレージの一括リセット |
Global.reset_save_data() に集約し、デバッグボタンから呼ぶだけにする |
| 「未所持」と「使用済み」の区別 |
_items(所持中)とは別に _used_item_ids(使用済み)を持ち、取得可否の判定は両方を見る |
今回の実装で、連載を通して確立してきたパターン(フェード演出・シーンをまたぐフラグ管理・ノード順によるz軸制御)が新しいギミックにもそのまま応用できることが確認できました。一方で「所持数の増減」と「進行上のフラグ」を混同すると再取得バグにつながるという教訓も得られました。
次回はこの「使用済み」の考え方を活かしつつ、複数アイテムの組み合わせによる新規アイテム生成を実装していきます。








