ability-system について
サードパーティ製アドオンを使わず、Godot 標準の仕組みでデータ駆動型のアビリティシステムを構築するスキルです。エディターでの設定を重視し、データ、実行処理、シグナル通信を分離したいゲーム開発者向けです。
主な機能
- 01Resource によるアビリティ定義
- 02AbilityComponent による発動とクールダウン管理
- 03時間制限付きバフとデバフ
- 04ステータス修飾子とゲームプレイタグ
- 05アビリティと効果のイベントシグナル
活用シーン
- 01Godot エディターで設定可能なアビリティの作成
- 02戦闘中のコストとクールダウン管理
- 03時間制限付きバフとデバフの適用
- 04ゲームプレイタグによる発動制御
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ jame581/ godotprompter --skill ability-systemインストール後、Claude Code で/ability-systemを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/stat-modifiers.mdtags-and-conditions.mdui-binding.md詳細
アビリティシステム
Godot 標準の要素を使って、データ駆動型のアビリティシステムを構築します。アビリティはResourceであり、AbilityComponentノードがアビリティを所有して実行し、その上にエフェクト/ステータス/タグを組み合わせます。サードパーティ製のアドオンは不要です。
関連スキル:
Resourceデータコンテナについてはresource-pattern、コンポーネントノードのパターンについてはcomponent-system、システム間のアビリティイベントについてはevent-bus、発動者の状態(例:詠唱中/スタン中)についてはstate-machine、クールダウン UI についてはhud-systemを参照してください。
1. アーキテクチャの概要
Godot 4.x のアビリティシステムは、連携する次の 3 つのレイヤーで構成されます。
-
データレイヤー —
Ability(Resource): 各アビリティは、エクスポートされたフィールド(ability_name、cost、cooldown、cast_time)と 2 つのメソッドを持つResourceサブクラスです。can_activate(caster) -> boolは前提条件を検証し、activate(caster) -> voidはエフェクトを実行します。アビリティを Resource として保存することで、デザイナーはコードに触れることなく、Godot エディター上で作成やバランス調整ができます。 -
動作レイヤー —
AbilityComponent(Node): アビリティを使用するエンティティに追加する単一のノードです。付与されたアビリティセットを保持し、コスト/クールダウンを管理して、Ability.activate()の呼び出しを実行します。疎結合のままゲームの他の部分に情報を伝える 4 つのシグナルがあります。ability_activated(ability)、ability_failed(ability, reason)、cooldown_started(ability, duration)、cooldown_finished(ability)です。実行時にgrant(ability)で新しいアビリティを付与し、try_activate(ability_name)で発動します。 -
エフェクトレイヤー — ステータス修飾子、バフ/デバフ、ゲームプレイタグ: アビリティは、発動者の
StatSet(名前付きのStatModifierエントリを辞書で管理する Resource)から値を読み取り、値を書き込んで、一時的または恒久的なステータス変化を適用できます。発動者の子ノードであるGameplayTagContainerが発動を制限します。たとえば、「stunned」タグによって、あらゆるアビリティの発動を防止できます。これら 3 つの詳細については、以下のリファレンスドキュメントを参照してください。
基本ルール: データは Resource に、動作はコンポーネントに、通信はシグナルで。
この分離により、Abilityリソースは Node 参照を持たないため、他の Godot Resource と同様に安全に複製、保存、読み込みができます。AbilityComponentが実行時の状態(クールダウンタイマー、アクティブなアビリティセット)を所有することで、Abilityリソースは状態を持たず、複数の発動者間で同時に再利用できます。
2. アビリティ(コスト/クールダウン/詠唱)
GDScript
# ability.gd
class_name Ability
extends Resource
@export var ability_name: String
@export var cost: float = 0.0
@export var cooldown: float = 1.0
@export var cast_time: float = 0.0
# Override in subclasses or compose via exported effect resources.
func can_activate(caster: Node) -> bool:
return true
func activate(caster: Node) -> void:
pass
# ability_component.gd
class_name AbilityComponent
extends Node
signal ability_activated(ability: Ability)
signal ability_failed(ability: Ability, reason: String)
signal cooldown_started(ability: Ability, duration: float)
signal cooldown_finished(ability: Ability)
@export var resource_pool: float = 100.0
var _granted: Dictionary = {} # ability_name -> Ability
var _cooldowns: Dictionary = {} # ability_name -> seconds remaining
func grant(ability: Ability) -> void:
_granted[ability.ability_name] = ability
func _process(delta: float) -> void:
for name in _cooldowns.keys():
_cooldowns[name] -= delta
if _cooldowns[name] <= 0.0:
_cooldowns.erase(name)
if _granted.has(name):
cooldown_finished.emit(_granted[name])
func try_activate(ability_name: String) -> bool:
var ability: Ability = _granted.get(ability_name)
if ability == null:
return false
if _cooldowns.has(ability_name):
ability_failed.emit(ability, "on_cooldown")
return false
if resource_pool < ability.cost:
ability_failed.emit(ability, "insufficient_resource")
return false
if not ability.can_activate(get_parent()):
ability_failed.emit(ability, "conditions_unmet")
return false
resource_pool -= ability.cost
ability.activate(get_parent())
_cooldowns[ability_name] = ability.cooldown
cooldown_started.emit(ability, ability.cooldown)
ability_activated.emit(ability)
return true
C#
// Ability.cs
using Godot;
[GlobalClass]
public partial class Ability : Resource
{
[Export] public string AbilityName { get; set; } = "";
[Export] public float Cost { get; set; } = 0.0f;
[Export] public float Cooldown { get; set; } = 1.0f;
[Export] public float CastTime { get; set; } = 0.0f;
public virtual bool CanActivate(Node caster) => true;
public virtual void Activate(Node caster) { }
}
// AbilityComponent.cs
using Godot;
using System.Collections.Generic;
public partial class AbilityComponent : Node
{
[Signal] public delegate void AbilityActivatedEventHandler(Ability ability);
[Signal] public delegate void AbilityFailedEventHandler(Ability ability, string reason);
[Signal] public delegate void CooldownStartedEventHandler(Ability ability, float duration);
[Signal] public delegate void CooldownFinishedEventHandler(Ability ability);
[Export] public float ResourcePool { get; set; } = 100.0f;
private readonly Dictionary<string, Ability> _granted = new();
private readonly Dictionary<string, float> _cooldowns = new();
public void Grant(Ability ability) => _granted[ability.AbilityName] = ability;
public override void _Process(double delta)
{
foreach (var name in new List<string>(_cooldowns.Keys))
{
_cooldowns[name] -= (float)delta;
if (_cooldowns[name] <= 0.0f)
{
_cooldowns.Remove(name);
if (_granted.TryGetValue(name, out var finished))
EmitSignal(SignalName.CooldownFinished, finished);
}
}
}
public bool TryActivate(string abilityName)
{
if (!_granted.TryGetValue(abilityName, out var ability)) return false;
if (_cooldowns.ContainsKey(abilityName))
{
EmitSignal(SignalName.AbilityFailed, ability, "on_cooldown");
return false;
}
if (ResourcePool < ability.Cost)
{
EmitSignal(SignalName.AbilityFailed, ability, "insufficient_resource");
return false;
}
if (!ability.CanActivate(GetParent()))
{
EmitSignal(SignalName.AbilityFailed, ability, "conditions_unmet");
return false;
}
ResourcePool -= ability.Cost;
ability.Activate(GetParent());
_cooldowns[abilityName] = ability.Cooldown;
EmitSignal(SignalName.CooldownStarted, ability, ability.Cooldown);
EmitSignal(SignalName.AbilityActivated, ability);
return true;
}
}
3. バフとデバフ(時間制限付きエフェクト)
エフェクトはResourceサブクラスです。デザイナーがエディター上で作成し、アビリティが適用します。EffectHolderノードが実行時の処理を管理し、経過時間を追跡して、定期的なティックを呼び出し、期限切れのエフェクトを削除します。
GDScript
# effect.gd
class_name Effect
extends Resource
@export var effect_name: String
@export var duration: float = 5.0 # seconds; <= 0 means instant
@export var tick_interval: float = 0.0 # 0 = no periodic tick
func on_apply(target: Node) -> void: pass
func on_tick(target: Node) -> void: pass
func on_expire(target: Node) -> void: pass
# effect_holder.gd
class_name EffectHolder
extends Node
signal effect_applied(effect: Effect)
signal effect_expired(effect: Effect)
# effect -> [elapsed, tick_accum]
var _active: Dictionary = {}
func apply_effect(effect: Effect) -> void:
effect.on_apply(get_parent())
effect_applied.emit(effect)
if effect.duration <= 0.0:
effect.on_expire(get_parent())
effect_expired.emit(effect)
return
_active[effect] = [0.0, 0.0]
func _process(delta: float) -> void:
var to_remove: Array = []
for effect in _active:
_active[effect][0] += delta
if effect.tick_interval > 0.0:
_active[effect][1] += delta
if _active[effect][1] >= effect.tick_interval:
_active[effect][1] -= effect.tick_interval
effect.on_tick(get_parent())
if effect.duration > 0.0 and _active[effect][0] >= effect.duration:
to_remove.append(effect)
for effect in to_remove:
_active.erase(effect)
effect.on_expire(get_parent())
effect_expired.emit(effect)
C#
// Effect.cs
using Godot;
[GlobalClass]
public partial class Effect : Resource
{
[Export] public string EffectName { get; set; } = "";
[Export] public float Duration { get; set; } = 5.0f; // <= 0 = instant
[Export] public float TickInterval { get; set; } = 0.0f; // 0 = no tick
public virtual void OnApply(Node target) { }
public virtual void OnTick(Node target) { }
public virtual void OnExpire(Node target) { }
}
// EffectHolder.cs
using Godot;
using System.Collections.Generic;
public partial class EffectHolder : Node
{
[Signal] public delegate void EffectAppliedEventHandler(Effect effect);
[Signal] public delegate void EffectExpiredEventHandler(Effect effect);
private readonly Dictionary<Effect, (float Elapsed, float TickAccum)> _active = new();
public void ApplyEffect(Effect effect)
{
effect.OnApply(GetParent());
EmitSignal(SignalName.EffectApplied, effect);
if (effect.Duration <= 0f)
{
effect.OnExpire(GetParent());
EmitSignal(SignalName.EffectExpired, effect);
return;
}
_active[effect] = (0f, 0f);
}
public override void _Process(double delta)
{
var toRemove = new List<Effect>();
foreach (var effect in new List<Effect>(_active.Keys))
{
var (elapsed, tickAccum) = _active[effect];
elapsed += (float)delta;
if (effect.TickInterval > 0f)
{
tickAccum += (float)delta;
if (tickAccum >= effect.TickInterval)
{
tickAccum -= effect.TickInterval;
effect.OnTick(GetParent());
}
}
if (effect.Duration > 0f && elapsed >= effect.Duration)
toRemove.Add(effect);
else
_active[effect] = (elapsed, tickAccum); // still active — write back updated elapsed and tick accumulator
}
foreach (var effect in toRemove)
{
_active.Remove(effect);
effect.OnExpire(GetParent());
EmitSignal(SignalName.EffectExpired, effect);
}
}
}
落とし穴: エントリを削除しながら
_activeを直接反復処理しないでください。GDScript ではto_removeリストを作成し、ループ後に削除します。C# では同じ理由でnew List<Effect>(_active.Keys)を反復処理します。
実装チェックリスト
- アビリティは
Resourceであり、動作はデータではなくAbilityComponentに実装されています。 - リソースを消費する前に、コスト、クールダウン、
can_activate()を検証します。 - クールダウンは
_process/_Processで更新され、開始/終了シグナルを発行します。 - バフ/デバフは適用 → ティック → 期限切れの順に処理され、シグナルを発行します。持続時間はソースごとに更新またはスタックされます。
- ステータス修飾子は決定的な順序(ADD → MULTIPLY → OVERRIDE)で再計算され、クランプされます。
- アビリティの発動制限にはゲームプレイタグコンテナを使用し、無効化効果でエフェクトをブロックします。
- HUD はコンポーネントのシグナルにバインドし、クールダウン状態をフレームごとにポーリングしません。