Godot 4 でカメラ追従を作るとき、つい Camera2D をプレイヤーに直接ペアレントして「プレイヤーの子にするだけ」で済ませがちですよね。
でもこの方法、いざ 「ちょっとだけ遅れて追いかけるカメラ」 を作ろうとすると、一気に面倒になります。

  • プレイヤーシーンの中にカメラのロジックが入り込んでしまう
  • 敵用のカメラ、イベント用のカメラ…と用途が増えると、継承やコピペ地獄になりやすい
  • 「カメラだけ別シーンにしたい」「ターゲットを動的に差し替えたい」といった要望に応えづらい

そこで今回は、カメラ本体とは独立した「コンポーネント」として、ターゲットを滑らかに追いかける SmoothPursuit を用意してみました。
カメラにはただこのコンポーネントをアタッチして、「誰を追いかけるか」だけ差し替える。継承ではなく合成(Composition)でカメラ挙動を組み立てていきましょう。

【Godot 4】遅れて追いかけて気持ちいい!「SmoothPursuit」コンポーネント

SmoothPursuit は「親ノード(主に Camera2D や Node2D)」の座標を、指定したターゲット座標へ向けて lerp / lerp_angle で滑らかに補間するコンポーネントです。

  • ターゲット: 任意の Node2D(プレイヤー、敵、イベント用ダミーノードなど)
  • 補間速度: 0〜1 の係数で調整(フレームごと / 秒ベースを選択可能)
  • 位置だけ / 回転も追従 / 片方だけ、を選べる
  • シーン構造はフラットなまま、カメラの「追従ロジック」だけを差し替え可能

ソースコード(Full Code)


extends Node
class_name SmoothPursuit
## 親(主に Camera2D / Node2D)の座標を、ターゲットへ滑らかに追従させるコンポーネント。
##
## 使い方:
## - Camera2D などの子としてこのノードを配置
## - `target` に追いかけたい Node2D を指定
## - `follow_position` / `follow_rotation` / `lerp_speed` などを調整

@export var target: Node2D:
	set(value):
		target = value
		if target:
			# 追従開始時に一度だけジャンプさせたい場合はここで制御もできる
			_last_valid_target_position = target.global_position
	get:
		return target

## 位置を追従するかどうか
@export var follow_position: bool = true

## 回転を追従するかどうか
@export var follow_rotation: bool = false

## 追従の速さ。
## 0.0 に近いほど「ゆっくり追いかける」、1.0 に近いほど「ほぼ即座に追いつく」イメージです。
## `use_delta_scaled_speed = true` の場合は「1秒あたりの追従速度」として扱われます。
@export_range(0.0, 10.0, 0.01)
var lerp_speed: float = 5.0

## true の場合、`lerp_speed` を「1秒あたりの追従速度」として扱い、delta でスケーリングします。
## false の場合、フレームごとの係数として扱います(フレームレート依存)。
@export var use_delta_scaled_speed: bool = true

## 追従時にオフセットを加算します(画面中央ではなく少し前方を見せたい場合など)。
@export var position_offset: Vector2 = Vector2.ZERO

## ターゲットが一時的に消えたときに、最後に有効だった位置を使うかどうか。
## false の場合、ターゲットが無効な間は追従を停止します。
@export var use_last_valid_position_when_target_missing: bool = true

## ターゲットが存在しないときに追従を無効化するか。
@export var disable_when_no_target: bool = false

## デバッグ用: 追従しているターゲット位置を表示するか
@export var debug_draw: bool = false

var _last_valid_target_position: Vector2 = Vector2.ZERO
var _last_valid_target_rotation: float = 0.0


func _ready() -> void:
	# 親が Node2D 系であることを軽くチェック
	if not (get_parent() is Node2D):
		push_warning("SmoothPursuit should be a child of Node2D/Camera2D. Current parent: %s" % [get_parent()])
	
	if target:
		_last_valid_target_position = target.global_position
		if target is Node2D:
			_last_valid_target_rotation = target.global_rotation


func _process(delta: float) -> void:
	var parent_2d := get_parent() as Node2D
	if parent_2d == null:
		return
	
	var effective_target := _get_effective_target()
	if effective_target == null:
		if disable_when_no_target:
			return
		# ターゲットがいないが、最後の位置を使う設定ならそこへ向かう
		if use_last_valid_position_when_target_missing:
			_update_parent_towards(parent_2d, _last_valid_target_position, _last_valid_target_rotation, delta)
		return
	
	# 有効なターゲットがいる
	var target_pos := effective_target.global_position + position_offset
	var target_rot := effective_target.global_rotation
	
	_last_valid_target_position = target_pos
	_last_valid_target_rotation = target_rot
	
	_update_parent_towards(parent_2d, target_pos, target_rot, delta)


func _get_effective_target() -> Node2D:
	# target が Node2D であり、かつツリー上に存在しているかを確認
	if target and is_instance_valid(target) and target.get_tree() != null:
		return target
	return null


func _update_parent_towards(parent_2d: Node2D, target_pos: Vector2, target_rot: float, delta: float) -> void:
	var t := lerp_speed
	if use_delta_scaled_speed:
		# 1秒あたりの速度を「フレームごとの係数」に変換する簡易式
		# 1 - exp(-speed * delta) でもよいが、ここではシンプルに clamp する。
		t = clamp(lerp_speed * delta, 0.0, 1.0)
	else:
		t = clamp(lerp_speed, 0.0, 1.0)
	
	if follow_position:
		var new_pos := parent_2d.global_position.lerp(target_pos, t)
		parent_2d.global_position = new_pos
	
	if follow_rotation:
		var new_rot := lerp_angle(parent_2d.global_rotation, target_rot, t)
		parent_2d.global_rotation = new_rot
	
	if debug_draw:
		_debug_draw_gizmo(parent_2d, target_pos)


func _debug_draw_gizmo(parent_2d: Node2D, target_pos: Vector2) -> void:
	# デバッグ用に、親のローカル空間でターゲット方向を線で描く簡易処理
	# 実際の描画は Editor ではなくゲーム中に行うため、あくまで簡易な目安です。
	if not Engine.is_editor_hint():
		var viewport := get_viewport()
		if viewport == null:
			return
		
		var canvas_layer := viewport.get_canvas_item()
		if canvas_layer == null:
			return
		
		var draw_from := parent_2d.global_position
		var draw_to := target_pos
		
		# DebugDraw のような専用ノードを使うのが理想だが、
		# ここでは簡易に `draw_line` を呼び出すための CustomDrawing ノードがある前提とします。
		# プロジェクト側で差し替えしやすいよう、ここはあえて最小限にしてあります。
		# (実運用では、この関数をオーバーライドして独自のデバッグ描画を行うのもアリです。)
		pass  # 必要であればここに独自のデバッグ描画を実装

使い方の手順

  1. Camera2D シーン(または Node2D)を用意する

    すでにプレイヤーシーンの子に Camera2D をぶら下げている場合は、そのままでも構いませんが、

    個人的には「カメラはカメラで独立シーン」にしておくと後々ラクです。
  2. Camera2D の子として SmoothPursuit ノードを追加
    • スクリプトブラウザから SmoothPursuit.gd を作成
    • Camera2D の子に Node を追加し、アタッチスクリプトに SmoothPursuit を指定
    • または、class_name SmoothPursuit を使って「ノードを追加」から直接追加
  3. ターゲット(プレイヤーなど)をインスペクタで指定
    • target に追いかけたい Node2D をドラッグ&ドロップ
    • カメラが少し遅れて追いかけるようにしたければ lerp_speed を 3〜7 くらいに調整
    • 視界の前方を見せたいなら position_offset に (50, 0) などを設定
  4. 必要に応じて回転追従やデバッグ描画をオンにする
    • トップダウンのシューティングなどで「カメラも一緒に回したい」場合は follow_rotation = true
    • 動き具合を確認したいときは debug_draw を true にして挙動をチェック

具体例1: プレイヤーを追いかけるカメラ

典型的な 2D アクションゲームの構成例です。

MainScene (Node2D)
 ├── Player (CharacterBody2D)
 │    ├── Sprite2D
 │    └── CollisionShape2D
 └── Camera2D
      └── SmoothPursuit (Node)
  • SmoothPursuit.targetPlayer を指定
  • follow_position = true, follow_rotation = false
  • lerp_speed = 5.0, use_delta_scaled_speed = true

これだけで、「プレイヤーを少し遅れて追いかけるカメラ」が完成します。
プレイヤーシーン側は一切カメラのことを知らなくていいので、プレイヤーを別ゲームに持っていくときもカメラロジックを引きずらないのがポイントです。

具体例2: 敵やイベントポイントにカメラを切り替える

ボス戦前の演出などで、「一時的にカメラを敵に向けたい」ケースを考えます。

MainScene (Node2D)
 ├── Player (CharacterBody2D)
 ├── Boss (CharacterBody2D)
 ├── EventFocus (Node2D)   # イベント用のダミー注視点
 └── Camera2D
      └── SmoothPursuit

スクリプト側では、例えば以下のようにターゲットを差し替えます。


# どこかのイベント制御スクリプトから
@onready var camera_pursuit: SmoothPursuit = $Camera2D/SmoothPursuit
@onready var player: Node2D = $Player
@onready var boss: Node2D = $Boss

func focus_on_boss():
	camera_pursuit.target = boss

func focus_on_player():
	camera_pursuit.target = player

カメラ本体やプレイヤーをいじらず、「カメラが今どこを見るか」だけをコンポーネントに任せられるのが気持ちいいところですね。

具体例3: 動く床やリフトに乗ったときだけカメラを追従

リフトに乗ったときだけ「リフトの動きに合わせて少し先を見せたい」場合も、コンポーネントの差し替えで対応できます。

Stage (Node2D)
 ├── Player (CharacterBody2D)
 ├── MovingPlatform (Node2D)
 │    └── PathFollow2D など
 └── Camera2D
      └── SmoothPursuit

例えばリフトのスクリプトで:


@onready var camera_pursuit: SmoothPursuit = get_tree().get_first_node_in_group("camera_pursuit") 

func _on_player_entered_platform(player: Node2D) -> void:
	# リフトに乗っている間だけ、カメラターゲットを自分に切り替える
	camera_pursuit.target = self
	camera_pursuit.position_offset = Vector2(0, -50)

func _on_player_exited_platform(player: Node2D) -> void:
	# プレイヤーに戻す
	camera_pursuit.target = player
	camera_pursuit.position_offset = Vector2.ZERO

SmoothPursuit ノードをグループ "camera_pursuit" に登録しておけば、どこからでも簡単にアクセスできます。
これも「継承ではなく合成」で、カメラ挙動を差し替えている良い例ですね。

メリットと応用

SmoothPursuit コンポーネントを使うメリットを、コンポーネント指向の観点から整理してみましょう。

  • カメラロジックが Camera2D から分離される

    Camera2D は「表示の器」としてシンプルなままにしておき、

    「誰をどう追いかけるか」というロジックは SmoothPursuit に閉じ込められます。
  • プレイヤーや敵シーンを汚さない

    追従処理をプレイヤースクリプトに書く必要がないので、

    プレイヤーを別プロジェクトに持っていくときもカメラ依存がゼロになります。
  • シーン構造がフラットで見通しが良い

    「Player の子に Camera2D、その子に揺れスクリプト、その子に…」という深いツリーを避けられます。

    Camera2D の直下に SmoothPursuitCameraShake などのコンポーネントを並べる構成にすると、

    何がどの役割を持っているか一目で分かります。
  • ターゲットの差し替えが容易で、演出に強い

    ボス戦のカットイン、イベントシーン、リプレイカメラなど、

    ターゲットを切り替えるだけで多彩な演出が作れます。

さらに、コンポーネントを増やすだけで機能拡張も簡単です。
例えば、別コンポーネントとして「画面揺れ(CameraShake)」「ズーム制御(CameraZoomController)」などを用意して、
カメラに好きなだけアタッチしていく、という構成が取りやすくなります。

改造案: 「デッドゾーン」付き追従にする

プレイヤーが少し動いただけではカメラを動かさず、「一定範囲を超えたら追いかける」
いわゆる「デッドゾーン」付きカメラを作りたい場合は、_update_parent_towards を少し改造すると実現できます。


func _update_parent_towards_with_deadzone(
	parent_2d: Node2D,
	target_pos: Vector2,
	target_rot: float,
	delta: float,
	deadzone_radius: float = 64.0
) -> void:
	var t := lerp_speed
	if use_delta_scaled_speed:
		t = clamp(lerp_speed * delta, 0.0, 1.0)
	else:
		t = clamp(lerp_speed, 0.0, 1.0)
	
	if follow_position:
		var current_pos := parent_2d.global_position
		var to_target := target_pos - current_pos
		var dist := to_target.length()
		
		if dist > deadzone_radius:
			# デッドゾーンの外側に出た分だけ追いかける
			var desired_pos := current_pos + to_target.normalized() * (dist - deadzone_radius)
			parent_2d.global_position = current_pos.lerp(desired_pos, t)
	
	if follow_rotation:
		var new_rot := lerp_angle(parent_2d.global_rotation, target_rot, t)
		parent_2d.global_rotation = new_rot

このように、SmoothPursuit 自体をベースにして、
「デッドゾーン」「軸ごとの制限(X だけ追従など)」「スクリーン境界の制約」などを
派生コンポーネントとして積み上げるのが、コンポーネント指向的な設計としておすすめです。

継承ベースで巨大な MySuperCamera を作るより、
「追従」「揺れ」「ズーム」「制限」といった小さなコンポーネントを組み合わせていく方が、
結果的に管理しやすく、ゲームごとのカスタマイズにも柔軟に対応できます。
ぜひ、あなたのプロジェクトでも SmoothPursuit をベースに、カメラを「合成」で育てていってみてください。