RPGっぽいAIをGodotで組もうとすると、つい「HealerEnemy」「HealerNPC」みたいな専用シーンを継承で量産しがちですよね。さらに
- 味方探索ロジックをそれぞれの敵シーンにベタ書き
- 回復量や射程を変えたいだけなのに新しいスクリプトを複製
- ヒーラーとアタッカーでノード階層やスクリプトがバラバラ
…といった「継承地獄」「コピペ地獄」にハマりがちです。
そこで今回は、「回復役AI」をまるごと 1 コンポーネントとして切り出して、どんな味方ユニットにもポン付けできる HealerAI コンポーネントを作ってみましょう。
「継承より合成」の考え方で、回復ロジックを 1 つの Node に閉じ込めて、ユニット本体にはアタッチするだけにします。
【Godot 4】味方を自動でケアする回復役AI!「HealerAI」コンポーネント
今回の HealerAI コンポーネントは、ざっくり言うとこんなことをします。
- 一定間隔で「回復が必要な味方」をリストから探す
- 一番 HP が減っている味方をターゲットに選ぶ
- ターゲットに向かって移動し、射程内に入ったら回復魔法を発動
- クールダウン中は待機、ターゲットがいなくなったら待機
味方ユニット側は「HP を持っている」「回復を受け取れる」ことさえ満たしていれば OK。
HealerAI 自体は どのユニットにも再利用できる汎用コンポーネントとして設計します。
フルコード(GDScript / Godot 4)
extends Node
class_name HealerAI
"""
HealerAI コンポーネント
- HP の減った味方を探して回復しに行く「回復役」AI。
- 親ノード(ヒーラー本体)にアタッチして使うことを想定。
【前提】
- 味方ユニットは Group "allies" に属している。
- 味方ユニットは以下のインターフェースを持つことを推奨:
- プロパティ: current_hp, max_hp
- メソッド: func apply_heal(amount: float) -> void
- 親ノードは 2D の移動オブジェクトを想定 (CharacterBody2D / Node2D など)。
"""
# === 設定パラメータ ===
@export_group("検索設定")
@export var ally_group_name: String = "allies"
## 回復対象として探すグループ名。
## 例: "allies", "party", "healable" など。
@export var search_radius: float = 600.0
## この半径以内の味方だけを回復対象として考える。
@export var min_missing_hp: float = 10.0
## この値以上 HP が減っている味方だけを「回復が必要」とみなす。
@export var search_interval: float = 0.5
## 何秒ごとに回復対象を再探索するか。
@export_group("移動設定")
@export var move_speed: float = 120.0
## ターゲットに向かう移動速度 (ピクセル/秒)。
@export var stop_distance: float = 48.0
## この距離まで近づいたら足を止めて回復を試みる。
@export_group("回復設定")
@export var heal_amount: float = 25.0
## 1 回の回復魔法で回復する HP 量。
@export var heal_range: float = 80.0
## この距離以内なら回復魔法を発動できる。
@export var heal_cooldown: float = 2.0
## 1 回回復した後、次の回復までのクールダウン時間(秒)。
@export var auto_start: bool = true
## true の場合、ready 時点で自動的に AI を開始する。
@export_group("デバッグ表示")
@export var debug_draw: bool = false
## true にすると search_radius / heal_range などを簡易表示。
# === 内部状態 ===
var _owner_node: Node2D
var _current_target: Node2D = null
var _time_since_last_search: float = 0.0
var _heal_cooldown_timer: float = 0.0
var _is_active: bool = false
func _ready() -> void:
# 親が Node2D 系であることを期待
_owner_node = owner as Node2D
if _owner_node == null:
push_warning("HealerAI: owner が Node2D ではありません。移動や距離計算が正しく動かない可能性があります。")
if auto_start:
start()
func _process(delta: float) -> void:
if not _is_active:
return
# クールダウンタイマー更新
if _heal_cooldown_timer > 0.0:
_heal_cooldown_timer -= delta
# ターゲット再探索
_time_since_last_search += delta
if _time_since_last_search >= search_interval:
_time_since_last_search = 0.0
_update_target()
# ターゲットがいなければ何もしない
if _current_target == null:
return
# ターゲットがまだ有効かどうかチェック
if not is_instance_valid(_current_target):
_current_target = null
return
# ターゲットとの距離を計算
if _owner_node == null:
return
var to_target: Vector2 = _current_target.global_position - _owner_node.global_position
var distance: float = to_target.length()
# 射程内なら回復を試みる
if distance <= heal_range:
_try_heal_target()
# 回復射程内なら無理に近づかなくてよいので移動しない
return
# まだ近づく必要がある場合、stop_distance までは移動
if distance > stop_distance:
_move_towards(_current_target.global_position, delta)
func _move_towards(target_pos: Vector2, delta: float) -> void:
if _owner_node == null:
return
var dir: Vector2 = (target_pos - _owner_node.global_position).normalized()
var step: Vector2 = dir * move_speed * delta
# CharacterBody2D なら move_and_slide を使いたいところだが、
# コンポーネントとして汎用性を持たせるため、ここでは position を直接更新。
_owner_node.global_position += step
func _update_target() -> void:
"""
現在位置から search_radius 以内にいる味方の中から、
「最も HP が減っている(割合ではなく絶対値)」ユニットを選ぶ。
"""
if _owner_node == null:
return
var candidates: Array = []
var owner_pos: Vector2 = _owner_node.global_position
# 指定グループに属する全ノードを走査
for ally in get_tree().get_nodes_in_group(ally_group_name):
if ally == _owner_node:
continue
if not ally is Node2D:
continue
var ally_node := ally as Node2D
var dist: float = ally_node.global_position.distance_to(owner_pos)
if dist > search_radius:
continue
# HP 情報を持っているかチェック
if not ally_node.has_method("apply_heal"):
continue
if not ally_node.has_variable("current_hp") or not ally_node.has_variable("max_hp"):
continue
var current_hp: float = ally_node.current_hp
var max_hp: float = ally_node.max_hp
var missing_hp: float = max_hp - current_hp
if missing_hp < min_missing_hp:
# あまり減っていない味方はスキップ
continue
candidates.append({
"node": ally_node,
"missing_hp": missing_hp
})
if candidates.is_empty():
_current_target = null
return
# 一番 HP が減っている味方を選ぶ
candidates.sort_custom(func(a, b):
return a["missing_hp"] > b["missing_hp"]
)
_current_target = candidates[0]["node"]
func _try_heal_target() -> void:
"""
射程内にいるターゲットを回復する。
クールダウン中は発動しない。
"""
if _heal_cooldown_timer > 0.0:
return
if _current_target == null:
return
if not is_instance_valid(_current_target):
_current_target = null
return
# まだ本当に回復が必要か確認(HP がほぼ満タンになっているかもしれない)
if not _current_target.has_variable("current_hp") or not _current_target.has_variable("max_hp"):
_current_target = null
return
var current_hp: float = _current_target.current_hp
var max_hp: float = _current_target.max_hp
var missing_hp: float = max_hp - current_hp
if missing_hp <= 0.0:
# もう回復不要
_current_target = null
return
# 実際に回復を適用
if _current_target.has_method("apply_heal"):
_current_target.apply_heal(heal_amount)
else:
# 最悪、直接 HP をいじるフォールバック(非推奨だが保険として)
_current_target.current_hp = min(current_hp + heal_amount, max_hp)
# クールダウン開始
_heal_cooldown_timer = heal_cooldown
# 回復後もまだ大きく減っているならターゲットを維持、
# そうでなければ次の探索で別の味方を探す。
if (max_hp - _current_target.current_hp) < min_missing_hp:
_current_target = null
func start() -> void:
"""
HealerAI を有効化する。
"""
_is_active = true
_time_since_last_search = 0.0
_heal_cooldown_timer = 0.0
func stop() -> void:
"""
HealerAI を停止する。
"""
_is_active = false
_current_target = null
func is_active() -> bool:
return _is_active
func _draw() -> void:
if not debug_draw or _owner_node == null:
return
# デバッグ用に search_radius と heal_range を円で描画
draw_circle(Vector2.ZERO, search_radius, Color(0, 0.5, 1, 0.1))
draw_circle(Vector2.ZERO, heal_range, Color(0, 1, 0, 0.15))
func _process_debug_draw() -> void:
if debug_draw:
queue_redraw()
func _physics_process(delta: float) -> void:
# debug_draw が有効なときだけ描画更新
_process_debug_draw()
使い方の手順
ここからは、実際に「回復役の敵キャラ」が味方 NPC を回復しに行く例で説明します。
前提:味方ユニットの HP コンポーネント
HealerAI が参照する最低限のインターフェースを用意しておきます。
これは味方用の HP 管理コンポーネントとして、どのユニットにもアタッチできるようにしておくと便利です。
extends Node
class_name Health
@export var max_hp: float = 100.0
var current_hp: float
func _ready() -> void:
current_hp = max_hp
func apply_damage(amount: float) -> void:
current_hp = max(current_hp - amount, 0.0)
func apply_heal(amount: float) -> void:
current_hp = min(current_hp + amount, max_hp)
各味方ユニットは、この Health を持っているノードをルートにしておくか、
もしくは Health のプロパティをルートに「委譲」しておくとよいです(ここでは簡単化のため、ルートに直接持たせるパターンを紹介します)。
手順①:味方ユニット(回復される側)のシーンを用意する
例として、味方戦士 NPC を作ります。
AllyWarrior (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── Health (Node) ※HPコンポーネント
そして、AllyWarrior のルートを “allies” グループに追加します。
# AllyWarrior.gd
extends CharacterBody2D
@onready var health: Health = $Health
func _ready() -> void:
add_to_group("allies")
# HealerAI が期待しているインターフェースをルートから委譲
var current_hp:
get: return health.current_hp
set(value): health.current_hp = value
var max_hp:
get: return health.max_hp
set(value): health.max_hp = value
func apply_heal(amount: float) -> void:
health.apply_heal(amount)
このようにしておくと、HealerAI は AllyWarrior を直接ターゲットにしつつ、
内部では Health コンポーネントで HP を管理できます。
手順②:ヒーラー(回復役)のシーンを作る
次に、実際に回復しに行く「ヒーラー敵」を作ります。
HealerEnemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D ├── Health (Node) └── HealerAI (Node) ※今回のコンポーネント
HealerEnemy.gd はこんな感じで最低限で OK です。
# HealerEnemy.gd
extends CharacterBody2D
@onready var health: Health = $Health
@onready var healer_ai: HealerAI = $HealerAI
func _ready() -> void:
add_to_group("allies") # 味方として扱いたいなら同じグループに
# HealerAI は auto_start = true なら何もしなくてよい
HealerAI ノードには、インスペクタから以下を設定しておきましょう。
ally_group_name:"allies"search_radius: 600 など、マップに合わせてheal_amount: 20〜40 くらいheal_range: 80 くらいmove_speed: 他ユニットとのバランスを見て調整
手順③:シーン構成図(例:小さなパーティ)
例えば、プレイヤーと味方 2 人、ヒーラー 1 人がいるシーンはこんな構成になります。
Main (Node2D)
├── Player (CharacterBody2D)
│ ├── Sprite2D
│ ├── CollisionShape2D
│ └── Health (Node)
│
├── AllyWarrior1 (CharacterBody2D)
│ ├── Sprite2D
│ ├── CollisionShape2D
│ └── Health (Node)
│
├── AllyWarrior2 (CharacterBody2D)
│ ├── Sprite2D
│ ├── CollisionShape2D
│ └── Health (Node)
│
└── HealerEnemy (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
├── Health (Node)
└── HealerAI (Node)
ここで、Player / AllyWarrior1 / AllyWarrior2 / HealerEnemy の 4 体すべてを "allies" グループに入れておけば、
HealerEnemy の HealerAI は「4 体の中から HP が減っているユニット」を自動で探して回復しに行きます。
手順④:動作確認とチューニング
- 敵の攻撃などで AllyWarrior の HP を減らす(
apply_damage()を呼ぶ) - HealerEnemy が一定間隔で味方をスキャンし、HP が減ったユニットに向かって移動する
- ヒーラーが
heal_range以内に入ると、apply_heal()が呼ばれて HP が回復する - クールダウン中は待機し、次に HP が減ったユニットがいればそちらへ向かう
HealerAI には debug_draw オプションも用意してあるので、
必要に応じて true にして、search_radius や heal_range の円を視覚的に確認しながら調整するとやりやすいです。
メリットと応用
この HealerAI コンポーネントを使うメリットはかなり多いです。
- ロール(役割)と見た目を完全に分離できる
「ヒーラーっぽい見た目」の敵シーンでも、「ただのスライム」でも、
HealerAI を 1 ノード足すだけで「回復役」にできます。 - シーン構造が浅く、読みやすい
回復ロジックは HealerAI にまとまっているので、
各ユニットのシーンは「見た目 + 当たり判定 + HP + 役割コンポーネント」の薄い構成で済みます。 - パラメータで AI の性格を変えやすい
search_radiusやheal_cooldownを変えるだけで、
「慎重なヒーラー」「突撃気味のヒーラー」など性格を簡単に作り分けられます。 - デバッグ・チューニングが 1 箇所で完結
回復の挙動がおかしいときは HealerAI.gd だけ見ればよく、
各ユニットのスクリプトを全部追う必要がありません。
「継承で HealerEnemy, SuperHealerEnemy, BossHealerEnemy…」と増やしていくより、
「共通の Enemy シーン + HealerAI コンポーネント」 という合成スタイルのほうが、
長期的には圧倒的に管理が楽になりますね。
改造案:HP が一番減っている味方ではなく、「プレイヤーを最優先で回復」する
例えば、「プレイヤーだけは最優先で守りたい」みたいなゲームデザインもあります。
そんなときは、ターゲット更新ロジックを少し変えて、プレイヤーが減っていれば必ずそちらを優先するようにできます。
func _update_target_prioritize_player(player: Node2D) -> void:
if _owner_node == null:
return
var owner_pos := _owner_node.global_position
var best_target: Node2D = null
var best_missing_hp: float = 0.0
# まずプレイヤーをチェック
if is_instance_valid(player) \
and player.is_in_group(ally_group_name) \
and player.has_variable("current_hp") \
and player.has_variable("max_hp"):
var dist_to_player := player.global_position.distance_to(owner_pos)
var missing_hp_player := player.max_hp - player.current_hp
if dist_to_player = min_missing_hp:
# プレイヤーが減っていれば即ターゲットにして return
_current_target = player
return
# プレイヤーが健康なら、通常通り「一番減っている味方」を探す
for ally in get_tree().get_nodes_in_group(ally_group_name):
if ally == _owner_node or ally == player:
continue
if not ally is Node2D:
continue
var ally_node := ally as Node2D
var dist := ally_node.global_position.distance_to(owner_pos)
if dist > search_radius:
continue
if not ally_node.has_variable("current_hp") or not ally_node.has_variable("max_hp"):
continue
var missing_hp := ally_node.max_hp - ally_node.current_hp
if missing_hp best_missing_hp:
best_missing_hp = missing_hp
best_target = ally_node
_current_target = best_target
このように、「ターゲット選択」「移動」「回復」の 3 つが HealerAI にまとまっているので、
ゲームごとの細かい仕様変更にも柔軟に対応しやすいですね。
ぜひ、自分のプロジェクト用に「支援系 AI コンポーネント」を増やしていって、
継承ではなく「合成」で戦える Godot プロジェクトに育てていきましょう。
