Godot でバトル系のゲームを作り始めると、だいたい「炎に弱い敵」「氷を半減するボス」みたいな話が出てきますよね。
多くの人が最初にやりがちなのは、Enemy.gdPlayer.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 アクションを例にして、「プレイヤーが炎のトラップからダメージを受ける」ケースで使い方を説明します。

① コンポーネントをプロジェクトに追加

  1. 上記のコードを res://components/ElementAffinity.gd など好きな場所に保存します。
  2. 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(氷に強い: 半減)
  • default_multiplier: 1.0(未定義属性は等倍)
  • round_result_to_int: true(ダメージは整数)
  • clamp_min_damage: true / min_damage: 1.0
  • debug_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 をベースに、自分のゲームの属性システムを育ててみてください。