查看源码下载文档
CHAPTER / 03

战场对象如何存在:继承、类型表、内存池与身份

一辆坦克在源码里不只是“坐标加贴图”。它有类型、阵营、生命值、任务、导航、攻击目标、朝向、动画,以及与地图、显示层、逻辑列表的关系。本章把这些关系拆开,解释红警如何在同一个游戏世界中管理大量相互引用、随时生成或被摧毁的对象。

分析固定于提交 0dc09bb1d6188d1ec79ab4765b22a48b75ffee32;除明确标为推论的部分外,以下均依据字段和函数实现。

1. 先认识对象主干,别被英文名字误导

主体沿着 AbstractClass → ObjectClass → MissionClass → RadioClass → TechnoClass 逐层增加能力。到 TechnoClass 分成建筑和可移动物体;可移动物体归 FootClass,进一步分为步兵、飞机及带驾驶逻辑的车船。

chapter-03-diagram-1:AbstractClass → ObjectClass;ObjectClass → MissionClass;MissionClass → RadioClass;RadioClass → TechnoClass;TechnoClass → BuildingClass;TechnoClass → FootClass;FootClass → InfantryClass;FootClass → AircraftClass;FootClass → DriveClass;DriveClass → UnitClass;DriveClass → VesselClass
查看流程图源文本
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 同时继承 FlasherClassStageClassCargoClassDoorClassAircraftClass 还继承 FlyClass。这是一套面向对象、部分多继承的设计,并非现代 ECS 那种“所有实体只是 ID,所有能力都是独立组件数组”的结构。TechnoClass 声明FootClassDriveClassAircraftClass

层次 新增的主要概念 游戏里对应什么
AbstractClass RTTI、池槽 ID、位置、高度、活动标志 世界中可被识别和定位的对象
ObjectClass 生命值、选择、地图占用、显示、Limbo、触发器 可以进入地图、受伤和被绘制的物体
MissionClass 当前任务、待接纳任务、任务状态、任务计时器 守卫、攻击、移动、采矿等行为调度
RadioClass 对象间联络及消息应答 运输、停靠、装卸等双方协作
TechnoClass 所属阵营、攻击目标、装填、弹药、朝向等 建筑与作战单位的共性
FootClass 导航目标、路径、导航队列、队伍关系 所有可移动单位,不只是步兵
DriveClass、具体子类 行驶轨迹及载具、船只、飞机、步兵特殊处理 坦克转弯、船只航行、飞机飞行等差异

前三层字段可分别核对 ABSTRACT.HOBJECT.HMISSION.HUnitClass 在这个项目中主要指地面载具,而不是“游戏中所有单位”;步兵有自己的 InfantryClass,船只则是 VesselClass载具步兵舰船

也不是所有东西都走完这条链。对象类型体系中子弹、动画、地形直接派生自 ObjectTypeClass,并不必具备阵营经济、运输和武器等完整“科技物体”能力。阅读一个具体系统,应先确认其真实基类和覆盖函数,而不是机械套用坦克路径。

2. “中型坦克是什么”与“眼前这辆坦克怎样了”分开保存

项目有两组平行概念:

共享类型对象 独立实例对象
UnitTypeClassBuildingTypeClass UnitClassBuildingClass
最高生命、成本、速度类型、射程相关数据、图片指针 当前生命、当前弹药、当前位置、所属阵营
例如“这一型号坦克的定义” 例如“池槽 37 中,玩家 A 的那辆坦克”
UnitTypes 等池 Units 等池

两组池都在 GLOBALS.CPP 定义,不能把 UnitTypesUnits 当作同一个容器。全局实例池与类型池。共享字段能在 ObjectTypeClassArmor/MaxStrength/ImageDataTechnoTypeClassCost/MaxSpeed/Speed/MaxAmmo 中找到。通用类型数据作战物体类型数据

UnitClass 的构造函数把 Class 指向 UnitTypes.Ptr(classid),把 AmmoClass->MaxAmmo 初始化,把 StrengthClass->MaxStrength 初始化。由此能看见一个很重要的边界:图片和型号规则可以共享,当前损伤、弹药等必须每个实例独立保存。UnitClass 构造函数

这些类型也并非运行时任意发现。UnitTypeClass::Init_HeapUnitType 枚举次序复制预定义类型,例如重型、中型、轻型坦克、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) / SizePtr 从活动列表取指针,Raw_Ptr 从固定槽取地址。两者不能混用:列表中第 3 辆存活坦克,未必位于池的第 3 号槽。ID 换算类型化访问接口

车辆重载的 operator new 调用 Units.Alloc(),成功后设置 IsActive=trueoperator 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) 读取种类,选择 UnitsInfantryBuildings 等池,再用 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_ptrstd::unique_ptr 不是同一种机制:析构只把自己的 Pointer 置零,没有 delete Pointer,也没有引用计数。SmartPtr 完整定义

CCPtr 的类型参数对应一个静态池指针,访问时用 ID 查池。它的 Is_Valid() 只检查 ID != -1;虽然访问处有范围断言,也并不等于“目标一定活着”。这种设计让这类引用无需像原始地址一样做存取档地址修复,但不能因此推断全游戏没有指针编码/解码需求。CCPtr

读代码时,一次“判空”到底验证了地址、ID,还是对象活动状态,应当按这个表逐项区分。项目中三种形式并存,它们承担的保证明显不同。

6. 实例:一辆坦克从分配到消失

下面以一辆普通车辆为例,完整跟踪其存在状态。

第一步,分配和构造。 new UnitClass(type, house) 先取得池槽并置 IsActive,再初始化父类和类型引用、加入阵营跟踪、设置生命与弹药。但基类 ObjectClass 构造时设定 IsInLimbo=trueIsDown=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_MovingStart_Of_Move车辆 AIFoot AIDrive AI

第四步,离开地图但仍存在。 Limbo 取消选择、断开引用、标记抬起,移出显示层与逻辑列表,最后设 IsInLimbo=true。它没有归还池槽,适合“已创建但尚未投放”或“装进运输工具”等状态。IsSentient 的名字也不应直译成人工智能:它实际表示该类型是否参加逻辑更新,地形为了动画也可能设置它。LimboIsSentient 定义

第五步,彻底销毁。 正常活动游戏中,车辆析构移出队伍与阵营跟踪,删除所带乘员,并调用 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_CoordTarget_CoordRender_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. 世界是共享全局状态,设计取舍也在这里

项目把 ScenLogicMapOptionsPlayerPtrSession 和各对象池作为全局状态组织。非编辑器情况下 Map 的实际类型还是继承了输入及界面能力的 MouseClass;它不仅是一张格子数组。场景与帧全局选项、逻辑与地图

这种结构让单位很容易查询地图、修改阵营状态、生成动画,符合一个进程主要运行一个世界的组织方式。工程代价也明显:单独测试一个对象需要准备许多全局依赖;同时运行两个独立战局、进行多线程更新或移植 64 位,都不能只替换一个外层循环。这是根据结构得出的工程判断,不是说这些改造在技术上不可能。

继承层次也没有形成严格独立模块:TechnoClass::AI 同时做后坐恢复、货物、任务、自修复、隐形和动画重绘标记。这种集中共享逻辑减少了重复实现,却增加跨行为耦合;修一个通用基类函数可能同时影响建筑、飞机、船只和坦克。TechnoClass::AI

10. 阅读路线与尚未验证的边界

建议路线:先并排阅读 ABSTRACT.H / OBJECT.H / TYPE.H,把类型数据和实例状态分清 → 沿 UnitClass::operator new 进入 HEAP.CPP → 跟踪构造、UnlimboLimbo、析构 → 比较 TARGETCCPtrSmartPtr → 回到 UnitClass::AI 追父类调用 → 读定点、坐标与随机数实现。与主循环章节配合,才能同时看清“对象是什么”和“对象什么时候更新”。

本章未执行原二进制,未以压力场景测出对象上限,也未证明每个销毁路径都完全正确清理引用。固定槽复用、不同 As_* 验证强度不一致、旧编译器和 32 位 ABI 依赖,是需要进一步测试的具体边界;不能把结构分析包装成已确认的崩溃、漏洞或跨平台兼容性结论。

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