Godot でアクションゲームやRPGを作っていると、つい「プレイヤー」「敵」ごとに専用スクリプトを生やして、その中に HP 計算・ダメージ処理・回避判定…と全部入りで書いてしまいがちですよね。さらにプレイヤーは CharacterBody2D を継承、敵は Node2D を継承、ボスはさらにそこから継承…とやっていると、「回避ロジックだけ共通化したいのに、継承ツリーが邪魔で切り出しづらい」という状況になりがちです。

Godot のノード階層にロジックをべったり書いてしまうと、

  • 「この敵だけ回避率を上げたい」→ スクリプトをコピペ・分岐だらけ
  • 「プレイヤーと敵で同じ回避判定を使いたい」→ 親クラスを共通化しないといけない
  • 「一部のオブジェクトは回避を使わない」→ 空実装や条件分岐が増える

といった「継承ベタベタ構造」のつらみが出てきます。

そこで今回は、「回避判定だけを独立したコンポーネント」としてアタッチできるようにした EvasionCalculator を紹介します。攻撃を受けたときに、Agility ステータスに基づいて確率でダメージを無効化(Miss)する処理を、このコンポーネント一つに閉じ込めましょう。


【Godot 4】回避ロジックを丸ごとコンポーネント化!「EvasionCalculator」コンポーネント

EvasionCalculator は、ダメージ計算の前段で「この攻撃は当たるか?ミスか?」を判定するための小さなコンポーネントです。

  • Agility(敏捷)ステータスを元に回避率を算出
  • 乱数により「命中 or 回避(Miss)」を判定
  • シグナルで「回避成功」「命中」を通知
  • 敵・プレイヤー・オブジェクトなど、どのノードにもペタッと貼れる

という思想で作っています。ダメージロジック本体は別コンポーネントに任せて、「当たったかどうか」だけを責務として切り出しているのがポイントですね。


フルコード:EvasionCalculator.gd


extends Node
class_name EvasionCalculator
## 攻撃を受けたときに「回避判定」を行うコンポーネント。
## Agility(敏捷)ステータスに基づいて、確率でダメージを無効化(Miss)します。
##
## 想定用途:
## - プレイヤーや敵のノードにアタッチし、被ダメージ前に should_evade() を呼び出す
## - ダメージ計算コンポーネントと組み合わせて使う

## --- 設定パラメータ(インスペクタで編集可能) ---

@export_category("Evasion Settings")

@export var base_evasion_rate: float = 0.05:
    ## 基本回避率(0.0〜1.0)。
    ## Agility が 0 のときの回避率。
    ## 例: 0.05 なら 5% の確率で回避。
    set(value):
        base_evasion_rate = clamp(value, 0.0, 1.0)

@export var evasion_per_agility: float = 0.01:
    ## Agility 1ポイントあたりの追加回避率。
    ## 例: 0.01 なら Agility 10 で +10% 回避。
    set(value):
        evasion_per_agility = max(value, 0.0)

@export var max_total_evasion: float = 0.8:
    ## 回避率の上限(0.0〜1.0)。
    ## どれだけ Agility が高くても、この値を超えて回避しないようにする。
    set(value):
        max_total_evasion = clamp(value, 0.0, 1.0)

@export var use_random_seed: bool = false:
    ## デバッグ用:true にすると _ready() で乱数シードを固定。
    ## リプレイ再現やテストで結果を安定させたい場合に使います。
    @export_range(-1, 999999, 1)
    var debug_seed: int = 12345

@export_category("Agility Source")

@export var agility: int = 0:
    ## このコンポーネント自身が持つ Agility 値。
    ## 外部から毎フレーム更新する or 別のステータスコンポーネントから同期して使う想定。
    set(value):
        agility = max(value, 0)

@export var auto_fetch_agility_from_owner: bool = false:
    ## true の場合、owner に定義された "agility" プロパティを自動取得して使う。
    ## 例: owner.agility が 20 なら、それを回避計算に使う。
    ##
    ## 使い方:
    ## - Player.gd や Enemy.gd に "var agility: int" を定義しておく
    ## - このフラグを ON にすると、should_evade() 実行時に owner.agility を読む
    ##
    ## 注意:
    ## - owner に agility プロパティが無い場合は、ローカルの agility を使用します。

## --- シグナル ---

signal evasion_success(context)
## 回避が成功したときに発火。
## context: 任意の情報(攻撃者、攻撃ID、ダメージ値など)を呼び出し側から渡せます。

signal evasion_failed(context)
## 回避に失敗(=攻撃が命中)したときに発火。
## context: 任意の情報(攻撃者、攻撃ID、ダメージ値など)を呼び出し側から渡せます。

## --- ライフサイクル ---

func _ready() -> void:
    if use_random_seed:
        # デバッグ用に乱数シードを固定
        randomize()
        seed(debug_seed)


## --- パブリックAPI ---

func get_current_agility() -> int:
    ## 現在の Agility を取得する。
    ## auto_fetch_agility_from_owner が true なら owner.agility を優先。
    if auto_fetch_agility_from_owner and owner != null:
        if "agility" in owner:
            var value = owner.agility
            if typeof(value) in [TYPE_INT, TYPE_FLOAT]:
                return int(value)
    return agility


func get_evasion_rate() -> float:
    ## 現在の Agility に基づいて、最終的な回避率(0.0〜1.0)を返す。
    var current_agility := get_current_agility()
    var rate := base_evasion_rate + evasion_per_agility * float(current_agility)
    rate = clamp(rate, 0.0, max_total_evasion)
    return rate


func should_evade(context := null) -> bool:
    ## 回避判定を行い、「回避成功なら true」「命中なら false」を返す。
    ## あわせて、対応するシグナルも発火します。
    ##
    ## context: 任意の追加情報(攻撃者、攻撃ID、予定ダメージなど)を渡せます。
    ##          シグナル受信側で参照できるので、ログ出力やエフェクト制御に便利です。

    var rate := get_evasion_rate()
    var roll := randf()

    var is_evaded := roll < rate

    if is_evaded:
        emit_signal("evasion_success", context)
    else:
        emit_signal("evasion_failed", context)

    return is_evaded


## --- デバッグ補助 ---

func debug_print_evasion() -> void:
    ## 現在の Agility と回避率を print するデバッグ用ヘルパー。
    var current_agility := get_current_agility()
    var rate := get_evasion_rate()
    print("[EvasionCalculator] agility=%d, evasion_rate=%.2f%%" % [current_agility, rate * 100.0])

使い方の手順

ここでは 2D アクションゲームを想定して、プレイヤーと敵に EvasionCalculator を付ける例を見ていきます。

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

  1. 上記の EvasionCalculator.gd をそのままファイルとして保存します。
    例: res://components/EvasionCalculator.gd
  2. Godot エディタを再読み込みすると、ノード追加時のスクリプト一覧EvasionCalculator が表示されるはずです。

手順②:プレイヤーにアタッチする

まずはプレイヤーのシーン構成例です。

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── DamageReceiver (Node)        ※ダメージ処理コンポーネント(仮)
 └── EvasionCalculator (Node)     ※今回のコンポーネント

Player 本体のスクリプト例:


extends CharacterBody2D

@export var agility: int = 15  # プレイヤーの敏捷ステータス

@onready var evasion: EvasionCalculator = $EvasionCalculator
@onready var damage_receiver = $DamageReceiver

func _ready() -> void:
    # owner.agility を使って欲しいので、コンポーネント側のフラグをONにする
    evasion.auto_fetch_agility_from_owner = true

    # 回避成功/失敗のシグナルを受信して、ログやエフェクトを出す
    evasion.evasion_success.connect(_on_evasion_success)
    evasion.evasion_failed.connect(_on_evasion_failed)


func receive_attack(attack_info: Dictionary) -> void:
    ## どこか別のノード(敵など)から呼ばれる想定の関数。
    ## attack_info には "damage" や "attacker" などを詰めておく。
    var is_evaded := evasion.should_evade(attack_info)
    if is_evaded:
        # 回避成功なら、ダメージ処理は行わずに終了
        return

    # 命中したので、実際のダメージ処理コンポーネントに委譲
    damage_receiver.apply_damage(attack_info)


func _on_evasion_success(context: Dictionary) -> void:
    print("[Player] Attack evaded! context=", context)
    # ここで回避エフェクト再生など
    # play_dodge_animation()


func _on_evasion_failed(context: Dictionary) -> void:
    print("[Player] Hit! context=", context)
    # ここで被弾エフェクト再生など
    # play_hit_animation()

ポイント:

  • Player は「回避の仕組み」をほとんど知らず、evasion.should_evade() の結果だけを見ています。
  • Agility の値は Player 側に持たせていて、EvasionCalculatorowner.agility を読むだけです。
  • ダメージ処理(HP 減少など)は DamageReceiver という別コンポーネントに分離しています。

手順③:敵にもそのまま再利用する

敵のシーン構成例:

EnemyGoblin (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── DamageReceiver (Node)
 └── EvasionCalculator (Node)

EnemyGoblin.gd の例:


extends CharacterBody2D

@export var agility: int = 5  # ゴブリンはちょっとだけ素早い

@onready var evasion: EvasionCalculator = $EvasionCalculator
@onready var damage_receiver = $DamageReceiver

func _ready() -> void:
    evasion.auto_fetch_agility_from_owner = true
    evasion.evasion_success.connect(_on_evasion_success)
    evasion.evasion_failed.connect(_on_evasion_failed)


func receive_attack(attack_info: Dictionary) -> void:
    var is_evaded := evasion.should_evade(attack_info)
    if is_evaded:
        return
    damage_receiver.apply_damage(attack_info)


func _on_evasion_success(context: Dictionary) -> void:
    print("[EnemyGoblin] dodged! context=", context)
    # 回避時のSEやアニメーション


func _on_evasion_failed(context: Dictionary) -> void:
    print("[EnemyGoblin] got hit! context=", context)
    # 被弾時のSEやアニメーション

プレイヤーとほぼ同じコードで、Agility の値だけ変えれば OK ですね。
「回避ロジックをどこに書くか」で悩む必要はなく、とりあえず EvasionCalculator をアタッチしておけばよい状態になります。

手順④:動く床やギミックにも「回避」を生やす

例えば「攻撃を受けると確率で壊れる床」を作りたい場合も、同じコンポーネントを使い回せます。

BreakableFloor (StaticBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── DamageReceiver (Node)
 └── EvasionCalculator (Node)

extends StaticBody2D

@export var agility: int = 0  # 床なので基本は 0。特別な床だけ上げてもOK

@onready var evasion: EvasionCalculator = $EvasionCalculator
@onready var damage_receiver = $DamageReceiver

func _ready() -> void:
    evasion.auto_fetch_agility_from_owner = true
    evasion.evasion_success.connect(_on_evasion_success)
    evasion.evasion_failed.connect(_on_evasion_failed)


func receive_attack(attack_info: Dictionary) -> void:
    if evasion.should_evade(attack_info):
        return
    damage_receiver.apply_damage(attack_info)


func _on_evasion_success(context: Dictionary) -> void:
    # 「攻撃を受けたけど壊れなかった」みたいな演出
    print("[BreakableFloor] resisted the attack.")


func _on_evasion_failed(context: Dictionary) -> void:
    print("[BreakableFloor] destroyed!")
    queue_free()

ノードの種類(CharacterBody2D / StaticBody2D など)と関係なく、EvasionCalculator をポンと付けるだけで回避判定を共有できるのが、コンポーネント指向の気持ちよさですね。


メリットと応用

EvasionCalculator をコンポーネントとして切り出すことで、次のようなメリットがあります。

  • シーン構造がシンプル
    プレイヤーや敵のメインスクリプトから、回避ロジックを追い出せるので、「移動」「入力」「AI」「ダメージ」「回避」がそれぞれ別コンポーネントに分かれ、見通しが良くなります。
  • 継承ツリーに縛られない
    プレイヤーも敵もギミックも、ベースクラスがバラバラでも同じ EvasionCalculator を使い回せます。「共通の親クラスを作るか…」と悩む必要がなくなります。
  • バランス調整が楽
    base_evasion_rateevasion_per_agility をインスペクタから調整できるので、「この敵だけ回避高め」「このボスはほぼ当たる」などを、コードを書き換えずに調整できます。
  • テストがしやすい
    debug_print_evasion()use_random_seed を使えば、回避率の確認やリプレイ再現がしやすくなります。ユニットテスト的に should_evade() を叩くことも簡単です。

さらに、別コンポーネントとの組み合わせで応用範囲が広がります。

  • 「命中率(Accuracy)」コンポーネントと組み合わせて、「命中側 vs 回避側」の対決にする
  • 「状態異常」コンポーネントから max_total_evasion を一時的に下げて、鈍足・スタンなどの影響を表現
  • 「装備」コンポーネントから agility を加算して、回避ビルドを作る

すべてを一つの巨大なスクリプトに押し込まず、小さな責務ごとのコンポーネントを合成してキャラクターを組み立てると、プロジェクトが大きくなっても破綻しにくくなります。

改造案:攻撃ごとに回避補正をかける

例えば「重い攻撃は避けづらい」「必中攻撃は回避できない」といった仕様を入れたい場合は、contextevasion_modifier を渡して、最終回避率に掛けるように改造できます。


func should_evade(context := null) -> bool:
    var rate := get_evasion_rate()

    # attack_info などから回避補正を読み取る(無ければ 1.0)
    var modifier := 1.0
    if typeof(context) == TYPE_DICTIONARY and context.has("evasion_modifier"):
        modifier = float(context.evasion_modifier)

    rate = clamp(rate * modifier, 0.0, 1.0)

    var roll := randf()
    var is_evaded := roll < rate

    if is_evaded:
        emit_signal("evasion_success", context)
    else:
        emit_signal("evasion_failed", context)

    return is_evaded

これで、攻撃側は


var attack_info = {
    "damage": 30,
    "evasion_modifier": 0.5  # この攻撃は回避しづらい(回避率半減)
}
target.receive_attack(attack_info)

のように補正をかけられます。
「回避判定」という一つの責務にフォーカスしたコンポーネントにしておくと、こうした改造も局所的に済むので、全体の見通しを保ったままゲーム性を盛っていけますね。