본문으로 건너뛰기
cccskills
무료GitHub에 공개

tools-unity-flowcanvas

FlowCanvas visual scripting patterns for abilities, custom nodes, and graph execution.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

설치 방법 보기

포함된 파일(1)

  • SKILL.md16.7 KB

SKILL.md(원문)

설치하기 전에 에이전트에게 전달되는 지시 내용을 확인할 수 있습니다.

FlowCanvas (ParadoxNotion)

Overview

FlowCanvas is a visual scripting solution used in Ungodly for ability graphs. This skill covers patterns for creating custom nodes, handling flow execution, and integrating with the ability system.

When to Use

  • Ability visual scripting
  • Designer-friendly logic
  • Complex ability sequences
  • Event-driven ability flow
  • Reusable ability patterns

Core Concepts

FlowNode Structure

using FlowCanvas;
using ParadoxNotion.Design;

[Category("Abilities")]
[Description("Base ability flow node")]
public abstract class FN_AbilityNode : FlowNode
{
    protected AbilityFlowScriptController Controller => 
        (AbilityFlowScriptController)graph.agent;
    
    protected AbilitySystemComponent AbilitySystem => 
        Controller?.AbilitySystemComponent;
    
    protected GameplayAbilitySpec AbilitySpec => 
        Controller?.CurrentAbilitySpec;
}

Custom Flow Nodes

Action Node

[Category("Abilities/Actions")]
[Name("Apply Damage")]
[Description("Applies damage to targets")]
public class FN_ApplyDamage : FN_AbilityNode
{
    [Input] public FlowInput enter;
    [Output] public FlowOutput exit;
    
    [Input] public ValueInput<List<GameObject>> targets;
    [Input] public ValueInput<float> baseDamage;
    [Input] public ValueInput<DamageType> damageType;
    
    [Output] public ValueOutput<int> hitCount;
    
    private int _hitCount;
    
    protected override void RegisterPorts()
    {
        enter = Input("Enter", Flow);
        exit = Output("Exit");
        
        targets = Input<List<GameObject>>("Targets");
        baseDamage = Input<float>("Base Damage", 10f);
        damageType = Input<DamageType>("Damage Type", DamageType.Physical);
        
        hitCount = Output<int>("Hit Count", () => _hitCount);
    }
    
    private void Flow(Flow f)
    {
        _hitCount = 0;
        
        var targetList = targets.value;
        if (targetList == null || targetList.Count == 0)
        {
            f.Call(exit);
            return;
        }
        
        foreach (var target in targetList)
        {
            if (target == null) continue;
            
            var damageReceiver = target.GetComponent<IDamageReceiver>();
            if (damageReceiver != null)
            {
                var damageInfo = new DamageInfo
                {
                    Damage = baseDamage.value,
                    DamageType = damageType.value,
                    Source = Controller.gameObject
                };
                
                damageReceiver.ApplyDamage(damageInfo);
                _hitCount++;
            }
        }
        
        f.Call(exit);
    }
}

Wait Node (Async)

[Category("Abilities/Flow")]
[Name("Wait For Duration")]
[Description("Waits for specified duration")]
public class FN_WaitDuration : FN_AbilityNode
{
    [Input] public FlowInput enter;
    [Output] public FlowOutput exit;
    
    [Input] public ValueInput<float> duration;
    
    private Coroutine _waitCoroutine;
    
    protected override void RegisterPorts()
    {
        enter = Input("Enter", Flow);
        exit = Output("Exit");
        
        duration = Input<float>("Duration", 1f);
    }
    
    private void Flow(Flow f)
    {
        if (_waitCoroutine != null)
        {
            Controller.StopCoroutine(_waitCoroutine);
        }
        
        _waitCoroutine = Controller.StartCoroutine(WaitRoutine(f));
    }
    
    private IEnumerator WaitRoutine(Flow f)
    {
        yield return new WaitForSeconds(duration.value);
        _waitCoroutine = null;
        f.Call(exit);
    }
    
    public override void OnGraphStoped()
    {
        if (_waitCoroutine != null)
        {
            Controller.StopCoroutine(_waitCoroutine);
            _waitCoroutine = null;
        }
    }
}

UniTask Wait Node

[Category("Abilities/Flow")]
[Name("Async Wait")]
[Description("Async wait with cancellation support")]
public class FN_AsyncWait : FN_AbilityNode
{
    [Input] public FlowInput enter;
    [Output] public FlowOutput exit;
    [Output] public FlowOutput cancelled;
    
    [Input] public ValueInput<float> duration;
    
    private CancellationTokenSource _cts;
    
    protected override void RegisterPorts()
    {
        enter = Input("Enter", Flow);
        exit = Output("Exit");
        cancelled = Output("Cancelled");
        
        duration = Input<float>("Duration", 1f);
    }
    
    private async void Flow(Flow f)
    {
        _cts?.Cancel();
        _cts = new CancellationTokenSource();
        
        try
        {
            await UniTask.Delay(
                TimeSpan.FromSeconds(duration.value),
                cancellationToken: _cts.Token
            );
            
            f.Call(exit);
        }
        catch (OperationCanceledException)
        {
            f.Call(cancelled);
        }
    }
    
    public override void OnGraphStoped()
    {
        _cts?.Cancel();
        _cts?.Dispose();
        _cts = null;
    }
}

Event Node

[Category("Abilities/Events")]
[Name("On Gameplay Event")]
[Description("Triggers on gameplay event")]
public class FN_OnGameplayEvent : FN_AbilityNode, IUpdatable
{
    [Output] public FlowOutput onEvent;
    [Output] public ValueOutput<GameplayEventData> eventData;
    
    [Input] public ValueInput<GameplayTag> eventTag;
    
    private GameplayEventData _lastEventData;
    private bool _eventReceived;
    
    protected override void RegisterPorts()
    {
        onEvent = Output("On Event");
        eventData = Output<GameplayEventData>("Event Data", () => _lastEventData);
        eventTag = Input<GameplayTag>("Event Tag");
    }
    
    public override void OnGraphStarted()
    {
        AbilitySystem.OnGameplayEvent += HandleGameplayEvent;
    }
    
    public override void OnGraphStoped()
    {
        AbilitySystem.OnGameplayEvent -= HandleGameplayEvent;
    }
    
    private void HandleGameplayEvent(GameplayEventData data)
    {
        if (data.EventTag.Matches(eventTag.value))
        {
            _lastEventData = data;
            _eventReceived = true;
        }
    }
    
    public void Update()
    {
        if (_eventReceived)
        {
            _eventReceived = false;
            onEvent.Call(new Flow());
        }
    }
}

Value Nodes

Getter Node

[Category("Abilities/Values")]
[Name("Get Attribute")]
[Description("Gets an attribute value")]
public class FN_GetAttribute : PureFunctionNode<float>
{
    [Input] public ValueInput<AttributeType> attribute;
    [Input] public ValueInput<GameObject> target;
    
    protected override void RegisterPorts()
    {
        attribute = Input<AttributeType>("Attribute");
        target = Input<GameObject>("Target");
    }
    
    public override float Invoke()
    {
        if (target.value == null) return 0f;
        
        var attrSet = target.value.GetComponent<AttributeSet>();
        if (attrSet == null) return 0f;
        
        return attrSet.GetAttributeValue(attribute.value);
    }
}

Math Node

[Category("Abilities/Math")]
[Name("Calculate Damage")]
[Description("Calculates final damage with modifiers")]
public class FN_CalculateDamage : PureFunctionNode<float>
{
    [Input] public ValueInput<float> baseDamage;
    [Input] public ValueInput<float> attackPower;
    [Input] public ValueInput<float> damageMultiplier;
    [Input] public ValueInput<float> critMultiplier;
    [Input] public ValueInput<bool> isCrit;
    
    protected override void RegisterPorts()
    {
        baseDamage = Input<float>("Base Damage", 10f);
        attackPower = Input<float>("Attack Power", 100f);
        damageMultiplier = Input<float>("Damage Mult", 1f);
        critMultiplier = Input<float>("Crit Mult", 1.5f);
        isCrit = Input<bool>("Is Crit", false);
    }
    
    public override float Invoke()
    {
        float damage = baseDamage.value * (1 + attackPower.value / 100f);
        damage *= damageMultiplier.value;
        
        if (isCrit.value)
        {
            damage *= critMultiplier.value;
        }
        
        return Mathf.Max(0, damage);
    }
}

Animation Integration

Play Animation Node

[Category("Abilities/Animation")]
[Name("Play Animation")]
[Description("Plays an Animancer animation")]
public class FN_PlayAnimation : FN_AbilityNode
{
    [Input] public FlowInput enter;
    [Output] public FlowOutput onComplete;
    [Output] public FlowOutput onInterrupt;
    
    [Input] public ValueInput<AnimationClip> clip;
    [Input] public ValueInput<float> fadeTime;
    [Input] public ValueInput<float> speed;
    [Input] public ValueInput<bool> waitForComplete;
    
    private AnimancerState _state;
    
    protected override void RegisterPorts()
    {
        enter = Input("Enter", Flow);
        onComplete = Output("Complete");
        onInterrupt = Output("Interrupted");
        
        clip = Input<AnimationClip>("Clip");
        fadeTime = Input<float>("Fade Time", 0.25f);
        speed = Input<float>("Speed", 1f);
        waitForComplete = Input<bool>("Wait", true);
    }
    
    private void Flow(Flow f)
    {
        var clipValue = clip.value;
        if (clipValue == null)
        {
            f.Call(onInterrupt);
            return;
        }
        
        var animancer = Controller.GetComponent<AnimancerComponent>();
        if (animancer == null)
        {
            f.Call(onInterrupt);
            return;
        }
        
        _state = animancer.Play(clipValue, fadeTime.value);
        _state.Speed = speed.value;
        
        if (waitForComplete.value)
        {
            _state.Events.OnEnd = () =>
            {
                _state = null;
                f.Call(onComplete);
            };
        }
        else
        {
            f.Call(onComplete);
        }
    }
    
    public override void OnGraphStoped()
    {
        if (_state != null && _state.IsPlaying)
        {
            _state.Stop();
            _state = null;
        }
    }
}

Wait For Animation Event

[Category("Abilities/Animation")]
[Name("Wait Animation Event")]
[Description("Waits for a specific animation event")]
public class FN_WaitAnimationEvent : FN_AbilityNode
{
    [Input] public FlowInput enter;
    [Output] public FlowOutput onEvent;
    
    [Input] public ValueInput<string> eventName;
    
    private bool _waiting;
    private Flow _currentFlow;
    
    protected override void RegisterPorts()
    {
        enter = Input("Enter", Flow);
        onEvent = Output("On Event");
        eventName = Input<string>("Event Name", "Hit");
    }
    
    private void Flow(Flow f)
    {
        _waiting = true;
        _currentFlow = f;
        
        var eventReceiver = Controller.GetComponent<AnimationEventReceiver>();
        if (eventReceiver != null)
        {
            eventReceiver.OnAnimationEvent += HandleAnimEvent;
        }
    }
    
    private void HandleAnimEvent(string name)
    {
        if (!_waiting || name != eventName.value) return;
        
        _waiting = false;
        
        var eventReceiver = Controller.GetComponent<AnimationEventReceiver>();
        if (eventReceiver != null)
        {
            eventReceiver.OnAnimationEvent -= HandleAnimEvent;
        }
        
        _currentFlow?.Call(onEvent);
    }
    
    public override void OnGraphStoped()
    {
        if (_waiting)
        {
            _waiting = false;
            var eventReceiver = Controller.GetComponent<AnimationEventReceiver>();
            eventReceiver?.OnAnimationEvent -= HandleAnimEvent;
        }
    }
}

Targeting Nodes

Find Targets Node

[Category("Abilities/Targeting")]
[Name("Find Targets In Area")]
[Description("Finds targets in a spherical area")]
public class FN_FindTargetsInArea : FN_AbilityNode
{
    [Input] public FlowInput enter;
    [Output] public FlowOutput exit;
    [Output] public FlowOutput noTargets;
    
    [Input] public ValueInput<Vector3> center;
    [Input] public ValueInput<float> radius;
    [Input] public ValueInput<LayerMask> targetLayers;
    [Input] public ValueInput<int> maxTargets;
    
    [Output] public ValueOutput<List<GameObject>> targets;
    
    private List<GameObject> _targets = new();
    private Collider[] _hitColliders = new Collider[32];
    
    protected override void RegisterPorts()
    {
        enter = Input("Enter", Flow);
        exit = Output("Exit");
        noTargets = Output("No Targets");
        
        center = Input<Vector3>("Center");
        radius = Input<float>("Radius", 5f);
        targetLayers = Input<LayerMask>("Layers");
        maxTargets = Input<int>("Max Targets", 10);
        
        targets = Output<List<GameObject>>("Targets", () => _targets);
    }
    
    private void Flow(Flow f)
    {
        _targets.Clear();
        
        int hitCount = Physics.OverlapSphereNonAlloc(
            center.value,
            radius.value,
            _hitColliders,
            targetLayers.value
        );
        
        for (int i = 0; i < hitCount && _targets.Count < maxTargets.value; i++)
        {
            var col = _hitColliders[i];
            
            // Filter self
            if (col.gameObject == Controller.gameObject)
                continue;
            
            // Validate target
            if (col.TryGetComponent<IDamageReceiver>(out _))
            {
                _targets.Add(col.gameObject);
            }
        }
        
        if (_targets.Count > 0)
        {
            f.Call(exit);
        }
        else
        {
            f.Call(noTargets);
        }
    }
}

Effect Application

Apply Gameplay Effect Node

[Category("Abilities/Effects")]
[Name("Apply Effect")]
[Description("Applies a gameplay effect to targets")]
public class FN_ApplyEffect : FN_AbilityNode
{
    [Input] public FlowInput enter;
    [Output] public FlowOutput exit;
    [Output] public FlowOutput failed;
    
    [Input] public ValueInput<GameplayEffectSO> effectDef;
    [Input] public ValueInput<List<GameObject>> targets;
    [Input] public ValueInput<float> level;
    
    [Output] public ValueOutput<int> appliedCount;
    
    private int _appliedCount;
    
    protected override void RegisterPorts()
    {
        enter = Input("Enter", Flow);
        exit = Output("Exit");
        failed = Output("Failed");
        
        effectDef = Input<GameplayEffectSO>("Effect");
        targets = Input<List<GameObject>>("Targets");
        level = Input<float>("Level", 1f);
        
        appliedCount = Output<int>("Applied Count", () => _appliedCount);
    }
    
    private void Flow(Flow f)
    {
        _appliedCount = 0;
        
        var effect = effectDef.value;
        var targetList = targets.value;
        
        if (effect == null || targetList == null || targetList.Count == 0)
        {
            f.Call(failed);
            return;
        }
        
        foreach (var target in targetList)
        {
            if (target == null) continue;
            
            var targetASC = target.GetComponent<AbilitySystemComponent>();
            if (targetASC == null) continue;
            
            var spec = AbilitySystem.MakeOutgoingSpec(effect, level.value);
            if (targetASC.ApplyGameplayEffect(spec))
            {
                _appliedCount++;
            }
        }
        
        if (_appliedCount > 0)
        {
            f.Call(exit);
        }
        else
        {
            f.Call(failed);
        }
    }
}

MemoryPack Serialization

Custom Formatter for FlowCanvas

using MemoryPack;

[MemoryPackable]
public partial class SerializableFlowData
{
    public string GraphJson;
    public Dictionary<string, object> Variables;
    
    [MemoryPackConstructor]
    public SerializableFlowData(string graphJson, Dictionary<string, object> variables)
    {
        GraphJson = graphJson;
        Variables = variables;
    }
}

Best Practices

  1. Inherit from FN_AbilityNode for ability-specific nodes
  2. Handle cancellation in OnGraphStoped
  3. Use ValueInput defaults for optional parameters
  4. Validate inputs before execution
  5. Clean up coroutines and subscriptions
  6. Use async nodes for long operations
  7. Cache component references when possible
  8. Provide clear node names and descriptions
  9. Use categories for organization
  10. Test node interruption scenarios

Troubleshooting

IssueSolution
Node not appearingCheck Category attribute, rebuild
Flow not continuingVerify f.Call(exit) is called
Null referenceAdd null checks for inputs
Memory leakClean up in OnGraphStoped
Animation stuckHandle interruption properly
Variables not updatingCheck port registration

리뷰

아직 리뷰가 없습니다. 사용해 본 소감을 남겨 주세요.

댓글

로그인 없이 쓸 수 있습니다. 사용 후기, 질문, 작동하지 않은 환경 등을 편하게 남겨 주세요.

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

보내면 바로 공개됩니다. 이메일 주소나 전화번호 같은 개인 정보는 쓰지 마세요. URL은 2개까지입니다.

0 / 1,000자

이 양식은 reCAPTCHA로 보호되며 Google 개인정보처리방침 및 서비스 약관이 적용됩니다.

같은 저장소의 스킬

adopt

무료

Brownfield onboarding — audits existing project artifacts for template format compliance (not just existence), classifies gaps by impact, and produces a numbered migration plan. Run this when joining an in-progress project or upgrading from an older template version. Distinct from /project-stage-detect (which checks what exists) — this checks whether what exists will actually work with the template's skills.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

IdoCohen560/claude-unity-game-studio222026년 7월 8일 업데이트

Creates an Architecture Decision Record (ADR) documenting a significant technical decision, its context, alternatives considered, and consequences. Every major technical choice should have an ADR.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

IdoCohen560/claude-unity-game-studio222026년 7월 8일 업데이트

Validates completeness and consistency of the project architecture against all GDDs. Builds a traceability matrix mapping every GDD technical requirement to ADRs, identifies coverage gaps, detects cross-ADR conflicts, verifies engine compatibility consistency across all decisions, and produces a PASS/CONCERNS/FAIL verdict. The architecture equivalent of /design-review.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

IdoCohen560/claude-unity-game-studio222026년 7월 8일 업데이트

art-bible

무료

Guided, section-by-section Art Bible authoring. Creates the visual identity specification that gates all asset production. Run after /brainstorm is approved and before /map-systems or any GDD authoring begins.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

IdoCohen560/claude-unity-game-studio222026년 7월 8일 업데이트

Audits game assets for compliance with naming conventions, file size budgets, format standards, and pipeline requirements. Identifies orphaned assets, missing references, and standard violations.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

IdoCohen560/claude-unity-game-studio222026년 7월 8일 업데이트

Generate per-asset visual specifications and AI generation prompts from GDDs, level docs, or character profiles. Produces structured spec files and updates the master asset manifest. Run after art bible and GDD/level design are approved, before production begins.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

IdoCohen560/claude-unity-game-studio222026년 7월 8일 업데이트

IdoCohen560의 스킬 모두 보기

이 스킬의 문제 신고