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