Godot で「レーザー罠」を作ろうとすると、ついこんな構成にしがちですよね。
- レーザーノードを継承した
LaserTrapA,LaserTrapB,LaserTrapBoss… を量産 - それぞれに「点滅ロジック」「ダメージ判定」「エフェクト制御」をベタ書き
- 敵やギミックのシーンの中に、さらに深い子ノード階層でレーザーを埋め込む
結果として、
- ちょっとだけ挙動を変えたいだけなのに、継承ツリーがどんどん増える
- 別シーンのレーザー挙動を直したいとき、どのスクリプトを直せばいいか分からない
- シーンツリーが深くなりすぎて、エディタ上で追いづらい
こういう「継承・深いノード階層」地獄から抜け出すには、やっぱりコンポーネント化が効きます。
今回紹介する 「LaserTrap」コンポーネント は、
- 任意のノードにアタッチするだけで「周期的に点滅するレーザー罠」になる
- ダメージ判定もコンポーネント内で完結
- ON/OFF の周期やダメージ量をインスペクタから簡単に調整できる
という、「置くだけレーザー罠」を目指したコンポーネントです。
プレイヤー用シーンにも、ステージギミック用シーンにも、同じコンポーネントをポンポン再利用していきましょう。
【Godot 4】置くだけ即死ゾーン!「LaserTrap」コンポーネント
今回の LaserTrap は以下の特徴を持ちます。
- 一定周期で ON/OFF を繰り返す点滅レーザー
- ON のときだけ
Area2Dの監視を有効化してダメージ判定 - ダメージ対象は「特定のグループ」に属するノード(例:
"damageable") - 対象側は「
apply_damage(amount, source)」というメソッドを持っていれば OK(ゆるい依存) - レーザーの見た目(Sprite2D / Line2D / MeshInstance2D)は、親シーン側で自由に作る
つまり、「見た目」と「挙動」を分離して、挙動だけをコンポーネントとしてアタッチするスタイルですね。
フルコード: LaserTrap.gd
extends Node2D
class_name LaserTrap
## 周期的に点滅し、ONの間だけダメージ判定を行うレーザー罠コンポーネント。
##
## 想定構成:
## - このノードの子に Area2D (+ CollisionShape2D) を置く
## - 見た目用に Sprite2D / Line2D / MeshInstance2D などを親シーン側で自由に配置
##
## ダメージ対象:
## - target_group に属しているノード
## - かつ apply_damage(amount: float, source: Node) メソッドを持っているノード
@export_category("Laser Timing")
@export var start_enabled: bool = true:
set(value):
start_enabled = value
# エディタ上でのプレビュー用: 値変更時に状態を更新
if Engine.is_editor_hint():
_is_on = value
_update_visual_state()
## レーザーがONになっている時間(秒)
@export_range(0.1, 60.0, 0.1)
@export var on_duration: float = 1.5
## レーザーがOFFになっている時間(秒)
@export_range(0.1, 60.0, 0.1)
@export var off_duration: float = 1.0
## 最初にONになるまでのディレイ(秒)
@export_range(0.0, 60.0, 0.1)
@export var initial_delay: float = 0.0
@export_category("Damage")
## 1回触れた時に与えるダメージ量
@export_range(0.0, 9999.0, 0.5)
@export var damage_amount: float = 10.0
## ダメージ対象となるノードが属するグループ名
## 例: "player", "enemy", "damageable" など
@export var target_group: StringName = &"damageable"
## 同じ対象に対するダメージの連射間隔(秒)
## 0 にすると、接触中は毎フレームダメージになるので注意
@export_range(0.0, 10.0, 0.05)
@export var damage_cooldown: float = 0.2
@export_category("Visual")
## ON/OFFで切り替えたいノードのグループ名
## 例: レーザーのSprite2DやGlowを "laser_visual" グループに入れておく
@export var visual_group: StringName = &"laser_visual"
## ON/OFFで切り替えたいサウンドプレイヤー(任意)
@export var sfx_on: AudioStreamPlayer2D
@export var sfx_off: AudioStreamPlayer2D
@export var sfx_hit: AudioStreamPlayer2D
## 内部状態
var _is_on: bool = false
var _area: Area2D
var _timer: Timer
## 対象ごとのダメージクールダウン管理用: { Node: next_allowed_time }
var _next_damage_time: Dictionary = {}
func _ready() -> void:
# 子ノードから Area2D を探す(明示的に名前を決めたくないので自動検出)
_area = _find_area2d()
if _area == null:
push_warning("[LaserTrap] 子ノードに Area2D が見つかりません。ダメージ判定は行われません。")
else:
# Area2D のシグナルを接続
_area.body_entered.connect(_on_body_entered)
_area.body_exited.connect(_on_body_exited)
_area.area_entered.connect(_on_area_entered)
_area.area_exited.connect(_on_area_exited)
# タイマーを内部で生成(外部ノードに依存しないようにする)
_timer = Timer.new()
_timer.one_shot = true
add_child(_timer)
_timer.timeout.connect(_on_timer_timeout)
# 初期状態を設定
_is_on = start_enabled
_update_visual_state()
_update_area_monitoring()
# 初回の切り替えスケジュール
if initial_delay > 0.0:
_timer.start(initial_delay)
else:
# すぐに次の状態切り替えをスケジュール
_schedule_next_toggle()
func _process(delta: float) -> void:
# ON状態のときのみ、接触中の対象に対してクールダウンをチェック
if not _is_on:
return
if damage_cooldown <= 0.0:
# クールダウンなしの場合は、Area2Dのentered系シグナルでのみダメージを与える運用にする
return
# 現在接触中のボディ/エリアを走査してクールダウン経過を確認
if _area == null:
return
var now := Time.get_ticks_msec() / 1000.0
for body in _area.get_overlapping_bodies():
_try_apply_damage(body, now)
for area in _area.get_overlapping_areas():
_try_apply_damage(area, now)
# --- 公開API ---
## レーザーを強制的にONにする
func turn_on() -> void:
_is_on = true
_update_visual_state()
_update_area_monitoring()
_schedule_next_toggle()
## レーザーを強制的にOFFにする
func turn_off() -> void:
_is_on = false
_update_visual_state()
_update_area_monitoring()
_schedule_next_toggle()
## 現在のON/OFF状態を取得
func is_on() -> bool:
return _is_on
# --- 内部処理 ---
func _find_area2d() -> Area2D:
# 自分の直下の子から Area2D を1つ見つけるだけのシンプル実装
for child in get_children():
if child is Area2D:
return child
return null
func _on_timer_timeout() -> void:
# ON/OFFをトグルして、次の切り替えをスケジュール
_is_on = not _is_on
_update_visual_state()
_update_area_monitoring()
_schedule_next_toggle()
func _schedule_next_toggle() -> void:
var wait_time := (_is_on) ? on_duration : off_duration
if wait_time <= 0.0:
return
_timer.start(wait_time)
func _update_visual_state() -> void:
# visual_group に属するノードの visible / modulate などを切り替える
var nodes := get_tree().get_nodes_in_group(visual_group)
for n in nodes:
# このコンポーネント配下にあるものだけに限定(安全のため)
if not is_ancestor_of(n):
continue
if "visible" in n:
n.visible = _is_on
# もし Emitting を持つパーティクルならON/OFF
if "emitting" in n:
n.emitting = _is_on
# サウンド
if _is_on and sfx_on:
sfx_on.play()
elif (not _is_on) and sfx_off:
sfx_off.play()
func _update_area_monitoring() -> void:
if _area == null:
return
# ONのときだけ監視を有効化
_area.monitoring = _is_on
_area.monitorable = _is_on
# OFFになったらクールダウンテーブルをクリア
if not _is_on:
_next_damage_time.clear()
func _on_body_entered(body: Node) -> void:
if not _is_on:
return
_try_apply_damage(body)
func _on_body_exited(body: Node) -> void:
# 接触が終わったのでクールダウン情報を削除
_next_damage_time.erase(body)
func _on_area_entered(area: Area2D) -> void:
if not _is_on:
return
_try_apply_damage(area)
func _on_area_exited(area: Area2D) -> void:
_next_damage_time.erase(area)
func _try_apply_damage(target: Node, now: float = -1.0) -> void:
if not is_instance_valid(target):
return
if not target.is_in_group(target_group):
return
if not target.has_method("apply_damage"):
push_warning("[LaserTrap] ターゲット %s は apply_damage() を持っていません。" % target.name)
return
if now < 0.0:
now = Time.get_ticks_msec() / 1000.0
if damage_cooldown > 0.0:
var next_time := _next_damage_time.get(target, 0.0)
if now < next_time:
return
_next_damage_time[target] = now + damage_cooldown
# 実際にダメージを与える
target.apply_damage(damage_amount, self)
if sfx_hit:
sfx_hit.play()
使い方の手順
ここでは 2D のステージギミックとして使う例で説明します。
① スクリプトを用意する
res://components/LaserTrap.gdなどのパスで、上記コードを保存します。- Godot を再読み込みすると、ノード追加ダイアログから
LaserTrapを直接追加できるようになります。
② レーザー罠シーンを作る
レーザー自体を 1 つのシーンとして作っておくと、ステージにポンポン配置できて便利です。
LaserTrapVertical (Node2D) ← シーンのルート ├── LaserTrap (Node2D) ← コンポーネント │ └── Area2D │ └── CollisionShape2D(RectangleShape2D など、レーザーの長さ・太さ) └── Sprite2D(レーザー本体の見た目。Line2D でもOK)
- LaserTrap (Node2D) に上記スクリプト
LaserTrap.gdをアタッチ - Area2D のコリジョンをレーザーの形状に合わせて伸ばす
- Sprite2D をレーザーの見た目に合わせて配置
- Sprite2D を
"laser_visual"グループに追加(右クリック > グループ)
こうしておくと、LaserTrap コンポーネントが ON/OFF に応じて "laser_visual" グループのノードを自動で表示・非表示してくれます。
③ ダメージを受ける側(プレイヤーなど)を用意する
プレイヤー側は、「damageable グループ」に入り、apply_damage メソッドを持っていればOKです。
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── PlayerHealth (Node / Script)
PlayerHealth.gd の例:
extends Node
@export var max_hp: float = 100.0
var current_hp: float
func _ready() -> void:
current_hp = max_hp
# 親ノード(Player)を damageable グループに入れる
get_parent().add_to_group("damageable")
## LaserTrap などから呼ばれる想定のメソッド
func apply_damage(amount: float, source: Node) -> void:
current_hp -= amount
print("Player took %s damage from %s. HP = %s" % [amount, source.name, current_hp])
if current_hp <= 0.0:
_die()
func _die() -> void:
print("Player died")
# ここでリスポーンやゲームオーバー処理など
このようにしておけば、LaserTrap は「damageable グループに属していて、apply_damage を持っているノード」にだけダメージを与えます。
インターフェイスとしての依存だけに留めることで、プレイヤー以外の敵や動く床などにも簡単に流用できます。
④ ステージに配置してパラメータを調整する
最後に、ステージシーンにレーザー罠シーンを配置します。
Stage01 (Node2D) ├── TileMap ├── Player (CharacterBody2D) ├── LaserTrapVertical (PackedScene Instance) └── LaserTrapHorizontal (PackedScene Instance)
- LaserTrap コンポーネントのインスペクタで、以下の値を調整します:
start_enabled: ステージ開始時に ON にしておくかon_duration: ON の時間(例: 2.0 秒)off_duration: OFF の時間(例: 1.0 秒)initial_delay: 最初の ON までのディレイ(複数レーザーのタイミングをずらすのに便利)damage_amount: ダメージ量(例: 25.0)damage_cooldown: 接触し続けたときの連射間隔(例: 0.5 秒)target_group: ダメージ対象のグループ名(デフォルト:damageable)visual_group: 見た目を切り替えるグループ名(デフォルト:laser_visual)
この時点で、もう「周期的に点滅するレーザー罠」が完成しています。
レーザーの形状や見た目を変えたいときは、シーン側の Sprite2D / Area2D を編集するだけでOK。コンポーネントのコードは触らなくて済みます。
メリットと応用
この LaserTrap コンポーネントを使うことで、以下のようなメリットがあります。
- シーン構造がシンプル
レーザーの挙動はLaserTrapに完全に閉じているので、ステージ側は「どこに置くか」「どんな見た目にするか」だけ考えればOKです。 - 継承ツリーが増えない
「ボス用レーザー」「チュートリアル用レーザー」なども、すべて同じコンポーネントを使い回し、パラメータだけ変えれば済みます。 - 見た目とロジックの分離
アーティストがレーザーの見た目をいじっても、コンポーネント側にはノータッチで済みます。逆に、ロジックを改善しても全シーンに自動で反映されます。 - ゆるい依存で他オブジェクトとも連携しやすい
「damageableグループ +apply_damageメソッド」というゆるい契約だけなので、プレイヤーでも敵でも、動く箱でも、同じレーザー罠でダメージ処理を共有できます。
こういう「汎用ギミック」は、1個のコンポーネントに閉じ込めるのが本当にラクです。
ステージ側は「インスタンスを置く」「パラメータをちょっと変える」だけでレベルデザインに集中できますね。
改造案: 一撃死モードを一時的に有効化する
例えば「ボス戦だけ、レーザーが即死になる」みたいなギミックを作りたい場合、
コンポーネントにこんなヘルパー関数を追加しておくと便利です。
## 一定時間だけ一撃死モードにする(ダメージ量を一時的に上書き)
func enable_one_shot_kill(duration: float, one_shot_damage: float = 9999.0) -> void:
var original_damage := damage_amount
damage_amount = one_shot_damage
var t := get_tree().create_timer(duration)
t.timeout.connect(func ():
# 時間が経ったら元のダメージに戻す
damage_amount = original_damage
)
ゲーム側からは、例えばボスのフェーズ移行時に:
# ボスのスクリプトなどから
for laser in get_tree().get_nodes_in_group("boss_lasers"):
if laser is LaserTrap:
laser.enable_one_shot_kill(5.0) # 5秒間だけ即死レーザー
のように呼び出せます。
このように、「コンポーネントに小さなAPIを生やしておく」と、ゲーム側からの制御もきれいに書けますね。
継承ベースでレーザー専用の巨大クラスを作るより、小さなコンポーネントとして LaserTrap を作っておくと、
「いろんなシーンで再利用」「パラメータだけ変えてバリエーション量産」が本当にやりやすくなります。
ぜひ、自分のプロジェクトでも「罠」「ギミック」「エフェクト制御」などをどんどんコンポーネント化していきましょう。




