翻译 ≠ 移植:什么时候该重新设计
主课的纪律是「第一遍要忠实」。这一页讲第二遍。逐节点翻译——不管是你手翻还是将来某个转换工具替你翻——能把结构原样搬过来,却搬不动设计意图。有些蓝图模式之所以长成那样,是因为蓝图只给了那一种办法;换到 Verse 里,那些理由就不成立了。三个最典型的模式,一个一个拆。
一、自动翻译的天花板
先把话说清楚:Epic 已承诺在弃用前提供转换工具,但尚未发布;Actor 与 Blueprint 在 UE6 Early Access(目标 2027 年底)及早期版本中完整支持,弃用要等 Scene Graph 足够成熟,时间未定。所以这一页讨论的不是「工具靠不靠谱」,而是一件无论谁来翻都成立的事:结构是可以机械搬运的,意图不行。
一张蓝图里的每个节点,都同时携带两种信息。一种是它做了什么——这部分能一对一搬走:Branch 变 if,Set 变 set,Delay 变 Sleep。另一种是你当初为什么这么写——这部分没有任何工具搬得动,因为它压根不在图里。而蓝图作者写下的很多形状,理由都是同一句话:蓝图只提供了这一种办法。
典型的三句自白:「我在 Tick 里每帧查一遍,是因为那个东西没有事件可绑」;「我用 Tick + Switch 做状态机,是因为执行线不能停在半路」;「我用五个布尔拼状态,是因为新建一个 Enumeration 资产太麻烦」。这三条约束在 Verse 里全部消失了。逐节点翻译会把这三种形状原封不动地搬过来,连同它们早已失效的理由——于是你得到一份「能跑,但没有一处用上新语言长处」的代码。下面三节,就是这三个形状各自的更好写法。
二、模式一:轮询 → 事件 / 并发
原图长什么样:Event Tick → Branch(某个条件成立了吗?)→ 成立就干活。每帧问一遍,问了一万次,九千九百九十九次答案是「没有」。
直译会变成什么:一个每帧转一圈的循环,里面塞着那个 Branch。它能编译、能跑,而且把蓝图里最贵的那个习惯完整保留了下来:
# 每帧转一圈,每圈问一遍「有人踩上来了吗」
PollLoop()<suspends>:void =
loop:
Sleep(0.0)
if (SomeoneOnPlate?):
OpenDoor()
该换成什么:停下来等那件事发生。Verse 的时间模型不是「每帧问一遍」,而是「挂起,等到了再往下走」——这也是为什么 Await() 和 Sleep() 需要 <suspends> 这块牌子:语言把「这条线可以停」当成一等公民(第 23、25 课)。
# 线停在这里,不占任何一帧;事件响了才继续
WaitLoop()<suspends>:void =
loop:
Plate.InteractedWithEvent.Await()
OpenDoor()
差别不只是省了几行。轮询版本有一个你必须自己回答的问题:轮询频率多少合适?太密,白烧性能;太稀,玩家会觉得「踩上去反应慢半拍」。等待版本根本不存在这个问题——事件响的那一刻就是继续的那一刻,没有中间态。凡是原图里出现「每帧查一遍某个状态」,先花五分钟找一找有没有对应的事件可以等;找到了,这一格就该重写而不是翻译。
顺带一提:主课那段代码里的 Sleep(0.0) 之所以在「翻译不过去」的表里被点名,正是这个原因——它写得出来,但它几乎总是在告诉你,这一格的设计还停在蓝图的约束里。
三、模式二:Tick 里的状态机 → loop + race
原图长什么样:一个 State 变量(枚举或整数)、一个 Timer 浮点数,Event Tick 里一个 Switch on Enum,每个分支里手动累加计时、手动判断条件、手动把 State 改成下一个值。这是蓝图里做「有先后顺序的流程」的标准姿势。
为什么当初要这么写:因为蓝图的执行线不能停在半路——一条线从事件节点出发,必须一口气跑完。想表达「先等 5 秒,再看看有没有人按」,只能把这个过程切成碎片,散在每一帧里,再用一个变量记住「上次跑到哪儿了」。那个 State 变量,本质上是手写的程序计数器。
该换成什么:在 Verse 里,执行线可以停在半路。于是「上次跑到哪儿了」这件事,由语言替你记着——执行线停在哪一行,本身就是状态。整个状态机塌缩成一段从上往下读的代码:
DoorLoop()<suspends>:void =
loop:
# 状态「关着」:停在这里等触发
Plate.InteractedWithEvent.Await()
OpenDoor()
# 状态「开着」:两件事赛跑,谁先到听谁的
race:
Sleep(OpenDuration)
Plate.InteractedWithEvent.Await()
CloseDoor()
对照着数一数省掉了什么:没有 State 变量,没有 Timer 累加,没有 Switch,没有「忘了把 State 改成下一个值」这种经典 bug,也没有「两个分支都把 State 改成了 Open」这种更经典的 bug。而且顺带多了一个功能:race 让「超时」和「再按一次」同时待命,先到先算,输的那条被自动取消(第 24 课)——在 Tick 状态机里实现同样的效果,你得再加一个 Timer、再加两个分支。
识别信号很明确:原图里只要出现「State 变量 + Tick 里的 Switch」这对组合,几乎一定该重新设计。翻译它只会把一段手写的程序计数器搬进一门本来就有程序计数器的语言里。
四、模式三:一堆布尔旗标 → enum + option
原图长什么样:变量面板里躺着 IsOpen、IsLocked、IsBroken、HasTarget 四个 Boolean,事件图里到处是它们的 Branch 组合。
问题在哪:四个布尔能组合出十六种情况,而其中大部分在游戏里物理上不存在——「又开着又锁着」、「坏了但没有目标却仍然开着」。这些非法状态没有被禁止,只是恰好没人去构造它们;等某天一根线连错,它们就出现了,而且没有任何报错。
该换成什么:两件工具,分工明确。enum 处理「多选一」的状态,option 处理「可能没有」的东西。
# enum 定义在模块层,和设备类平级(第 11 课)
door_state := enum{Closed, Open, Locked, Broken}
my_door := class(creative_device):
# 一块铭牌代替三个布尔:四种状态,不多不少
var State:door_state = door_state.Closed
# 「可能没有」不再用一个布尔旁证,类型自己说了算(第 18 课)
var MaybeTarget:?door_actuator = false
Describe(S:door_state):string =
case (S):
door_state.Closed => "关着"
door_state.Open => "开着"
door_state.Locked => "锁着"
door_state.Broken => "坏了"
三个布尔的八种组合,收敛成四个确定的值——「又开着又锁着」在类型层面就写不出来。这条原则叫「让非法状态无法表示」,它是类型设计里性价比最高的一招,而 enum 是它最便宜的实现(第 11 课)。
option 那一行同样值得细看。蓝图里表达「这个引用可能还没绑」,常见做法是配一个 HasTarget 布尔当旁证——于是你有了两个必须保持同步的东西,而它们迟早会不同步。Verse 的 ?door_actuator 把两者合成一个:值和「有没有值」装在同一个箱子里,不可能对不上,而且开箱必须过一道判定,忘了检查编译期就拦下(第 18 课)。
识别信号:变量面板里三个以上的布尔,名字都以 Is / Has / Can 开头,而且经常成组出现在同一个 Branch 里——这就是一组该被合并的旗标。
五、什么时候忍住别改
重新设计有瘾,得给它划个边界。三条止损线:
其一,译本还没跑通之前,一律不改。主课那条纪律不是客套话:你需要一个行为对齐的参照系,否则新出的 bug 到底来自翻译还是来自重构,你查不清。先绿,再改。
其二,原图那个形状可能有你不知道的理由。看起来多余的布尔,也许是另一张图在读它;看起来该合并的两个状态,也许美术那边挂了不同的动画。能问到原作者就先问一句;问不到,就在重构前先把它标出来,别顺手删。
其三,这段逻辑将来还会不会被人读。一段马上要被整体替换掉的老逻辑,忠实翻译过去、跑起来、然后扔掉,才是理性的。重构的回报来自「以后还要在它上面继续开发」,没有以后就没有回报。
反过来,最值得重构的时机恰恰就是迁移这一刻:你正逐节点重读这张图,对它的理解处在历史最高点,而代码又还没在新项目里长出依赖。迁移不是把旧设计原样搬到新语言里,而是新语言给了你一次重新审视旧设计的机会——工具能搬结构,判断哪一段该重写的人,只能是你。
六、小测验
原图用「State 变量 + Event Tick 里的 Switch on Enum」实现一套流程。为什么说这个形状在 Verse 里几乎一定该重新设计?