2D/3Dどちらでもアクションゲームを作っていると、「奈落に落ちたら即死」みたいな処理、ほぼ必須ですよね。
Godotの教科書的な実装だと、こんな感じになりがちです。
- プレイヤーのスクリプトに
if global_position.y > 1000: die()を直書き - 敵キャラにも同じようなコードをコピペ
- ステージごとに「このステージは -2000 で落下死」みたいな魔法の数字が散乱
これ、最初はシンプルなんですが、
- プレイヤーと敵で「落下死の閾値」が違ってバグる
- 奈落ラインをステージごとに変えたいとき、全部のスクリプトを書き換えるハメになる
- 「このオブジェクトは落下しても死なない」みたいな例外処理でスクリプトが汚れる
と、だんだんカオスになっていきます。
そこで今回は、「落下死」という概念をキャラ本体から切り離して、どのノードにもポン付けできるコンポーネントとして実装してみましょう。
その名も 「KillZ」コンポーネント。Y座標が一定以下になったら、HPが何であろうと即死させます。
【Godot 4】奈落はコンポーネントに任せよう!「KillZ」コンポーネント
今回の方針はシンプルです。
- 「落下死ライン」はステージごとに 1 つだけ置く
- 「落下死の対象になるかどうか」は各キャラに
KillZコンポーネントを付けるだけで決まる - ダメージシステムやHP管理とは疎結合にして、「kill() さえ用意しておけば勝手に即死してくれる」
つまり、継承で「KillableCharacter」みたいな巨大ベースクラスを作るのではなく、
「落下死」という単機能をコンポーネントに分離して、必要なノードにだけアタッチする設計ですね。
フルコード:KillZ.gd
extends Node
class_name KillZ
## 落下死コンポーネント
## 親ノードの Y 座標が一定以下になったら、即死処理を呼び出します。
##
## 想定する親ノード:
## - CharacterBody2D / CharacterBody3D
## - RigidBody2D / RigidBody3D
## - Node2D / Node3D など、global_position を持つノード
##
## 即死処理の呼び出し優先順位:
## 1. 親に "kill()" メソッドがあれば呼び出す
## 2. なければ "die()" メソッドを探して呼び出す
## 3. それもなければ、オプションで queue_free() する
@export_category("KillZ Settings")
@export var kill_y: float = 1000.0:
set(value):
kill_y = value
_update_debug_line()
## Z ではなく Y 軸で判定するかどうか(2Dなら true 推奨)
## 3D で「高さ」で判定したい場合は true のまま Y を使い、
## 3D で「奥行き」で判定したいなら false にして Z を使う、などアレンジ可能。
@export var use_y_axis: bool = true
## 落下死判定を一時的に無効化したいときに使います(デバッグ・イベント用)。
@export var enabled: bool = true
## 親に kill()/die() が無かった場合、queue_free() するかどうか。
## false の場合は何もせず警告ログのみ出します。
@export var fallback_queue_free: bool = true
## デバッグ用に、エディタ上で「Kill ライン」を表示するかどうか
@export var show_debug_line: bool = true:
set(value):
show_debug_line = value
_update_debug_line()
## デバッグラインの色
@export var debug_color: Color = Color(1, 0, 0, 0.5):
set(value):
debug_color = value
_update_debug_line()
## デバッグラインの長さ(2D/3Dで意味合いが少し違います)
@export var debug_line_length: float = 5000.0:
set(value):
debug_line_length = max(0.0, value)
_update_debug_line()
## 2D/3D どちらでも使えるように、内部的に Node2D/Node3D を探して描画します
var _debug_node: Node = null
func _ready() -> void:
# 親を持っていることを前提とします
if not get_parent():
push_warning("KillZ コンポーネントは、必ず何かの子ノードとして配置してください。")
_update_debug_line()
func _physics_process(delta: float) -> void:
if not enabled:
return
var target := get_parent()
if target == null:
return
# 2D / 3D 両対応のために、Dynamic に座標を取得する
var global_pos := _get_global_position(target)
if global_pos == null:
return
# 判定軸を決定
var axis_value := global_pos.y if use_y_axis else global_pos.z if "z" in global_pos else global_pos.y
if axis_value > kill_y:
# 閾値を超えたので即死処理
_execute_kill(target)
func _execute_kill(target: Node) -> void:
# 二重に呼ばれないように、自分を無効化
enabled = false
# 1. kill() メソッドがあればそれを使う
if target.has_method("kill"):
target.call("kill")
return
# 2. die() メソッドがあればそれを使う
if target.has_method("die"):
target.call("die")
return
# 3. フォールバックとして queue_free()
if fallback_queue_free:
target.queue_free()
else:
push_warning("KillZ: 親ノードに kill()/die() が見つかりませんでした。fallback_queue_free=false のため、何もしません。")
func _get_global_position(target: Node) -> Variant:
# 2D 系
if target is Node2D:
return (target as Node2D).global_position
# 3D 系
if target is Node3D:
return (target as Node3D).global_position
# それ以外(Position3D, Marker2D など)にも対応したければここで拡張
if "global_position" in target:
return target.global_position
push_warning("KillZ: 親ノードが global_position を持っていないため、落下判定ができません。")
return null
func _update_debug_line() -> void:
if not is_inside_tree():
return
# 既存のデバッグノードを削除
if _debug_node and is_instance_valid(_debug_node):
_debug_node.queue_free()
_debug_node = null
if not show_debug_line:
return
# 親が 2D か 3D かによって、描画ノードを変える
var parent := get_parent()
if parent == null:
return
if parent is Node2D:
_debug_node = _create_debug_line_2d()
parent.add_child(_debug_node)
_debug_node.owner = get_tree().edited_scene_root
elif parent is Node3D:
_debug_node = _create_debug_line_3d()
parent.add_child(_debug_node)
_debug_node.owner = get_tree().edited_scene_root
else:
# どちらでもない場合は、特に何もしない(ログだけ出す)
push_warning("KillZ: 親が Node2D/Node3D ではないため、デバッグラインは描画されません。")
func _create_debug_line_2d() -> Node2D:
var line := Node2D.new()
line.name = "KillZ_DebugLine2D"
line.set_script(_get_debug_line_2d_script())
line.set("kill_y", kill_y)
line.set("debug_color", debug_color)
line.set("debug_line_length", debug_line_length)
return line
func _create_debug_line_3d() -> Node3D:
var line := Node3D.new()
line.name = "KillZ_DebugLine3D"
line.set_script(_get_debug_line_3d_script())
line.set("kill_y", kill_y)
line.set("debug_color", debug_color)
line.set("debug_line_length", debug_line_length)
return line
func _get_debug_line_2d_script() -> Script:
var src := '''
extends Node2D
@export var kill_y: float = 1000.0
@export var debug_color: Color = Color(1, 0, 0, 0.5)
@export var debug_line_length: float = 5000.0
func _draw() -> void:
var half_len := debug_line_length * 0.5
var from := Vector2(-half_len, kill_y)
var to := Vector2(half_len, kill_y)
draw_line(from, to, debug_color, 2.0)
func _notification(what: int) -> void:
if what == NOTIFICATION_TRANSFORM_CHANGED or what == NOTIFICATION_VISIBILITY_CHANGED:
queue_redraw()
'''
return GDScript.new().from_source_code(src)
func _get_debug_line_3d_script() -> Script:
var src := '''
extends Node3D
@export var kill_y: float = 1000.0
@export var debug_color: Color = Color(1, 0, 0, 0.5)
@export var debug_line_length: float = 5000.0
func _ready() -> void:
# 単純な PlaneMesh を Y=kill_y の位置に置く
var mesh_instance := MeshInstance3D.new()
var plane := PlaneMesh.new()
plane.size = Vector2(debug_line_length, debug_line_length)
mesh_instance.mesh = plane
var mat := StandardMaterial3D.new()
mat.albedo_color = debug_color
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mesh_instance.material_override = mat
add_child(mesh_instance)
mesh_instance.global_position.y = kill_y
'''
return GDScript.new().from_source_code(src)
使い方の手順
ここでは 2D アクションゲームの例で説明しますが、3D でもほぼ同じです。
例1: プレイヤーキャラに落下死を付ける
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── KillZ (Node)
- KillZ.gd を用意
上記のコードをres://components/KillZ.gdなどに保存します。 - Player シーンを開く
ルートがCharacterBody2Dのプレイヤーシーンを想定します。 - 子ノードとして KillZ を追加
- Player の子に
Nodeを追加 - そのノードに
KillZ.gdをアタッチ - ノード名を
KillZにしておくと分かりやすいです
- Player の子に
- プレイヤーに kill() を実装
Player のスクリプトに、落下死時の処理を書きます。
# Player.gd(抜粋)
extends CharacterBody2D
var is_dead: bool = false
func kill() -> void:
if is_dead:
return
is_dead = true
# アニメーション再生や SE 再生など
print("Player: 落下死しました")
# ここでは単純にシーンをリスタート
get_tree().reload_current_scene()
あとは、KillZ コンポーネントのインスペクタで kill_y を調整すれば、
その高さより下に落ちたときに、自動的に Player.kill() が呼ばれます。
例2: 敵キャラにも同じコンポーネントを再利用
EnemyGoblin (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── KillZ (Node)
敵シーンにもまったく同じように KillZ を子ノードとして追加し、
敵スクリプト側に kill() か die() を実装するだけです。
# EnemyGoblin.gd(抜粋)
extends CharacterBody2D
func die() -> void:
print("ゴブリンが奈落に消えた…")
queue_free()
プレイヤーは kill()、敵は die() と名前が違っていても、
KillZ コンポーネントが両方探してくれるので、そのまま動きます。
例3: 動く足場も奈落で消したい場合
MovingPlatform (Node2D) ├── Sprite2D ├── CollisionShape2D └── KillZ (Node)
# MovingPlatform.gd(抜粋)
extends Node2D
func kill() -> void:
# 落下したら消えるだけで OK
queue_free()
これで、プレイヤー・敵・動く床など、どのシーンにも同じコンポーネントをアタッチするだけで奈落処理が統一できます。
メリットと応用
この KillZ コンポーネントを使うメリットを整理してみましょう。
- シーン構造がスッキリする
「落下死」のロジックがキャラ本体のスクリプトから分離されるので、
プレイヤーのコードは「移動」「攻撃」「入力処理」に集中できます。 - 使い回しが圧倒的に楽
新しい敵キャラを作るときも、KillZを子ノードとして追加するだけ。
「この敵は奈落で死なない」みたいな仕様なら、単にKillZを付けなければいいだけです。 - レベルデザインに強い
ステージごとにkill_yを変えたい場合も、
シーンインスタンスごとにパラメータを変えるだけで対応できます。
「地下ステージは -3000 まで落ちてもセーフ」みたいな調整も簡単ですね。 - HP システムと疎結合
KillZは「HP を 0 にする」のではなく、「kill()/die() を呼ぶ」だけ。
その先で HP を減らすのか、アニメーションを再生するのか、リスポーンするのかは、
各キャラ側の責務として分離されています。
「継承より合成」の観点で見ると、
- 巨大な
BaseCharacterクラスに「落下死」「HP」「移動」など全部入りにするのではなく KillZのような小さなコンポーネントを組み合わせて機能を構成する
という設計になるので、長期的に見てかなり保守が楽になります。
改造案:シグナルで「落下死イベント」を飛ばす
「死ぬ前に UI にエフェクトを出したい」「統計用にカウンタを増やしたい」など、
もう少し柔軟にフックしたい場合は、signal を追加するのがおすすめです。
signal fell_below_kill_line(target: Node, axis_value: float)
func _execute_kill(target: Node) -> void:
enabled = false
# シグナルで事前に通知
var axis_value := _get_global_position(target).y
fell_below_kill_line.emit(target, axis_value)
if target.has_method("kill"):
target.call("kill")
return
if target.has_method("die"):
target.call("die")
return
if fallback_queue_free:
target.queue_free()
これで、ゲーム全体のマネージャーが KillZ.fell_below_kill_line を接続しておけば、
「何かが奈落に落ちた瞬間」にスコアを増やしたり、ログを残したりできます。
コンポーネントの責務は「落下死を検知して通知・実行する」とだけ決めておくと、
あとからの拡張もしやすいですね。
こんな感じで、「落下死」みたいな一見どこにでも書けそうな処理ほど、
コンポーネントに切り出しておくと、プロジェクトが大きくなったときに効いてきます。
ぜひ、自分のプロジェクトでも KillZ を試してみてください。




