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 が減っているユニット」を自動で探して回復しに行きます。


手順④:動作確認とチューニング

  1. 敵の攻撃などで AllyWarrior の HP を減らす(apply_damage() を呼ぶ)
  2. HealerEnemy が一定間隔で味方をスキャンし、HP が減ったユニットに向かって移動する
  3. ヒーラーが heal_range 以内に入ると、apply_heal() が呼ばれて HP が回復する
  4. クールダウン中は待機し、次に HP が減ったユニットがいればそちらへ向かう

HealerAI には debug_draw オプションも用意してあるので、
必要に応じて true にして、search_radiusheal_range の円を視覚的に確認しながら調整するとやりやすいです。


メリットと応用

この HealerAI コンポーネントを使うメリットはかなり多いです。

  • ロール(役割)と見た目を完全に分離できる
    「ヒーラーっぽい見た目」の敵シーンでも、「ただのスライム」でも、
    HealerAI を 1 ノード足すだけで「回復役」にできます。
  • シーン構造が浅く、読みやすい
    回復ロジックは HealerAI にまとまっているので、
    各ユニットのシーンは「見た目 + 当たり判定 + HP + 役割コンポーネント」の薄い構成で済みます。
  • パラメータで AI の性格を変えやすい
    search_radiusheal_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 プロジェクトに育てていきましょう。