Godotで「鍵付きドア」を実装しようとすると、Doorシーンを継承したり、プレイヤー側に「鍵を持っているかどうか」の判定ロジックを書き込んだりしがちですよね。
その結果、

  • プレイヤーとドアが相互依存してしまう
  • ドアの種類が増えるたびに継承クラスが増える
  • レベルデザイン時に「このドア、どのスクリプトだっけ?」と迷子になる

といった「あるある沼」にハマりがちです。

そこで今回は、Inventoryに「鍵」アイテムがある場合のみ開き、鍵を消費するという処理を、
どんなドアノードにもポン付けできるコンポーネントとして実装してみましょう。

プレイヤー側には「Inventoryインターフェース」だけを要求し、
ドア側は「鍵のチェックと消費」という責務に集中させることで、継承より合成な設計にしていきます。


【Godot 4】鍵消費でスマート開閉!「KeyDoor」コンポーネント

この KeyDoor コンポーネントは、次のような役割を持ちます。

  • プレイヤーがドア付近に来て「使用」したときにだけ判定を行う
  • 指定したInventoryノードから「鍵アイテム」があるか確認する
  • 鍵があれば消費し、ドアを開く(アニメーション or コールバック)
  • 鍵がなければ、開かない(任意でエラーメッセージ用シグナル発火)

ドア本体は StaticBody2D でも Area2D でも構いません。
このコンポーネントをアタッチして、プレイヤー側から「インタラクト」シグナルを送るだけで動くようにしてあります。


フルコード:KeyDoor.gd


extends Node
class_name KeyDoor
## 鍵付きドア用コンポーネント
## - Inventoryに指定キーがある場合のみ開く
## - 開くときに鍵を1つ消費する

## --- 設定パラメータ(エディタから変更可) ---

@export var key_item_id: String = "key"
## Inventory上で「鍵」として扱うアイテムID
## 例: "red_key", "boss_key" など

@export var consume_key_on_open: bool = true
## true の場合、ドアを開くときに鍵を1つ消費する

@export var auto_close: bool = false
## true の場合、一定時間後に自動で閉じる

@export var auto_close_time: float = 3.0
## 自動で閉じるまでの時間(秒)

@export var inventory_node_path: NodePath
## プレイヤーのInventoryノードへのパス
## - 空の場合は、プレイヤーからシグナル経由で渡してもよい
## - 例: "../Player/Inventory"

@export var open_animation_name: String = "open"
@export var close_animation_name: String = "close"
## ドアの開閉アニメーション名
## AnimationPlayer が見つからない場合は、シグナルだけ発火する

@export var is_locked: bool = true
## 初期状態でロックされているかどうか

@export var disable_collision_on_open: bool = true
## 開いたときに衝突を無効にするかどうか
## - StaticBody2D や CollisionShape2D を想定

## 衝突を無効にする対象ノードのパス(任意)
@export var collision_node_path: NodePath
## 例: "../CollisionShape2D"

## --- シグナル ---

signal door_opened
## ドアが開いたときに発火

signal door_closed
## ドアが閉じたときに発火

signal open_failed_no_key
## 鍵がなくて開かなかった場合に発火

signal open_failed_locked
## is_locked = false でない限り開かない、などの追加条件用に利用可能


## --- 内部状態 ---

var _is_open: bool = false
var _inventory: Node = null
var _animation_player: AnimationPlayer = null
var _collision_node: Node = null


func _ready() -> void:
	# Inventory ノードの取得(指定されていれば)
	if inventory_node_path != NodePath():
		_inventory = get_node_or_null(inventory_node_path)

	# AnimationPlayer の自動検出(親 or 自分)
	_animation_player = _find_animation_player()

	# 衝突ノードの取得
	if collision_node_path != NodePath():
		_collision_node = get_node_or_null(collision_node_path)
	else:
		# 明示されていなければ、親ノードにある CollisionShape2D / CollisionPolygon2D を探す
		_collision_node = _find_collision_node()

	# 初期状態に応じて見た目と衝突を調整
	if _is_open:
		_apply_open_state()
	else:
		_apply_closed_state()


## 外部から呼び出される想定のメインAPI
## プレイヤーが「Eキー」などでインタラクトしたときに呼ぶ
func try_open(inventory: Node = null) -> void:
	# すでに開いている場合は何もしない
	if _is_open:
		return

	# ロック状態のチェック
	if not is_locked:
		# ロックされていないなら、そのまま開けてよいケース
		_open_without_key()
		return

	# Inventory の決定
	if inventory != null:
		_inventory = inventory
	elif _inventory == null and inventory_node_path != NodePath():
		_inventory = get_node_or_null(inventory_node_path)

	# Inventory が見つからない場合は失敗扱い
	if _inventory == null:
		push_warning("KeyDoor: Inventory node not found. Cannot check key.")
		emit_signal("open_failed_no_key")
		return

	# Inventory に対して必要なインターフェースを想定
	# - has_item(id: String) -> bool
	# - remove_item(id: String, amount: int = 1) -> void
	if not _inventory.has_method("has_item") or not _inventory.has_method("remove_item"):
		push_warning("KeyDoor: Inventory does not implement has_item/remove_item.")
		emit_signal("open_failed_no_key")
		return

	# 鍵を持っているかチェック
	if not _inventory.has_item(key_item_id):
		emit_signal("open_failed_no_key")
		return

	# 鍵を消費(設定に応じて)
	if consume_key_on_open:
		_inventory.remove_item(key_item_id, 1)

	# ドアを開く
	_open_without_key()


## 内部用:鍵チェック済みでドアを開く
func _open_without_key() -> void:
	_is_open = true
	_apply_open_state()
	emit_signal("door_opened")

	if auto_close:
		# 一定時間後に自動で閉じる
		_start_auto_close_timer()


## 内部用:ドアを閉じる
func close() -> void:
	if not _is_open:
		return

	_is_open = false
	_apply_closed_state()
	emit_signal("door_closed")


## 実際に開いた状態を見た目・衝突に反映
func _apply_open_state() -> void:
	# アニメーション再生
	if _animation_player and open_animation_name != "":
		if _animation_player.has_animation(open_animation_name):
			_animation_player.play(open_animation_name)

	# 衝突を無効化
	if disable_collision_on_open and _collision_node:
		_set_collision_enabled(_collision_node, false)


## 実際に閉じた状態を見た目・衝突に反映
func _apply_closed_state() -> void:
	# アニメーション再生
	if _animation_player and close_animation_name != "":
		if _animation_player.has_animation(close_animation_name):
			_animation_player.play(close_animation_name)

	# 衝突を有効化
	if disable_collision_on_open and _collision_node:
		_set_collision_enabled(_collision_node, true)


## AnimationPlayer を親または自分から探す
func _find_animation_player() -> AnimationPlayer:
	var ap: AnimationPlayer = get_node_or_null("AnimationPlayer")
	if ap:
		return ap
	if owner and owner.has_node("AnimationPlayer"):
		return owner.get_node("AnimationPlayer")
	return null


## CollisionShape2D / CollisionPolygon2D を親から探す
func _find_collision_node() -> Node:
	if owner == null:
		return null
	for child in owner.get_children():
		if child is CollisionShape2D or child is CollisionPolygon2D:
			return child
	return null


## 衝突有効/無効を切り替えるヘルパー
func _set_collision_enabled(target: Node, enabled: bool) -> void:
	if target is CollisionShape2D:
		target.disabled = not enabled
	elif target is CollisionPolygon2D:
		target.disabled = not enabled
	elif target.has_method("set_disabled"):
		target.call("set_disabled", not enabled)
	else:
		push_warning("KeyDoor: Cannot toggle collision on node: %s" % [target])


## 自動クローズ用のタイマー開始
func _start_auto_close_timer() -> void:
	var timer := get_tree().create_timer(auto_close_time)
	timer.timeout.connect(_on_auto_close_timeout)


func _on_auto_close_timeout() -> void:
	# まだ開いているなら閉じる
	if _is_open:
		close()

使い方の手順

ここでは 2D を例にしますが、3Dでも基本は同じです。

手順①:プレイヤー側にシンプルな Inventory を用意する

まずは、かなりシンプルな Inventory コンポーネント例です。
実際のプロジェクトでは、スタック数やUI連携などを載せ替えて使ってください。


# Inventory.gd
extends Node
class_name Inventory

var _items: Dictionary = {}  # {item_id: count}

func add_item(id: String, amount: int = 1) -> void:
	_items[id] = (_items.get(id, 0) + amount)

func has_item(id: String) -> bool:
	return _items.get(id, 0) > 0

func remove_item(id: String, amount: int = 1) -> void:
	if not _items.has(id):
		return
	_items[id] -= amount
	if _items[id] <= 0:
		_items.erase(id)

プレイヤーシーンにこのコンポーネントをアタッチしておきます。

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── Inventory (Node, script: Inventory.gd)

手順②:ドアシーンを作り、「KeyDoor」をアタッチ

例として、Door.tscn を用意します。

Door (StaticBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── AnimationPlayer
 └── KeyDoor (Node, script: KeyDoor.gd)
  • KeyDoor ノードに KeyDoor.gd をアタッチ
  • inventory_node_path にプレイヤーの Inventory を指定したい場合は、
    • レベルシーン側で ../Player/Inventory のように設定
  • open_animation_name, close_animation_name
    AnimationPlayer 上のアニメーション名を設定
  • collision_node_path"../CollisionShape2D" などを指定しておくと確実です

手順③:プレイヤーから「インタラクト」してドアを開く

プレイヤーに「Eキーでインタラクト」みたいな処理を追加して、
範囲内のドアに対して try_open() を呼び出すようにします。


# Player.gd (一例)
extends CharacterBody2D

@export var interact_distance: float = 32.0
@onready var inventory: Inventory = $Inventory

func _input(event: InputEvent) -> void:
	if event.is_action_pressed("ui_accept"):
		_try_interact()

func _try_interact() -> void:
	# シンプルに「前方にある KeyDoor を探して開く」例
	var space_state = get_world_2d().direct_space_state
	var query := PhysicsRayQueryParameters2D.create(
		global_position,
		global_position + Vector2.RIGHT * interact_distance
	)
	var result = space_state.intersect_ray(query)
	if result:
		var collider = result.collider
		# コライダ自身か、その子孫に KeyDoor がいれば try_open を呼ぶ
		var key_door: KeyDoor = null
		if collider is KeyDoor:
			key_door = collider
		elif collider is Node:
			key_door = collider.get_node_or_null("KeyDoor")
		if key_door:
			key_door.try_open(inventory)

実際には Area2D を使った「インタラクト可能オブジェクトの一覧管理」などにした方が柔軟ですが、
サンプルとしてはこれくらいで十分動きます。

手順④:鍵アイテムを配布する(宝箱や拾得アイテム)

鍵アイテムをプレイヤーに渡す仕組みを作れば、もう完成です。

KeyItem (Area2D)
 ├── Sprite2D
 └── CollisionShape2D

# KeyItem.gd
extends Area2D

@export var key_item_id: String = "key"

func _on_body_entered(body: Node) -> void:
	if body.has_node("Inventory"):
		var inv: Inventory = body.get_node("Inventory")
		inv.add_item(key_item_id, 1)
		queue_free()

これで、プレイヤーが鍵を拾う → ドアの前でインタラクト → Inventoryに鍵があれば開く、という流れが完成します。


メリットと応用

KeyDoor をコンポーネントとして切り出すことで、次のようなメリットがあります。

  • ドアの種類を増やしてもスクリプトは1つ
    赤いドア、ボスドア、隠しドア…など、key_item_id を変えるだけで済むので、
    継承クラスを量産する必要がありません。
  • プレイヤーとドアの依存がゆるくなる
    ドアは「Inventoryに has_item / remove_item がある」というインターフェースだけを要求し、
    プレイヤーの具体的な実装には踏み込みません。
  • シーン構造がスッキリする
    ドア本体はあくまで見た目とコリジョン担当、KeyDoor はロジック担当。
    役割が分離されているので、レベルデザイン中に「どこを触ればいいか」が明確になります。
  • 他オブジェクトにも流用しやすい
    「鍵付き宝箱」「鍵付きエレベーター」などにも、そのまま KeyDoor をアタッチして
    開閉アニメーションだけ変える、といった応用が簡単です。

コンポーネント指向らしく、「鍵チェック+消費」という責務を1つのノードに閉じ込めることで、
ゲーム全体の見通しがかなり良くなりますね。

改造案:開けるたびにセーブデータへ記録する

例えば「一度開けたドアは、再ロード後も開いたまま」にしたい場合、
door_opened シグナルからセーブマネージャーに通知するだけで済みます。


# KeyDoor.gd の一部に追記する例

@export var door_id: String = ""  # セーブ用の一意ID

func _ready() -> void:
	# ...既存の処理...
	if door_id != "" and _is_door_saved_as_open():
		# セーブデータで「開いている」と記録されていれば、最初から開いた状態に
		_is_open = true
		_apply_open_state()

	door_opened.connect(_on_door_opened_for_save)


func _on_door_opened_for_save() -> void:
	if door_id == "":
		return
	var save_manager = get_node_or_null("/root/SaveManager")
	if save_manager and save_manager.has_method("set_door_open"):
		save_manager.set_door_open(door_id, true)


func _is_door_saved_as_open() -> bool:
	var save_manager = get_node_or_null("/root/SaveManager")
	if save_manager and save_manager.has_method("is_door_open"):
		return save_manager.is_door_open(door_id)
	return false

このように、KeyDoor 自体を肥大化させず、
シグナルと小さなヘルパー関数で拡張していくと、コンポーネント指向らしい綺麗な構成になります。
ぜひ自分のプロジェクト用にカスタマイズしてみてください。