GodotでFPSっぽい「スナイパー演出」を作ろうとすると、ついこんな実装をしがちですよね。

  • プレイヤーシーンを継承して「SniperPlayer」「EnemySniper」みたいな派生シーンを量産する
  • キャラの子ノードとして Line2DRayCast2D をベタ書きして、スクリプトもプレイヤー本体に直書き
  • 「この敵だけレーザーを点滅」「この銃だけ色を変えたい」→ 条件分岐がどんどん増える

結果として、

  • プレイヤー/敵スクリプトがレーザーの表示ロジックで肥大化
  • 別のシーンでも同じレーザーを使いたいのに、コピペ or 継承地獄
  • 銃の位置や向きを変えるたびに、レーザーのコードも修正

こういう「レーザーサイト」という明らかに独立した機能は、コンポーネントとして分離しておくとめちゃくちゃ楽になります。
そこで今回は、銃口から伸びる赤いレーザーサイトを点滅表示するコンポーネント 「SniperLine」 を用意して、どんなキャラ・どんな銃にもポン付けできる形にしていきましょう。

【Godot 4】狙い撃ちの演出をコンポーネント化!「SniperLine」コンポーネント

このコンポーネントは、

  • 銃口の位置から
  • 指定方向(基本はノードの向き)に
  • 障害物までの赤いレーザーラインを
  • 点滅させながら描画

…という、いかにも「スナイパー」「チャージショット」系の事前予告ラインを作るためのものです。
コンポーネントとして独立しているので、プレイヤーでも敵でも動く砲台でも、同じ SniperLine をアタッチするだけで同じ演出が使えます。


SniperLine.gd フルコード


extends Node2D
class_name SniperLine
## 銃口から伸びる赤いレーザーサイトを点滅表示するコンポーネント
##
## 任意の Node2D にアタッチして使います。
## - このノードのグローバル位置を「銃口」とみなす
## - このノードの右方向(Vector2.RIGHT を回転)を「照準方向」とみなす
## - RayCast2D でヒットした位置まで Line2D で描画
## - タイマーで点滅制御

@export_category("Line Visual")
## レーザーの色
@export var line_color: Color = Color(1, 0, 0, 1.0)

## レーザーの太さ
@export var line_width: float = 2.0

## レーザーの最大長さ(何もヒットしない場合の長さ)
@export var max_distance: float = 1000.0

## レーザーの先端に描く小さな点のサイズ(0 なら描かない)
@export var dot_size: float = 4.0

@export_category("Blink")
## 点滅させるかどうか
@export var enable_blink: bool = true

## ON の時間(秒)
@export var blink_on_time: float = 0.2

## OFF の時間(秒)
@export var blink_off_time: float = 0.1

## 点滅開始時に自動で ON にするか
@export var start_visible: bool = true

@export_category("Raycast")
## 衝突判定に使うコリジョンレイヤーマスク
@export_flags_2d_physics var collision_mask: int = 1

## レーザーを撃つ方向を強制的に指定したい場合に使う。
## Vector2.ZERO のままなら、Node2D の rotation を元に右方向(Vector2.RIGHT)で撃つ。
@export var override_direction: Vector2 = Vector2.ZERO

## デバッグ用:RayCast2D のヒット位置を表示するか
@export var debug_draw_hit_point: bool = false

## 外から ON/OFF を切り替えられるフラグ
var is_active: bool = true:
	set(value):
		is_active = value
		visible = value

## 内部用:実際に描画する Line2D
var _line: Line2D
## 内部用:ヒット位置を検出する RayCast2D
var _ray: RayCast2D
## 内部用:点滅制御用タイマー
var _blink_timer: Timer
## 内部用:現在 ON 状態かどうか
var _is_blink_on: bool = true

func _ready() -> void:
	# Line2D を動的に生成して子ノードとして追加
	_line = Line2D.new()
	_line.name = "SniperLine_Line2D"
	_line.width = line_width
	_line.default_color = line_color
	_line.joint_mode = Line2D.LINE_JOINT_ROUND
	_line.begin_cap_mode = Line2D.LINE_CAP_ROUND
	_line.end_cap_mode = Line2D.LINE_CAP_ROUND
	add_child(_line)

	# RayCast2D を動的に生成
	_ray = RayCast2D.new()
	_ray.name = "SniperLine_RayCast2D"
	_ray.enabled = true
	_ray.collision_mask = collision_mask
	add_child(_ray)

	# タイマー生成(点滅用)
	_blink_timer = Timer.new()
	_blink_timer.name = "SniperLine_BlinkTimer"
	_blink_timer.one_shot = true
	add_child(_blink_timer)
	_blink_timer.timeout.connect(_on_blink_timeout)

	# 初期可視状態
	visible = start_visible
	_is_blink_on = start_visible

	# エディタ上でも動作させたい場合は下をコメントアウト
	#if Engine.is_editor_hint():
	#	set_process(false)

func _process(delta: float) -> void:
	if not is_active:
		_line.clear_points()
		return

	_update_raycast()
	_update_line()
	_update_blink_visibility()

func _update_raycast() -> void:
	# 照準方向を決定
	var dir: Vector2
	if override_direction != Vector2.ZERO:
		dir = override_direction.normalized()
	else:
		# Node2D の rotation を使って右方向を回転させる
		dir = Vector2.RIGHT.rotated(global_rotation)

	# RayCast2D の開始位置は常に原点(ローカル)から
	_ray.position = Vector2.ZERO
	_ray.target_position = dir * max_distance
	_ray.force_raycast_update()

func _update_line() -> void:
	_line.clear_points()

	if not _is_blink_on:
		# OFF 状態なら描画しない
		return

	var start_pos := Vector2.ZERO
	var end_pos: Vector2

	if _ray.is_colliding():
		end_pos = _ray.get_collision_point() - global_position
	else:
		# 何もヒットしない場合は最大距離まで伸ばす
		end_pos = _ray.target_position

	_line.width = line_width
	_line.default_color = line_color

	# ライン本体
	_line.add_point(start_pos)
	_line.add_point(end_pos)

	# 先端の点(オプション)
	if dot_size > 0.0:
		var dir := (end_pos - start_pos).normalized()
		var dot_back := end_pos - dir * dot_size
		_line.add_point(dot_back)
		_line.add_point(end_pos)

	# デバッグ用のヒットポイント表示
	if debug_draw_hit_point and _ray.is_colliding():
		_debug_draw_hit()

func _update_blink_visibility() -> void:
	if not enable_blink:
		# 点滅しない場合、常に ON
		_is_blink_on = true
		return

	# タイマーが動いていなければ起動
	if _blink_timer.is_stopped():
		# 現在の状態に応じて次の時間をセット
		if _is_blink_on:
			_blink_timer.wait_time = blink_on_time
		else:
			_blink_timer.wait_time = blink_off_time
		_blink_timer.start()

func _on_blink_timeout() -> void:
	# ON/OFF をトグル
	_is_blink_on = not _is_blink_on

func _debug_draw_hit() -> void:
	# エディタ上のデバッグ用途。実際のゲームでは不要。
	var vp := get_viewport()
	if not vp:
		return
	var canvas_item := vp.get_canvas_item()
	var draw_pos := _ray.get_collision_point()

	# すごくシンプルなデバッグ描画(1フレームだけ)
	var ci := CanvasItem.new()
	vp.add_child(ci)
	ci.global_position = draw_pos
	ci.draw_circle(Vector2.ZERO, 3.0, Color(1, 1, 0, 0.8))
	ci.queue_free() # すぐ消す

# --- 外部から呼び出すためのヘルパー関数群 ---

## レーザーを有効化
func activate() -> void:
	is_active = true
	visible = true
	_is_blink_on = true
	_blink_timer.stop()

## レーザーを無効化
func deactivate() -> void:
	is_active = false
	visible = false
	_blink_timer.stop()

## 一定時間だけレーザーを表示したい場合に使う簡易API
func flash_for(duration: float) -> void:
	activate()
	await get_tree().create_timer(duration).timeout
	deactivate()

使い方の手順

ここでは 2D を想定した例で説明しますが、3Dでも考え方は同じ(RayCast3D + MeshInstance3D などに置き換え)です。

手順① コンポーネントをプロジェクトに追加

  1. SniperLine.gd をプロジェクトのどこか(例: res://components/)に保存します。
  2. Godotエディタで一度プロジェクトを再読み込みすると、class_name SniperLine のおかげで、ノード追加ダイアログから SniperLine が選べるようになります。

手順② プレイヤーにレーザーサイトをつける

例えばこんなプレイヤーシーンがあるとします:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── Muzzle (Node2D)   # 銃口

ここに SniperLine コンポーネントを追加して、銃口からレーザーを出す構成にしましょう。

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── Muzzle (Node2D)
      └── SniperLine (SniperLine)
  • Muzzle は銃口の位置と向きを表す Node2D です。
  • SniperLine を Muzzle の子ノードとして追加すると、その グローバル位置=銃口位置 からレーザーが伸びるようになります。
  • プレイヤーの向きに合わせて Muzzle の回転を変えている場合、override_direction を設定しなくても自動でその方向に伸びます。

手順③ 発射前だけ点滅させる

例えば「スナイパーライフルを撃つ前に 1 秒間レーザーを点滅させ、その後発射」というロジックをプレイヤー側に書くとします。


# Player.gd 抜粋(CharacterBody2D 用)
extends CharacterBody2D

@onready var muzzle: Node2D = $Muzzle
@onready var sniper_line: SniperLine = $Muzzle/SniperLine

@export var charge_time: float = 1.0

func _ready() -> void:
	# 初期状態ではレーザーを消しておく
	sniper_line.deactivate()

func shoot_sniper() -> void:
	# 1. レーザーを点滅させながら charge_time 秒間見せる
	sniper_line.enable_blink = true
	sniper_line.activate()

	await get_tree().create_timer(charge_time).timeout

	# 2. 弾を発射する(ここに実際の弾生成処理を書く)
	_fire_bullet()

	# 3. レーザーを消す
	sniper_line.deactivate()

func _fire_bullet() -> void:
	# ここに弾生成などの処理を書く
	print("Sniper shot fired!")

こうしておけば、プレイヤーのロジックは「いつレーザーを ON/OFF するか」だけに集中できます。
レーザーの見た目や点滅間隔は SniperLine 側のエクスポート変数で調整できるので、プレイヤーコードを汚さずに演出をいじれます。

手順④ 敵スナイパーや動く砲台にもそのまま流用

別の例として、敵スナイパーに同じレーザー演出を付ける場合:

EnemySniper (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── GunPivot (Node2D)
      ├── Muzzle (Node2D)
      │    └── SniperLine (SniperLine)
      └── Sprite2D (銃の見た目)

敵のスクリプト側では、


extends CharacterBody2D

@onready var sniper_line: SniperLine = $GunPivot/Muzzle/SniperLine

func _ready() -> void:
	sniper_line.deactivate()

func aim_and_warn_player() -> void:
	# プレイヤーを狙う処理(GunPivot の rotation を変えるなど)を書いたあと、
	# 一定時間だけレーザーを見せる
	await sniper_line.flash_for(0.8)
	_shoot()

func _shoot() -> void:
	print("Enemy sniper shot!")

というように、プレイヤーか敵かに関係なく同じコンポーネント API で扱えます。
シーン構造も「GunPivot」「Muzzle」「SniperLine」の3レイヤー程度で済むので、深いノード階層に埋もれたレーザー処理を追いかける必要もありません。


メリットと応用

この SniperLine コンポーネントを使うと、次のようなメリットがあります。

  • 継承ではなく合成でレーザー機能を付け外しできる
    プレイヤー用クラス、敵用クラスそれぞれに「レーザー付きバージョン」を作る必要がなく、「SniperLine を付けるかどうか」だけで機能を切り替えられます。
  • シーン構造がシンプルになる
    レーザーの描画・RayCast・点滅制御がすべてコンポーネント内に閉じているので、プレイヤー/敵スクリプトは「いつ見せるか」だけを考えればOKです。
  • 演出調整が楽
    色・太さ・点滅間隔・最大距離などはすべて @export 変数で公開しているので、シーンごと・敵ごとにインスペクタから調整できます。
  • レベルデザインの段階でも付け外ししやすい
    「この敵だけレーザーを見せたい」「このギミックだけラインを長くしたい」といった要望にも、シーン上でコンポーネントを追加・調整するだけで対応できます。

応用としては、

  • レーザーの色を敵の状態(チャージ中=黄色、発射直前=赤)によって変える
  • レーザーの長さを時間経過で伸ばして「ロックオン中」の演出にする
  • 衝突したオブジェクトに応じて先端のエフェクトを変える(壁なら火花、プレイヤーなら警告マークなど)

…といった拡張も簡単にできます。

改造案:時間経過で太さを変えるチャージ演出

例えば「チャージが進むほどレーザーが太くなる」ような演出を追加したい場合、SniperLine に次のような関数を足すだけでOKです。


## 0.0 ~ 1.0 の値を渡して、チャージに応じてレーザーの太さを変える
func set_charge_ratio(ratio: float) -> void:
	var clamped := clampf(ratio, 0.0, 1.0)
	# 最小 1.0, 最大 line_width * 3 くらいまで太くする例
	var min_width := 1.0
	var max_width := line_width * 3.0
	line_width = lerpf(min_width, max_width, clamped)

プレイヤー側では、


func _process(delta: float) -> void:
	if Input.is_action_pressed("shoot"):
		_charge_time += delta
		var ratio := clampf(_charge_time / charge_time_max, 0.0, 1.0)
		sniper_line.set_charge_ratio(ratio)
	else:
		_charge_time = 0.0
		sniper_line.set_charge_ratio(0.0)

のように呼び出せば、チャージ量に応じてレーザーがじわじわ太くなっていくスナイパー演出が簡単に実装できます。
このように、「レーザーサイト」という機能をコンポーネントとして切り出しておくと、後からの演出追加・改造がとてもやりやすくなりますね。