tools-unity-flowcanvas
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
- Inherit from FN_AbilityNode for ability-specific nodes
- Handle cancellation in OnGraphStoped
- Use ValueInput defaults for optional parameters
- Validate inputs before execution
- Clean up coroutines and subscriptions
- Use async nodes for long operations
- Cache component references when possible
- Provide clear node names and descriptions
- Use categories for organization
- Test node interruption scenarios
Troubleshooting
| Issue | Solution |
|---|---|
| Node not appearing | Check Category attribute, rebuild |
| Flow not continuing | Verify f.Call(exit) is called |
| Null reference | Add null checks for inputs |
| Memory leak | Clean up in OnGraphStoped |
| Animation stuck | Handle interruption properly |
| Variables not updating | Check port registration |
Weekly Installs
1
Repository
tjboudreaux/cc-…-gamedevGitHub Stars
1
First Seen
2 days ago
Security Audits
Installed on
amp1
cline1
opencode1
cursor1
kimi-cli1
codex1