Godot 4 で「確認ダイアログ」を作ろうとすると、だいたいこんな流れになりますよね。

  • シーンごとに WindowAcceptDialog を継承したノードを用意する
  • 「はい」「いいえ」ボタンを配置して、ボタンの pressed シグナルをそれぞれのシーンのスクリプトに接続する
  • ポーズ処理や入力ブロックを自前で書く

これを各シーンごとにやっていると、

  • シーン階層がどんどん深くなる
  • 「確認ダイアログのロジック」があちこちにコピペされる
  • デザインを変えたいときに、全シーンを回収する羽目になる

という、典型的な「継承+巨大シーン」のつらみが出てきます。

そこで今回は、「どのシーンにもポン付けできる」コンポーネントとして、汎用的なモーダル確認ウィンドウ ModalWindow を用意しました。
シーンに 1 個置いておくだけで、「本当に終了しますか?」のような確認ダイアログを簡単に呼び出せて、背景の入力もきっちりブロックしてくれます。

【Godot 4】背景入力を完全ブロック!「ModalWindow」コンポーネント

このコンポーネントはざっくりいうと「汎用・確認モーダル」です。

  • 任意のメッセージを表示
  • 「OK / Cancel」ボタンを表示
  • どちらが押されたかを await で待てる
  • 背景の入力をブロック(マウス&キーボード)
  • シーンどこからでも呼べる(シグナルやシングルトンにしなくてもOK)

つまり「ゲーム終了確認」「セーブデータ上書き確認」「タイトルに戻る確認」など、よくあるパターンを全部これ 1 個で賄えるようにしてしまおう、というコンポーネントですね。


フルコード: ModalWindow.gd

このコンポーネントは Control を継承した 2D UI 用コンポーネントとして実装します。
シーン構造をあまり気にせず、どこかの UI ルート(例: CanvasLayer 配下)に 1 つ置いておくだけで使える想定です。


extends Control
class_name ModalWindow
## 汎用確認モーダルコンポーネント
##
## 使い方:
##   1. シーンの UI ルート (例: CanvasLayer) 配下に配置
##   2. 必要に応じて export 変数で見た目や文言を調整
##   3. 任意のスクリプトから:
##        var ok = await modal_window.show_confirm("本当に終了しますか?")
##        if ok:
##            get_tree().quit()

signal confirmed(result: bool)
## result: true  -> OK / Yes が押された
##         false -> Cancel / No が押された or 閉じられた

@export_category("表示テキスト")
@export var default_title: String = "確認" :
	set(value):
		default_title = value
		if is_inside_tree():
			_title_label.text = default_title

@export var default_message: String = "本当に実行しますか?" :
	set(value):
		default_message = value
		if is_inside_tree():
			_message_label.text = default_message

@export_category("ボタンラベル")
@export var ok_text: String = "OK" :
	set(value):
		ok_text = value
		if is_inside_tree():
			_ok_button.text = ok_text

@export var cancel_text: String = "キャンセル" :
	set(value):
		cancel_text = value
		if is_inside_tree():
			_cancel_button.text = cancel_text

@export_category("動作設定")
## true のとき、モーダル表示中は Tree を pause する
## (ゲーム進行を完全に止めたいときに便利)
@export var pause_tree_while_open: bool = true

## ESC キーでキャンセル扱いにするか
@export var esc_closes_as_cancel: bool = true

## 背景の暗さ (0.0~1.0)
@export_range(0.0, 1.0, 0.05)
var dim_opacity: float = 0.6 :
	set(value):
		dim_opacity = value
		if is_inside_tree():
			_dim_rect.modulate.a = dim_opacity

## 内部状態
var _is_open: bool = false
var _previous_pause_state: bool = false
var _pending_promise: GDScriptFunctionState = null

## 子ノードへの参照
var _dim_rect: ColorRect
var _panel: Panel
var _title_label: Label
var _message_label: Label
var _ok_button: Button
var _cancel_button: Button

func _ready() -> void:
	## 子ノードをコード側で生成する方式にしています。
	## そのため、このコンポーネント単体をシーンにポンと置くだけで動きます。
	_build_ui()
	hide() # 初期状態では非表示

func _build_ui() -> void:
	## レイアウト全体をフルスクリーンに広げる
	anchor_left = 0.0
	anchor_top = 0.0
	anchor_right = 1.0
	anchor_bottom = 1.0
	offset_left = 0.0
	offset_top = 0.0
	offset_right = 0.0
	offset_bottom = 0.0
	mouse_filter = Control.MOUSE_FILTER_STOP
	## 背景の暗転用 ColorRect
	_dim_rect = ColorRect.new()
	_dim_rect.name = "DimRect"
	_dim_rect.color = Color.BLACK
	_dim_rect.modulate.a = dim_opacity
	_dim_rect.mouse_filter = Control.MOUSE_FILTER_STOP
	_dim_rect.anchor_left = 0.0
	_dim_rect.anchor_top = 0.0
	_dim_rect.anchor_right = 1.0
	_dim_rect.anchor_bottom = 1.0
	add_child(_dim_rect)

	## 中央のパネル
	_panel = Panel.new()
	_panel.name = "Panel"
	_panel.custom_minimum_size = Vector2(320, 160)
	_panel.mouse_filter = Control.MOUSE_FILTER_STOP
	_panel.anchor_left = 0.5
	_panel.anchor_top = 0.5
	_panel.anchor_right = 0.5
	_panel.anchor_bottom = 0.5
	_panel.offset_left = -160
	_panel.offset_top = -80
	_panel.offset_right = 160
	_panel.offset_bottom = 80
	add_child(_panel)

	var vbox := VBoxContainer.new()
	vbox.name = "VBox"
	vbox.anchor_left = 0
	vbox.anchor_top = 0
	vbox.anchor_right = 1
	vbox.anchor_bottom = 1
	vbox.offset_left = 16
	vbox.offset_top = 16
	vbox.offset_right = -16
	vbox.offset_bottom = -16
	_panel.add_child(vbox)

	_title_label = Label.new()
	_title_label.name = "Title"
	_title_label.text = default_title
	_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	vbox.add_child(_title_label)

	var sep := HSeparator.new()
	vbox.add_child(sep)

	_message_label = Label.new()
	_message_label.name = "Message"
	_message_label.text = default_message
	_message_label.autowrap_mode = TextServer.AUTOWRAP_WORD
	_message_label.size_flags_vertical = Control.SIZE_EXPAND_FILL
	vbox.add_child(_message_label)

	var button_hbox := HBoxContainer.new()
	button_hbox.name = "Buttons"
	button_hbox.alignment = BoxContainer.ALIGNMENT_END
	button_hbox.size_flags_vertical = Control.SIZE_SHRINK_CENTER
	vbox.add_child(button_hbox)

	_cancel_button = Button.new()
	_cancel_button.name = "CancelButton"
	_cancel_button.text = cancel_text
	button_hbox.add_child(_cancel_button)

	_ok_button = Button.new()
	_ok_button.name = "OkButton"
	_ok_button.text = ok_text
	button_hbox.add_child(_ok_button)

	_ok_button.pressed.connect(_on_ok_pressed)
	_cancel_button.pressed.connect(_on_cancel_pressed)

func _unhandled_input(event: InputEvent) -> void:
	if not _is_open:
		return
	if esc_closes_as_cancel and event is InputEventKey and event.pressed and not event.echo:
		if event.keycode == KEY_ESCAPE:
			_on_cancel_pressed()

## 公開 API: 確認ダイアログを表示し、結果を bool で返す
##
## 使用例:
##   var ok = await $ModalWindow.show_confirm("本当に終了しますか?")
##   if ok:
##       get_tree().quit()
func show_confirm(message: String, title: String = "") -> GDScriptFunctionState:
	## すでに開いている場合は、前の待ちをキャンセルして新しいものを優先
	if _pending_promise and not _pending_promise.is_completed():
		_pending_promise.resume(false)

	if title != "":
		_title_label.text = title
	else:
		_title_label.text = default_title

	_message_label.text = message

	_open()
	## await 用のダミーコルーチンを作る
	var state := _wait_for_result()
	_pending_promise = state
	return state

## 内部: 実際に await されるコルーチン
func _wait_for_result() -> GDScriptFunctionState:
	return await confirmed

## モーダルを開く共通処理
func _open() -> void:
	if _is_open:
		return
	_is_open = true
	show()
	## 入力をこの Control で止める設定はすでにしてあるが、
	## 念のためフォーカスも OK ボタンに移しておく
	_ok_button.grab_focus()

	if pause_tree_while_open:
		_previous_pause_state = get_tree().paused
		get_tree().paused = true

## モーダルを閉じる共通処理
func _close() -> void:
	if not _is_open:
		return
	_is_open = false
	hide()
	if pause_tree_while_open:
		get_tree().paused = _previous_pause_state

## OK ボタンが押されたとき
func _on_ok_pressed() -> void:
	_close()
	confirmed.emit(true)

## Cancel ボタン or ESC が押されたとき
func _on_cancel_pressed() -> void:
	_close()
	confirmed.emit(false)

使い方の手順

ここからは、実際にゲームに組み込む具体的な手順を見ていきましょう。

手順①: コンポーネントシーンの用意

  1. Godot で新規スクリプト ModalWindow.gd を作成し、上記コードをそのままコピペします。
  2. 新しいシーンを作成し、ルートノードに Control を追加します。
  3. そのルートに ModalWindow.gd をアタッチします。
  4. シーン名を ModalWindow.tscn として保存します。

このシーンは「コンポーネント」として再利用するので、プレイヤーやメインシーンとは分けておくと管理が楽です。

手順②: メインシーンに組み込む

例として、2D アクションゲームのメインシーン構成を考えます。

MainScene (Node2D)
 ├── Player (CharacterBody2D)
 │    ├── Sprite2D
 │    └── CollisionShape2D
 ├── UI (CanvasLayer)
 │    └── ModalWindow (Control)  ← ここにコンポーネントをインスタンス
 └── EnemySpawner (Node)
  1. UI (CanvasLayer) の子として ModalWindow.tscn をインスタンスします。
  2. 名前を ModalWindow のままにしておくと、$UI/ModalWindow で簡単に参照できます。

これで、どのスクリプトからでも $"/root/MainScene/UI/ModalWindow" のように辿れば呼び出し可能ですが、基本は「同じシーン内のパス(例: $UI/ModalWindow)」を使うのが無難ですね。

手順③: 「本当に終了しますか?」確認を実装する(プレイヤー例)

たとえば、プレイヤーが ESC キーを押したら「本当に終了しますか?」と聞きたい場合の例です。


# Player.gd (例)
extends CharacterBody2D

@onready var modal_window: ModalWindow = $"../UI/ModalWindow"

func _unhandled_input(event: InputEvent) -> void:
	if event is InputEventKey and event.pressed and not event.echo:
		if event.keycode == KEY_ESCAPE:
			# 確認モーダルを表示し、結果を await する
			var ok := await modal_window.show_confirm("本当にゲームを終了しますか?", "ゲーム終了")
			if ok:
				get_tree().quit()
  • await modal_window.show_confirm(...) の部分がポイントで、処理を一時停止して結果(true / false)を受け取れるようになっています。
  • ゲーム進行は pause_tree_while_open によって止まっているので、プレイヤーが動き続ける心配もありません。

手順④: 敵や「タイトルに戻る」ボタンにも再利用する

同じコンポーネントを、敵の自爆確認やタイトル戻り確認にも使い回せます。

MainScene (Node2D)
 ├── Player (CharacterBody2D)
 ├── EnemyBoss (CharacterBody2D)
 │    └── BossAI (Node)
 ├── UI (CanvasLayer)
 │    ├── MainMenu (Control)
 │    └── ModalWindow (Control)

例えば、タイトルに戻るボタンから呼ぶ例:


# MainMenu.gd
extends Control

@onready var modal_window: ModalWindow = $"../ModalWindow"

func _on_back_to_title_button_pressed() -> void:
	var ok := await modal_window.show_confirm("タイトル画面に戻りますか?\nセーブしていない進行状況は失われます。", "確認")
	if ok:
		get_tree().change_scene_to_file("res://scenes/Title.tscn")

ボス戦の「本当に自爆しますか?」みたいな遊び要素にも、そのまま流用できますね。


メリットと応用

この ModalWindow コンポーネントを使うことで、次のようなメリットがあります。

  • シーン構造がスッキリ
    各シーンに AcceptDialog を継承したノードを増やす必要がなく、CanvasLayer 配下に 1 個置くだけで済みます。
  • ロジックの重複が消える
    確認ダイアログの表示・入力ブロック・ポーズ処理などを、1 か所(コンポーネント)で完結できます。
  • 「合成」で機能を足すスタイルになる
    「プレイヤーがダイアログを持つ」のではなく、「UI にダイアログコンポーネントを 1 個足して、誰からでも呼ぶ」構造になります。これは継承ツリーを増やさない、コンポーネント指向の良いパターンですね。
  • デザイン変更が一発で反映
    将来、UI スキンを変えたくなったら、このコンポーネントシーンをいじるだけで、全シーンの確認ダイアログが一括で変わります。

さらに、今回の実装は「入力ブロック+ポーズ+await で結果を返す」という最低限の汎用機能に絞っているので、後からいくらでも拡張できるのもポイントです。

改造案: タイムアウト付きの確認ダイアログ

例えば「10 秒以内に入力がなければ自動的にキャンセル扱いにする」改造を入れたい場合、こんな関数を追加できます。


## 指定秒数で自動キャンセルされる確認ダイアログ
func show_confirm_with_timeout(message: String, title: String = "", timeout_sec: float = 10.0) -> GDScriptFunctionState:
	# 通常の confirm を開始
	var state := show_confirm(message, title)

	# タイマーで自動キャンセル
	var timer := get_tree().create_timer(timeout_sec)
	await timer.timeout

	# まだユーザーが応答していなければ false を返す
	if _is_open and _pending_promise and not _pending_promise.is_completed():
		_on_cancel_pressed()

	return state

このように、ModalWindow 自体を 1 つの「UI コンポーネント」として育てていくと、プロジェクト全体の UI 制御がかなり楽になります。継承ツリーを増やすのではなく、「必要な機能を持ったコンポーネントを UI ルートに足していく」スタイルを、ぜひ試してみてください。