红警 2 地图触发组件的逻辑原理
红警 2 地图触发组件的逻辑原理
阅览注意
本文的章节安排是有意模仿我的毕业论文的。也由于本文的内容比较硬核,没有了解过程序设计的地图作者可能会有阅读困难。
对此,我会试图在有涉及的地方以这种注解的形式加以「翻译」。
绪言
红警 2 的地图创作,但凡要实现一些功能的,首先会想到的必然是触发和 INI。 特别是单人战役和多人合作战役,离了触发,剧情流程也就荡然无存1,其本质便只剩下遭遇战了。
然而,大部分的地图创作者们由于缺乏对触发比较明晰的认识,圈子里的教程也往往偏「实用主义」, 他们经常是「知其然而不知其所以然」,若需要加入稍微复杂一点的流程设计,往往束手无策。
我们已知一条基本哲理:复杂事物是若干简单事物的有机组合。由于触发与数学的「命题」——若 p 则 q——非常类似, 因此私以为从逻辑层面剖析触发的运行原理对于复杂触发的设计应能起到一定指导作用, 并与Ares
Phobos
等扩展平台一道为我们展现出gamemd.exe
这个古董的更多可能性。
一、触发组件相关理论基础
1.1 触发
地图触发是早在《命运与征服:泰伯利亚黎明》就引入的系统,负责处理地图当中的「事件」1。
一局游戏瞬息万变,其中总有一些既成的、游戏引擎能感知的事,叫做事件。比如什么关键建筑被打爆了啊,哪家缺电缺钱了,等等。 这些事件会被触发捕捉到,并驱动后者去执行相应的行为,也就是游戏引擎能做到的各种效果:可以是刷兵,改变光照,炸个桥,平地起心灵信标……诸如此类。
再次强调,捕获到的「事件」、要执行的「行为」,都仅限「引擎能做到的」范围之内。 你不能张口就要求给 20 多年前的游戏引擎加多核优化,引擎也只能表示:臣妾做不到啊。
「事件」「行为」
对于没有接触过编程的读者来说,你可以简单将事件理解为「条件」,行为则理解成「结果」。
在红警 2,触发更类似于数学中的命题:「若 p 则 q」,其中事件 p 和行为 q 都可以不止一条,并且 p1 p2 p... 之间、q1 q2 q... 之间有一定的连接关系。
1.2 局部变量
变量系统则在《命运与征服:泰伯利亚之日》才开始出现,根据[VariableNames]
小节所处的位置不同, 分为 Rules 里的全局变量,和地图里的局部变量2。本文主要讨论局部变量。
在地图创作中,局部变量就是对设计者有具体意义的,会因触发(和动作脚本)做出改变的,临时在某一局游戏起作用的数值。
值得一提的是,从原版一直到 Ares 扩展平台,局部变量均有上限 100 个、bool
类型的限制。
直到 @secsome 在 Phobos 平台引入了无限量int32
扩展局部变量,这种限制才被打破。
数值的类型
数值类型在大多数编程语言中应该都是一脉相承的:
bool
:布尔(所谓开关),通常分为0
(表示否定)和1
(表示肯定)两种取值;short
:短整数(或称int16
),因为是 16 位,可至多表示 216 - 1,即65535
。long
或int
:长整数(即int32
),有些玩家应该很熟悉 232 - 1,即2147483647
。
更详细的内容还请移步「计算机组成原理」第二章:数据的表示与运算。
二、触发组件的程序逻辑分析
2.1 触发的逻辑本质
在程序语言里,有一种分支结构称为if
用来描述上述的「若 p 则 q」:
if (condition) // 条件
do_something(); // 结果
抛开触发的所属、难度开关等等其他属性,我们不妨就把一个触发当成是这种if
结构。
重要
为了方便讨论,「允许/禁止触发」的行为不会直接使用do_something()
这种函数表示。
触发行为在 INI 和引擎中的实际表示
以触发行为为例,触发行为在地图里是这种 INI 表示:
0x01BF52 = len_actions, a1_type, a1p1, a1p2, ..., a1p6, a1_waypoint, a2_type, ...
在游戏引擎中,一条 Action 由TActionClass
管理(下列声明有所省略,详见 YRpp):
class TActionClass : public AbstractClass {
public:
TriggerAction ActionKind; // aX_type
union {
RectangleStruct Bounds; // map bounds for use with action 40
struct {
int Param3;
int Param4;
int Param5;
int Param6;
};
}; // It's enough for calling Bounds.X, just use a union here now. - secsome
int Waypoint; // aX_waypoint
int Value2; // multipurpose // aXp2
int Value; // multipurpose // aXp1
};
其中 P1 决定了 P2 参数的类型。由于 P3-P6 均为int
类型,无法满足文本、数值、触发等多种类型需求,所以由 Value (P1) 采取类似enum
的设计,Value2 则记录真实参数 P2 的指针地址。
触发行为的具体实现
游戏引擎仍然用TActionClass
声明和实现原版的行为(也就是地编靠前的 100 多号),扩展平台则用TActionExt
实现扩展。 以 Phobos 的「编辑变量」功能为例:
bool TActionExt::EditVariable(
TActionClass* pThis, HouseClass* pHouse, ObjectClass* pObject,
TriggerClass* pTrigger, CellStruct const& location)
{
// blabla
return true;
}
pThis
为TActionClass
的指针,在「触发行为在 INI 和引擎中的实际表示」中已经介绍过,它可以记录行为参数;pHouse
系触发所属方,比如行为 36 - 全部更改所属 要变走「触发所属方」的全部东西。pObject
是关联对象,也就是地编里点某个建筑、载具出来的「关联触发」。pTrigger
是这条行为归属的触发,作用我还不清楚。location
可能是路径点,有可能是别的什么表示位置的玩意,我也不清楚。
至于事件condition
和行为do_something()
,我们已经知道它们可以不止一条。那么多条件和多结果是如何串起来的呢? 你可以自行翻阅各扩展平台的 YRpp,或是在 FA2 等地图编辑器中实验一下,这边就直接说结论了:
- 多个条件之间以逻辑且(AND)连接,所有条件必须全部满足,才可以执行对应的结果;
- 多个结果之间链式组织,但因为每一条结果执行耗时极短,表面上看似乎是同一帧内同时完成。
逻辑且
不妨用一句电影中的名台词:I will find you AND kill you.
,翻译过来即“我会找到你,并且杀了你”。 于是易得:
- 我要是没找到你,到死也没能杀你,那整句话都是假
False
命题; - 我找到了你,但是你被车撞了,那后半句“我会杀你”就是假
False
命题; - 我没找到你,但我雇佣杀手杀了你,那前半句“我会找到你”也是假
False
命题; - 当且仅当我找到你,并且杀了你,两个子句都为真,整句话才是真
True
命题。
2.2 从程序执行流说起……
2.2.1 顺序结构
以被广为改编的「脑死」为例,它的任务流程具有很典型的顺序性:首先需要建立一座锅盖苏军雷达,然后打掉最后一座心灵控制器之后扫黑除恶。 实际上,大部分的剧情流程也是这种线性结构,将目标从头执行到尾。
而程序当中最常见的莫过于这种顺序结构:
int main(void) {
printf("hello world");
printf("\n");
printf("by ssks");
return 0;
}
上面的 C 语言代码中,首先在终端中打出hello world
这一串鹰文,然后打出\n
换行,最后在下一行打出by ssks
又一串。
这样的「首先……然后……最后……」可以表明,上述main(void)
当中的语句是逐条、自上而下执行的,这就是程序中的顺序结构。
既然如此,我们不妨就把一整局战役当作一个超大的main
函数。并且,我们已知了触发可以用if
语句表示, 那么实际上由触发主导的任务流程便可以看作是一个个if
串联起来的函数主体:
int Braindead(void) { // 脑死
// [intro] 0. start
if (true) { // 事件 8 - 任何事件
DisableUserInput(); // 行为 46 - 禁止用户输入
PlayEVA("EVA_EstablishBattlefieldControl"); // 行为 21 - 播放语音
}
// [intro] 1. brief, init stage1
if (TimeElapsed(6)) { // 事件 13 - 流逝时间(游戏秒
ShowText("……是这样的,xx只需要blabla就可以,而……要考虑的就很多了。"); // 行为 11 - 文本触发
SpawnReinforcement("0X01BF52"); // 行为 7 - 援军小队 // 顺带一提 0x01BF52 = 114514
}
}
在上述的代码段中,我们可以看到有两个触发[intro] 0. start
和[intro] 1. brief, init stage1
。
其中,0 号触发的条件是true
,无需判断便立即执行{}
里的块,也就是常见的战役开局。 1 号触发紧接在 0 号后面,说明 1 号需要等 0 号执行了方可执行。结合前面对「顺序结构」的叙述,我们不难想到一个行为:53 - 允许触发。
大部分任务的设计大体上就是由「允许触发」串起来的流程链条。
2.2.2 循环结构
注意到触发中有这么一个选项:重复类型。
其中重复类型有以下三种取值(目前没有第四种!)
- 任一(关联对象)满足事件,触发一次
- 所有(关联对象)满足事件,触发一次
- 任一(关联对象)满足事件,重复触发
可以看到,触发是有可能重复的。那么重复起来的触发长啥样呢?以最简单的开局无条件输出文本为例,将其改造为重复触发后 该触发会逐帧向屏幕的字幕区(一般在游戏左上角)打出Hello World
文本。由于红警里 1 秒 = 15 帧,所以这个动作实际上非常快:
Hello World
Hello World
Hello Wor
Hello W
Hell
He
而在程序语言中,while
则取代if
扮演这个执行重复主体的角色:
int main(void) {
while (1)
printf("Hello World\n");
}
将上述 C 语言代码粘贴进 IDE 编译运行,你也能看到终端里打出来一行行Hello World
,除非用任务管理器干掉这个进程,否则它会无休止地输出下去。
分析这个 C 语言代码不难发现,首先它走到while (1)
处执行判断,由于bool
里 1 为真,条件满足,执行printf
;然后循环并没有结束,它重新回到while (1)
处重复执行前面说的流程,将坏掉的乐土打字机事业推进下去爱莉希雅死辣。
「重复触发」也是类似的道理,一个「等待条件满足→执行结果」的循环。至此,我们可以用while
循环表示一个重复触发了:
while (TimeElapsed(6)) { // 每隔 6 秒
ShowText("Warning!"); // 输出文本
SpawnReinforcements("0X01BF52"); // 刷出臭援军(
}
2.3 「选择肢」—— 借用 GalGame 的设计思维
触发的选择结构与一般程序语言的if-elif-else
和switch-case
不同。 红警 2 的触发只定义了「若 p 则 q」的语义,对于 p 不满足的情形,触发并没有别的措施,自然没有elif
和else
这种设计; 而至于switch-case
,原版的局部变量是bool
类型,讨论多种case
也没有什么意义。
不如说,触发的选择结构更像是玩 GalGame 时的剧情分叉,或者说「选择肢」。通常来说,你选择了某一条分支,就沿着这条线一直走向结局(可能 BE 也可能 HE),并没有回头路。触发的选择也是同样的机制:保留自身,排除异己。
我们以“选中某个建筑,某建筑就刷漆归我,但只能选一个建筑”这个例子为例。那么自然地,我们先把“选中刷漆”的功能做出来。
if (ObjectSelected(pObject_A)) { // 事件 36 - 被玩家选中(注意,多人合作任务不可用)
ChangeOwner(pObject_A, houseid); // 行为 14 - 更改(关联对象)所属(至特定所属方)
}
if (ObjectSelected(pObject_B)) {
ChangeOwner(pObject_B, houseid);
}
if (ObjectSelected(pObject_C)) {
ChangeOwner(pObject_C, houseid);
}
接下来就是选择了。既然同一帧内不可能点两个建筑,那么我们完全可以趁你改某一个建筑所属的时候,把其他改所属的触发给 ban 掉。 (下面的代码为了演示方便,改用 Python 写法)
def selection_getA(): # 只是一条触发声明
if ObjectSelected(pA):
ChangeOwner(pA, houseID)
del selection_getB # 行为 12 - 摧毁触发事件
del selection_getC
return True
def selection_getB():
if ObjectSelected(pB):
ChangeOwner(pB, houseID)
del selection_getA
del selection_getC
return True
def selection_getC():
if ObjectSelected(pC):
ChangeOwner(pC, houseID)
del selection_getA
del selection_getB
return True
如果你需要像 GalGame 那样表达一种「除了 SL 没有回头路可选」的决绝,那么最好还是用行为 12; 而若是用行为 54 - 禁止触发,那么别的分支仍有可能在后续流程中「死灰复燃」。
2.4 触发的时序关系
前面基于程序代码论述了触发在不同执行流中的逻辑表现,但实际上,触发并不总是这么「循规蹈矩」。 事实上,许多 UI 程序和 web 程序对于这种「触发器」的设计是异步的:你在点了某个按钮之后程序会执行一些业务,但这不应该影响你去操作别的按钮。 触发也一样。
两个触发之间其实并没有特别明显的制约关系,同一帧内其实也允许两条触发同时执行。以上面那三个def
为例,你可以将行号当作时间轴,把那三个def
并排放(比如 VSCode 允许你对同一个文件纵向分屏),这样三者间的关系可能更清楚些。
2.4.1 时序基本原则
若没有特别限定,默认的触发是未被阻塞(也就是地编里未勾选「禁止」触发)、所有难度都可以执行的。这种情况下,它从开局就开始等待事件(或者说判断条件),一旦有这么一个事件(组)(或者说条件(组)满足),它就执行相应的行为。
如果该触发只执行一次,则行为执行完毕时触发废止,后续也不再接受其他「允许触发」的唤醒;若是重复触发,则行为执行结束后接着等待(详见循环结构)。而对于重复类型 1 - 所有满足,触发一次,情况则有点复杂。
对于地图上预先摆着的实体,「所有满足,触发一次」就是字面意思。好比我把指定的猴子全炸了,才出来一句「文体两开花」。但小队不同。 小队当中可以不止一个成员,同样一局游戏可以有好几批同种小队。
对于某一批小队里的所有成员,「所有满足,触发一次」字面义同样适用,也很容易理解;但对于不同批次的小队,有多少批次触发就会执行多少次,届时该触发实际上变成了重复触发。例子嘛,可以参考原版 RA2(不是尤里复仇)的盟军 11:核爆辐射尘,有一个经典的工程师修桥名场面。
2.4.2 强制触发
强制触发仍然遵循触发时序的基本前提——这个触发未被「禁止」。对于一个被阻塞的触发,通过行为 22 - 强制触发 试图运行它,结果是什么都没有发生。 也就是说,对于同一个被阻塞的触发,至少你需要先用行为 53 - 允许触发 将其唤醒,然后才紧接着 22 - 强制触发。
强制触发如我所猜测的那样,会忽略该触发的事件(组),直接运行触发行为。
2.5 引入局部变量后触发逻辑的变化
前面的分析都是基于触发系统本身,贴的代码也只是对假想条件和结果的调用。我们注意到「运输船找妈妈」这一经典例子中运用了局部变量,那么引入局部变量后触发逻辑有什么变化呢?
首先考虑局部变量的位置。C 语言里的局部变量不允许在声明之前就使用:
// 我们就假定你的 main.cpp 就这么点,
// 甭纠结全局有没有 i 了。
int main(void) { // buxv
i += 1;
int i;
}
在 FA2 的局部变量窗口中,你需要为局部变量起名(声明),同时为这个变量赋初值(初始化)。 那么这些局部变量保存到地图的[VariableNames]
里肯定也是带着初值的(如果你没赋,默认也是 0,如没)。 这些局部变量最终又被引擎读进内存,初始化成bool[]
或是int[]
。所以,在游戏开始之前,这些变量就已经就位了。
那么我们不妨把局部变量放在main()
的开头:
int main(void) {
bool badguy1_gone = false; // C 语言不许空格变量名,但局部变量可以:"badguy1 gone"
int player_captured_oil = 0;
// [intro] 0. INIT
if (true) { ... }
}
既然声明了局部变量,那么就要用起来。触发里有一组事件和一组结果分别读写局部变量(设待操作的局部变量为x
):
- 事件 36:(指定)局部变量被设定(值为 1),即
if (x == 1)
- 行为 56:设置(指定)局部变量(值为 1),即令
x = 1
- 事件 37:(指定)局部变量被清除(0),即
if (x == 0)
- 行为 57:清除(指定)局部变量(值),即令
x = 0
翻译成 C 语言伪代码如下:
#include <stdbool.h>
// 这些宏后面仍会用到。
#define and &&
#define or ||
#define not !
bool x = false;
if (x == 0) // 事件 37
x = 1; // 行为 56
if (x == 1) // 事件 36
x = 0; // 行为 57
再次提醒,这两个if
指代的触发可以不是顺序结构。否则这样颠来倒去的着实很蠢。
触发就是通过对局部变量值的变动,以实现一些较为复杂的逻辑判断。并且随着int32
扩展局部变量的出现,触发的判断也不再局限于 0 和 1 的「左手倒右手」,而是与科技类型、超级武器、随机数等联系了起来,实现更加精细的随机机制和流程控制。
三、基本触发组件存在的问题
3.1 只有「且」的逻辑运算局限性较大
从上面的分析中不难发现,红警 2 的触发条件判断只遵循逻辑「且」运算,同时并没有对条件(组)不满足时作出补充。 这样实际上对剧情流程的设计额外带来了困难,纸面上可能还有「或」「非」逻辑的触发设计,但落实到地编里却没法直观地做出来,最后可能就干脆砍设定了。
此外,从原版引擎一直到 Phobos 扩展平台以前,一张地图可声明的局部变量总数也是有限的——上限 100 个。当然我们也无从得知这个限制是基于当时的内存规模考虑,还是别的什么原因。但毫无疑问这个限制使得仍在创作的 mapper 们整不了多少花活。
相关信息
在 Phobos 之前,单人任务可使用的所属方至多 32 个;地图中能放置的路径点至多 701 个。
3.2 触发的事件和行为仍受引擎和扩展的限制
由于以前的逆向水平有限,RP、NP、NPExt 与其说是「扩展平台」,不如说是对gamemd.exe
本身打补丁。并且当时的 mod 圈子更重视 INI 花活,很多 mod 遭遇战体系很成熟,但战役却莫得;就算是有,由于认识不足,表现效果也远不如今。
而 Ares、Phobos 等平台的出现,虽然标志着逆向程度更进一步,一定程度上也扩展了已有的触发库,但叙事如何设计还是得看平台脸色。若是平台没有轮子,地图师也不可能搞出「检测游戏目录下是否有rules*.ini
,若有则游戏失败」这种反作弊来。
四、对上述问题的相关解决方案
4.1 通过局部变量实现「或」「非」逻辑运算
依据软硬件的逻辑等价性原理,触发确实可以做到或、非逻辑的判定。但显然,用软件实现的乘法相比起直接一条 mul 汇编指令,总是麻烦得多。自行实现的或、非逻辑也一样。
4.1.1 「或」运算——殊途同归
假定我要办一场肉人运动会,锻炼下他们在战场的跑腿能力,设有 A、B 两个肉人选手。那么无论 A、B 谁到终点,比赛都会结束,对吧?
既然是运动会,自然终点线得是同一条,所以两条终点线、分别判定的选择结构方案显然被驳回了。这时候我们不妨考虑「触发的关联」,两个人都判断己方「进入事件」,然后通过关联触发链接在一起。
提示
你可以将触发以链式方式组织起来,以共享同一个关联对象(或「单元标记」划定的区域)。典型的例子是《尤里的复仇》盟军 03,那些被抢来抢去的电厂。
具体来说,关联遵循如下规则……
- 根触发作为链头,绑定一切该关联的东西。
比如 YRA03,电厂首先有可能被盟军占领,那么对盟军的判定作为链头。 - 其他触发则作为链节,被根触发关联。
比如还是这个例子,电厂还有可能被尤里反占,但电厂上只能挂一个触发(标签),那就只能让链头(盟军判定)共享关联的电厂给它。 - 链尾不绑定任何触发,也不许绑定根触发当作循环链表。
问就是我不知道后果如何,有人想逝就试试。
总结起来就是谁去绑定实体,谁就是领头,要承担关联小弟触发的职责。若是「电厂>A>B>C」三触发关联,那么 B 既接受了 A 的共享绑到了电厂,同时要负起责任把电厂共享给 C 去绑定。
两边踩终点线的判定写完需要(各自)报胜利者,同时设置局部变量通知场外群演欢呼:
bool cheers = 0;
if (Entered(A)) {
ShowText("A won");
cheers = 1;
}
if (Entered(B)) {
ShowText("B won");
cheers = 1;
}
// real cheers
if (cheers == 1) {
Cheers(A);
Cheers(B);
ShowText("... 圆满结束致辞 ...");
// blabla
}
可以看到,无论是哪一队踩到了终点线,整个「踩终点线」的条件组都会满足,也就相应执行起欢呼的流程。这就是「或」的精髓——一真皆真。
4.1.2 「非」运算——逆向思维
与上述「或」运算的实现不同,触发事件的「非」运算需要借助原条件。
举个例子,游戏里基洛夫飞艇飞得比较慢,我们就假定它要送外卖(大嘘) 炸弹吧。要求指定时间内摧毁目标。 那么对于基洛夫要判断两件事:
- 计时器没超时(流逝时间小于指定值)
- 摧毁目标
翻译过来就是ObjectDestroyed(target) and not TimeElapsed(max))
。
我们首先假定它超时了,超时就任务失败嘛。那么它原本应该是能完成任务的。所以伪代码这么写:
// 我的变量名取「及时」的语义,代表任务初始可以完成
bool mission_in_time = true;
if (TimeElapsed(max)) mission_in_time = false;
if (mission_in_time == 1 and ObjectDestroyed(target)) {
// blabla
}
这样一来,触发通过判断原条件「已经过了指定时间」成立,反向推出「仍在指定时间内」这一条件不成立,实现了对「超时」的判断。
总之,我们可以借助局部变量的bool
开关特性,将判断「条件的否定」分解为「判断条件」和「给条件取反」两个步骤, 并用局部变量将「条件的否定」带给其他触发,间接完成条件的「非」运算。
4.2 通过脚本语言等方式对游戏作更全面的干预
这个路线圈子里已经有大佬这么尝试了,我毕竟也没什么编程和逆向水平,恕不做过多展开了。
简单来说,可以用一些脚本化的语言作为「干预」的载体,并且根据现有逆向成果尝试为脚本提供接口,通过 Hook 等方式试图调用脚本来干预gamemd.exe
的运行(以聚哈的 DynamicPatcher 为代表);更进一步地,可以在引擎中集成 console(类似「我的世界」的控制台命令),以更实时的方式干预游戏进程。
结论
综上所述,红警 2 的触发在逻辑层面上类似if
单分支语句的设计,支持顺序、选择、循环三种结构,可以实现大部分任务所需的线性叙事。然而其在逻辑运算上又有所欠缺,导致要追求完整的逻辑判断要通过局部变量绕路实现,对于地图师的逻辑思维能力是一大考验。
并且,随着时代变迁,触发的客制化需求也与日俱增,人们已经不再满足于扩展平台炒的大锅饭,开始转向脚本式的外部干预。但截至 24.6.17 尚没有公开、可行的相应方案。
综上所述,红警 2 对剧情表现的探索仍有很长的路要走。
参考文献
- ModEnc.
Triggers [EB/OL]
, https://modenc.renegadeprojects.com/Triggers, 1.31.2024, 6.17.2024. - ModEnc.
VariableNames [EB/OL]
, https://modenc.renegadeprojects.com/VariableNames, 5.16.2024, 6.17.2024. - RN Studio.
Map Tutorial [EB/OL]
, https://github.com/revengenowstudio/map_tutorial, 4.29.2024, 5.6.2024.
注
上述参考文献大致遵循 GB/T 7714 规范。如有误还请在 Issues 或 Pull Requests 中指出。
致谢
首先需要感谢圈子里孜孜不倦地逆向gamemd.exe
这块老古董的大佬们。没有你们的代码支持,我对触发组件逻辑执行的论证将始终停留在猜测层面上。
然后得感谢各大与我交流过的 mapper 大佬们。你们的经验为这篇文章留下了非常重要的注脚。
最后感谢模组「星辰之光」的玩家和相关创作人士对本文疵漏作出的补充修正。