Godot 4で敵AIを書くとき、つい「EnemyBase」を作って、そこから「EnemyWithShield」「EnemyWithGun」…みたいに継承ツリーを伸ばしてしまいがちですよね。さらに、盾の向き制御は敵スクリプトの中に直書き、プレイヤー追尾も同じクラスに直書き…とやっていくと、気づいた頃には1ファイル数百行の巨大スクリプトになってしまいます。

しかも「この盾の挙動だけ別の敵にも使い回したい」と思っても、継承構造に縛られて簡単には切り出せない…。ノード階層も、敵本体の下に「Shield」「Hitbox」「AI」などの子ノードが増えていって、どの敵がどの機能を持っているのかパッと見で分かりづらくなります。

そこで今回は、「盾を常にプレイヤー方向へ向けつつ、じりじりと接近する」挙動をまるごと1つのコンポーネントに閉じ込めた ShieldBearer コンポーネントを作ってみましょう。敵本体は単なる CharacterBody2D でも Node2D でもよくて、「盾持ちにしたい敵」にこのコンポーネントをアタッチするだけで、盾向き+接近AIが動くようにします。

【Godot 4】盾でプレイヤーをロックオン!「ShieldBearer」コンポーネント

このコンポーネントのゴールはシンプルです。

  • プレイヤーの位置を追跡する
  • プレイヤーの方向に盾(無敵判定)を向ける
  • 一定速度でじりじり近づく(突進ではなく「にじり寄り」)
  • 敵本体のスクリプトとは疎結合にする(合成で後付けできる)

敵の「移動」や「アニメーション制御」とは分離しておくことで、同じ ShieldBearer を「歩く敵」「飛ぶ敵」「動く床」など、色々なホストノードにそのまま再利用できるようにしておきます。

フルコード:ShieldBearer.gd


extends Node2D
class_name ShieldBearer
# Godot 4.x 用

## プレイヤーの方へ盾を向けながら、ホスト(親)をじりじり接近させるコンポーネント。
## - ホストは基本的に CharacterBody2D or Node2D を想定。
## - 盾用の子ノード(例: CollisionShape2D, Sprite2D)の回転をプレイヤー向きに合わせる。

@export_node_path("Node2D") var player_path: NodePath
# プレイヤーへの参照。直接ドラッグ&ドロップで設定できます。
# 何も設定しない場合は、自動でグローバルに最初に見つかった "Player" グループを探します。

@export var move_speed: float = 40.0
# プレイヤーへ近づく速度。小さめにして「じりじり感」を演出。

@export var stop_distance: float = 48.0
# この距離まで近づいたら、それ以上は前進しない。
# 近接攻撃判定との兼ね合いで調整しましょう。

@export var shield_node_path: NodePath
# 盾として回転させたいノード(Sprite2D, Node2D, CollisionShape2D など)のパス。
# 未設定の場合、このコンポーネント自身(Node2D)を盾として回転させます。

@export var rotate_shield_only: bool = true
# true: 盾ノードだけをプレイヤー向きに回転
# false: ホストノードごとプレイヤー向きに回転(トップダウン視点などで便利)

@export var auto_find_player_by_group: StringName = &"Player"
# player_path が未設定のときに、自動で探すグループ名。
# Player シーン側で "Player" グループに入れておくと自動追尾してくれます。

@export var debug_draw_direction: bool = false
# デバッグ用。盾の向きを線で描画します。

var _player: Node2D
var _host_body_2d: CharacterBody2D
var _host_node_2d: Node2D
var _shield_node: Node2D


func _ready() -> void:
	# ホスト(親)を取得
	_host_body_2d = get_parent() as CharacterBody2D
	_host_node_2d = get_parent() as Node2D
	if _host_node_2d == null:
		push_warning("ShieldBearer: 親が Node2D ではありません。このコンポーネントは Node2D/CharacterBody2D の子として使ってください。")

	# プレイヤー参照を解決
	_resolve_player()

	# 盾ノードの参照を解決
	if shield_node_path.is_empty():
		# 未設定なら、このコンポーネント自身を盾として扱う
		_shield_node = self
	else:
		_shield_node = get_node_or_null(shield_node_path) as Node2D
		if _shield_node == null:
			push_warning("ShieldBearer: shield_node_path に Node2D が見つかりません。自分自身を盾として使用します。")
			_shield_node = self


func _process(delta: float) -> void:
	if _player == null or _host_node_2d == null:
		return

	# プレイヤーへの方向と距離を計算
	var to_player: Vector2 = _player.global_position - _host_node_2d.global_position
	var distance: float = to_player.length()
	if distance == 0.0:
		return

	var direction: Vector2 = to_player.normalized()

	# 盾(or 本体)をプレイヤー方向へ回転
	var angle_to_player: float = direction.angle()

	if rotate_shield_only:
		if _shield_node:
			_shield_node.global_rotation = angle_to_player
	else:
		# ホストごとプレイヤーへ向ける
		_host_node_2d.global_rotation = angle_to_player

	# 一定距離まではじりじり接近
	if distance > stop_distance:
		var move_vec: Vector2 = direction * move_speed * delta

		if _host_body_2d:
			# CharacterBody2D の場合は velocity を使って移動
			_host_body_2d.velocity = move_vec / delta  # 速度に変換
			_host_body_2d.move_and_slide()
		else:
			# 単なる Node2D の場合は position を直接動かす
			_host_node_2d.global_position += move_vec
	else:
		# 一定距離以内では停止
		if _host_body_2d:
			_host_body_2d.velocity = Vector2.ZERO

	if debug_draw_direction:
		queue_redraw()


func _draw() -> void:
	if not debug_draw_direction:
		return

	if _player and _shield_node:
		var from_pos: Vector2 = _shield_node.to_local(_shield_node.global_position)
		var to_pos: Vector2 = _shield_node.to_local(_player.global_position)
		draw_line(from_pos, to_pos, Color.GREEN, 2.0)


func _resolve_player() -> void:
	# すでに設定されている場合
	if player_path != NodePath(""):
		var node := get_node_or_null(player_path)
		if node and node is Node2D:
			_player = node
			return
		else:
			push_warning("ShieldBearer: player_path に Node2D が見つかりません。グループ検索にフォールバックします。")

	# グループから自動検索
	if auto_find_player_by_group != StringName(""):
		var candidates := get_tree().get_nodes_in_group(auto_find_player_by_group)
		for c in candidates:
			if c is Node2D:
				_player = c
				return

	# 見つからなかった場合
	if _player == null:
		push_warning("ShieldBearer: プレイヤーが見つかりません。player_path または Player グループを確認してください。")


# --- 便利な補助メソッド群 ---

func set_player(target: Node2D) -> void:
	# スクリプトから直接プレイヤーを差し替えたいとき用
	_player = target
	player_path = NodePath("")  # 手動指定時はパスを無効化


func is_player_in_front(angle_tolerance_rad: float = 0.4) -> bool:
	# プレイヤーが「盾の正面側」にいるかどうかをざっくり判定
	if _player == null or _shield_node == null:
		return false

	var to_player: Vector2 = (_player.global_position - _shield_node.global_position).normalized()
	var forward: Vector2 = Vector2.RIGHT.rotated(_shield_node.global_rotation)
	var dot: float = forward.dot(to_player)
	# cos(θ) を使った判定。1 に近いほど正面。
	return dot >= cos(angle_tolerance_rad)

使い方の手順

ここでは、典型的な「2Dアクションゲームの敵キャラ」に ShieldBearer を付ける例で説明します。

シーン構成例①:盾持ち歩行敵

EnemyShieldBearer (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── ShieldSprite (Sprite2D)        ← 見た目の盾
 └── ShieldBearer (Node2D)          ← 今回のコンポーネント

手順①:コンポーネントスクリプトを用意

  1. 上記の ShieldBearer.gd を新規スクリプトとして保存します(例: res://components/ShieldBearer.gd)。
  2. Godot エディタを再読み込みすると、クラス名 ShieldBearer がインスペクタから選べるようになります。

手順②:敵シーンにコンポーネントをアタッチ

  1. 敵シーン(例: EnemyShieldBearer.tscn)を開きます。
  2. ルートを CharacterBody2D にして、Sprite2DCollisionShape2D を子に置きます。
  3. 盾の見た目用に ShieldSprite (Sprite2D) を追加し、敵の体の前に位置を調整します。
  4. さらに子ノードとして Node2D を追加し、スクリプトに ShieldBearer.gd をアタッチします。

シーン構成の全体像はこんな感じです:

EnemyShieldBearer (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── ShieldSprite (Sprite2D)
 └── ShieldBearer (Node2D)   ← この Node2D に ShieldBearer.gd をアタッチ

手順③:インスペクタでパラメータ設定

  • player_path
    プレイヤーシーンがすでにツリー上にあるなら、エディタ上でプレイヤーノードをドラッグして設定します。
    もしプレイヤーに "Player" グループを付けているなら、未設定でも自動検出されます。
  • move_speed
    例: 40.060.0 あたりにしておくと「じりじり感」が出ます。
  • stop_distance
    例: 48.0。敵の当たり判定や攻撃判定のサイズに合わせて調整してください。
  • shield_node_path
    ShieldSprite を指定します。これで盾のスプライトだけがプレイヤー方向へくるっと回転します。
  • rotate_shield_only
    true のままでOKです(敵の体はそのまま、盾だけが回転)。
    トップダウン視点で「敵そのものがプレイヤーを向く」ゲームなら false にしても良いですね。
  • debug_draw_direction
    開発中にオンにすると、盾からプレイヤーへ伸びる緑の線が表示され、向きの確認に便利です。

手順④:プレイヤー側の準備

ShieldBearer はプレイヤーを Node2D として参照します。プレイヤーシーンのルートが CharacterBody2DNode2D であればOKです。

プレイヤーシーンの例:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── Camera2D

プレイヤーを自動検出させたい場合は、プレイヤーの Nodeタブ → グループPlayer グループを追加しておきましょう。
これで player_path を空のままでも、ShieldBearer が自力で見つけてくれます。

メリットと応用

この ShieldBearer コンポーネントを使うと、敵 AI の「盾をプレイヤーへ向けながら接近する」ロジックを、敵スクリプト本体から完全に切り離せます。

  • シーン構造がスッキリ
    敵本体のスクリプトは「HP管理」「攻撃タイミング」「アニメーション制御」などに集中させ、移動や盾の向きはコンポーネントに任せられます。
  • 再利用性が高い
    「ShieldBearer を持った飛行敵」「動く盾付きプラットフォーム」「ボスの回転バリア」など、ShieldBearer をペタッと貼るだけで同じ挙動を再利用できます。
  • 継承ツリーからの解放
    「盾持ちの敵だけ別クラスにしたいから、EnemyBase をさらに継承して…」という継承地獄から抜け出せます。
    ベース敵クラスは1つだけにして、あとはコンポーネントを足したり引いたりするだけでバリエーションを増やせます。
  • テストがしやすい
    ShieldBearer 単体をシーンに置いて、プレイヤーダミーを動かすだけで挙動テストができます。
    複雑な敵ロジックと絡める前に、コンポーネント単体でデバッグできるのは大きなメリットですね。

改造案:攻撃可能距離に入ったらシグナルを飛ばす

「盾持ち敵が十分近づいたら攻撃アニメーションを始めたい」というケースでは、ShieldBearer にシグナルを追加して、ホスト側のスクリプトから受け取るようにするときれいに分離できます。

例えば、ShieldBearer に次のようなコードを追加してみましょう:


signal player_in_range
signal player_out_of_range

@export var attack_distance: float = 64.0
var _was_in_range: bool = false

func _process(delta: float) -> void:
	# 既存の処理の最後あたりに追加
	if _player and _host_node_2d:
		var distance := _player.global_position.distance_to(_host_node_2d.global_position)
		var in_range := distance <= attack_distance

		if in_range and not _was_in_range:
			emit_signal("player_in_range")
		elif not in_range and _was_in_range:
			emit_signal("player_out_of_range")

		_was_in_range = in_range

そして敵本体のスクリプト側では、


func _ready() -> void:
	var shield_bearer := $ShieldBearer
	shield_bearer.player_in_range.connect(_on_player_in_range)
	shield_bearer.player_out_of_range.connect(_on_player_out_of_range)


func _on_player_in_range() -> void:
	# ここで攻撃アニメーションを再生したり、攻撃ステートに遷移させる
	print("Player in attack range!")


func _on_player_out_of_range() -> void:
	# 攻撃をキャンセルしたり、待機ステートに戻したり
	print("Player left attack range.")

という感じで、「距離判定」=コンポーネント、「どう攻撃するか」=敵本体 と役割分担できます。
こうやってロジックをどんどんコンポーネント化していくと、継承ではなく合成でゲーム全体を組み立てられるようになって、後からの拡張や調整がかなり楽になりますよ。