GodotでFPSっぽい「スナイパー演出」を作ろうとすると、ついこんな実装をしがちですよね。
- プレイヤーシーンを継承して「SniperPlayer」「EnemySniper」みたいな派生シーンを量産する
- キャラの子ノードとして
Line2DやRayCast2Dをベタ書きして、スクリプトもプレイヤー本体に直書き - 「この敵だけレーザーを点滅」「この銃だけ色を変えたい」→ 条件分岐がどんどん増える
結果として、
- プレイヤー/敵スクリプトがレーザーの表示ロジックで肥大化
- 別のシーンでも同じレーザーを使いたいのに、コピペ 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 などに置き換え)です。
手順① コンポーネントをプロジェクトに追加
SniperLine.gdをプロジェクトのどこか(例:res://components/)に保存します。- 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)
のように呼び出せば、チャージ量に応じてレーザーがじわじわ太くなっていくスナイパー演出が簡単に実装できます。
このように、「レーザーサイト」という機能をコンポーネントとして切り出しておくと、後からの演出追加・改造がとてもやりやすくなりますね。
