战场对象如何存在:继承、类型表、内存池与身份
一辆坦克在源码里不只是“坐标加贴图”。它有类型、阵营、生命值、任务、导航、攻击目标、朝向、动画,以及与地图、显示层、逻辑列表的关系。本章把这些关系拆开,解释红警如何在同一个游戏世界中管理大量相互引用、随时生成或被摧毁的对象。
分析固定于提交 0dc09bb1d6188d1ec79ab4765b22a48b75ffee32;除明确标为推论的部分外,以下均依据字段和函数实现。
1. 先认识对象主干,别被英文名字误导
主体沿着 AbstractClass → ObjectClass → MissionClass → RadioClass → TechnoClass 逐层增加能力。到 TechnoClass 分成建筑和可移动物体;可移动物体归 FootClass,进一步分为步兵、飞机及带驾驶逻辑的车船。
查看流程图源文本
flowchart TD
A[AbstractClass] --> O[ObjectClass]
O --> M[MissionClass]
M --> R[RadioClass]
R --> T[TechnoClass]
T --> B[BuildingClass]
T --> F[FootClass]
F --> I[InfantryClass]
F --> P[AircraftClass]
F --> D[DriveClass]
D --> U[UnitClass]
D --> V[VesselClass]图中只画主继承链。TechnoClass 同时继承 FlasherClass、StageClass、CargoClass、DoorClass;AircraftClass 还继承 FlyClass。这是一套面向对象、部分多继承的设计,并非现代 ECS 那种“所有实体只是 ID,所有能力都是独立组件数组”的结构。TechnoClass 声明、FootClass、DriveClass、AircraftClass。
| 层次 | 新增的主要概念 | 游戏里对应什么 |
|---|---|---|
AbstractClass |
RTTI、池槽 ID、位置、高度、活动标志 | 世界中可被识别和定位的对象 |
ObjectClass |
生命值、选择、地图占用、显示、Limbo、触发器 | 可以进入地图、受伤和被绘制的物体 |
MissionClass |
当前任务、待接纳任务、任务状态、任务计时器 | 守卫、攻击、移动、采矿等行为调度 |
RadioClass |
对象间联络及消息应答 | 运输、停靠、装卸等双方协作 |
TechnoClass |
所属阵营、攻击目标、装填、弹药、朝向等 | 建筑与作战单位的共性 |
FootClass |
导航目标、路径、导航队列、队伍关系 | 所有可移动单位,不只是步兵 |
DriveClass、具体子类 |
行驶轨迹及载具、船只、飞机、步兵特殊处理 | 坦克转弯、船只航行、飞机飞行等差异 |
前三层字段可分别核对 ABSTRACT.H、OBJECT.H、MISSION.H。UnitClass 在这个项目中主要指地面载具,而不是“游戏中所有单位”;步兵有自己的 InfantryClass,船只则是 VesselClass。载具、步兵、舰船。
也不是所有东西都走完这条链。对象类型体系中子弹、动画、地形直接派生自 ObjectTypeClass,并不必具备阵营经济、运输和武器等完整“科技物体”能力。阅读一个具体系统,应先确认其真实基类和覆盖函数,而不是机械套用坦克路径。
2. “中型坦克是什么”与“眼前这辆坦克怎样了”分开保存
项目有两组平行概念:
| 共享类型对象 | 独立实例对象 |
|---|---|
UnitTypeClass、BuildingTypeClass 等 |
UnitClass、BuildingClass 等 |
| 最高生命、成本、速度类型、射程相关数据、图片指针 | 当前生命、当前弹药、当前位置、所属阵营 |
| 例如“这一型号坦克的定义” | 例如“池槽 37 中,玩家 A 的那辆坦克” |
UnitTypes 等池 |
Units 等池 |
两组池都在 GLOBALS.CPP 定义,不能把 UnitTypes 和 Units 当作同一个容器。全局实例池与类型池。共享字段能在 ObjectTypeClass 的 Armor/MaxStrength/ImageData 和 TechnoTypeClass 的 Cost/MaxSpeed/Speed/MaxAmmo 中找到。通用类型数据、作战物体类型数据。
UnitClass 的构造函数把 Class 指向 UnitTypes.Ptr(classid),把 Ammo 从 Class->MaxAmmo 初始化,把 Strength 从 Class->MaxStrength 初始化。由此能看见一个很重要的边界:图片和型号规则可以共享,当前损伤、弹药等必须每个实例独立保存。UnitClass 构造函数。
这些类型也并非运行时任意发现。UnitTypeClass::Init_Heap 按 UnitType 枚举次序复制预定义类型,例如重型、中型、轻型坦克、APC、矿车、MCV。注释明确要求分配顺序匹配枚举,因为池槽同时承担类型编号;扩展单位还受编译宏控制。随后规则文件可以调整定义,但“增加一个完全新型号”不能简单理解为随意多写一个 INI 小节即可。类型池初始化顺序。
工程上,这类似共享只读定义与实例状态的分离,能减少重复数据。但这里的“共享”不等于永远不可修改;规则加载阶段确实会处理已有类型,不能把它想成编译后绝对不可变的常量数据库。
3. 内存池:每类对象预留一排固定槽位
实际对象不是每次产生都直接向通用堆申请任意大小内存。FixedHeapClass::Set_Heap(count) 分配 count * Size 字节,并创建布尔占用表。Allocate 查找第一个空闲槽,置占用标记并返回该槽地址;没有容量时返回零。Free 只清占用标记,整块池内存仍在。建立池与分配、释放槽位。
TFixedIHeapClass<T> 在底层固定池之上提供类型化接口;中间的 FixedIHeapClass 维护 ActivePointers,用于只遍历已分配对象。分配时把指针加入这个列表,并将槽内字节清零;释放时从列表移除。可遍历池实现。
这里有两个完全不同的“索引”:
| 访问方式 | 含义 | 删除其他对象后是否可能改变 |
|---|---|---|
固定槽 ID、Raw_Ptr(id) |
对象在整块池中的位置 | 对象尚存活时保持原槽 |
Ptr(index)、ActivePointers[index] |
活动对象列表的第几项 | 可能改变 |
ID(pointer) 直接计算 (pointer - Buffer) / Size;Ptr 从活动列表取指针,Raw_Ptr 从固定槽取地址。两者不能混用:列表中第 3 辆存活坦克,未必位于池的第 3 号槽。ID 换算、类型化访问接口。
车辆重载的 operator new 调用 Units.Alloc(),成功后设置 IsActive=true;operator delete 设置它为假,再归还池槽。构造函数收到的 ID 就来自 Units.ID(this)。车辆分配与释放。
这种安排的工程收益是对象地址稳定、容量可预测、同类对象大小统一,也便于用编号表示对象。代价是容量上限真实存在,分配失败必须处理;分配过程需要找空闲位,不能不经实现检查就宣称它严格 O(1)。更不能把注释里的“数据段数组”理解成所有池都静态编译进可执行文件,当前 Set_Heap 明确可以动态分配整块缓冲区。
4. TARGET:跨对象类型的地址簿
坦克既能攻击步兵,也能攻击建筑,还能强制攻击地面。若每种情况使用不同指针类型,很多命令和存档结构就会变得笨重。TARGET 因而将身份统一打包:在原 32 位目标 ABI 下,它由 8 位种类 + 24 位值组成。对象目标的值通常是相应池的固定槽 ID,格子目标的值则表示格子;AbstractClass::As_Target() 调用 Build_Target(RTTI, ID)。TARGET 布局与打包、对象转目标。
As_Object(target) 读取种类,选择 Units、Infantry、Buildings 等池,再用 Raw_Ptr(value) 找回地址。其末尾会拒绝 IsActive=false 的对象,注释特别提到“网络消息发送后、接收前目标已经死亡”的情况。目标解析与活动检查。
TargetClass/xTargetClass 是另一层包装。拆成两个类有具体的老编译器缘由:Watcom 对 union 中带构造函数的类有限制;事件联合体需要没有构造函数的 xTargetClass,便利构造则放在派生包装中。TARGET 包装及 Watcom 限制。
必须保留两个边界:
TARGET不是网络传输的内存指针,也不是跨平台无条件安全的序列化格式。其布局依赖原项目的整数宽度、位域和 ABI。- 这个标识没有现代常用的“代数 generation”。槽位释放后可以复用;如果某处保留了旧 ID,仅凭种类和槽号无法知道它是否已经对应另一对象。当前代码靠活动标记、对象状态检查及销毁时主动断开引用维持一致性。由结构可推出这种限制,但不能仅凭它断言存在某个可复现的游戏漏洞。
尤其不能把所有 As_* 当成同等严格的验证器:例如自由函数 As_Unit 只按种类返回固定槽地址,没有复制 As_Object 末尾的活动检查。As_Unit。
5. 三类“指针”,没有现代自动内存管理的保证
| 表达方式 | 实际保存什么 | 它不负责什么 |
|---|---|---|
T* |
直接内存地址 | 自动失效、序列化修复、所有权 |
SmartPtr<T> |
一个 T*,解引用时带断言等便利操作 |
引用计数、自动删除目标 |
CCPtr<T> |
某类型固定池中的 ID | 自动验证目标仍存活、区分槽复用 |
源码中的 SmartPtr 与现代 std::shared_ptr、std::unique_ptr 不是同一种机制:析构只把自己的 Pointer 置零,没有 delete Pointer,也没有引用计数。SmartPtr 完整定义。
CCPtr 的类型参数对应一个静态池指针,访问时用 ID 查池。它的 Is_Valid() 只检查 ID != -1;虽然访问处有范围断言,也并不等于“目标一定活着”。这种设计让这类引用无需像原始地址一样做存取档地址修复,但不能因此推断全游戏没有指针编码/解码需求。CCPtr。
读代码时,一次“判空”到底验证了地址、ID,还是对象活动状态,应当按这个表逐项区分。项目中三种形式并存,它们承担的保证明显不同。
6. 实例:一辆坦克从分配到消失
下面以一辆普通车辆为例,完整跟踪其存在状态。
第一步,分配和构造。 new UnitClass(type, house) 先取得池槽并置 IsActive,再初始化父类和类型引用、加入阵营跟踪、设置生命与弹药。但基类 ObjectClass 构造时设定 IsInLimbo=true、IsDown=false。此时“内存中的对象已经活着”,不代表它已经出现在地图上。对象初始状态、车辆初始状态。
第二步,放入世界。 ObjectClass::Unlimbo 检查正在游戏、对象处于 Limbo 且尚未占地图;场景初始化期间允许特殊处理,否则要求目标格可进入。它修正坐标、Mark(MARK_DOWN),随后加入相应显示层;类型若有 IsSentient,再 Logic.Submit(this) 加入更新列表。放置可能失败,调用者必须看返回值。Unlimbo。
第三步,参与模拟。 Logic 遍历时通过虚函数到达 UnitClass::AI。车辆接纳任务,调用 DriveClass::AI → FootClass::AI → TechnoClass::AI 等父层处理,再处理射击、炮塔等。注意这个版本 FootClass::AI 的有效函数体基本只调用 TechnoClass::AI,后面的旧逻辑藏在 OBSOLETE 内;不能仅因文件名叫 FOOT,就认定所有移动步骤都在它的 AI 函数里。真正的车船轨迹推进在 DriveClass::AI 中调用 While_Moving、Start_Of_Move。车辆 AI、Foot AI、Drive AI。
第四步,离开地图但仍存在。 Limbo 取消选择、断开引用、标记抬起,移出显示层与逻辑列表,最后设 IsInLimbo=true。它没有归还池槽,适合“已创建但尚未投放”或“装进运输工具”等状态。IsSentient 的名字也不应直译成人工智能:它实际表示该类型是否参加逻辑更新,地形为了动画也可能设置它。Limbo、IsSentient 定义。
第五步,彻底销毁。 正常活动游戏中,车辆析构移出队伍与阵营跟踪,删除所带乘员,并调用 Limbo;最后令自身 ID=-1。随后重载 delete 归还池槽。Detach_All 还会通知地图,并以 As_Target() 调用 Detach_This_From_All,防止其他单位继续把这辆坦克当攻击或导航目标。车辆析构、断开引用入口。
由此可以明确区分四件事:池槽是否分配、对象是否在地图上、是否参加逻辑更新、是否需要显示。它们分别由活动状态、Limbo/占用状态、逻辑列表、显示层及重绘标记管理,不能用一个“可见”布尔值代替。
7. 攻击、移动和对象协作是不同关系
TechnoClass::TarCom 保存攻击目标;FootClass::NavCom 保存导航目标。坦克可以朝一个位置行驶,同时让炮塔攻击另一个目标,两者必须独立。FootClass 还持有 NavQueue[10]、固定长度方向路径、队伍引用和下一个成员指针;这些分别表达玩家路径队列、当前寻路结果和编队关系。攻击状态、导航与队伍状态。
RadioClass 则用于两个对象协商:默认把消息发给当前联络对象;RADIO_HELLO 尝试建立联系,对方回复 RADIO_ROGER 才接受;RADIO_OVER_OUT 切断关系。其核心是直接调用 to->Receive_Message(...) 并立刻取得返回值,所以它是同步的对象协作机制,不能因“Radio”就理解成音频播放或网络通信。消息发送实现、握手接收实现。
这让“矿车到精炼厂卸矿”“乘员进入运输工具”具备双方可拒绝、可确认、可结束的交互基础。具体业务消息由派生类解释;基类提供的是联系和应答框架,而非完整采矿流程。
8. 坐标、定点数与随机数:可重复世界的基础
格子与格内精度
CELL 是紧凑的地图格索引;COORDINATE 表示更细的位置。每个轴由一个 16 位 LEPTON 值表示,拆成 8 位格坐标与 8 位格内位置,因此一格对应 256 个 lepton。这个“逻辑长度”与屏幕像素分开:地图索引用格子,平滑行驶、距离计算用更细坐标。坐标数据布局。
对象的 Coord 语义还因种类而异:车辆通常用中心点,建筑以左上参考位置为基础。因此提供 Center_Coord、Target_Coord、Render_Coord 等虚函数,避免各系统把某个原始坐标一概当成目标中心。基础坐标说明、对象坐标接口。
比例使用有限精度的 fixed
fixed 由两个无符号字节组成整数与小数部分,共 16 位,也就是常说的无符号 8.8 定点表示。构造 fixed(n,d) 时计算 n*256/d;整数乘比例时则加 128 再除 256,体现其取整规则。例:fixed(1,2) 的内部值为 128,乘 75 按该运算符得到 38,而不是直接截成 37。比例构造、转换及乘法、内部布局。
这不是无限精度数学,也不是所有运算都采用同一种舍入。改用浮点后即使公式“看上去一样”,临界血量、速度、伤害也可能变动。定点与整数有利于控制计算结果,但不能单独保证跨平台确定性;数据宽度、运算顺序、随机数调用和容器遍历顺序同样重要。
随机数必须分用途
RandomClass 用线性同余更新:Seed = Seed * 0x41C64E6D + 0x3039,再右移 10 位提取 15 位结果。区间版本构造位掩码,若抽中值大于范围就重抽,而非简单 % 范围。因此某次区间调用可能消耗多个原始随机数。常量、原始与区间生成。
Random_Pick 使用 Scen.RandomNumber,用于会影响战局的随机性;Sim_Random_Pick 使用 NonCriticalRandomNumber,用于不影响游戏结果、无需各机一致的用途。这里 Sim 的名字尤其容易误导,必须看实现和注释。若给本地特效多抽一次战局随机数,后面的单位行为抽样就会错位,联机结果可能分叉。关键与非关键随机源。
9. 世界是共享全局状态,设计取舍也在这里
项目把 Scen、Logic、Map、Options、PlayerPtr、Session 和各对象池作为全局状态组织。非编辑器情况下 Map 的实际类型还是继承了输入及界面能力的 MouseClass;它不仅是一张格子数组。场景与帧、全局选项、逻辑与地图。
这种结构让单位很容易查询地图、修改阵营状态、生成动画,符合一个进程主要运行一个世界的组织方式。工程代价也明显:单独测试一个对象需要准备许多全局依赖;同时运行两个独立战局、进行多线程更新或移植 64 位,都不能只替换一个外层循环。这是根据结构得出的工程判断,不是说这些改造在技术上不可能。
继承层次也没有形成严格独立模块:TechnoClass::AI 同时做后坐恢复、货物、任务、自修复、隐形和动画重绘标记。这种集中共享逻辑减少了重复实现,却增加跨行为耦合;修一个通用基类函数可能同时影响建筑、飞机、船只和坦克。TechnoClass::AI。
10. 阅读路线与尚未验证的边界
建议路线:先并排阅读 ABSTRACT.H / OBJECT.H / TYPE.H,把类型数据和实例状态分清 → 沿 UnitClass::operator new 进入 HEAP.CPP → 跟踪构造、Unlimbo、Limbo、析构 → 比较 TARGET、CCPtr、SmartPtr → 回到 UnitClass::AI 追父类调用 → 读定点、坐标与随机数实现。与主循环章节配合,才能同时看清“对象是什么”和“对象什么时候更新”。
本章未执行原二进制,未以压力场景测出对象上限,也未证明每个销毁路径都完全正确清理引用。固定槽复用、不同 As_* 验证强度不一致、旧编译器和 32 位 ABI 依赖,是需要进一步测试的具体边界;不能把结构分析包装成已确认的崩溃、漏洞或跨平台兼容性结论。