自定义节点
自定义节点就是一个普通的 runtime C# class:继承一个节点家族基类,挂上 [FlowNode],Unity 编译完成后它就出现在 Add Node 菜单里。没有注册调用,不写编辑器代码。
面向为做卡作者扩充节点库的程序员。自定义节点用来补上内置节点库没覆盖的行为,做卡作者照常在 FlowGraph 编辑器里连图。典型场景是内置节点已经很接近,但你的游戏还差一个具体动作:"治疗半血以下的友方"、"累计 combo 层数"、"选中带自定义关键字标记的敌人",或者"从我自己的战斗视图播一个表现节拍"。
整个扩展面都在 TinyGiants.GCS.Runtime 里。你的 class 永远不引用 TinyGiants.GCS.Editor,GCS 会通过反射,从任何引用了 runtime 的已加载程序集里发现它:
class 放在哪
节点文件放进你自己的、引用了 TinyGiants.GCS.Runtime 的 runtime assembly,绝不要放进 editor-only assembly。FlowGraph 编辑器通过 public runtime API 读取 runtime 节点,所以编辑器能画出你的节点,而你的代码不依赖任何 editor 类型。
最快的起点是随包的样例文件夹:
Assets/TinyGiants/GameCardSystem/Samples~/CustomNodes/
复制一个样例进你的项目,然后重命名 namespace、class、category、display name 和字段。样例讲解一页会逐个文件展开。
一个可用节点需要三项内容:
| 要求 | 为什么重要 |
|---|---|
[Serializable] | Unity 把节点实例序列化在拥有者的 FlowGraph 里 |
[FlowNode(category, displayName)] | 把节点注册到 Add Node 菜单的某个分组下 |
| 一个 public 节点基类 | 决定图运行到或读取这个节点时发生什么 |
using System;
using TinyGiants.GCS.Runtime;
namespace YourGame.Cards
{
[Serializable]
[FlowNode("Action", "Gain Armor To Self")]
public sealed class GainArmorToSelfNode : MutatorNode
{
public int Amount = 1;
public override void Execute(IExecutionContext ctx)
{
int amount = ctx.PullInputOr(this, nameof(Amount), Amount);
if (amount <= 0 || ctx.Host == null) return;
ctx.Controller.GainArmor(ctx.Host, amount);
}
}
}
选节点家族
按做卡作者期待的行为选基类,不要按实现细节来选。
| Base class | Override | 适用场景 |
|---|---|---|
MutatorNode | Execute(IExecutionContext ctx) | 改战斗状态:伤害、治疗、护甲、状态、卡牌移动、能量、变量 |
OperatorNode | Evaluate(IEvaluationContext ctx, string outPort) | 纯值:数学、比较、转换、字符串或数字结果 |
SourceNode | Evaluate(IEvaluationContext ctx, string outPort) | 读上下文数据:当前回合、来源单位、打出的卡、状态层数 |
SelectorNode | Evaluate(IEvaluationContext ctx, string outPort) | 产出供其他节点消费的单位或卡牌选择 |
ControlNode | DecideNext(IExecutionContext ctx) | 把流程路由到一个或多个命名分支 |
PatternNode | DecideNext(IExecutionContext ctx) | 敌人目标模式,决定下一个意图分支 |
VfxNode | Play(IExecutionContext ctx) | 纯表现:特效、相机冲击、UI 节拍 |
大多数 gameplay 节点最终是 MutatorNode 或 OperatorNode。如果节点改变战斗状态,就是 mutator;如果节点只是为其他节点提供一个值,就是 operator。
这些家族正是 demo 内容自己在跑的家族。Hunter 场景的敌人 Mire Reaper,整个回合都由 pattern 节点驱动选出意图分支,每个分支最后落在你的自定义 MutatorNode 或 VfxNode 将要并排站着的那批 action 和 FX 节点上:

为做卡作者起名
[FlowNode] 的 category 就是 Add Node 菜单分组。节点适合排在内置行为旁边时,复用内置分组:Action 放改状态的效果,Operator 放计算值,Get 放上下文读取和选择,Flow 放分支,FX 放表现,Intent 放敌人意图模式。写其他任意字符串则新开一个分组,一整族游戏专属节点正该这么做。
display name 应该描述内容作者想要的结果(Heal If Below Half),而不是实现用的 helper(ConditionalHpRestoreUtil)。设计师浏览这份菜单时关注的是:这张卡能实现什么效果?
字段就是创作 UI
public 字段就是设计师在节点上看到并编辑的内容。从这里开始:
public UnitSource TargetSource = UnitSource.Self;
public int HealAmount = 5;
public bool OnlyBelowHalf = true;
GCS 会从字段推断端口:
- 每个 public 的
int、float、bool、string字段都可以暴露成 scalar 输入端口 - 名为
<Name>Source的UnitSource字段暴露一个名为<Name>的单位输入,所以TargetSource给出Target端口 - 名为
<Name>Source的CardSource字段以同样方式暴露卡牌输入 - 其他 public 字段类型(枚举、prefab、颜色)显示为节点上的配置行,不是端口
暴露端口是做卡作者的选择,在 FlowGraph 编辑器里按节点操作。字段没被暴露、没接线之前,它就是节点上的一个普通填值。
scalar 字段一律通过 ctx.PullInputOr(this, nameof(Field), Field) 读取。这一个调用就给出设计师期待的创作行为:接了线的端口优先,没接线就回落到节点上填的字段值。
所有改动走 controller
在 MutatorNode.Execute 里,每一处战斗改动都要经过 ctx.Controller。绝不直接改 UnitState、CardInstance、牌堆、HP、状态或能量。
public override void Execute(IExecutionContext ctx)
{
int amount = ctx.PullInputOr(this, nameof(HealAmount), HealAmount);
if (amount <= 0) return;
foreach (var target in ctx.ResolveUnits(this, TargetSource, nameof(TargetSource)))
{
if (target == null || target.IsDead) continue;
ctx.Controller.GainHp(target, amount);
}
}
controller 调用是 runtime 事件、状态 Hook、Monitor 输出和表现层保持同步的前提。直接写字段会绕过这一切,结果就是一张数字上"生效"、却不发事件、没有视觉反馈、也不触发任何反应的卡。
发布可被其他节点读取的结果
如果下游节点需要读你的节点产出的值,实现 INodePorts 并声明一个 result 输出。
using System;
using System.Collections.Generic;
using TinyGiants.GCS.Runtime;
[Serializable]
[FlowNode("Action", "Count Living Targets")]
public sealed class CountLivingTargetsNode : MutatorNode, INodePorts
{
public UnitSource TargetSource = UnitSource.AllEnemies;
public IEnumerable<NodePort> DeclarePorts()
{
yield return NodePort.ResultOut("Count", PortDataType.Int);
}
public override void Execute(IExecutionContext ctx)
{
int count = 0;
foreach (var target in ctx.ResolveUnits(this, TargetSource, nameof(TargetSource)))
if (target != null && !target.IsDead) count++;
ctx.StoreResult(this, "Count", count);
}
}
NodePort.ResultOut("Count", ...) 里的名字必须和 ctx.StoreResult(this, "Count", value) 传入的名字一致。
在编辑器下,StoreResult 的端口名匹配不到任何已声明输出时会打一条 warning。名字拼错会变成一条 Console 消息,而不是一个无提示丢失的值。
把名字当成已保存内容的契约
一旦真实的图用上了你的节点,它的 class 名、字段名和已声明端口名就是写进已保存内容的契约。只有连同迁移引用它们的 FlowGraph 资产时,才去重命名。
好的自定义节点小而直白:
- 一个节点只做一件做卡的事
- 字段名描述可见的选择:
HealAmount、TargetSource、OnlyBelowHalf - 输出名描述那个值:
Healed、DamageDealt、Count - 作者可能理解错的规则,写进字段名或 tooltip,不要留给他们自行推断
编译并找到它
- 让 Unity 无错误编译完成
- 打开任意一张 GCS FlowGraph
- 打开 Add Node 菜单,搜索你在
[FlowNode]里写的 display name - 加上节点,暴露需要的端口,开着 Monitor 跑一场战斗
节点没出现时,检查 class 是否为具体类(不能是 abstract)、带 [Serializable]、带 [FlowNode],并且编译在引用了 TinyGiants.GCS.Runtime 的 runtime assembly 里。