マルチプレイや「仲間を連れて歩く」タイプのゲームで、みんなをちゃんと画面内に収めたいのに、Camera2Dを1つのプレイヤーにだけ追従させていると、どうしても誰かが画面外に行ってしまいますよね。
Godot標準でもCamera2Dにリミットを付けたり、スクリプトでpositionをいじったりして調整はできますが、
- プレイヤーが2人になったら?
- 仲間NPCも一緒に映したくなったら?
- ボス戦だけ「ボス+プレイヤー全員」を映したくなったら?
…と、要件が増えるたびにカメラのロジックをプレイヤーやゲームのメインシーンに書き足していくと、スクリプトがどんどん肥大化していきます。
しかも「このシーンではAとBを映したい」「別シーンではCとDだけ映したい」といったバリエーションが増えると、継承ベースのカメラ制御は途端にスパゲッティ化しがちです。
そこで今回は、「複数のターゲットの中間点にカメラを移動させ、全員が入るようズーム調整する」機能を、完全に独立したコンポーネントとして切り出します。Camera2Dにこのコンポーネントをポン付けするだけで、ターゲットのリストを渡せばOK、という構成を目指しましょう。
【Godot 4】みんなをちゃんと映すカメラ!「TargetGroup」コンポーネント
今回作る TargetGroup コンポーネントは、ざっくり言うと:
- 複数のターゲットの位置から「バウンディングボックス(最小矩形)」を計算
- その中心にカメラを移動
- 全員が画面内に収まるように
Camera2D.zoomを自動調整 - スムージングや最小・最大ズーム、マージンなどを
@exportで柔軟に設定
というものです。
カメラ本体はあくまで素の Camera2D のままにしておき、「複数ターゲット追尾+ズーム制御」だけをこのコンポーネントに委譲することで、継承地獄や巨大スクリプトから解放されます。
フルコード(GDScript / Godot 4)
extends Node
class_name TargetGroup
## 複数ターゲットをまとめて追尾し、
## その全員が画面内に入るように Camera2D の位置とズームを調整するコンポーネント。
##
## 使い方:
## - Camera2D の子ノードとしてこのコンポーネントを追加
## - コードやエディタから `add_target(node)` で追尾対象を登録
## - もしくは `autoload_group_name` にグループ名を指定して、自動でそのグループを追尾
@export_category("基本設定")
## 追尾対象を自動取得するグループ名。
## 空文字のままなら自動取得は行わず、スクリプトから add_target/remove_target で管理します。
@export var autoload_group_name: StringName = &""
## Camera2D の親ノードを自動的に取得するかどうか。
## 通常は true で OK。特殊な構成で別の Camera2D を制御したい場合だけ false にして、
## camera_path を手動指定します。
@export var auto_find_camera: bool = true
## 手動で制御したい Camera2D へのパス。
## auto_find_camera = false のとき、ここに有効なパスを指定してください。
@export var camera_path: NodePath
@export_category("ズーム調整")
## 画面にターゲット全員を収める際の余白率(0.1 = 10%)。
## 値を大きくすると、ターゲットの周りに余白を多めに取ります。
@export_range(0.0, 1.0, 0.01)
var margin_ratio: float = 0.2
## 許容する最小ズーム(数値が小さいほど「引きのカメラ」になる)。
## 例: Vector2(0.5, 0.5) だと、デフォルトの 2 倍広く映します。
@export var min_zoom: Vector2 = Vector2(0.5, 0.5)
## 許容する最大ズーム(数値が大きいほど「寄りのカメラ」になる)。
## 例: Vector2(2.0, 2.0) だと、デフォルトの 1/2 の範囲まで拡大します。
@export var max_zoom: Vector2 = Vector2(2.0, 2.0)
## ターゲットがほぼ同じ位置にいる場合に使う「デフォルトズーム」。
## ほぼ動いていないときは、このサイズに近づけます。
@export var default_zoom: Vector2 = Vector2(1.0, 1.0)
## ターゲットが 1 体だけ、あるいはほぼ重なっていると判定するしきい値(ピクセル)。
@export_range(0.0, 1000.0, 1.0)
var min_bounds_size: float = 64.0
@export_category("スムージング")
## 位置の追従スピード。値が大きいほどカメラが素早く追従します。
@export_range(0.0, 20.0, 0.1)
var position_lerp_speed: float = 8.0
## ズームの追従スピード。値が大きいほどズームが素早く変化します。
@export_range(0.0, 20.0, 0.1)
var zoom_lerp_speed: float = 6.0
## 位置・ズームを補間するときに使う「イージングカーブ」。
## 未設定の場合は線形補間になります。
@export var easing_curve: Curve
@export_category("デバッグ")
## ターゲットのバウンディングボックスを画面上に描画して確認するためのフラグ。
@export var debug_draw_bounds: bool = false
## デバッグ描画の色
@export var debug_color: Color = Color(0.2, 1.0, 0.2, 0.7)
## 内部状態
var _camera: Camera2D
var _targets: Array[Node2D] = []
var _viewport_size: Vector2
func _ready() -> void:
_setup_camera()
_viewport_size = get_viewport().get_visible_rect().size
if autoload_group_name != &"":
_load_targets_from_group()
set_process(true)
func _setup_camera() -> void:
## Camera2D を取得するユーティリティ
if auto_find_camera:
_camera = get_parent() as Camera2D
if _camera == null:
push_warning("TargetGroup: 親ノードが Camera2D ではありません。auto_find_camera=false にして camera_path を指定してください。")
else:
if camera_path.is_empty():
push_warning("TargetGroup: auto_find_camera=false ですが camera_path が空です。")
else:
_camera = get_node_or_null(camera_path) as Camera2D
if _camera == null:
push_warning("TargetGroup: camera_path に指定されたノードが Camera2D ではありません。")
func _load_targets_from_group() -> void:
## 指定グループに属する Node2D をすべて登録
_targets.clear()
var nodes := get_tree().get_nodes_in_group(autoload_group_name)
for n in nodes:
if n is Node2D:
_targets.append(n)
func add_target(target: Node2D) -> void:
## 追尾対象を手動で追加する。
if target == null:
return
if not _targets.has(target):
_targets.append(target)
func remove_target(target: Node2D) -> void:
## 追尾対象を手動で削除する。
_targets.erase(target)
func clear_targets() -> void:
## 追尾対象をすべてクリアする。
_targets.clear()
func _process(delta: float) -> void:
if _camera == null:
return
if autoload_group_name != &"":
## グループ追尾モードのときは毎フレーム再スキャンしてもよいが、
## 頻繁な追加/削除がないなら _ready で一度だけでもOK。
## ここでは「動的に増減する」前提で毎フレーム取り直す実装にしておきます。
_load_targets_from_group()
if _targets.is_empty():
return
var bounds := _calculate_targets_bounds()
if bounds.size.x <= 0.0 or bounds.size.y <= 0.0:
return
var target_position := bounds.position + bounds.size * 0.5
var target_zoom := _calculate_target_zoom(bounds)
# 位置とズームをスムーズに補間
_camera.position = _smooth_step_vector(
_camera.position,
target_position,
position_lerp_speed,
delta
)
_camera.zoom = _smooth_step_vector(
_camera.zoom,
target_zoom,
zoom_lerp_speed,
delta
)
if debug_draw_bounds:
queue_redraw()
func _calculate_targets_bounds() -> Rect2:
## すべてのターゲットを含むバウンディングボックスを計算
var first := true
var min_x := 0.0
var min_y := 0.0
var max_x := 0.0
var max_y := 0.0
for t in _targets:
if not is_instance_valid(t):
continue
var p := t.global_position
if first:
min_x = p.x
max_x = p.x
min_y = p.y
max_y = p.y
first = false
else:
min_x = min(min_x, p.x)
max_x = max(max_x, p.x)
min_y = min(min_y, p.y)
max_y = max(max_y, p.y)
if first:
# 有効なターゲットが1つもなかった場合
return Rect2.ZERO
var size := Vector2(max_x - min_x, max_y - min_y)
return Rect2(Vector2(min_x, min_y), size)
func _calculate_target_zoom(bounds: Rect2) -> Vector2:
## ターゲット全員を画面内に収めるためのズーム量を計算
var bounds_size := bounds.size
# ターゲットがほぼ同じ位置にいる場合は default_zoom を目指す
if bounds_size.x < min_bounds_size and bounds_size.y < min_bounds_size:
return _clamp_zoom(default_zoom)
# ビューポートサイズに margin を加味して必要なズームを計算
var size_with_margin := bounds_size * (1.0 + margin_ratio)
if size_with_margin.x <= 0.0 or size_with_margin.y <= 0.0:
return _clamp_zoom(default_zoom)
# Camera2D の zoom は「世界座標 / 画面座標」の倍率なので、
# viewport_size に対して bounds のサイズがどれくらいかで決まる。
var zoom_x := size_with_margin.x / _viewport_size.x
var zoom_y := size_with_margin.y / _viewport_size.y
# どちらか大きい方を採用することで、縦横どちらも確実に収まるようにする
var zoom_factor := max(zoom_x, zoom_y)
var zoom := Vector2(zoom_factor, zoom_factor)
return _clamp_zoom(zoom)
func _clamp_zoom(z: Vector2) -> Vector2:
## min_zoom / max_zoom の範囲に収める
var clamped := z
clamped.x = clamp(clamped.x, min_zoom.x, max_zoom.x)
clamped.y = clamp(clamped.y, min_zoom.y, max_zoom.y)
return clamped
func _smooth_step_vector(from: Vector2, to: Vector2, speed: float, delta: float) -> Vector2:
if speed <= 0.0:
return to
var t := speed * delta
# 0..1 の範囲に制限
t = clamp(t, 0.0, 1.0)
if easing_curve:
# easing_curve は 0..1 の入力に対して 0..1 の出力を返す前提
t = easing_curve.sample(t)
return from.lerp(to, t)
func _draw() -> void:
if not debug_draw_bounds:
return
if _targets.is_empty():
return
var bounds := _calculate_targets_bounds()
if bounds.size.x <= 0.0 or bounds.size.y <= 0.0:
return
# Camera2D のローカル空間で描画するため、global_position との差分を補正
if _camera == null:
return
var center := bounds.position + bounds.size * 0.5
var local_rect_position := bounds.position - _camera.global_position
draw_rect(Rect2(local_rect_position, bounds.size), debug_color, false, 2.0)
draw_circle(center - _camera.global_position, 4.0, debug_color)
使い方の手順
ここでは典型的な「2人プレイヤー+ボス戦」の例で説明します。プレイヤーとボス全員をカメラに収めたいケースですね。
手順①:コンポーネントをシーンに追加
まずは、ゲーム用の Camera2D に TargetGroup をアタッチします。
Main (Node2D)
├── World (Node2D)
│ ├── Player1 (CharacterBody2D)
│ ├── Player2 (CharacterBody2D)
│ └── Boss (CharacterBody2D)
└── Camera2D
└── TargetGroup (Node)
Camera2Dを選択し、「子ノードを追加」からNodeを作成- その
Nodeに、上記のTargetGroup.gdをアタッチ - もしくは、
TargetGroup.gdを スクリプトとして直接子ノードに指定してもOK
auto_find_camera はデフォルトで true なので、親が Camera2D であれば自動的に制御対象になります。
手順②:ターゲットをグループで管理する
複数追尾の肝は「ターゲットをどう指定するか」です。ここでは Godot のグループ機能を使いましょう。
例として:
Player1/Player2/Bossすべてに"camera_targets"グループを付与
エディタ上で各ノードを選択し、右側の「ノード」タブ → グループ から camera_targets を追加するか、スクリプトで:
func _ready() -> void:
add_to_group("camera_targets")
のように追加しておきます。
そのうえで、TargetGroup のインスペクタから:
autoload_group_nameにcamera_targetsを設定
これで、そのグループに属する Node2D をすべて自動追尾してくれるようになります。
手順③:ズームとスムージングを調整する
ゲームの見た目に合わせて、以下をお好みで調整しましょう:
margin_ratio:0.2〜0.3 くらいから試すとよいですmin_zoom/max_zoom:ステージサイズやピクセルアートのドット感に合わせてdefault_zoom:普段のカメラの寄り具合position_lerp_speed/zoom_lerp_speed:酔いにくさとレスポンスのバランスを調整easing_curve:ゆっくり始まってスッと止まるようなカーブを設定すると、気持ちいい動きになります
テスト時には debug_draw_bounds を true にしておくと、ターゲットを囲む矩形と中心点が表示されるので、挙動の理解がしやすいです。
手順④:別のシーンでもそのまま再利用
このコンポーネントは カメラの子にぶら下がっているだけの独立ノードなので、他のシーンでも簡単に再利用できます。
例えば、「協力プレイのステージ」と「ボス戦ステージ」で構成が少し違う場合:
CoopStage (Node2D)
├── Player1 (CharacterBody2D) [group: camera_targets]
├── Player2 (CharacterBody2D) [group: camera_targets]
└── Camera2D
└── TargetGroup (Node) # autoload_group_name = "camera_targets"
BossStage (Node2D)
├── Player1 (CharacterBody2D) [group: camera_targets]
├── Player2 (CharacterBody2D) [group: camera_targets]
├── Boss (CharacterBody2D) [group: camera_targets]
└── Camera2D
└── TargetGroup (Node) # 同じ設定でOK
どちらのステージでも、TargetGroup の設定はほぼそのままで使い回せます。
「ボス戦だけボスも映したい」となったら、ボスにグループを付けるだけで完了です。
メリットと応用
この TargetGroup コンポーネントを使うと:
- カメラ制御のロジックが Camera2D 本体から完全に分離されるので、カメラスクリプトがスリムになる
- プレイヤーや敵のスクリプトに「カメラ用の特別処理」を書かなくてよい(コンポーネントが全部やってくれる)
- シーンをまたいだ再利用が簡単(Camera2D+TargetGroup のセットを丸ごとプリセット化しても良い)
- 「誰を映すか」の切り替えがグループや
add_target/remove_targetだけで済むので、レベルデザイン時の試行錯誤が楽
継承ベースで「マルチプレイ用カメラ」「ボス戦用カメラ」「イベントシーン用カメラ」…と増やしていくより、
「Camera2Dはただのカメラ」「TargetGroupが複数追尾ロジック」と役割を分けておいた方が、後からの変更にも強くなります。
改造案:一時的に特定ターゲットを強調する「フォーカス」機能
例えば、イベントシーン中だけ「プレイヤーではなくボスを中心に映したい」といったニーズもありますよね。
そんなときは、ターゲット全員の中心ではなく、特定ターゲットに寄せた中心を使うように改造できます。
以下は簡易的な「フォーカス」機能の追加例です:
var _focus_target: Node2D
var _focus_weight: float = 0.0 # 0.0 = 通常, 1.0 = 完全にフォーカスターゲット
func set_focus(target: Node2D, weight: float = 1.0) -> void:
_focus_target = target
_focus_weight = clamp(weight, 0.0, 1.0)
func clear_focus() -> void:
_focus_target = null
_focus_weight = 0.0
func _get_center_with_focus(bounds_center: Vector2) -> Vector2:
if _focus_target and is_instance_valid(_focus_target) and _focus_weight > 0.0:
return bounds_center.lerp(_focus_target.global_position, _focus_weight)
return bounds_center
そして _process() 内の:
var target_position := bounds.position + bounds.size * 0.5
を:
var bounds_center := bounds.position + bounds.size * 0.5
var target_position := _get_center_with_focus(bounds_center)
のように差し替えれば、set_focus(boss, 0.7) のような呼び出しで、
「ターゲット全員の中心 70% + ボスの位置 30%」といった感じのフォーカスが簡単に実現できます。
このように、カメラの複雑な振る舞いを「Camera2Dの継承」ではなく「TargetGroupコンポーネントの改造」で表現すると、
シーン構造はシンプルなまま、表現力だけをどんどん拡張していけるのでおすすめです。
