Verse Wiki — 写给 UE6 时代的 Verse 互动手册
拓展 · EXTRA

用事件打断循环:Subscribe 回调与 loop 的正确协作

「玩家按下停止按钮时,让那个每帧跑的循环停下来」——听起来简单,直接写却处处碰壁。这一页把第 19-21 课的 Sleep、race 取消语义、事件与订阅Delay、race 的取消规则、事件与 Bind串成一个真实案例,给出两种成熟解法。本文整理自 Epic 开发者论坛。

一、问题:回调里 break 不了别人的循环

需求原型来自论坛帖《How to break a loop with a subscribe event?》:一个 loop: 在后台每帧巡逻,玩家被淘汰(或按下按钮)时要让它终止。新手的直觉是——在 Subscribe 的回调里写 break。可惜此路不通:break 只能出现在循环体内部,而回调是另一个函数,和那个 loop: 根本不在同一个作用域里。它够不着。

需求原型来自论坛帖《How to break a loop with a subscribe event?》:一个 loop: 循环在后台每帧巡逻,玩家被淘汰(或按下按钮)时要让它终止。新手的直觉是——在 Subscribe 绑定的那张函数图里写 break(跳出循环)。可惜此路不通:break 只能待在循环体内部,而你绑的那张图是另一张图,跟那个 loop: 根本不在一起,够不着它。

换句话说,回调你绑上去的那张图和循环是两条并行的时间线,想让一条时间线掐停另一条,必须借助它们都能看到的东西:要么是一个共享变量(标志位),要么是一个事件。这正好对应两种成熟模式。

二、方案 A:标志位——朴素但可靠

让回调把一个 logic 变量拨成 true,循环每圈自查一次、发现旗子升起来就 break:

让绑上去的那张图把一个 logic(真/假)变量拨成 true,循环每圈自查一次、发现旗子升起来就 break 跳出:

patrol_flag_device.verse
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }

patrol_flag_device := class(creative_device):

    @editable
    StopButton:button_device = button_device{}

    var ShouldStop:logic = false

    OnBegin<override>()<suspends>:void =
        StopButton.InteractedWithEvent.Subscribe(OnStop)
        Patrol()

    OnStop(Agent:agent):void =
        # 回调只做一件事:升旗
        set ShouldStop = true

    Patrol()<suspends>:void =
        loop:
            if (ShouldStop?):
                break
            Print("巡逻中……")
            # 每圈必须让出控制权,否则回调永远没机会跑
            Sleep(0.0)
        Print("巡逻结束")

两个细节决定成败。第一,if (ShouldStop?): 里的问号是对 logic 值的查询,值为 true 时条件成立——这是第 5 课失败上下文的老朋友。第二,循环里那句 Sleep(0.0) 不能省:第 19 课讲过,没有挂起点的 loop: 永不让出控制权,服务器直接冻结,回调根本没有机会执行,旗子永远升不起来。标志位模式本质是「每圈轮询一次」,简单直白,缺点是终止最多延迟一圈。

两个细节决定成败。第一,if (ShouldStop?): 里的问号是在查这个 logic 的值,值为 true 时这条 Branch 才走通——这是第 5 课「能不能走通」的老朋友。第二,循环里那句 Sleep(0.0) 不能省:第 19 课讲过,一个循环里如果没有一个「能让出去喘口气」的点(哪怕 Sleep(0.0) 这种 0 秒 Delay),它就会死死占住不撒手,服务器直接冻住,绑上去的那张图根本没机会跑,旗子永远升不起来。标志位模式本质是「每圈自己查一次」,简单直白,缺点是最多晚一圈才停。

三、方案 B:race + 事件——让取消自动发生

更优雅的解法是把「巡逻」和「等停止信号」摆进同一个 race: 里赛跑。回调只负责 Signal;事件臂一旦获胜,输掉的循环臂会被自动取消——不需要任何标志位和 break:

更优雅的解法是把「巡逻」和「等停止信号」摆进同一个 race: 里赛跑(几条线一起跑,第一个到终点的算数,其余当场剪断)。绑上去的那张图只负责 Signal(拉响事件);等信号那条臂一旦先到,输掉的巡逻循环臂会被自动剪断——不需要任何标志位、也不用 break:

patrol_race_device.verse
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
# event(t) 的 Signal / Await 来自 /Verse.org/Concurrency
using { /Verse.org/Concurrency }

patrol_race_device := class(creative_device):

    @editable
    StopButton:button_device = button_device{}

    StopEvent:event() = event(){}

    OnBegin<override>()<suspends>:void =
        StopButton.InteractedWithEvent.Subscribe(OnStop)
        race:
            block:
                loop:
                    Print("巡逻中……")
                    Sleep(1.0)
            block:
                # 这一臂安静地等信号;信号一到,它获胜
                StopEvent.Await()
        Print("巡逻结束")

    OnStop(Agent:agent):void =
        StopEvent.Signal()

链路走一遍:玩家按下按钮 → OnStop 回调被调用 → StopEvent.Signal() → 第二臂的 Await() 返回、该臂完成 → race 判它获胜 → 巡逻臂被取消 → 执行流走到 Print("巡逻结束")。注意第 20 课的取消语义在这里生效:输掉的循环臂不是在 Signal 的瞬间被掐断,而是在它的下一个挂起点(那句 Sleep)处退出——所以循环体里有挂起点,依然是这套模式能转起来的前提。

链路走一遍:玩家按下按钮 → OnStop 那张图跑起来 → StopEvent.Signal() 拉响 → 第二臂里 Await() 收到信号、这条臂算跑完 → race 判它先到、获胜 → 巡逻臂被剪断 → 执行流走到 Print("巡逻结束")。注意第 20 课的取消规则在这里生效:输掉的循环臂不是在 Signal 的那一瞬间就被掐断,而是要跑到它的下一个「能让出去的点」(那句 Sleep)才退出——所以循环体里必须有这么一个点,这套模式才转得起来。

四、两种方案怎么选?

标志位模式代码最少、心智负担最小,适合循环体里本来就要做条件检查的场合;它的轮询本质意味着响应有最多一圈的延迟,而且旗子、循环、回调三处代码要互相记得对方。race + 事件模式零轮询、意图一目了然(「巡逻,直到收到停止信号」几乎是自然语言),取消由语言自动完成,状态也少一个——代价是要求你对 race 的取消时机心里有数。论坛帖里两种都有人用;写给别人读的代码,建议优先 race 模式。

标志位模式代码最少、心智负担最小,适合循环体里本来就要做条件检查的场合;它每圈查一次,意味着最多晚一圈才响应,而且旗子、循环、绑上去的图三处要互相记得对方。race + 事件模式不用一圈圈查、意图一目了然(「巡逻,直到收到停止信号」几乎就是大白话),取消由引擎自动完成,还少维护一个状态——代价是要求你对 race 什么时候剪断输家心里有数。论坛帖里两种都有人用;写给别人读的代码,建议优先 race 模式。

race 模式里,StopEvent.Signal() 之后,巡逻 loop 是什么时候真正停下的?

来源

本文整理自 Epic 开发者论坛: