用事件打断循环: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 跳出:
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:
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 开发者论坛: