1
1

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脱出ゲームを作ってみる⑧〜戻るボタン・ギミック正解判定・ローカル保存を実装する〜

1
Last updated at Posted at 2026-07-11

はじめに

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

前回(第7回)ではポップアップ内にダイヤル錠ギミックを実装しました。今回はギミックを「解ける」状態にする部分、つまり正解判定・リセットボタン・開封後の専用シーン・そして再起動後もフラグを保持するローカル保存まで、一連の仕上げを行います。

この記事で作るもの

  • post.tscn に戻るボタンを追加してメイン画面へ戻る導線
  • ポップアップ表示中は戻るボタンをタップ不可にする制御
  • ダイヤル錠の「リセット」「正解判定」ボタン
  • 正解後に専用の開封シーン(post_open.tscn)へ遷移する導線
  • Global オートロードによるシーンをまたいだ状態管理
  • user:// へのJSON書き込みによる正解フラグの永続化

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

前提条件

  • Claude Code + godot-mcpのセットアップ完了(第2回参照)
  • scenes/post.tscnscripts/post.gd が作成済み(第5回参照)
  • scenes/components/popup.tscnscripts/components/popup.gd が作成済み(第6回参照)
  • scenes/components/dial_lock.tscnscripts/components/dial_lock.gd が作成済み(第7回参照)
  • 以下の画像素材が assets/images/ に配置済み
ファイル 用途 画像
btn_back.png 戻るボタン btn_back.png
btn_reload.png リセットボタン btn_reload.png
btn_ok.png 正解判定ボタン btn_ok.png
bg_stage_002_2.png 開封後の背景画像

bg_stage_002_2.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ノードは「ノードツリーの後方にあるものが手前に描画され、入力判定も先に来る」というルールがあります。BtnBackPopup より後ろに置いてしまうと、ポップアップの全画面 Blocker より BtnBack が手前になり、ポップアップ表示中でも戻るボタンをタップできてしまいます。

そこで post.tscn の子ノード順を BtnBackPopup(Popupを最後尾=最前面) に並べ替えます。

2-2. 生成された差分(post.tscn ノード順)

# BtnBack が先、Popup が後(最前面)
[node name="BtnBack" type="TextureButton" parent="."]
...
[node name="Popup" parent="." instance=ExtResource("3_popup")]

あわせて popup.tscn 内の Blockermouse_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 でボタンとフレームを横並び配置

BtnReloadFrameBtnOkHBoxContainer(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.tscnBtnBack と同じ座標・テクスチャで配置し、遷移先だけ 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.gdpopup.gdGlobal.POST_OPEN をそのまま読み書きするだけで、ファイルへの保存は Global 側が自動で行います。

user:// パスはプラットフォームごとに適切な場所に保存される

iOS では Documents/ 配下、macOS では ~/Library/Application Support/ 配下など、Godotがプラットフォームに応じた場所に自動でマッピングしてくれます。


動作確認

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

  1. post.tscn に遷移し、郵便受けをタップするとポップアップが開く
  2. ポップアップ表示中は戻るボタン(btn_back.png)をタップしても反応しない
  3. リセットボタンでダイヤルが「000」に戻る
  4. ダイヤルを「678」に合わせてOKボタンをタップするとポップアップが閉じる
  5. 再度郵便受けをタップすると post_open.tscn へ遷移する
  6. ゲームを再起動しても郵便受けをタップすると post_open.tscn へ遷移する(永続化確認)

郵便受けに接近
スクリーンショット 2026-07-11 13.55.03.png

ダイヤル錠が取り付けられている
スクリーンショット 2026-07-11 13.55.10.png

[678]で開錠!
スクリーンショット 2026-07-11 13.55.23.png

郵便受け近接に戻り、もう一度ダイヤル錠のついた扉をタップすると・・・
スクリーンショット 2026-07-11 13.55.32.png

ダイヤル錠を解錠済みなので、開くことができました!
スクリーンショット 2026-07-11 13.55.39.png

うまく動かない場合

症状 確認ポイント
戻るボタンがポップアップ越しに押せてしまう post.tscn のノード順で PopupBtnBack より後(末尾)になっているか
リセットボタンが反応しない popup.gd_ready()BtnReload.pressed が接続されているか
正解しても何も起きない DialLockget_combination()[6, 7, 8] を返しているか。_CORRECT_COMBINATION の値を確認
再起動後にフラグがリセットされる global.gd が Autoload に登録されているか。_save() が呼ばれているかを print で確認
post_open.tscn へ遷移しない Global.POST_OPENtrue になっているか

まとめ

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

やったこと ポイント
セーフゾーンを避けたボタン配置 実際のビューポート高さ(841)基準で offset を計算
ポップアップ表示中の下層タップ防止 ノードツリー順(Popupを最後尾)+ Blocker の mouse_filter = 0
ダイヤル錠のリセット 各ダイヤルに set_digit(0) を呼ぶ reset()
正解判定 get_combination() の配列比較(== で要素ごとに比較)
シーンをまたぐ状態管理 Global オートロードにプロパティとして持たせる
ゲーム再起動後も状態を保持 user:// 配下のJSONへ書き込み/読み込み(write-throughパターン)

今回で「郵便受けのダイヤル錠ギミック」が完全に動作する状態になりました。正解判定・開封演出・永続化という、脱出ゲームのコアとなる仕組みを一通り実装したことになります。次回はこの実装パターンを他のギミックに応用していきます。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?