中EN
概念页

游戏脚本系统

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 先到时两条线程的角色互换,结果同样同步。

  1. Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
  2. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.9 节

相关页面

游戏开发字节码模式Bytecode Pattern字节码模式 (Bytecode Pattern) 把行为编码成虚拟机 (VM) 能执行的指令序列,让行为拥有数据一样的灵活性:可以热加载、与引擎核心隔离(沙盒化),还可以由非程序员编写。它是全书最复杂的模式,代价是要写编译器或创作工具,调试器也用不上了,而且这类小语言容易无节制地膨胀。游戏开发 · UE5蓝图与 C++ 协作Blueprint & C++ InteropEpic 官方建议:多数项目混用蓝图 (Blueprint) 与 C++,以 C++ 作为基础、在其上构建蓝图类。C++ 适合“编程”——定义系统;蓝图适合“脚本”——在已有系统上定义具体行为。两者通过反射说明符互相调用:C++ 用 BlueprintCallable/BlueprintReadWrite 等暴露给蓝图,用 BlueprintImplementableEvent/BlueprintNativeEvent 让蓝图实现或覆盖 C++ 声明的函数。游戏开发事件与消息传递Events & Message Passing游戏天然是事件驱动的:爆炸、玩家被敌人发现、血包被捡起都是事件,系统要通知感兴趣的对象并让它们以各自的方式处理。用虚函数 OnExplosion() 通知属于静态类型的后期绑定,迫使基类为所有可能的事件声明虚函数;GEA 主张把事件封装成对象(类型 + 参数),实现动态类型的后期绑定,从而获得单一处理函数、持久性和盲转发。GEA 依次讨论事件类型(枚举 vs 字符串/字符串 id)、参数(派生类、Variant 数组、键值对)、处理器、职责链、兴趣注册、排队与否的利弊,最后是数据驱动的事件系统和以数据通路连接端口的图形化系统(如 UE 蓝图)(GEA4 卷 II 17.8)。游戏开发状态模式与有限状态机State Pattern & Finite State Machines状态模式 (State Pattern) 把对象“依赖状态的行为”分散到各个状态类中,对象只持有指向当前状态的指针并把调用委托给它,状态切换就是换指针。它是实现有限状态机 (Finite State Machine, FSM) 的一种方式。FSM 能用很强的结构约束理清一团乱麻的标志位代码;状态组合爆炸、需要复用和需要“回到之前状态”时,分别用并发状态机、分层状态机和下推自动机来扩展。游戏开发进程、线程与纤程Processes, Threads & Fibers并发编程依赖操作系统提供的服务:内核态与用户态、中断、系统调用、抢占式多任务。进程只是为线程提供运行环境(虚拟地址空间与资源),线程才是操作系统的基本执行单位(调用栈 + 寄存器)。线程由内核抢占调度;纤程 (Fiber) 由程序协作调度,适合实现引擎任务系统;用户级线程与协程完全在用户空间切换上下文,更轻量。游戏开发字符串与唯一标识符Strings & Unique Identifiers字符串在 C/C++ 中只是字符数组,变长意味着要么硬编码长度上限、要么动态分配;比较和复制都是 O(n)。引擎内部大量用字符串做资源名和对象 ID,处理不当会严重拖慢游戏。GEA 的建议:运行时代码尽量避免字符串类,若用则了解其成本并按引用传递;路径类是值得专门做的例外;对象与资产的唯一标识用哈希字符串 ID(string id),兼得字符串的可读性与整数的比较速度。游戏开发游戏对象引用与查询Game Object References & Queries游戏对象需要唯一 id 来区分、查找和充当通信目标;找到对象后还要能引用它,引用的寿命可以从一次函数调用到许多分钟。GEA 比较三种实现:裸指针最快最简单,但有孤儿对象、悬垂指针、非法指针三类问题,且不能配合内存重定位;智能指针可以检测对象已删除、做引用计数,但很难写对,GEA 个人建议远离它,非用不可就用 C++11 标准库或 Boost 的成熟实现;句柄是全局句柄表的整数索引,加上唯一 id 防止槽位复用后指向错误对象,简单而安全。对象查询则不追求通用数据库,而是为最常用的查询建专门的数据结构(GEA4 卷 II 17.5)。游戏开发运行时对象模型架构Runtime Object Model Architectures玩法基础系统 (gameplay foundation system) 是位于"引擎与游戏分界线"附近、供构建游戏规则和动态元素的一组运行时组件,其中最复杂的是运行时对象模型:它在运行时实现世界编辑器展示给策划的抽象对象模型。GEA 把架构分为两大类:以对象为中心(每个工具侧对象对应一个或一小组类实例)和以属性为中心(对象只是一个 id,属性分散在按属性类型划分、以 id 为键的表里)。前者从单体类层次走向组合与组件,后者更省内存、更缓存友好、更易数据驱动,但难以表达属性间关系、也更难调试(GEA4 卷 II 17.1–17.2)。游戏开发子类沙盒Subclass Sandbox子类沙盒 (Subclass Sandbox) 让基类提供一组受保护的“提供的操作”和一个抽象的沙盒方法,派生类只用这些操作来实现沙盒方法。这样大量派生类(例如几十种超能力)只耦合到基类,不直接接触引擎的各个系统;代价是基类会越来越臃肿,变成脆弱基类。游戏开发 · UE5编辑器扩展与 Python 脚本Editor Extension & Python Scripting扩展 UE 编辑器有三条主要路线,门槛由低到高:Python 脚本(只在编辑器中可用,适合批量处理资产和串联外部 DCC 管线)、Editor Utility Widget(基于 UMG 的蓝图编辑器界面,做成自定义标签页),以及 C++ 编辑器插件(如 Editor Standalone Window 模板,用 Slate 的声明式语法构建窗口)。游戏开发 · UE5Live Coding 实时编译Live CodingLive Coding 集成了 Live++,在引擎运行时重新编译 C++ 并给二进制打补丁;它比旧的 Hot Reload 快得多、也更灵活,新安装默认启用,快捷键 Ctrl+Alt+F11。默认开启的「对象重实例化 (Object Reinstancing)」会替换掉现有的 UCLASS/USTRUCT 等对象实例,从而支持新增函数、变量这类结构性修改;关闭它时只适合小改动,大改通常会崩溃。坑:在 .cpp 构造函数里改的默认值不会作用到已存在的实例。游戏开发组件模式Component Pattern组件模式 (Component Pattern) 让一个实体横跨多个领域(输入、物理、图形、AI……),同时不让这些领域彼此耦合:每个领域的代码放进自己的组件类,实体本身退化为组件的容器。它是现代引擎实体模型的基础,代价是对象变成需要正确装配的一组对象,组件之间如何通信也成了要做的设计决策。游戏开发游戏世界与游戏对象Game World & Game Objects游戏由玩法 (gameplay) 而不是技术定义;游戏机制 (game mechanics) 是支配游戏中各实体交互的规则,以及目标、成败标准、玩家能力、非玩家实体和整体流程(GEA 引用的论文称实现玩法的软件系统为游戏的 G 因子)。大多数游戏世界由静态元素和动态元素组成,划分为若干世界块,并由高层游戏流程组织玩家目标;动态元素通常实现为游戏对象 (GO),引擎提供的建模设施称为游戏对象模型,且分为工具侧和运行时两套(GEA4 卷 II 16、16.1–16.2)。综合引擎运行时架构总览Engine Runtime Architecture Overview本页把 wiki 中分散在 GEA 第 1、6、8、17 章及 GPP、UE5 相关页面里的内容串成一条线,回答"一个游戏引擎在运行时是怎样组织起来的":静态结构上是自下而上的分层,上层依赖下层;启动关闭按依赖顺序显式进行;每一帧由主循环驱动各子系统批量更新,游戏对象的逻辑被安排在子系统阶段之间,并按依赖分桶或设先后关系;多核上一帧被拆成成百上千个作业;玩法基础系统(对象模型、事件、脚本、世界加载流送、高层流程)位于引擎与游戏的分界线附近。各结论的细节与出处见所链接的概念页。游戏策划迭代与原型Iteration & Prototyping选定点子后,设计要反复通过八个过滤器 (Eight Filters) 的检验;而循环法则 (Rule of the Loop)——"测试并改进设计的次数越多,游戏越好"——没有例外。要让每次循环有价值(风险评估与缓解)、让循环尽可能快(粗糙原型),这正是 Boehm 螺旋模型和敏捷开发 (agile development) 的核心。另有 10 条原型技巧、"先做玩具"原则、Cerny "The Method"、作者的"计划砍半"与"50%"两条经验法则,以及别忘了检查激情 (passion)(第 3 版第 8 章;第 1 版为第 7 章)。

被这些页面引用1 篇

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原