mcpField
A
ability-system

ability-system

github.com/jame581開発889145 回インストール4 日前に更新
リポジトリ

ability-system について

サードパーティ製アドオンを使わず、Godot 標準の仕組みでデータ駆動型のアビリティシステムを構築するスキルです。エディターでの設定を重視し、データ、実行処理、シグナル通信を分離したいゲーム開発者向けです。

主な機能

  1. 01Resource によるアビリティ定義
  2. 02AbilityComponent による発動とクールダウン管理
  3. 03時間制限付きバフとデバフ
  4. 04ステータス修飾子とゲームプレイタグ
  5. 05アビリティと効果のイベントシグナル

活用シーン

  1. 01Godot エディターで設定可能なアビリティの作成
  2. 02戦闘中のコストとクールダウン管理
  3. 03時間制限付きバフとデバフの適用
  4. 04ゲームプレイタグによる発動制御

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/jame581/godotprompter --skill ability-system

インストール後、Claude Code で/ability-systemを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

references/
stat-modifiers.md
tags-and-conditions.md
ui-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 はコンポーネントのシグナルにバインドし、クールダウン状態をフレームごとにポーリングしません。

コメント