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

对动态数量的任务做 race:事件聚合模式

race 的臂数在写代码时就定死了,可真实需求常常是「等 N 个按钮里最先被按下的那个」——N 要到运行时才知道。race 的臂数在连图时就定死了,可真实需求常常是「等 N 个按钮里最先被按下的那个」——到底几个,得等游戏跑起来才知道。本页整理社区实战出的标准解法:spawn 哨兵 + event 聚合,顺便讲清一个会把编辑器搞崩的语法陷阱。

一、问题:race 不吃运行时数组

正课里的 race,每条臂都是亲手写在代码里的:两条臂、三条臂,编译期就数得一清二楚。可假设你的关卡里摆着一排抢答按钮,数量由关卡设计师在 @editable 数组里随意增减——「等这一排按钮里最先被按下的那个」,臂数在运行时才揭晓,race 的写法立刻卡壳。

正课里的 race,每条臂都是你亲手连在图里的:两条臂、三条臂,还没运行就数得一清二楚。可假设你的关卡里摆着一排抢答按钮,数量由关卡设计师随意增减(那个数组变量点亮了 Instance Editable 小眼睛,能在 Details 里改)——「等这一排按钮里最先被按下的那个」,到底几条臂,得游戏跑起来才揭晓,race 的写法立刻卡壳。

论坛帖《How to (race) await an array of tasks?》里,楼主试了一个看起来非常合理的写法:race(T : Tasks){ T.Await() }——像 for 一样对数组展开 race。结果更刺激:编译器前端居然放行,后端却没有实现,直接把编辑器送走(24.30 版实测崩溃)。Epic 引擎工程师 Andrew Scheidecker 在帖子里确认:这个语法「意图支持,但目前只实现了前端」。结论很干脆:这种写法眼下别碰,等官方实装。同一系列的雷还有一个:把 range 表达式(如 0..10)当参数传给 sync / race / rush,项目必崩——同样绕开为妙。

论坛帖《How to (race) await an array of tasks?》里,楼主试了一个看起来非常合理的写法:race(T : Tasks){ T.Await() }——想像 ForEach 那样,把数组里每个任务都自动摊成 race 的一条臂。结果更刺激:编译器负责检查写法的前半段居然放行了,可真正把它变成可运行逻辑的后半段压根没实现,于是直接把编辑器搞崩(24.30 版实测崩溃)。Epic 引擎工程师 Andrew Scheidecker 在帖子里确认:这个语法「意图支持,但目前只做了前半段」。结论很干脆:这种写法眼下别碰,等官方实装。同一系列的雷还有一个:把一个范围写法(比如 0..10,表示 0 到 10 这一串数)当参数塞给 sync / race / rush,项目必崩——同样绕开为妙。

二、解法:每人一个哨兵,谁被按了谁报数

既然 race 的臂数定死,那就反过来想:不让主流程去 race N 个任务,而是给每个按钮派一名 spawn 哨兵。哨兵的工作只有两步:Await 自己盯的那个按钮;按钮被按下后,把自己的编号 Signal 到一个共享的 event(int) 上(event 及其 Signal/Await 来自 /Verse.org/Concurrency)。主流程完全不关心有几个哨兵——它只需要 Await 这个事件,第一个 Signal 到达的瞬间,它就醒了,手里还攥着「是几号按钮」。

既然 race 的臂数定死,那就反过来想:不让主流程去 race N 个任务,而是给每个按钮派一名 spawn 哨兵。哨兵的工作只有两步:Await(等)自己盯的那个按钮;按钮一被按下,就把自己的编号 Signal 到一个共享的「事件分发器」event(int) 上——Signal 相当于 Call 一次这个分发器、还捎带一个整数(event 及其 Signal / Await 来自 /Verse.org/Concurrency)。主流程完全不关心有几个哨兵——它只要 Bind 上去、Await 这个分发器,第一声 Signal 一到,它就醒了,手里还攥着「是几号按钮」。

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

which_button_device := class(creative_device):

    @editable
    Buttons:[]button_device = array{}

    FirstPressed:event(int) = event(int){}

    WatchButton(Index:int, Button:button_device)<suspends>:void =
        Button.InteractedWithEvent.Await()
        FirstPressed.Signal(Index)

    AwaitAnyButton()<suspends>:int =
        for (Index -> Button : Buttons):
            spawn{ WatchButton(Index, Button) }
        FirstPressed.Await()

    OnBegin<override>()<suspends>:void =
        Winner := AwaitAnyButton()
        Print("最先被按下的是 {Winner} 号按钮")

读图:Buttons 是一个按钮数组变量,点亮了 Instance Editable(@editable),关卡里摆几个就往里拖几个。FirstPressed 是一个「带整数负载的事件分发器」(event(int))——谁 Signal 它,就等于朝它喊一嗓子并附带一个编号。WatchButton 是每个按钮的哨兵函数:先 Await(等)自己盯的那个按钮,一旦被按,就把自己的编号 Signal(喊)给 FirstPressedAwaitAnyButton 用一个 ForEach 给每个按钮 spawn 一名哨兵,然后自己只 Await 一次 FirstPressed——第一个哨兵一喊,它就醒,手里攥着编号。OnBegin(Event BeginPlay)调用它,再 Print String 打出结果。

逐段拆一下。for (Index -> Button : Buttons) 同时拿到下标和元素,给每个按钮 spawn 一名哨兵——注意 spawn 体是单个异步函数调用,完全合规。FirstPressed.Await()AwaitAnyButton 的最后一个表达式,它等来的编号(哨兵 Signal 出来的 Index)就是整个函数的返回值——Verse 的隐式返回,一个 return 都不用写。别忘了在 UEFN 细节面板里把关卡中的按钮拖进 Buttons 数组(下一课细讲 @editable),否则数组是空的,哨兵一个也派不出去。

逐段拆一下。for (Index -> Button : Buttons) 是个 ForEach Loop,一圈同时拿到下标和按钮本体,给每个按钮 spawn 一名哨兵——注意 spawn 体里只有一个函数调用,完全合规。FirstPressed.Await()AwaitAnyButton 里的最后一个节点,它等来的编号(哨兵 Signal 出来的 Index)会直接接到这个函数的输出引脚上——这就是 Verse 的「隐式返回」:最后一个节点的输出即返回值,不用专门连一个 Return。别忘了在 UEFN 的 Details 面板里把关卡中的按钮拖进 Buttons 数组(那只 Instance Editable 小眼睛,下一课细讲),否则数组是空的,哨兵一个也派不出去。

三、模式的边角与改进

有两处边角值得留意。第一,哨兵是 spawn 出来的——按正课的说法,它们是「放出去的野马」,不会随作用域结束被取消:主流程拿到第一名之后,其余哨兵还各自挂在 Await 上。多数场景无伤大雅(之后再有按钮被按,Signal 一声也没人听);但若想干净收场,可以给哨兵内部加一场 race:一条臂等按钮,另一条臂等一个「收工事件」,主流程拿到结果后 Signal 收工——这正是拔高页「取消与 defer」里讲的自制取消模式。

有两处边角值得留意。第一,哨兵是 spawn 出来的——按正课的说法,它们是「放出去的野马」,不会随那段图结束被取消:主流程拿到第一名之后,其余哨兵还各自卡在 Await 上等着。多数场景无伤大雅(之后再有按钮被按,Signal 一声也没人接);但若想干净收场,可以给哨兵内部加一场 race:一条线等按钮,另一条线等一个「收工事件」,主流程拿到结果后朝这个事件 Signal 一声——这正是拔高页「取消与 defer」里讲的自制取消模式。

第二,同样的思路稍作变形,就能做出「动态 sync」:想等全部任务完成,就让每个哨兵完成后把计数器加一,计满时 Signal 完工事件,主流程照样只 Await 一次。event 就像一座广播塔:任务数量随便变,主流程永远只用听一个频道。这也是 Verse 社区处理「编译期表达式 + 运行时数据」矛盾的通用套路。

第二,同样的思路稍作变形,就能做出「动态 sync」:想等全部任务完成,就让每个哨兵干完后把一个计数器变量加一,加满时朝一个「完工事件」Signal 一声,主流程照样只 Await 一次。event 就像一座广播塔(其实就是事件分发器):任务数量随便变,主流程永远只用听一个频道。这也是 Verse 社区处理「图连好时数目定死、真实数目却要跑起来才知道」这对矛盾的通用套路。

想等「任意数量按钮中最先被按下的那一个」,当前版本的正确做法是?

本文整理自 Epic 开发者论坛:How to (race) await an array of tasks? ↗(含 Epic 工程师对 race 数组语法实现状态的确认)。