はじめに
この記事は、Godot 4.xで脱出ゲームを作る連載の第8回です。
- 第1回:環境構築とプロジェクト作成
- 第2回:Claude Code + godot-mcpのセットアップ
- 第3回:Claude CodeとMCPでスプラッシュ画面を実装する
- 第4回:Claude CodeとMCPでタイトル画面を実装してシーンを遷移する
- 第5回:背景の特定領域をタップしてフェード遷移する
- 第6回:再利用可能なモーダルポップアップを作る
- 第7回:スプライトシートを使ったダイヤル錠ギミックを実装する
- 第8回:戻るボタン・正解判定・開封演出・ローカル保存を実装する(今回)
前回(第7回)ではポップアップ内にダイヤル錠ギミックを実装しました。今回はギミックを「解ける」状態にする部分、つまり正解判定・リセットボタン・開封後の専用シーン・そして再起動後もフラグを保持するローカル保存まで、一連の仕上げを行います。
この記事で作るもの
-
post.tscnに戻るボタンを追加してメイン画面へ戻る導線 - ポップアップ表示中は戻るボタンをタップ不可にする制御
- ダイヤル錠の「リセット」「正解判定」ボタン
- 正解後に専用の開封シーン(
post_open.tscn)へ遷移する導線 -
Globalオートロードによるシーンをまたいだ状態管理 -
user://へのJSON書き込みによる正解フラグの永続化
前提条件
- Claude Code + godot-mcpのセットアップ完了(第2回参照)
-
scenes/post.tscn・scripts/post.gdが作成済み(第5回参照) -
scenes/components/popup.tscn・scripts/components/popup.gdが作成済み(第6回参照) -
scenes/components/dial_lock.tscn・scripts/components/dial_lock.gdが作成済み(第7回参照) - 以下の画像素材が
assets/images/に配置済み
| ファイル | 用途 | 画像 |
|---|---|---|
btn_back.png |
戻るボタン | ![]() |
btn_reload.png |
リセットボタン | ![]() |
btn_ok.png |
正解判定ボタン | ![]() |
bg_stage_002_2.png |
開封後の背景画像 |
設計方針
今回はClaude Codeへの指示を機能単位で出し、段階的に実装を積み上げていきます。特に「ポップアップ表示中のタップ制御」と「ローカル保存」は、既存の仕組みとの兼ね合いで少し工夫が必要な箇所なので、その過程も含めてそのまま記録しています。
ファイル構成
scenes/
post.tscn ← 戻るボタン追加・ノード順変更
post_open.tscn ← 今回作成(開封後の専用シーン)
components/
popup.tscn ← リセット・OKボタン追加
scripts/
post.gd ← 遷移先分岐処理を追記
post_open.gd ← 今回作成
global.gd ← 今回作成(オートロード)
components/
dial_lock.gd ← reset()追加
popup.gd ← 正解判定処理追加
1. postシーンに戻るボタンを設置する
postシーンの画面下、セーフゾーンより上にbtn_back.pngのボタンを設置して、mainシーンに戻るように
1-1. ビューポートとセーフゾーンについて
このプロジェクトの背景画像は 1920×1083 のキャンバスを想定していますが、project.godot の実際のビューポートは 1870×841 です。既存の当たり判定(MailboxArea など)もすべてy座標841以内に収まっているため、実質的な表示・入力範囲は幅1920×高さ841として扱います。
下から100pxをセーフゾーンとして確保し、その直上・水平中央にボタンを配置します。
1-2. 生成された差分(post.tscn)
[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("4_btn_back")
1-3. 生成されたスクリプト(post.gd 追記分)
遷移処理はすでに main.gd で使っている「フェードアウト→change_scene_to_file」のパターンをそのまま踏襲します。
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")
2. ポップアップ表示中は戻るボタンを押せないようにする
ポップアップが表示されている場合は、戻るボタンは押させたくないため、ポップアップのレイヤーをz軸の上にして、下のレイヤーはタップ不可にしてください
2-1. ポイント解説:ノードツリー順が入力優先度を決める
GodotのControlノードは「ノードツリーの後方にあるものが手前に描画され、入力判定も先に来る」というルールがあります。BtnBack を Popup より後ろに置いてしまうと、ポップアップの全画面 Blocker より BtnBack が手前になり、ポップアップ表示中でも戻るボタンをタップできてしまいます。
そこで post.tscn の子ノード順を BtnBack → Popup(Popupを最後尾=最前面) に並べ替えます。
2-2. 生成された差分(post.tscn ノード順)
# BtnBack が先、Popup が後(最前面)
[node name="BtnBack" type="TextureButton" parent="."]
...
[node name="Popup" parent="." instance=ExtResource("3_popup")]
あわせて popup.tscn 内の Blocker に mouse_filter = 0(STOP)を明示します。
[node name="Blocker" type="ColorRect" parent="."]
...
mouse_filter = 0
color = Color(0, 0, 0, 0.6)
これでポップアップが開いている間は全画面の Blocker が入力を吸収し、その下にある BtnBack には届かなくなります。
3. ギミックのリセット・正解判定ボタンを実装する
btn_reload.pngをギミックポップアップの左に配置してタップされたらダイヤル錠を「000」にリセット。btn_ok.pngをギミックポップアップの右に配置してタップされたらダイヤル錠が「678」であるかをチェックして、是ならグローバル変数のPOST_OPENをtrueにしてポップアップを自動で閉じる
3-1. dial_lock.gdへの reset() 追加
まず dial_lock.gd に3つのダイヤルを一括で0へ戻す reset() を追加します。
# scripts/components/dial_lock.gd(追記分)
func reset() -> void:
for dial in $Dials.get_children():
dial.set_digit(0)
3-2. popup.tscnのノード構成変更
frame_popup.png(Frame)を HBoxContainer(FrameRow)で包み、左に BtnReload、右に BtnOk を配置します。
[node name="FrameRow" type="HBoxContainer" parent="Content"]
layout_mode = 2
size_flags_horizontal = 4
mouse_filter = 2
alignment = 1
theme_override_constants/separation = 40
[node name="BtnReload" type="TextureButton" parent="Content/FrameRow"]
layout_mode = 2
size_flags_vertical = 4
texture_normal = ExtResource("5_btn_reload")
[node name="Frame" type="TextureRect" parent="Content/FrameRow"]
layout_mode = 2
mouse_filter = 2
texture = ExtResource("1_frame")
stretch_mode = 3
[node name="BtnOk" type="TextureButton" parent="Content/FrameRow"]
layout_mode = 2
size_flags_vertical = 4
texture_normal = ExtResource("6_btn_ok")
3-3. Global オートロードの新設
「グローバル変数の POST_OPEN」を持たせるため、専用のオートロード Global を新設します。
# scripts/global.gd(このステップ時点)
extends Node
var POST_OPEN: bool = false
; project.godot(抜粋)
[autoload]
Global="*res://scripts/global.gd"
3-4. popup.gdへの正解判定処理追加
# scripts/components/popup.gd(追記分)
const _CORRECT_COMBINATION: Array[int] = [6, 7, 8]
func _ready() -> void:
$Content/BtnBack.pressed.connect(close)
$Content/FrameRow/BtnReload.pressed.connect(_on_btn_reload_pressed)
$Content/FrameRow/BtnOk.pressed.connect(_on_btn_ok_pressed)
hide()
func _on_btn_reload_pressed() -> void:
$DialLock.reset()
func _on_btn_ok_pressed() -> void:
if $DialLock.get_combination() == _CORRECT_COMBINATION:
Global.POST_OPEN = true
close()
3-5. ポイント解説
HBoxContainer + separation でボタンとフレームを横並び配置
BtnReload・Frame・BtnOk を HBoxContainer(FrameRow)でまとめ、separation = 40 で間隔を調整しています。第7回のダイヤル間隔と同じアプローチです。
正解判定は配列の比較だけ
get_combination() が返す Array[int] と _CORRECT_COMBINATION を == で比較するだけで、GDScriptは配列の中身を要素ごとに比較してくれます。
4. 正解後に専用の開封シーンへ遷移する
post_openシーンを作成。背景はbg_stage_002_2.png。Global.POST_OPENがtrueになった場合、postシーンで郵便受けをタップした時は、post_openシーンに遷移
4-1. 生成されたシーン(post_open.tscn)
post.tscn と同じ構成(フェードイン付きの Control + 背景 TextureRect)で新設します。
[gd_scene format=3]
[ext_resource type="Texture2D" path="res://assets/images/bg_stage_002_2.png" id="1_bg"]
[ext_resource type="Script" path="res://scripts/post_open.gd" id="2_post_open"]
[node name="PostOpen" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("2_post_open")
[node name="BgStage002_2" type="TextureRect" parent="."]
layout_mode = 0
offset_right = 1920.0
offset_bottom = 1083.0
texture = ExtResource("1_bg")
expand_mode = 1
stretch_mode = 1
4-2. 生成されたスクリプト(post_open.gd)
extends Control
@export var fade_in_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)
4-3. post.gdへの遷移先分岐追加
郵便受けタップ時に Global.POST_OPEN の値で分岐させます。すでに正解済みならポップアップを開かず開封シーンへ、未正解ならこれまで通りポップアップを開きます。
func _on_mailbox_area_gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
if Global.POST_OPEN:
_go_to_post_open()
else:
$Popup.open()
func _go_to_post_open() -> void:
var tween := create_tween()
tween.tween_property(self, "modulate:a", 0.0, fade_out_duration)
tween.tween_callback(_on_fade_out_to_post_open_complete)
func _on_fade_out_to_post_open_complete() -> void:
get_tree().change_scene_to_file("res://scenes/post_open.tscn")
5. 開封シーンにも戻るボタンを設置する
post_openシーンに、postシーンと同じような戻るボタンを設置。タップされたらpostシーンに移動
5-1. 生成された差分
post.tscn の BtnBack と同じ座標・テクスチャで配置し、遷移先だけ post.tscn に変えます。
[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/post_open.gd(追記分)
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/post.tscn")
6. POST_OPENをローカルストレージへ永続化する
Global.POST_OPENはローカルストレージに保存しておいて、参照時はそのローカルストレージの値を参照
6-1. 問題:オートロードの変数はゲーム再起動でリセットされる
これまでの Global.POST_OPEN はメモリ上の変数です。ゲームを再起動すると false にリセットされてしまいます。一度ダイヤル錠を解いた状態を維持するには、ファイルへの永続化が必要です。
6-2. 生成されたスクリプト(global.gd 最終版)
POST_OPEN をGDScriptのカスタムgetter/setter付きプロパティにし、書き込みのたびに user://save_data.json へ保存する「write-through」な形にします。
extends Node
const _SAVE_PATH := "user://save_data.json"
var _post_open: bool = false
var POST_OPEN: bool:
get:
return _post_open
set(value):
_post_open = value
_save()
func _ready() -> void:
_load()
func _load() -> void:
if not FileAccess.file_exists(_SAVE_PATH):
return
var file := FileAccess.open(_SAVE_PATH, FileAccess.READ)
var parsed = JSON.parse_string(file.get_as_text())
if typeof(parsed) == TYPE_DICTIONARY:
_post_open = parsed.get("POST_OPEN", false)
func _save() -> void:
var file := FileAccess.open(_SAVE_PATH, FileAccess.WRITE)
file.store_string(JSON.stringify({"POST_OPEN": _post_open}))
6-3. ポイント解説
バッキングフィールドで _load() と _save() を分離する
_load() では _post_open(バッキングフィールド)へ直接代入しています。プロパティ POST_OPEN 経由で代入すると setter が呼ばれて _save() が毎回走ってしまうため、起動時の読み込みと通常の書き込みを分離しています。
呼び出し側のコードは変更不要
post.gd・popup.gd は Global.POST_OPEN をそのまま読み書きするだけで、ファイルへの保存は Global 側が自動で行います。
user:// パスはプラットフォームごとに適切な場所に保存される
iOS では Documents/ 配下、macOS では ~/Library/Application Support/ 配下など、Godotがプラットフォームに応じた場所に自動でマッピングしてくれます。
動作確認
ゲームを実行し、以下の流れで動作すれば成功です。
-
post.tscnに遷移し、郵便受けをタップするとポップアップが開く - ポップアップ表示中は戻るボタン(
btn_back.png)をタップしても反応しない - リセットボタンでダイヤルが「000」に戻る
- ダイヤルを「678」に合わせてOKボタンをタップするとポップアップが閉じる
- 再度郵便受けをタップすると
post_open.tscnへ遷移する - ゲームを再起動しても郵便受けをタップすると
post_open.tscnへ遷移する(永続化確認)
郵便受け近接に戻り、もう一度ダイヤル錠のついた扉をタップすると・・・

うまく動かない場合
| 症状 | 確認ポイント |
|---|---|
| 戻るボタンがポップアップ越しに押せてしまう |
post.tscn のノード順で Popup が BtnBack より後(末尾)になっているか |
| リセットボタンが反応しない |
popup.gd の _ready() で BtnReload.pressed が接続されているか |
| 正解しても何も起きない |
DialLock の get_combination() が [6, 7, 8] を返しているか。_CORRECT_COMBINATION の値を確認 |
| 再起動後にフラグがリセットされる |
global.gd が Autoload に登録されているか。_save() が呼ばれているかを print で確認 |
post_open.tscn へ遷移しない |
Global.POST_OPEN が true になっているか |
まとめ
今回はClaude Code + godot-mcpを使って以下を実装しました。
| やったこと | ポイント |
|---|---|
| セーフゾーンを避けたボタン配置 | 実際のビューポート高さ(841)基準で offset を計算 |
| ポップアップ表示中の下層タップ防止 | ノードツリー順(Popupを最後尾)+ Blocker の mouse_filter = 0
|
| ダイヤル錠のリセット | 各ダイヤルに set_digit(0) を呼ぶ reset()
|
| 正解判定 |
get_combination() の配列比較(== で要素ごとに比較) |
| シーンをまたぐ状態管理 |
Global オートロードにプロパティとして持たせる |
| ゲーム再起動後も状態を保持 |
user:// 配下のJSONへ書き込み/読み込み(write-throughパターン) |
今回で「郵便受けのダイヤル錠ギミック」が完全に動作する状態になりました。正解判定・開封演出・永続化という、脱出ゲームのコアとなる仕組みを一通り実装したことになります。次回はこの実装パターンを他のギミックに応用していきます。








