Godot でレーザーギミックを作ろうとすると、ついこんな構成になりがちですよね。

  • レーザー発射ノード(RayCast2D)をプレイヤーや砲台シーンに「継承」でベタ書き
  • 反射鏡専用の敵クラスを作って、そこにレーザー処理を全部書いてしまう
  • RayCast2D を複数並べて、反射ごとに新しいノードを追加してしまう

結果として、

  • シーンツリーがレーザー関連ノードでパンパンになる
  • 「このレーザーはどこで計算してるんだっけ?」とコードを追いづらい
  • レーザー反射ギミックを別シーンで使い回したいのに、継承や依存関係が邪魔

こういう「レーザー専用敵」「レーザー専用ギミック」みたいな継承ツリーは、後から仕様変更が入ると一気にツラくなります。そこで今回は、どんなノードにもポン付けできる「反射鏡コンポーネント」を作って、継承ではなく合成(Composition)でレーザーギミックを組み立てていきましょう。

登場するのが、今回のコンポーネント ReflectorMirror (光の反射鏡) です。これは「RayCast を受け取って、反射方向を計算し、反射レーザーを RayCast2D で出す」ことに特化した、小さくて再利用しやすい部品です。


【Godot 4】レーザー反射ギミックをコンポーネント化!「ReflectorMirror」コンポーネント

このコンポーネントは、2D のレーザー反射に特化した Node2D ベースのコンポーネントです。やることはシンプル:

  1. 外部から「入射レーザー」の情報(位置と方向)を受け取る
  2. 自分の法線方向(ノードの向き)から反射ベクトルを計算
  3. RayCast2D を使って反射方向にレーザーを飛ばす
  4. ヒットした相手に「レーザーを受けたよ」というシグナルを送る(任意)

レーザー砲台やプレイヤーからは「ReflectorMirror にレーザーを撃つ」というメソッドを呼ぶだけで OK です。


フルコード: ReflectorMirror.gd


extends Node2D
class_name ReflectorMirror
## ReflectorMirror (光の反射鏡)
## 
## 入射レーザーの情報を受け取り、反射方向を計算して RayCast2D で
## 反射レーザーを飛ばすコンポーネント。
##
## 想定用途:
## - レーザー砲台からのビームを反射する鏡
## - プレイヤーが動かせる鏡ブロック
## - レーザーパズルの経路制御ギミック など

## ======================
## エディタ用パラメータ
## ======================

@export_category("Laser / Reflection")

@export var max_reflect_distance: float = 800.0:
	set(value):
		max_reflect_distance = max(value, 0.0)
		_update_raycast_length()
## 反射レーザーの最大距離(ピクセル)。
## シーンのスケールに合わせて調整しましょう。

@export var enable_bounce_chain: bool = true
## true の場合、反射先のオブジェクトに「laser_hit」メソッドがあれば呼び出し、
## そこでさらに反射や処理をバトンタッチできます。

@export var debug_draw: bool = true
## true の場合、エディタ & 実行時にレーザーの線を簡易表示します。

@export var laser_color: Color = Color(1, 0, 0, 1)
## デバッグ描画用のレーザー色。

@export var laser_width: float = 2.0
## デバッグ描画用のレーザー線の太さ。

@export var surface_normal_from_rotation: bool = true
## true: このノードの rotation から鏡の法線を計算する(通常はこちら)。
## false: 入射側から渡される法線ベクトルをそのまま使う。


## ======================
## シグナル
## ======================

signal reflected_laser_emitted(global_start: Vector2, global_end: Vector2)
## 反射レーザーを発射したときに emit。
## エフェクト用の Line2D やパーティクルを別ノードで制御したいときに便利。

signal incoming_laser_hit(global_point: Vector2, incoming_dir: Vector2)
## 入射レーザーを受けた瞬間に emit。
## SE 再生やアニメーション再生に使えます。


## ======================
## 内部用
## ======================

var _raycast: RayCast2D
var _last_start: Vector2
var _last_end: Vector2
var _has_valid_laser: bool = false


func _ready() -> void:
	## 子ノードに RayCast2D がなければ自動生成します。
	_raycast = _find_or_create_raycast()
	_update_raycast_length()
	_raycast.enabled = true


func _find_or_create_raycast() -> RayCast2D:
	## すでにある RayCast2D を優先的に利用
	for child in get_children():
		if child is RayCast2D:
			return child

	## なければ新規作成(コンポーネント単体で完結させるため)
	var rc := RayCast2D.new()
	rc.name = "ReflectRayCast2D"
	add_child(rc)
	rc.target_position = Vector2.RIGHT * max_reflect_distance
	rc.enabled = true
	return rc


func _update_raycast_length() -> void:
	if _raycast:
		_raycast.target_position = Vector2.RIGHT * max_reflect_distance


func _process(delta: float) -> void:
	if debug_draw:
		queue_redraw()


func _draw() -> void:
	if not debug_draw:
		return
	if not _has_valid_laser:
		return

	## ローカル座標系に変換して描画
	var local_start := to_local(_last_start)
	var local_end := to_local(_last_end)
	draw_line(local_start, local_end, laser_color, laser_width)


## ======================
## 公開 API
## ======================

## 外部から呼び出すメインの関数。
## 
## @param global_hit_point: 入射レーザーがこの鏡に当たったワールド座標
## @param incoming_dir: 入射レーザーの正規化された方向ベクトル(進行方向)
## @param incoming_normal: 入射面の法線(不要なら Vector2.ZERO で OK)
##
## 例: レーザー砲台側で RayCast2D がヒットしたら、
##     mirror.receive_laser(hit_pos, ray_direction)
func receive_laser(
	global_hit_point: Vector2,
	incoming_dir: Vector2,
	incoming_normal: Vector2 = Vector2.ZERO
) -> void:
	if incoming_dir == Vector2.ZERO:
		return

	emit_signal("incoming_laser_hit", global_hit_point, incoming_dir)

	var normal := _get_surface_normal(incoming_normal)
	var reflected_dir := _reflect_vector(incoming_dir.normalized(), normal)

	## RayCast2D を反射方向に向けて配置する
	global_position = global_hit_point
	rotation = reflected_dir.angle()

	_raycast.global_position = global_hit_point
	_raycast.target_position = reflected_dir * max_reflect_distance
	_raycast.force_raycast_update()

	var end_point := global_hit_point + reflected_dir * max_reflect_distance

	if _raycast.is_colliding():
		end_point = _raycast.get_collision_point()

		## 反射先に「laser_hit」メソッドがあれば呼んでバトンタッチ
		if enable_bounce_chain:
			var collider := _raycast.get_collider()
			if collider and collider.has_method("laser_hit"):
				## 反射先にも同じ情報を渡す
				var hit_normal := _raycast.get_collision_normal()
				collider.laser_hit(end_point, reflected_dir, hit_normal)

	## デバッグ描画用に記録
	_last_start = global_hit_point
	_last_end = end_point
	_has_valid_laser = true

	emit_signal("reflected_laser_emitted", _last_start, _last_end)


## ======================
## ヘルパー
## ======================

## 鏡の法線ベクトルを決定する。
func _get_surface_normal(incoming_normal: Vector2) -> Vector2:
	if surface_normal_from_rotation:
		## Node2D の rotation から法線を計算。
		## ここでは「右向き(+X)」を鏡面の向きとし、
		## そこから 90 度回転させた方向を法線とみなします。
		var surface_dir := Vector2.RIGHT.rotated(rotation)
		var normal := surface_dir.rotated(-PI / 2.0) # 法線
		return normal.normalized()
	else:
		if incoming_normal == Vector2.ZERO:
			## フォールバックとして上向き
			return Vector2.UP
		return incoming_normal.normalized()


## ベクトルの反射計算: r = d - 2(d·n)n
func _reflect_vector(dir: Vector2, normal: Vector2) -> Vector2:
	var dot := dir.dot(normal)
	return (dir - 2.0 * dot * normal).normalized()


## 外部から「レーザーが当たった」という形で呼びたい場合のエイリアス。
## 反射鏡自身をコライダとして使うときに便利です。
func laser_hit(
	global_hit_point: Vector2,
	incoming_dir: Vector2,
	incoming_normal: Vector2 = Vector2.ZERO
) -> void:
	receive_laser(global_hit_point, incoming_dir, incoming_normal)


## デバッグ用: 手動で最後のレーザーをクリア
func clear_laser() -> void:
	_has_valid_laser = false
	queue_redraw()

使い方の手順

ここでは、典型的な 2D レーザーギミックの構成を例に使い方を見ていきましょう。

例1: 固定レーザー砲台 + 反射鏡ブロック

構成イメージ:

LaserTurret (Node2D)
 ├── RayCast2D             # 発射レーザー
 └── (スクリプト)         # レーザー発射ロジック

MirrorBlock (Node2D)
 ├── Sprite2D              # 見た目(鏡のグラフィック)
 ├── CollisionShape2D      # 当たり判定(任意)
 └── ReflectorMirror       # 今回のコンポーネント(Node2D)

手順①: ReflectorMirror.gd をプロジェクトに追加

  • 上記コードを res://components/ReflectorMirror.gd などに保存します。
  • Godot が自動的にクラス名を認識し、ノード追加ダイアログで ReflectorMirror として選べるようになります。

手順②: MirrorBlock シーンに ReflectorMirror をアタッチ

  • MirrorBlock(親) は Node2D でも StaticBody2D でも OK です。
  • 子ノードとして ReflectorMirror を追加します。
  • 鏡の向きは ReflectorMirror ノードの rotation で決めます(surface_normal_from_rotation = true の場合)。

手順③: レーザー砲台から「鏡にレーザーを撃つ」処理を書く

レーザー砲台側の簡単な例:


# LaserTurret.gd
extends Node2D

@onready var ray: RayCast2D = $RayCast2D

var laser_direction: Vector2 = Vector2.RIGHT

func _process(delta: float) -> void:
	# 砲台の向きからレーザー方向を決める
	laser_direction = Vector2.RIGHT.rotated(rotation)

	ray.target_position = laser_direction * 1000.0
	ray.force_raycast_update()

	if ray.is_colliding():
		var hit_point: Vector2 = ray.get_collision_point()
		var collider := ray.get_collider()

		# ReflectorMirror を直接持っている場合
		if collider is ReflectorMirror:
			collider.receive_laser(hit_point, laser_direction)
		# もしくは、何かのシーンの子として ReflectorMirror を持っている場合
		elif collider.has_node("ReflectorMirror"):
			var mirror: ReflectorMirror = collider.get_node("ReflectorMirror")
			mirror.receive_laser(hit_point, laser_direction)

これで、砲台から出たレーザーが MirrorBlock に当たると、ReflectorMirror が反射レーザーを計算してくれます。

手順④: 反射先でさらにギミックをつなげる

ReflectorMirror は、反射先に laser_hit(global_hit_point, dir, normal) メソッドを持つオブジェクトがいれば自動で呼び出します(enable_bounce_chain = true の場合)。

たとえば、レーザーを受けてドアを開けるスイッチ:

LaserSwitch (Area2D)
 ├── CollisionShape2D
 └── (スクリプト) # laser_hit を実装

# LaserSwitch.gd
extends Area2D

var is_on: bool = false

func laser_hit(global_hit_point: Vector2, incoming_dir: Vector2, incoming_normal: Vector2) -> void:
	if not is_on:
		is_on = true
		print("レーザースイッチ ON!")
		# ここでドアを開けるシグナルを emit したりする

こうしておくと、ReflectorMirror からの反射レーザーが LaserSwitch に当たったとき、自動的に laser_hit が呼ばれます。


メリットと応用

この ReflectorMirror コンポーネントを使うことで、レーザーギミック周りの構造がかなりスッキリします。

  • ノード構造が浅くて済む
    レーザー専用クラスを継承で増やさなくてよくなり、
    「鏡っぽいオブジェクト」に ReflectorMirror を 1 個アタッチするだけで反射機能を付与できます。
  • 再利用性が高い
    プレイヤーが押して動かせるブロックにも、壁に埋め込まれた固定ミラーにも、敵の盾にも、同じ ReflectorMirror を使い回せます。
  • 責務が分離される
    砲台は「レーザーを撃つだけ」、ReflectorMirror は「反射するだけ」、スイッチは「レーザーを受けたら状態を変えるだけ」と責務が明確になります。
  • 視覚エフェクトを別ノードで差し替えやすい
    reflected_laser_emitted シグナルを受けて Line2D やパーティクルを制御すれば、ロジックと見た目を完全に分離できます。

特に「パズルゲームで何十個も鏡やスイッチを置く」ようなケースでは、コンポーネント化しておくことでレベルデザイン時の負荷がかなり減りますね。

改造案: 反射回数を制限する

現状の ReflectorMirror は「自分自身の反射」しか担当していませんが、レーザー砲台側で「最大反射回数」を制限したい場合があります。その一例として、砲台側にこんな関数を追加してみましょう。


# LaserTurret.gd 内の改造案
func fire_with_bounce_limit(max_bounce: int = 3) -> void:
	var current_origin := global_position
	var current_dir := Vector2.RIGHT.rotated(rotation)

	for i in max_bounce:
		ray.global_position = current_origin
		ray.target_position = current_dir * 1000.0
		ray.force_raycast_update()

		if not ray.is_colliding():
			break

		var hit_point: Vector2 = ray.get_collision_point()
		var collider := ray.get_collider()

		if collider is ReflectorMirror:
			collider.receive_laser(hit_point, current_dir)
			# ReflectorMirror 側で反射レーザーを描画するので、
			# 砲台側では単に次の起点を更新するだけでも OK。
			current_origin = hit_point
			# 反射方向は ReflectorMirror からシグナルで受け取ってもよい
		else:
			# 鏡以外に当たったら終了
			break

このように、ReflectorMirror 自体はシンプルな「反射計算 + RayCast」コンポーネントとして保ちつつ、砲台側やスイッチ側でロジックを自由に組めるのがコンポーネント指向の強みですね。継承ツリーを深くせず、「レーザーを扱いたいノードに ReflectorMirror をポン付けする」スタイルで、気持ちよくギミックを増やしていきましょう。