Godot でバトル系のゲームを作り始めると、だいたい「炎に弱い敵」「氷を半減するボス」みたいな話が出てきますよね。
多くの人が最初にやりがちなのは、Enemy.gd や Player.gd の中に「炎耐性」「氷耐性」みたいな変数をベタ書きしてしまうパターンです。
- プレイヤーにも敵にも同じような耐性ロジックを書く
- ボス専用の耐性を作るときに、また別クラスを継承して増やす
- 「毒」「雷」「聖属性」など、属性の種類が増えるたびにクラスが肥大化する
こうなると、「耐性の仕様を変えたいだけなのに、全キャラのスクリプトを直す」 みたいな地獄が始まります。
Godot はノード継承が強力ですが、キャラごとにスクリプトを継承で分岐させていくと、後からの変更がつらくなりがちです。
そこで今回は、「属性耐性だけを独立したコンポーネントにする」アプローチを紹介します。
炎でも氷でも雷でも毒でも、全部このコンポーネントに投げて倍率を返してもらうようにして、キャラ側は「計算済みのダメージを受け取るだけ」にしてしまいましょう。
【Godot 4】属性計算は丸投げしよう!「ElementAffinity」コンポーネント
ここでは、ElementAffinity というコンポーネントを作ります。
- 「炎」「氷」などの属性ごとに倍率を設定できる
- ダメージ計算時に「元のダメージ」と「属性名」を渡すと、補正済みダメージを返す
- 未知の属性を受けたときの挙動(等倍・無効など)も設定できる
プレイヤーでも敵でも、動くギミックでも、「属性ダメージを受ける可能性があるもの」にペタっと貼れば再利用できます。
フルコード:ElementAffinity.gd
extends Node
class_name ElementAffinity
## ElementAffinity
## 炎・氷などの属性ごとのダメージ倍率を管理するコンポーネント。
## ダメージ計算ロジックをキャラクター本体から切り離して再利用可能にします。
## 属性倍率の定義用データ構造
## 「属性ID」「表示名」「倍率」をセットで管理します。
class ElementAffinityEntry:
var id: StringName
var display_name: String
var multiplier: float
func _init(_id: StringName = "none", _display_name: String = "None", _multiplier: float = 1.0) -> void:
id = _id
display_name = _display_name
multiplier = _multiplier
## -------------------------
## エディタで設定するパラメータ
## -------------------------
## 属性倍率の一覧。
## 例:
## id: "fire", display_name: "炎", multiplier: 1.5 (炎に弱い)
## id: "ice", display_name: "氷", multiplier: 0.5 (氷を半減)
@export var affinities: Array[ElementAffinityEntry] = []
## 未定義の属性を受けたときのデフォルト倍率。
## 1.0: 等倍 / 0.0: 完全無効 / 2.0: 全部弱点、などゲームに合わせて調整。
@export var default_multiplier: float = 1.0
## ダメージ計算の最終結果を丸めるかどうか。
## RPG 的に「整数ダメージ」にしたいときに使います。
@export var round_result_to_int: bool = true
## 最小ダメージを保証するかどうか。
## 0 以下になるのを防ぎたいときに利用します。
@export var clamp_min_damage: bool = true
## 最小ダメージ値 (clamp_min_damage が true のときだけ有効)。
@export var min_damage: float = 1.0
## デバッグログを出すかどうか。
## テスト中は true、本番ビルドでは false にしてもいいですね。
@export var debug_log: bool = false
## 内部キャッシュ: id -> multiplier
var _multiplier_table: Dictionary = {}
func _ready() -> void:
_build_multiplier_table()
## エディタ上で配列を編集したときにテーブルを再構築したいときは、
## 必要に応じてこの関数を外部から呼び出してください。
func _build_multiplier_table() -> void:
_multiplier_table.clear()
for entry in affinities:
if entry == null:
continue
if entry.id == StringName():
# 空IDはスキップ
continue
_multiplier_table[entry.id] = entry.multiplier
if debug_log:
print("[ElementAffinity] multiplier table built: ", _multiplier_table)
## 指定した属性IDに対する倍率を返します。
## 未定義の属性の場合は default_multiplier を返します。
func get_multiplier(element_id: StringName) -> float:
if element_id in _multiplier_table:
return _multiplier_table[element_id]
return default_multiplier
## 元ダメージと属性IDから、補正済みのダメージを計算して返します。
##
## 使用例:
## var final_damage := element_affinity.apply_affinity(100, "fire")
##
## @param base_damage 元のダメージ値 (物理計算や攻撃力などから算出した値)
## @param element_id 属性ID ("fire" / "ice" / "thunder" など任意)
## @return 補正済みダメージ値
func apply_affinity(base_damage: float, element_id: StringName) -> float:
var multiplier := get_multiplier(element_id)
var result := base_damage * multiplier
if clamp_min_damage:
if result < min_damage:
result = min_damage
if round_result_to_int:
# 四捨五入して整数に
result = round(result)
if debug_log:
print("[ElementAffinity] element=", element_id, " base=", base_damage,
" mult=", multiplier, " result=", result)
return result
## 属性倍率を動的に変更したいときに使えるヘルパー。
## すでに存在する属性なら上書き、なければ追加します。
func set_affinity(element_id: StringName, multiplier: float, display_name: String = "") -> void:
var found := false
for entry in affinities:
if entry != null and entry.id == element_id:
entry.multiplier = multiplier
if display_name != "":
entry.display_name = display_name
found = true
break
if not found:
var new_entry := ElementAffinityEntry.new(element_id, display_name if display_name != "" else str(element_id), multiplier)
affinities.append(new_entry)
# テーブルを再構築
_build_multiplier_table()
if debug_log:
print("[ElementAffinity] set_affinity: ", element_id, "=", multiplier)
## 指定した属性の倍率を取得 (存在しない場合は null を返す)。
func try_get_affinity(element_id: StringName) -> float:
if element_id in _multiplier_table:
return _multiplier_table[element_id]
return null
使い方の手順
ここでは 2D アクションを例にして、「プレイヤーが炎のトラップからダメージを受ける」ケースで使い方を説明します。
① コンポーネントをプロジェクトに追加
- 上記のコードを
res://components/ElementAffinity.gdなど好きな場所に保存します。 - Godot エディタで再読み込みすると、ノード追加ダイアログの「スクリプト」カテゴリに
ElementAffinityが表示されます。
② プレイヤーに ElementAffinity をアタッチ
プレイヤーシーンの構成例:
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── ElementAffinity (Node)
設定例(プレイヤーの属性耐性)
affinitiesに以下のような要素を追加:- Entry 1
id:"fire"
display_name:"炎"
multiplier:1.5(炎に弱い: 1.5倍ダメージ) - Entry 2
id:"ice"
display_name:"氷"
multiplier:0.5(氷に強い: 半減)
- Entry 1
default_multiplier:1.0(未定義属性は等倍)round_result_to_int:true(ダメージは整数)clamp_min_damage:true/min_damage:1.0debug_log: 必要に応じてtrue(テスト中は便利)
③ ダメージを受ける側で「属性付きダメージ」を計算する
プレイヤーのスクリプト例 (Player.gd) です。
ポイントは、ダメージ計算を ElementAffinity に丸投げすることです。
extends CharacterBody2D
@onready var element_affinity: ElementAffinity = $ElementAffinity
var hp: int = 100
func apply_damage(base_damage: float, element_id: StringName) -> void:
# 属性倍率を適用して最終ダメージを計算
var final_damage := base_damage
if element_affinity:
final_damage = element_affinity.apply_affinity(base_damage, element_id)
hp -= int(final_damage)
print("[Player] took ", final_damage, " damage from element=", element_id, " HP=", hp)
if hp <= 0:
die()
func die() -> void:
print("[Player] Dead")
# ゲーム用の死亡処理をここに書く
queue_free()
このようにしておけば、プレイヤー側は「炎か氷か」を意識せず、来たダメージに対してコンポーネントを通すだけで済みます。
④ 攻撃側(例: 炎トラップ)から属性ダメージを送る
次に、炎トラップのシーン例です。
FireTrap (Area2D) ├── CollisionShape2D └── AnimatedSprite2D
FireTrap.gd 例:
extends Area2D
@export var base_damage: float = 20.0
@export var element_id: StringName = "fire"
func _ready() -> void:
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node) -> void:
# ダメージを受ける側が apply_damage(base_damage, element_id) を
# 実装している前提で呼び出します。
if body.has_method("apply_damage"):
body.apply_damage(base_damage, element_id)
これで、「炎トラップに触れたら element_id = "fire" のダメージを受ける」という構造になりました。
プレイヤーの ElementAffinity が炎に弱ければ 1.5 倍、炎に耐性があれば 0.5 倍…と、自動で計算されます。
メリットと応用
1. 継承地獄からの脱出
「炎に弱い敵」「氷に弱い敵」「炎と氷の両方に弱いボス」…などを、継承で細かく分けていくとクラス階層がすぐにカオスになります。ElementAffinity コンポーネントなら、どのキャラでも同じロジックを再利用できるので、
- プレイヤー
- 雑魚敵
- ボス
- 動く床(炎の床など)
すべて同じ「属性耐性の仕組み」で管理できます。
2. シーン構造がシンプルになる
「HP 管理コンポーネント」「属性耐性コンポーネント」「ステートマシンコンポーネント」…と分けていけば、
各シーンは「機能ごとの小さいノード」がぶら下がるだけになり、巨大なスクリプト 1 本に全部詰め込む必要がなくなります。
3. レベルデザインが楽になる
敵ごとの耐性調整も、インスペクタで倍率をいじるだけです。
「このステージの敵は炎に強くしよう」みたいな調整も、コード修正なしで行えます。
4. 属性追加が怖くない
新しい属性(雷・毒・闇・聖など)を追加したいときも、ElementAffinity 側にエントリを足すだけで済みます。
キャラ本体のスクリプトを増やさなくていいのは、長期開発ではかなり効いてきますね。
改造案:属性ごとに「説明テキスト」を返す関数
最後に、UI 用に「このキャラの属性耐性を表示したい」ときの簡単な改造案です。
既存の ElementAffinity に、次の関数を追加してみてください。
## 現在設定されている属性耐性を、人間向けテキストにして返す。
## 例: "炎: 1.5x / 氷: 0.5x / 雷: 1.0x(デフォルト)"
func get_affinity_summary() -> String:
var parts: Array[String] = []
for entry in affinities:
if entry == null:
continue
var label := entry.display_name if entry.display_name != "" else str(entry.id)
parts.append("%s: %.2fx" % [label, entry.multiplier])
# デフォルト倍率も添えておく
parts.append("その他: %.2fx" % default_multiplier)
return " / ".join(parts)
これを使えば、例えばステータス画面で:
label.text = element_affinity.get_affinity_summary()
とするだけで、「このキャラは炎に弱くて氷に強い」みたいな情報を簡単に表示できます。
こんな感じで、「継承で増やす」のではなく「コンポーネントを足す」発想に切り替えると、Godot の開発体験がかなり快適になります。
ぜひ ElementAffinity をベースに、自分のゲームの属性システムを育ててみてください。
