ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Fungus自定义Command开发指南:从原理到实战

Fungus自定义Command开发指南:从原理到实战

1. 为什么我要给Fungus写自定义Command

Fungus这个Unity插件,做互动叙事、对话系统、点击式冒险游戏的人应该都不陌生。它最大的价值在于把流程图式的叙事逻辑做成了可视化编辑,策划和文案不用碰代码就能把分支对话、场景跳转、变量判断全部串起来。但用久了你会发现一个很现实的问题:内置Command再多,也架不住项目需求千奇百怪。比如我想在对话中途调用一个自定义的存档接口、想根据某个数值动态切换背景音乐的音量曲线、想在某个Block执行前做一次条件校验并弹出自定义UI——这些内置Command统统覆盖不到。

这时候唯一的出路就是写自定义Command。听起来像是“高级玩法”,其实门槛比想象中低得多。Fungus的Command体系设计得相当干净,核心就是继承一个基类、重写几个方法、挂上几个特性标签,剩下的它自己会处理。我前后在三个项目里写过二十多个自定义Command,从最简单的“设置某个UI文本”到复杂的“异步加载资源并等待回调”,踩过的坑基本覆盖了新手能遇到的所有类型。

这篇内容适合两类人:一类是已经用过Fungus、能跑通基本对话流程、但被内置功能卡住的开发者;另一类是刚接触Fungus、想提前了解它的扩展机制到底好不好用的技术选型者。我会从设计思路讲到具体实现,再到实际项目中的问题排查,尽量把每个“为什么这么做”都讲清楚。你不需要是C#高手,但至少得看得懂类和方法的定义。

2. Fungus的Command体系到底是怎么运转的

2.1 从Block执行链路看Command的生命周期

要写自定义Command,先得搞明白Fungus在运行时是怎么调度Command的。一个Flowchart里包含多个Block,每个Block里挂着一串Command。当Block被触发执行时,Fungus会按顺序遍历这个Command列表,对每个Command依次调用几个关键方法。整个生命周期大致是这样的:首先是OnEnter(),在Command即将被执行时调用;然后是Execute(),这是实际执行逻辑的地方;如果这个Command需要等待(比如等待动画播完、等待玩家点击),它会通过IsExecuting()返回true来告诉Fungus“我还没完事,先别往下走”;等到执行完毕,OnExit()被调用,Fungus继续下一个Command。

这个链路里最关键的是IsExecuting()。很多人第一次写自定义Command时会忽略它,结果发现Command执行完但流程卡住了,或者流程直接跳过不等结果。默认情况下,IsExecuting()返回false,意味着Command是瞬时的,执行完Execute()就立刻继续。如果你需要异步等待,就必须重写这个方法,用一个内部标志位来控制什么时候返回false。

还有一个容易忽略的点是OnStopExecuting()。当Block被强制中断(比如玩家跳过了当前对话、或者Flowchart被停止)时,Fungus会调用这个方法。如果你在Command里开了协程、启动了定时器、或者注册了事件监听,必须在这里做清理,否则会出现“对话已经跳过了但回调还在跑”的诡异现象。我在早期项目里就因为这个原因,导致跳过对话后背景音乐还在渐弱,玩家再进新对话时音量直接乱掉。

2.2 Command类的继承关系与核心方法

Fungus里所有Command都继承自Command这个基类,它本身又继承自MonoBehaviour。这意味着你的自定义Command实际上是一个组件,会被挂在Flowchart所在的GameObject上。这个设计有个隐含后果:Command实例的生命周期和Flowchart绑定,而不是和Block绑定。也就是说,同一个Command类在多个Block里使用时,每个Block里的实例是独立的,但它们共享同一个GameObject。

核心方法除了上面提到的OnEnter()、Execute()、IsExecuting()、OnExit()、OnStopExecuting()之外,还有几个值得关注的。GetSummary()用于在编辑器的Block Inspector里显示这个Command的摘要信息,默认显示类名,但你可以重写它来显示关键参数,比如“设置文本:Hello World”,这样策划一眼就能看懂这个Command在干什么。OnReset()在Command被添加到Block时调用,可以用来初始化默认值。OnValidate()在Inspector里修改参数时调用,适合做实时校验。

还有一个隐藏但极其重要的机制是CommandInfo特性。你可以给Command类加上[CommandInfo("分类名", "命令名", "描述")],这样它就会出现在Fungus的Command添加菜单里,并且归到指定分类下。分类名用斜杠可以创建子菜单,比如"Custom/UI"会在Custom分类下创建一个UI子分类。这个特性不写的话,Command虽然能用,但不会出现在菜单里,只能手动挂载,非常不方便。

2.3 变量系统与Command的交互方式

Fungus有一套自己的变量系统,支持Integer、Float、String、Boolean四种基本类型,以及它们的List版本。在自定义Command里访问这些变量,通常有两种方式:一种是通过GetFlowchart()拿到当前Flowchart,然后用GetVariable<T>()获取变量引用;另一种是直接在Command里定义public字段,用VariableReference类型,这样在Inspector里可以直接从下拉菜单选择变量。

第二种方式更符合Fungus的设计哲学,因为它让策划可以在不碰代码的情况下重新绑定变量。比如你写一个“根据变量值设置文本颜色”的Command,用VariableReference暴露一个Float变量,策划就能在Inspector里选择任意一个Float变量来控制颜色。如果你用第一种方式硬编码变量名,策划每次改需求你都得改代码。

但VariableReference有个坑:它在Inspector里显示的是变量名,但实际存储的是变量在Flowchart里的索引。如果你在Flowchart里删除了某个变量,所有引用它的Command都会失效,而且不会报错,只会在运行时静默失败。我建议在Command的OnEnter()里加一个空值检查,如果变量引用为空就输出一条警告日志,方便排查。

3. 从零写一个可用的自定义Command

3.1 最小可用模板与命名规范

先看一个最简模板,这个Command的功能是在Console里输出一条消息:

using UnityEngine; using Fungus; [CommandInfo("Custom", "Log Message", "在Console输出一条自定义消息")] public class LogMessageCommand : Command { [Tooltip("要输出的消息内容")] [SerializeField] protected string message = "Hello Fungus"; public override void OnEnter() { Debug.Log(message); Continue(); } }

就这么几行,一个能用的Command就完成了。Continue()是基类提供的方法,调用它等于告诉Fungus“我执行完了,继续下一个”。如果你不调用Continue(),流程会卡在这里,除非你重写了IsExecuting()并返回true来做异步等待。

命名规范方面,我建议类名以Command结尾,比如LogMessageCommand、SetTextCommand,这样在代码里搜索和识别都很方便。分类名用项目缩写或功能模块名,比如"MyGame/UI"、"MyGame/Audio",避免和Fungus内置分类混淆。描述文字要写清楚这个Command干什么、有什么前置条件,因为策划在菜单里看到的就是这段文字。

3.2 参数暴露与Inspector体验优化

让Command在Inspector里好用,是区分“能跑”和“好用”的关键。Fungus基于Unity的序列化系统,所以public字段和带[SerializeField]的protected/private字段都会显示在Inspector里。但光显示不够,还得加[Tooltip]说明每个参数的用途,加[Header]分组,加[Range]限制数值范围。

对于需要引用场景对象的参数,直接用Unity的序列化就行,比如[SerializeField] protected AudioSource audioSource;。对于需要引用Flowchart变量的参数,用VariableReference类型。对于需要选择枚举的参数,定义一个public enum然后暴露字段即可。

这里有个实用技巧:如果你希望某个参数只在特定条件下显示,可以用[HideInInspector]配合自定义Editor来实现条件显示。不过这会增加不少代码量,我一般只在参数特别多、不分组会混乱的情况下才这么做。大多数时候,用[Header]和[Space]做视觉分组就够了。

还有一个细节是GetSummary()的重写。默认摘要只显示类名,但你可以这样写:

public override string GetSummary() { if (string.IsNullOrEmpty(message)) return "输出空消息"; return "输出: " + message; }

这样在Block Inspector里,每个Command会显示“输出: Hello Fungus”,策划一眼就能看出这行命令在干什么,不用点开看详情。对于参数复杂的Command,摘要信息能极大提升编辑效率。

3.3 异步Command与IsExecuting的正确用法

异步Command是新手最容易翻车的地方。假设你要写一个“等待N秒后继续”的Command,直觉上可能会在OnEnter()里启动一个协程,等协程结束再调用Continue()。但问题是,Fungus在调用OnEnter()之后会立刻检查IsExecuting(),如果返回false,它会认为Command已经完成,直接调用OnExit()并继续下一个。你的协程还没跑完,流程就已经走过去了。

正确的做法是维护一个内部状态标志:

[CommandInfo("Custom", "Wait Seconds", "等待指定秒数后继续")] public class WaitSecondsCommand : Command { [SerializeField] protected float duration = 1f; private float elapsedTime = 0f; private bool isWaiting = false; public override void OnEnter() { elapsedTime = 0f; isWaiting = true; } public override void Execute() { // 实际逻辑在Update里处理 } public override bool IsExecuting() { return isWaiting; } public override void OnUpdate() { if (!isWaiting) return; elapsedTime += Time.deltaTime; if (elapsedTime >= duration) { isWaiting = false; Continue(); } } public override void OnStopExecuting() { isWaiting = false; } }

注意这里用了OnUpdate()而不是协程。Fungus的Command基类提供了OnUpdate()虚方法,它会在Command执行期间每帧调用。用OnUpdate()比协程更可控,因为你可以精确地在OnStopExecuting()里重置状态。如果用协程,中断时需要手动StopCoroutine,容易漏掉。

OnStopExecuting()里必须把isWaiting设为false,否则如果Block被中断,这个Command会一直认为自己在执行,下次再进入这个Block时状态就乱了。我见过一个项目因为没重置标志位,导致第二次进入对话时直接卡死,排查了半天才发现是这里的问题。

4. 实际项目中的进阶玩法与踩坑记录

4.1 调用外部系统与事件解耦

自定义Command最核心的价值是打通Fungus和项目其他系统。比如你的游戏有一套自己的对话UI系统,不想用Fungus自带的SayDialog,那就可以写一个CustomSayCommand,在OnEnter()里调用你的UI接口,然后等待UI回调再Continue()。

这里的关键是解耦。不要在Command里直接引用具体的UI类,而是定义一个接口或事件。比如:

public static class DialogueEvents { public static event System.Action<string, System.Action> OnShowDialogue; public static void ShowDialogue(string text, System.Action onComplete) { OnShowDialogue?.Invoke(text, onComplete); } }

Command里只调用DialogueEvents.ShowDialogue(text, Continue),具体谁来处理这个事件、怎么显示、什么时候回调,都由外部系统决定。这样Command不依赖任何具体UI实现,换UI系统时Command代码完全不用改。

这种模式我在两个项目里都用过,效果很好。唯一需要注意的是事件的生命周期管理。如果外部系统在场景切换时销毁了但事件没取消注册,会出现空引用。建议在外部系统的OnDestroy()里取消所有注册,或者在Command的OnStopExecuting()里做超时保护。

4.2 条件分支与变量读写的实战细节

Fungus内置了If、Else、While等流程控制Command,但有时候你需要更复杂的条件判断,比如“当玩家金币大于100且背包有空位且当前不是战斗状态时”才执行某段对话。内置的If Command只能做单条件判断,多条件需要嵌套多个If,非常臃肿。

这时候可以写一个MultiConditionCommand,暴露多个VariableReference和比较运算符,在OnEnter()里统一计算,然后根据结果决定是Continue()还是跳转到指定Block。跳转Block用GetFlowchart().FindBlock(blockName)拿到目标Block,然后调用GetFlowchart().ExecuteBlock(targetBlock)。

变量读写方面,用VariableReference的GetValue()和SetValue()方法。但要注意,GetValue()返回的是object,需要根据变量类型做转换。Fungus提供了泛型版本GetValue<T>(),但只在变量类型匹配时有效,类型不匹配会抛异常。我建议在Command里加一层类型检查,如果类型不对就输出警告并跳过,而不是让整个流程崩溃。

还有一个坑是变量的作用域。Fungus的变量默认是Flowchart级别的,但如果你用了多个Flowchart,变量是不共享的。跨Flowchart读写变量需要通过Flowchart.GetFlowchart(name)拿到目标Flowchart,再操作它的变量。这个过程比较繁琐,如果项目里大量需要跨Flowchart通信,建议写一个专门的CrossFlowchartVariableCommand来封装。

4.3 编辑器扩展让Command更好用

Command写多了之后,你会发现有些重复性工作很烦人。比如每次新建Command都要手动写[CommandInfo]特性、写GetSummary()、写OnStopExecuting()清理逻辑。这时候可以写一个代码生成模板,或者做一个自定义Editor来简化流程。

更实用的编辑器扩展是给Command加自定义Inspector。比如你的Command有一个“目标Block”参数,默认显示的是一个字符串输入框,策划需要手动输入Block名字,容易打错。你可以写一个自定义Editor,把这个字段显示成下拉菜单,自动列出当前Flowchart里所有Block的名字。

[CustomEditor(typeof(JumpToBlockCommand))] public class JumpToBlockCommandEditor : CommandEditor { public override void OnInspectorGUI() { base.OnInspectorGUI(); var command = target as JumpToBlockCommand; if (command == null) return; var flowchart = command.GetFlowchart(); if (flowchart == null) return; var blockNames = flowchart.GetBlockNames(); int selectedIndex = Mathf.Max(0, System.Array.IndexOf(blockNames, command.targetBlock)); selectedIndex = EditorGUILayout.Popup("目标Block", selectedIndex, blockNames); command.targetBlock = blockNames[selectedIndex]; } }

这段代码继承自CommandEditor而不是普通的Editor,因为Fungus提供了一些便利方法。GetBlockNames()不是Fungus的原生API,需要你自己在Flowchart上扩展一个方法,或者直接遍历flowchart.GetComponents<Block>()来获取。这个扩展做一次,后面所有需要选择Block的Command都能复用,省下来的时间非常可观。

4.4 常见问题速查与排查思路

下面这张表是我在实际项目中遇到过的典型问题,按出现频率排序:

问题现象可能原因排查方法解决方案
Command不出现在添加菜单缺少[CommandInfo]特性检查类定义上方是否有该特性补上特性,分类名不要用中文
执行后流程卡住忘记调用Continue()在OnEnter()或Execute()末尾检查确保所有分支都调用了Continue()
异步等待不生效IsExecuting()未重写或返回false在IsExecuting()里打日志重写方法并返回正确的状态标志
跳过对话后逻辑错乱OnStopExecuting()未清理状态检查是否有未重置的标志位或未停止的协程在OnStopExecuting()里重置所有状态
变量引用为空Flowchart里变量被删除在OnEnter()里检查VariableReference是否有效加空值保护,输出警告日志
Inspector里参数不显示字段不是public且没有[SerializeField]检查字段修饰符加[SerializeField]或改为public
多个Flowchart变量不共享变量作用域是Flowchart级别确认是否在操作同一个Flowchart实例通过GetFlowchart()获取目标Flowchart

除了表格里的问题,还有一个隐蔽的坑是Command的执行顺序。Fungus按Block里Command的排列顺序执行,但如果你在Command里调用了GetFlowchart().ExecuteBlock()来跳转,当前Block的剩余Command会被跳过。这个行为是符合预期的,但如果不清楚这一点,可能会困惑为什么后面的Command没执行。

另一个经验是:尽量让每个Command只做一件事。我见过一个“万能Command”,参数有十几个,根据不同的模式执行完全不同的逻辑。结果就是调试极其困难,策划也搞不懂什么时候该用哪个参数。拆成多个小Command,每个职责单一,虽然数量多了,但维护成本反而更低。

5. 性能考量与项目集成建议

5.1 Command执行开销与优化方向

单个Command的开销其实很小,主要成本在OnUpdate()里的每帧调用和GetFlowchart()的查找上。GetFlowchart()在基类里做了缓存,第一次调用后会记住引用,所以不用太担心。但如果你的Command在OnUpdate()里做了复杂的计算或频繁的字符串操作,累积起来还是会影响性能。

优化方向有几个:一是尽量用Execute()而不是OnUpdate(),只有真正需要每帧更新的逻辑才放在OnUpdate()里;二是避免在OnUpdate()里做GC分配,比如字符串拼接、LINQ查询、new对象;三是如果Command需要等待很长时间,考虑用事件驱动而不是轮询。

还有一个容易忽略的点是Command实例的复用。Fungus在Block执行时会实例化Command组件,执行完后不会立刻销毁,而是保留在GameObject上。这意味着如果你的Command在OnEnter()里分配了内存,这些内存会一直保留到Flowchart销毁。对于频繁执行的Command,建议在OnStopExecuting()里释放不必要的引用。

5.2 团队协作中的规范与文档

当团队里有多个人写Command时,规范就很重要了。我建议制定几条简单规则:所有Command必须放在统一的命名空间下,比如MyGame.Fungus.Commands;分类名统一用项目缩写开头;每个Command必须有[Tooltip]和GetSummary();异步Command必须在OnStopExecuting()里清理状态。

文档方面,不需要写很正式的文档,但可以在项目里维护一个Command列表,记录每个Command的用途、参数说明、使用示例。我用过一个简单方法:在Command类的XML注释里写清楚用法,然后用工具生成Markdown文档。这样代码和文档同步更新,不会出现文档过时的问题。

另外,建议给Command写单元测试。Fungus的Command可以在EditMode下测试,模拟OnEnter()和OnUpdate()的调用,验证状态变化和Continue()是否被正确调用。虽然写测试需要额外时间,但对于核心的、复杂的Command,测试能省下大量调试时间。

5.3 版本升级与兼容性处理

Fungus本身在持续更新,不同版本之间API可能有变化。我遇到过从某个版本升级后,Command基类的某个虚方法签名变了,导致所有自定义Command编译报错。处理这类问题的原则是:尽量只依赖稳定的核心API,比如OnEnter()、Execute()、Continue()、GetFlowchart(),这些方法很少变动。对于可能变化的API,做一层封装,升级时只需要改封装层。

如果项目用的Fungus版本比较老,而你想用新版本才有的功能,可以考虑把新版本的某个Command实现移植过来,而不是整体升级。Fungus的Command实现通常比较独立,移植单个Command的风险比整体升级小得多。

最后一点经验:在项目初期就把自定义Command的目录结构规划好,按功能模块分文件夹,比如Commands/UI、Commands/Audio、Commands/Logic。不要所有Command都堆在一个文件夹里,等到几十个Command的时候找起来会非常痛苦。命名也要有规律,方便搜索和过滤。这些看似琐碎的细节,在项目后期会直接影响开发效率。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进