游戏脚本系统
Game Scripting
脚本语言是主要用来让用户控制和定制软件行为的编程语言;在游戏引擎中,它是高层、相对易用、能方便访问引擎大多数常用功能的语言,程序员和非程序员都能用它开发新游戏或给现有游戏做 mod。GEA 先区分数据定义语言和运行时脚本语言,梳理语言分类与游戏脚本的典型特征(解释执行、轻量、快速迭代、方便),介绍 QuakeC、UnrealScript、Lua、Python、Pawn,列出从"脚本回调"到"脚本驱动整个游戏"的六种架构,再以 Naughty Dog 的 DCScript 为例讲虚拟机与原生函数接口,最后是对象引用、事件收发、面向对象脚本、脚本状态机和协作式多线程脚本(GEA4 卷 II 17.9)。
运行时 vs 数据定义
游戏脚本语言通常有两类(卷 II 17.9.1):
- 数据定义语言:让用户创建并填充之后由引擎使用的数据结构,常是声明式的,在离线或数据载入内存时执行或解析;
- 运行时脚本语言:在运行时于引擎上下文中执行,通常用来扩展或定制引擎对象模型或其他系统的硬编码功能。
GEA 本节主要讨论后者。
编程语言的分类
(卷 II 17.9.2)
| 维度 | 说明 |
|---|---|
| 解释型 vs 编译型 | 编译型源码由编译器译成 CPU 可直接执行的机器码;解释型源码在运行时直接解析,或预编译成平台无关的字节码由虚拟机 (VM) 执行。VM 像一个虚构 CPU 的仿真,很容易移植到几乎任何硬件、嵌入到游戏引擎这样的宿主程序中;最大代价是执行速度通常远慢于原生机器码 |
| 命令式 | 程序是一串指令,每条执行操作或改变内存中数据的状态,如 C、C++ |
| 声明式 | 描述要做什么而不指定如何得到结果,由语言实现者决定;如 Prolog,HTML、TeX 等标记语言也可归入此类 |
| 函数式 | 技术上是声明式的子集,力图完全避免状态;程序由一组无副作用的函数组成,数据逐个函数传递直到得到结果。适合数据处理流水线;没有可变状态,多线程时不需要互斥锁。如 OCaml、Haskell、F# |
| 过程式 vs 面向对象 | 过程式以过程(函数)为基本构造单元;面向对象以类为基本单元,数据结构与"知道"如何操作它的函数紧密结合 |
| 反射式 | 数据类型、成员布局、函数和类层次关系在运行时可查询;非反射语言的元信息大多只在编译期可知。C# 是反射语言,C 和 C++ 不是 |
字节码与虚拟机的通用模式见 字节码模式。
游戏脚本语言的典型特征(卷 II 17.9.2.1):
- 解释执行:多数由 VM 解释,而不是编译,为了灵活、可移植和快速迭代。平台无关的字节码可以像数据一样处理,像其他资产一样载入内存,而不需要操作系统帮忙(PC 上的 DLL、PS3 上的 PRX 就需要);由 VM 而不是 CPU 直接执行,引擎能灵活决定脚本何时、如何运行。
- 轻量:多为嵌入式使用而设计,VM 简单、内存占用小。
- 支持快速迭代:改原生代码要重新编译链接、关闭再重启游戏(除非开发环境支持某种"编辑并继续");改脚本通常很快就能看到效果,有的引擎能即时重载脚本、完全不关游戏。无论哪种,从修改到在游戏中看到效果的周转时间通常都比改原生代码快得多。
- 方便易用:常针对具体游戏定制,提供让常见任务简单直观、不易出错的函数或专门语法:按名字查找对象、收发事件、暂停或操纵时间流逝、等待一段时间、实现有限状态机、把可调参数暴露给世界编辑器,甚至处理多人游戏的网络复制。
UE 的对照:UE 的 C++ 侧用 Live Coding 实时编译 缩短原生代码的修改周转,属于 GEA 括号里提到的"编辑并继续"一类(对比归纳)。
常见游戏脚本语言
根本抉择:选第三方商业或开源语言再定制,还是从零设计实现自定义语言(卷 II 17.9.3)?从零做自定义语言通常不值得麻烦和整个项目期间的维护成本;也很难招到已熟悉自研语言的策划和程序员,有培训成本;但它最灵活、最可定制,这种灵活性可能值得投入。很多工作室更愿意选一门相当知名、成熟的脚本语言,再扩展引擎专用功能。
-
QuakeC:id Software 的 John Carmack 为《雷神之锤》实现,本质是 C 的简化变体,直接挂接 Quake 引擎;不支持指针和任意结构体,但能方便地操作实体(Quake 对游戏对象的叫法),收发和处理游戏事件。解释型、命令式、过程式。它交到玩家手中的能力是催生如今 mod 社区的因素之一(卷 II 17.9.3.1)。
-
UnrealScript:大概是最著名的完全自定义脚本语言,类 C++ 语法,支持类、局部变量、循环、数组、结构体、字符串、哈希字符串 id(Unreal 中称
FName)和对象引用(但没有自由指针);解释型、命令式、面向对象。Epic 已不再支持 UnrealScript,开发者改用蓝图图形化"脚本"系统或写 C++ 来定制游戏行为(卷 II 17.9.3.2,另见 蓝图与 C++ 协作)。 -
Lua:知名且流行、易于集成,官网称其为"游戏中领先的脚本语言"。官网列出的优点:健壮成熟(用于 Adobe Photoshop Lightroom 和《魔兽世界》等许多游戏);文档好;运行时性能优秀,执行字节码比许多其他脚本语言快;可移植(各种 Windows 和 UNIX、移动设备、嵌入式微处理器);为嵌入式设计,内存占用很小(解释器加所有库约 350 KiB);简单强大可扩展,核心很小但有元机制可以几乎无限扩展,例如 Lua 本身不是面向对象语言,但可以通过元机制加入 OOP 支持;免费,MIT 许可证开源。Lua 是动态类型(变量没有类型,只有值有类型),主要数据结构是表(关联数组,针对按键索引做了优化);与 C 接口方便;把代码块(chunk)当作一等对象,能执行包含 Lua 代码的字符串;支持协程这种协作式多任务(线程必须显式让出 CPU,而不是像抢占式多线程那样被时间片切换)。陷阱:灵活的函数绑定使得很容易把
sin()这样的重要全局函数重定义成完全不同的东西。总体上是优秀的游戏脚本语言(卷 II 17.9.3.3)。 -
Python:过程式、面向对象、动态类型,为易用、与其他语言集成和灵活性而设计。官网列出的特点:语法清晰可读(缩进是唯一的作用域定义方式,与 C 的花括号相对);反射(类是一等对象,运行时可操作和查询);面向对象内建于核心语言,这是相对 Lua 的优势,使它更容易与游戏对象模型集成;模块化(层次化包);基于异常的错误处理;标准库和第三方模块丰富;可嵌入;文档丰富。主要数据结构是列表和字典,两者可互相嵌套。支持鸭子类型:对象的函数接口决定其类型,而不是静态继承层次,因此不需要继承就能多态;GEA 说它在某些方面类似 C++ 模板元编程,但调用方与被调用方在运行时动态绑定,可以说更灵活(名字来自据称出自 James Whitcomb Riley 的"像鸭子一样走路、像鸭子一样叫,我就叫它鸭子")(卷 II 17.9.3.4)。
-
Pawn(Small-C):Marc Peter 创建的轻量、动态类型、类 C 脚本语言,前身是 Small,Small 又演化自 Ron Cain 和 James Hendrix 写的 C 子集 Small-C。源码编译成字节码(也叫 P-code)由 VM 解释。内存占用小、执行快;支持有限状态机,包括状态局部变量,这使它很适合游戏;开源,Zlib/libpng 许可证免费使用;曾用于 Midway 的 Freaky Flyers 等项目(卷 II 17.9.3.5)。
原书问题:(1) GEA 两次说 Pawn 是"动态类型"的,但 Pawn 官方(CompuPhase Pawn 主页与《The Pawn Language Guide》)称它是无类型 (typeless) 语言:只有一种"单元 (cell)"数据类型,用编译期的标签 (tag) 做类型检查,变量值并不在运行时携带类型,这与 Lua 那种"值带类型"的动态类型不是一回事。(2) 许可证已过时:CompuPhase 主页现写明 Pawn 工具包采用 Apache License 2.0(附链接例外条款),而非 Zlib/libpng。
补充:Lua 的"约 350 KiB"随版本变化;Lua 官网 About 页对 Lua 5.5.1 的说法是 64 位 Linux 上解释器加全部标准库约 293K,Lua 库约 484K。
脚本的架构
脚本在引擎中可以扮演各种角色,从替对象或系统完成简单功能的小片段,到管理整个游戏运行的高层脚本(卷 II 17.9.4):
| 架构 | 说明 |
|---|---|
| 脚本回调 | 引擎功能主要用原生语言硬编码,但关键部分设计成可定制,常用钩子函数/回调实现;例如游戏循环更新对象时调用一个可以用脚本写的可选回调,让用户定制对象随时间的更新方式 |
| 脚本事件处理器 | 事件处理器是特殊的钩子,让对象响应世界中(如爆炸)或引擎本身(如内存不足)的事件;很多引擎允许用脚本或原生语言写 |
| 用脚本扩展或定义对象类型 | 回调和事件处理器是小规模的例子;可以一直扩展到完全用脚本定义新对象类型:用继承(脚本类派生自原生类)或组合(把脚本类实例挂到原生对象上) |
| 脚本组件或属性 | 在基于组件或属性的对象模型中,允许部分或完全用脚本构造新组件/属性。Gas Powered Games 的《地牢围攻》采用基于属性的对象模型,属性可以用 C++ 或其自研脚本语言 Skrit 实现,项目结束时约有 148 种脚本属性类型和 21 种原生 C++ 属性类型 |
| 脚本驱动引擎 | 用脚本驱动整个引擎系统,例如对象模型完全用脚本编写,只在需要底层服务时调用原生代码 |
| 脚本驱动游戏 | 反转关系:脚本主导一切,原生引擎只是提供高速功能的库。Panda3D 是例子:游戏可以完全用 Python 编写,C++ 实现的原生引擎像一个被脚本调用的库(Panda3D 游戏也可以完全用 C++ 写) |
与原生语言的接口
脚本必须能被引擎执行,通常也要能在引擎中发起操作(卷 II 17.9.5.1)。运行时脚本语言的 VM 一般嵌入引擎:引擎初始化 VM,在需要时运行脚本并管理其执行。执行单位因语言而异:函数式脚本语言常以函数为主要单位,引擎要按名字找到对应字节码,再生成一个 VM(或让已有 VM)执行;面向对象脚本语言常以类为主要单位,可以生成销毁对象、在实例上调用方法。
双向通信通常很有益,所以多数脚本语言也允许从脚本调用原生代码:基本做法是让某些脚本函数用原生语言实现,脚本照常调用,VM 发现该函数有原生实现,就(按名字或其他唯一函数标识)查找对应原生函数地址并调用。例如 Python 类或模块的部分或全部成员函数可以用 C 函数实现,Python 维护一张方法表,把每个 Python 函数名(字符串)映射到实现它的 C 函数地址。
案例:Naughty Dog 的 DCScript
DC 是 "data compiler"(数据编译器),是 Naughty Dog 构建引擎所用各种数据的工具,编译后的 DCScript 程序只是 DC 产出的众多数据类型之一。DC 本身和 DCScript 都是 Scheme(Lisp 的变体)的变体。DCScript 中的可执行代码块称为 script lambda,大致相当于 Lisp 家族中的函数或代码块;程序员写 script lambda 并给它们全局唯一的名字,DC 编译器把它们转换成字节码块,游戏运行时载入内存,C++ 里可以用简单的函数接口按名字查找(卷 II 17.9.5.1)。
虚拟机:引擎拿到 script lambda 字节码指针后,调用"虚拟机执行"函数并传入指针即可。这个函数出奇地简单:循环逐条读取并执行字节码指令,全部执行完就返回。
- VM 有一组寄存器,用 Variant(所有数据类型的联合体,见 事件与消息传递)实现,可以存脚本要处理的任何数据;有把数据载入寄存器的指令、查找使用寄存器数据的指令、语言中所有数学运算的指令,以及条件检查指令(实现
(if ...)、(when ...)、(cond ...)等)。 - VM 支持函数调用栈:script lambda 可以调用用
(defun ...)定义的其他 script lambda。DCScript VM 的调用栈字面上就是寄存器组的栈,每个新函数有自己私有的寄存器组,因此不必在调用前保存、返回后恢复寄存器。遇到调用指令时按名字查找被调 lambda 的字节码,压入新栈帧,从它的第一条指令继续;遇到返回指令时弹出栈帧和返回"地址"(其实就是调用方 lambda 中调用指令之后那条指令的索引)。
GEA 给出的核心指令循环伪代码(已核对排版页卷 II p.645–647,原书代码无误;这里压缩了注释):
void DcExecuteScript(DCByteCode* pCode) {
DCStackFrame* pCurStackFrame = DcPushStackFrame(pCode);
// 直到栈帧用完(顶层 script lambda 返回)为止
while (pCurStackFrame != nullptr) {
// 取下一条指令;返回指令总在最后并会弹出栈帧,所以不会取空
DCInstruction& instr = pCurStackFrame->GetNextInstruction();
switch (instr.GetOperation()) {
case DC_LOAD_REGISTER_IMMEDIATE: {
Variant& data = instr.GetImmediateValue();
U32 iReg = instr.GetDestRegisterIndex();
Variant& reg = pCurStackFrame->GetRegister(iReg);
reg = data;
} break;
// 其他载入/存储寄存器操作……
case DC_ADD_REGISTERS: {
U32 iRegA = instr.GetDestRegisterIndex(); // 结果存入 A
U32 iRegB = instr.GetSrcRegisterIndex();
Variant& dataA = pCurStackFrame->GetRegister(iRegA);
Variant& dataB = pCurStackFrame->GetRegister(iRegB);
dataA = dataA + dataB;
} break;
// 其他数学运算……
case DC_CALL_SCRIPT_LAMBDA: {
U32 iReg = instr.GetSrcRegisterIndex(); // 存放被调 lambda 名字的寄存器
Variant& lambda = pCurStackFrame->GetRegister(iReg);
DCByteCode* pCalledCode = DcLookUpByteCode(lambda.AsStringId());
if (pCalledCode)
pCurStackFrame = DcPushStackFrame(pCalledCode);
} break;
case DC_RETURN: {
// 弹出栈帧;若已是栈顶 lambda 则返回 nullptr,循环结束
pCurStackFrame = DcPopStackFrame();
} break;
// 其他指令……
}
}
}
GEA 说明 DcPushStackFrame()、DcPopStackFrame() 以某种方式管理寄存器组栈,DcLookUpByteCode() 能按名字找到任何 script lambda,这里不给出实现,示例只为展示 VM 内循环如何工作。
原生函数:script lambda 还能调用原生函数,即作为引擎钩子的 C++ 全局函数。VM 遇到调用原生函数的指令时,用引擎程序员硬编码的全局表按名字查找 C++ 函数地址,找到就从当前栈帧的寄存器取参数调用。因此 C++ 函数的参数总是 Variant 类型,返回值也必须是 Variant,存入当前栈帧的寄存器供后续指令使用:
typedef Variant DcNativeFunction(U32 argCount, Variant* aArgs);
struct DcNativeFunctionEntry { StringId m_name; DcNativeFunction* m_pFunc; };
DcNativeFunctionEntry g_aNativeFunctionLookupTable[] = {
{ SID("get-object-pos"), DcGetObjectPos },
{ SID("animate-object"), DcAnimateObject },
// ...
};
Variant DcGetObjectPos(U32 argCount, Variant* aArgs) {
DcArgIterator args(argCount, aArgs, 2); // 最多 2 个参数
Variant result;
result.SetAsVector(Vector(0.0f, 0.0f, 0.0f)); // 默认返回值
StringId objectName = args.NextStringId(); // 缺失或非法参数会自动报错
Point* pDefaultPos = args.NextPoint(kDcOptional);
GameObject* pObject = GameObject::LookUpByName(objectName);
if (pObject) {
result.SetAsVector(pObject->GetPosition());
} else if (pDefaultPos) {
result.SetAsVector(*pDefaultPos);
} else {
DcErrorMessage("get-object-pos: Object '%s' not found.\n", objectName.ToDebugString());
}
return result;
}
原生函数必须检查收到的参数个数和类型是否符合预期,并准备处理脚本程序员的调用错误;Naughty Dog 写了一个参数迭代器来方便地逐个提取并校验参数。StringId::ToDebugString() 做反向查找,需要引擎维护字符串 id 到原始字符串的数据库;开发期间很有用,但很占内存,最终发行版应去掉。函数名里的 "Debug" 提醒:反向转换只用于调试,游戏本身绝不能依赖它。
脚本中的对象引用
原生语言的对象引用机制(C++ 指针或引用)在脚本里未必有效(脚本可能根本不支持指针),需要可靠的方式让脚本引用对象(卷 II 17.9.5.2,对象引用的一般讨论见 游戏对象引用与查询):
- 不透明数字句柄:脚本由引擎传入,或通过查询(玩家半径内的所有对象、某名字对应的对象)获得句柄,再把句柄作为参数调用原生函数操作对象,原生侧把句柄转回指针。简单,任何支持整数的脚本语言都能支持;但不直观、难用。
- 对象名字符串:人类可读、直观,与世界编辑器中的对象名直接对应;还可以保留特殊名字赋予"魔法"含义,例如 Naughty Dog 脚本中保留名
self总是指当前运行脚本所附着的对象,策划写好脚本挂到对象上,就能用(animate 'self name-of-animation)在该对象上播放动画。缺点:比整数 id 占内存;长度可变,复制需要动态分配;比较慢;手打对象名容易出错;有人在世界编辑器里改了对象名却忘了改脚本,脚本就会坏。 - 哈希字符串 id:把任意长度的字符串转为整数,理论上两全其美:像字符串一样可读,又有整数的运行时性能。前提是脚本语言以某种方式支持它,理想是由脚本编译器把字符串转成哈希 id,运行时代码只处理 id(调试时能在调试器里看到对应字符串更好);做不到的话,也可以让用户在脚本里用字符串,在调用原生函数时于运行时转换。DCScript 利用 Scheme 原生的符号编码字符串 id:在 DC/Scheme 中写
'foo(或完整写法(quote foo))对应 C++ 中的SID("foo")(见 字符串与唯一标识符)。
在脚本中接收与发送事件
允许用脚本写事件处理器,为定制游戏的硬编码行为打开了强大的途径(卷 II 17.9.5.3)。事件通常发给单个对象并在其上下文中处理,所以脚本处理器要以某种方式与对象关联:
- 按对象类型注册:不同类型对同一事件有不同响应,同类型所有实例一致响应;处理器可以是简单脚本函数,或面向对象脚本语言中类的成员;通常会传入事件目标对象的句柄,就像 C++ 成员函数收到
this指针; - 按实例关联:同一类型的不同实例对同一事件可以有不同响应;
- Naughty Dog 引擎(神秘海域、最后生还者系列)中脚本本身就是对象:可以关联到单个游戏对象、挂到区域(用于触发游戏事件的凸体)上,或作为独立对象存在于世界中。每个脚本可以有多个状态(脚本就是有限状态机),每个状态可以有一个或多个事件处理代码块。对象收到事件时可以选择用原生 C++ 处理,同时检查是否附有脚本对象,有就把事件发给该脚本的当前状态;该状态有对应处理器就调用,否则忽略。
发送事件(卷 II 17.9.5.4)更强大:从脚本向引擎或其他脚本生成并发送事件,最好还能在脚本中定义全新的事件类型。事件类型是字符串或字符串 id 时这很简单:想个新名字写进脚本即可。脚本 A 定义新事件类型发给脚本 B,B 定义了该类型的处理器,A 就能"和 B 说话"。有的引擎里,事件或消息传递是脚本中对象间通信的唯一方式,这可以是优雅而强大灵活的方案。
面向对象的脚本
有的脚本语言天生面向对象,有的不直接支持对象但提供实现类和对象的机制;既然很多引擎的玩法基于面向对象的对象模型,在脚本中支持某种 OOP 也合理(卷 II 17.9.5.5)。
- 在脚本中定义类:类不过是一堆数据加一些相关函数,任何能定义新数据结构、能存储和操作函数的脚本语言都能实现类,例如 Lua 中可以用存储数据成员和成员函数的表构建类。
- 脚本中的继承:有两种:脚本类派生自脚本类(面向对象脚本语言多半原生支持),以及脚本类派生自原生类(即使脚本语言支持继承也很难实现,难在跨越两种语言、两套底层对象模型,具体实现取决于所集成的语言对)。GEA 说 UnrealScript 是他遇到过的唯一能让脚本类无缝派生自原生类的脚本语言。
- 脚本中的组合/聚合:不依赖继承也能扩展类层次,只需能定义类并把实例关联到原生对象:例如游戏对象持有一个指向完全用脚本写的可选组件的指针或引用,把某些关键功能委托给它;脚本组件可以有在对象更新时被调用的
Update(),也可以把部分成员函数注册为事件处理器,对象收到事件时调用脚本组件上的相应处理器(见 组件模式)。
UE 的对照:GEA 说 UnrealScript 能让脚本类无缝派生自原生类;在 UE5 中这一角色由蓝图承担,蓝图类可以以 C++ 类为父类,C++ 通过
BlueprintImplementableEvent、BlueprintNativeEvent等把扩展点留给蓝图,见 蓝图与 C++ 协作。GEA 的"唯一"是作者个人经验的说法;第 4 版同时说明 Epic 已不再支持 UnrealScript,所以这条比较只具历史意义。
脚本有限状态机
游戏编程中的很多问题用有限状态机解决很自然,所以有的引擎把 FSM 直接内建进核心对象模型:每个对象可以有一个或多个状态,更新函数、事件处理函数等都放在状态里而不是对象本身;简单对象只定义一个状态,复杂对象可以定义多个各有不同更新和事件处理行为的状态(卷 II 17.9.5.6)。
- 若引擎支持状态驱动的对象模型,在脚本语言中也支持 FSM 就很合理;即使核心对象模型不原生支持,也可以在脚本侧用状态机提供状态驱动的行为;
- 任何语言都能用类实例表示状态来实现 FSM,但有的脚本语言专门提供工具:面向对象脚本语言可能提供让一个类包含多个状态的专门语法,或帮助把状态对象聚合进中央枢纽对象、再把更新和事件处理委托给它的工具;即使都没有,也可以约定一套 FSM 实现方法,在每个脚本中遵守(见 状态模式与有限状态机)。
多线程脚本
能并行执行多个脚本往往很有用,在高度并行的硬件上尤其如此;从脚本程序员的角度看,这是并行编程范式,哪怕所有脚本实际上在一个 CPU 上轮流执行(卷 II 17.9.5.7)。
- 多数提供并行的脚本系统采用协作式多任务:脚本一直执行,直到显式让给另一个脚本;与之相对的抢占式多任务中,任何脚本随时可能被中断以让其他脚本执行。
- 简单的协作式做法是让脚本显式睡眠,等待相关的事发生:等待指定秒数、等到收到某个事件、等另一个执行线程到达预定义的同步点。脚本睡眠时把自己放进睡眠脚本线程列表,并告诉 VM 可以开始执行另一个符合条件的脚本;系统跟踪唤醒每个睡眠脚本的条件,条件成立时唤醒等待它的脚本继续执行。(协程与纤程见 进程、线程与纤程。)
GEA 的例子:脚本管理两个角色和一扇门的动画。两个角色走向门,各自到达的时间不同且不可预测;两人都到达后,其中一人播放"开门"动画;动画时长不硬编码进脚本,这样动画师改了动画也不用改脚本,所以线程再次睡眠等待动画播完。
procedure DoorCinematic() {
thread Guy1() {
CharacterWalkToPoint(guy1, doorPosition);
WaitUntil(CHARACTER_ARRIVAL); // 睡到他走到
RaiseSignal("Guy1Arrived"); // 用信号告诉其他线程
WaitUntil(SIGNAL, "Guy2Arrived"); // 等另一个人也到
CharacterAnimate(guy1, "OpenDoor");
WaitUntil(ANIMATION_DONE);
RaiseSignal("DoorOpen");
CharacterWalkToPoint(guy1, beyondDoorPosition);
}
thread Guy2() {
CharacterWalkToPoint(guy2, doorPosition);
WaitUntil(CHARACTER_ARRIVAL);
RaiseSignal("Guy2Arrived");
WaitUntil(SIGNAL, "Guy1Arrived");
WaitUntil(SIGNAL, "DoorOpen"); // 等 guy1 开门
CharacterWalkToPoint(guy2, beyondDoorPosition);
}
}
- 假想的脚本语言能在一个函数里定义多个执行线程。"睡到角色到达目标点"在现实中可能是让角色向脚本回发一个事件,事件到达时唤醒线程。
- 信号就是一个有名字的布尔标志,初始为 false,线程调用
RaiseSignal(name)后变为 true;其他线程可以睡眠等待某个具名信号变真,变真时被唤醒继续执行。 - **"先发自己的信号、再等对方的信号"**的模式用于同步两个线程:谁先发信号无关紧要,只有两个信号都发出后两个线程才会都醒来,并且完全同步(GEA 图 17.24 画了 Guy1 先到和 Guy2 先到两种情形)。
sequenceDiagram participant G1 as Guy1 线程 participant S as 信号 participant G2 as Guy2 线程 G1->>S: RaiseSignal Guy1Arrived G1->>S: 等待 Guy2Arrived(睡眠) G2->>S: RaiseSignal Guy2Arrived S-->>G1: 唤醒 G2->>S: 等待 Guy1Arrived(已为真,不等待) G1->>S: 播放开门并等待动画完成,然后 RaiseSignal DoorOpen S-->>G2: 等待 DoorOpen 后唤醒,穿过门
上图是 Guy1 先到的情形,从上到下按时间顺序;Guy2 先到时两条线程的角色互换,结果同样同步。
出处
- Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.9 节