查看源码下载文档
CHAPTER / 10

关卡、触发器与战役:怎样用数据编排一场战争

一关红警并不是一段专门编译的 C++ 程序。引擎读取地图、阵营、单位、队伍配方、触发器和影视配置,再用通用逻辑将它们组合成一场有开场、目标、援军、转折与结局的游戏。与此同时,源码确实保留了特定战役和资料片的分支;这是一套大量依靠数据驱动、又允许程序补丁解决特殊需求的实际生产系统。

本章以读取函数和事件执行函数为依据。没有运行原版关卡,下面的教学案例也不是冒充官方地图的复原记录。

1. 关卡文件是对象图的配方,加载顺序就是依赖顺序

入口关系是 Start_Scenario → Read_Scenario → Read_Scenario_INI → Fill_In_Data。读取关卡前清理旧场景,在 ScenarioInit 状态下构造对象;读取失败会回报错误。ScenarioInit 是多处递增、递减的初始化控制计数,不能只当作普通开关看待,因为对象放置期间有些常规运行限制需要暂时改变。读取总入口INI 读取

Read_Scenario_INI 先恢复基础规则;启用相应资料片宏时再应用 AftermathINI;随后用当前地图 INI 覆盖 General、AI、Land、Objects、Difficulty 等规则。因此关卡作者可以在一张地图内改变单位或规则,而不必重新编译全游戏。Read_Scenario 外层在特定多人/资料片条件下还可能再应用 MPLAYER.INI,所以“地图规则永远是最后一层”也不是跨全部模式成立的结论。规则叠加额外多人规则层

主要内容按下面次序建立:

顺序 内容 为什么在这里读取
1 Basic 元信息 影片、玩家阵营、格式版本、跨关继承控制
2 Houses 所有归属关系先有目标
3 TeamTypes 触发动作可以引用队伍类型
4 PlayerPtr 对象创建时已知本地玩家
5 Trigs 放置单位和格子时能绑定触发器
6 Map、Waypoints、CellTriggers、MapPack 地图尺寸、剧场、路径点与底图
7 Terrain、Units、Ships、Infantry、Structures 构造并摆放对象
8 Base、Overlay、Smudge、Briefing 基地计划、地面覆盖物、污迹和简报

这个顺序不是说明性伪码,而是从调用次序归纳;例如队伍类型先于触发器,正是为了解决后者引用前者的问题。源码先尝试 MISSION.INI 中该关的简报文本,若为空再取地图中的 Briefing;不能反过来描述它们的覆盖优先级。场景构建次序

2. 地图为什么既是 INI,又包含压缩二进制

DisplayClass::Read_INI 读取可玩矩形和 Theater,再初始化对应地形、单位、建筑、弹体与动画图像。它逐个加载 Waypoints;CellTriggers 则把“格编号→触发器类型”变成运行时引用,同时增加附着计数。地图尺寸与剧场路径点与格触发器

大量地砖不逐行列为 cell=tileMapPackGet_UUBlock 读出字节块,再由 Map.Read_Binary 接上 LCWStraw 解压。较新格式先读整张图的模板编号,再读整张图的图块子编号;旧格式逐格交错读取两者。这种分离排列可以让相似字段靠在一起,利于压缩,同时保持关卡作为一个文本文件流转。MapPack 入口压缩读取与格式分支

文本读完并不等于场景可以马上开始。Fill_In_Data 重新计算可建造列表、移动区域、触发器分类表、地图边缘可见状态,创建跨关继承对象,统计胜利阻挡条件与未毁桥数,最后让对象观察周围。这是“存配置、运行时重建派生数据”的典型做法,减少了文件中相互矛盾的冗余信息。派生状态重建

3. 触发器是两条事件、两条动作和一份运行状态

TriggerTypeClass 存配置:持久性、所属方、事件组合方式、动作组合方式、Event1/2 与 Action1/2。TriggerClass 存实例状态:动态事件数据、计时器、是否已触发、附着计数以及格位置。Find_Or_Make 会查找已有的同类型实例,否则创建。因此多个物体或格子引用同一触发器类型时,可能共享一个运行中的触发器;不能默认“每个格都拥有独立计时器”。触发器实例初始化实例复用

TriggerTypeClass::Fill_In 真实读取的头部字段是:持久类型、House、EventControl、ActionControl;随后两次读事件、两次读动作。新格式事件包含事件号、Team 引用、Data;动作包含动作号、Team 引用、Trigger 引用、Data。文件上方某些旧注释仍写成早期简化字段,不能拿来当当前格式说明。NewINIFormat 会改变解析方式,旧版字符串引用还有后续修复步骤。触发器字段顺序事件格式动作格式

4. 事件有的靠通知,有的靠查询,而且并非全都锁存

事件输入从多个地方来。单位完成格级处理时,向本格触发器发送 TEVENT_PLAYER_ENTERED,同时检查横线、纵线及移动区域进入;这里明确排除了处于 Cloaked 状态的单位。House 自己检查经济、建筑、生产等条件;通用逻辑定期检查计时器、全局标志和桥梁状态。单位触发入口通用事件轮询

TEventClass::operator() 先看动态 IsTripped。一旦某个事件已锁存,后续检查可直接为真;进入格子等事件会验证对象所属方,再设锁存。于是组合条件可能表达“曾经进入过这里,并且另一个条件后来成立”,不要求两者必须同一帧发生。但时间、全局标志和任务倒计时分支直接判断当前状态返回,并没有都写 IsTripped=trueAND 不等于所有条件永久记忆,更不等于所有条件同帧出现。 必须逐类读事件实现。锁存、全局条件与进入校验

“敌军全灭”也不是遍历全部可见精灵再肉眼计数。例如 TEVENT_ALL_DESTROYED 使用 ActiveBScan、ActiveUScan、ActiveIScan、ActiveVScan 的按位或;建筑存在、特定新造单位、资金达到阈值则分别用对应统计。事件名称容易给人一种无限普遍的含义,真实判断范围应以函数里包含哪些扫描字段为准。阵营事件条件

TriggerClass::Spring 组合结果后执行动作:ONLY 仅检查事件一;AND 要两个都成立;OR 任一成立;LINKED 也可由任一事件启动,但事件一对应动作一、事件二对应动作二。普通双动作按 ActionControl 决定调用一个还是两个。组合与动作分派

5. 一次性、半持久、持久:触发后的生命周期

模式 关键行为 适合表达的关卡意图
VOLATILE 有动作成功后脱离引用并删除 一次伏击、一次完成提示
SEMIPERSISTANT 每次满足时解除当前附着并减少计数,仍有附着则暂不执行最终动作 一组对象全部满足后才行动
PERSISTANT 执行动作后重置动态事件状态 周期增援、可重复机关

这个生命周期与动作返回值有关:至少一个动作报告成功,才进入删除或重置流程;援军动作会把 Do_Reinforcements 的结果向上传回。因此“条件达成”与“该触发器已经完成”不是绝对等价的事。附着计数、成功条件与重置

半持久尤其不能理解成“触发两次”。计数来自附着对象或格子的数量,逻辑会在事件完成时解除该处绑定。若同一触发器横跨多种附着方式,或者动作自己销毁对象,还需结合具体实例的附着状态分析。引擎中触发器可在执行中删除自己,调用者随即检查对象是否仍有效;这是很多 if (!IsActive) return 的实际原因。

6. 完整推演:一分钟后,玩家到达路口,装甲援军出现

设计一个教学触发器 R:一次性;事件一为玩家进入路口格;事件二为 Elapsed Time,Data=10;组合 AND;动作是给指定 TeamType 发援军。再给队伍设置从某侧入场、移动至 waypoint B 的脚本。此处是语义配置,不提供未经运行验证的数字枚举 INI。

  1. 地图加载 CellTriggers 时调用 Find_Or_Make(R),生成实例;构造函数对两事件 Reset。Elapsed Time 的计时器设置为 Data * (TICKS_PER_MINUTE/10),所以 10 表示一分钟逻辑时间。
  2. R 同时具有格事件和通用时间事件,派生数据阶段将相应实例列入通用触发器表。玩家三十秒时进入路口,事件一锁存;事件二仍假,不发援军。
  3. 一分钟逻辑时间到,通用事件检查再次调用 Spring,事件一因锁存为真、时间条件也真,动作才执行。如果玩家到一分半才到路口,则到达时可以立即执行。
  4. 动作调用 Do_Reinforcements,创建队伍和其成员,决定地图入口并放置,接着由 Team 脚本接管成员移动。
  5. 动作报告成功后,R 脱离引用并删除,不会在下一轮再发同一波援军。

最容易弄错的是时间原点:这里不是“进入路口后再等一分钟”,而是从触发器初始化或重置时开始计时。若需要进入后才启动倒计时,要用后续触发器、全局状态或任务倒计时动作重新表达因果。事件计时器 Reset按事件决定附着类型

还要区分 Elapsed Time 和界面上的 Scen.MissionTimer。后者有开始、暂停、加时、减时、设定等动作;设定动作会启动计时器,任务到时事件要求计时器仍在活动且已为零。通用逻辑在检查完到期触发器后再停表,避免本帧尚未检查的到期事件失去机会。任务倒计时操作到期检查与停表

7. 援军和“创建队伍”为什么不是同一个按钮

TACTION_CREATE_TEAM 创建 Team 逻辑实例,通常让它按配方招募已有单位;TACTION_REINFORCEMENTS 则进入 _Create_Group 真正创建成员对象。两者都引用 TeamType,但一个偏向组织兵力,一个同时负责把新的兵力带入场景。两种动作

_Create_Group 创建并强制激活队伍,按成员类型与数量造出对象、加入团队,区分运输载具和乘客,并处理部分飞机作为临时援助单位的标记。Do_Reinforcements 随后处理纯步兵从建筑出来的特殊情况,否则按 House 的边缘设置和 Origin waypoint 计算入场格,调用 Unlimbo 让对象离开未放置状态。入场失败时尝试相邻地图外位置,仍无法处理的对象会走清理分支。援军不是绕过对象系统的一团画面效果。成员创建与运输归组入场选择与放置

还有一个接口层面的边界:常规入场路径在函数末尾直接返回 true,并没有检查“所有预定成员均已成功放置”。因此动作报告成功足以使一次性触发器完成,却不能当作援军完整到场的证明。调试这类问题必须同时检查创建数量、Unlimbo 结果与场上对象数。入场结果返回

这种复用使同一套队伍脚本能服务空投、地图边缘进攻、建筑出兵和救援。相应地,设计关卡时除了数量,还要检查运输容量、入口可达性、地图边界以及后续脚本,否则“触发了援军”不代表玩家一定能在预期位置看见所有成员。小队脚本机制接续解释入场之后的控制。

8. 胜利从一个标记变成电影、结算与下一关

TACTION_WIN/LOSE 先把指定阵营与玩家比较,再调用玩家 House 的 Flag_To_Win/Lose。这些函数设置延迟与状态;House 更新时才在 BorrowedTime 到零、胜利阻挡条件许可时写入 PlayerWins/PlayerLoses。主循环随后消费标记,调用 Do_Win/Do_Lose。所以最后目标被毁后稍等片刻才宣布任务完成,是流程中明确存在的阶段。胜负动作延迟标记House 结算条件主循环消费结果

Allow Win 更特殊:加载阶段统计相关触发器增加 House 的 Blockage,而触发器销毁时有专门逻辑减少阻挡并重设计时。这份实现存在值得进一步核验的不对称:初始化统计第一动作及有效的第二动作,析构处则只检查 Action1。讲解时应保留这个边界,不能凭名称断言把 Allow Win 放在任意动作槽都完全等价。阻挡初始化析构解除阻挡

Do_Win 对多人游戏转入多人结算后结束;战役则播放胜利影片、视配置显示分数、处理一次性关卡及最终结局,再进入地图选择或特别的下半关分支。它可以记录资金、建筑、车辆、步兵、舰船,形成 Carryover 链;下一关声明继承时再重建这些对象。资金还可按百分比和上限结转,任务计时器也有独立继承控制。跨关延续因此是有选择的状态导出与重新构造,不是让整个旧世界一直留在内存里。胜利后的战役分流跨关对象与计时器

9. 编辑器不是另一套游戏世界

仓库中 MAPEDIT.CPP 的主体受 SCENARIO_EDITOR 控制。编辑器复用地图、对象、任务、队伍及触发器类型,有调试状态下的游戏/编辑切换逻辑;TeamType 编辑界面直接修改脚本表和成员配置。写关卡的 Write_Scenario_INI 又受 CHEAT_KEYS 条件控制,并先载入旧文件以保留部分手工维护的内容。编辑器编译边界编辑切换关卡保存入口

这体现了一种很务实的制作流程:程序员提供可组合的事件、动作和对象,关卡作者通过数据组织流程,编辑工具直接操纵同一模型。工具代码存在并不证明任意发行版默认开放完整编辑功能;具体可用性仍由构建宏、调试开关和资源决定。

源码阅读路线与待验证边界

Read_Scenario_INI → DisplayClass::Read_INI → Fill_In_Data → TriggerTypeClass::Fill_In → TEventClass::operator() → TriggerClass::Spring → TActionClass::operator() → Do_Reinforcements → Flag_To_Win → Do_Win 阅读,可以把“一张 INI”一直追到“下一关开始”。

需要运行验证的重点包括:特定版本 INI 的兼容性、半持久复杂附着计数、Allow Win 第二动作槽的表现、援军入口失败清理以及真实战役的跨关继承。本文没有用臆造地图冒充官方关卡实例,也没有把条件编译的特殊关卡补丁解释为所有关卡的通用规则。

红色警戒初代 · 源码剖析18 章正文 / 5 份附录
输入关键词,检索 24 份完整文档
全文检索 · 点击结果直达对应小节