查看源码下载文档
CHAPTER / 04

地图、坐标与寻路:一辆坦克怎样穿过战场

玩家看见的是连续运动的坦克、能走的小路和逐步揭开的黑幕;程序同时维护的却是三种不同尺度:格子负责空间索引,lepton 坐标负责移动精度,屏幕像素负责显示。寻路的结果也不是一条可永久照着行驶的曲线,而是一段需要不断检查、可能被放弃的方向命令。

本章依据固定源码提交,讨论地面地图与移动主干。所有行为都是静态代码分析;示例为解释算法的构造案例,没有宣称运行过原版游戏。

1. 世界是一张固定容量的方格数组

DEFINES.H 把地图容量规定为 128×128,共 16,384 格。CELLsigned shortCELL_COMPOSITE 用两个 7 位字段表达 X、Y。于是常见地图索引可以理解为 cell = y * 128 + x。这说的是容器容量,不代表每一关可玩的矩形都铺满这张数组;MapCellX/MapCellY/MapCellWidth/MapCellHeight 另行规定关卡边界。地图容量与类型定义区域边界检查

一个 COORDINATE 则将 X、Y 两个 lepton 分量打包,各分量里又有整格和格内偏移。Cell_Coord 转换出来的是格中心,会把两个格内偏移设为半格;Coord_Cell 反向提取整格。位域、联合体与大小端分支让这种换算非常便宜,但也说明现代移植不能随意把原平台的整数宽度和内存布局当作跨平台保证。坐标打包格中心转换

可以手算一个例子:格 (10,20) 的编号是 2570;每格 256 lepton,因此格中心是 (2688,5248)。单位向东走了半格,视觉上已经明显移动,但格编号可能刚刚才改变。基础图块是 24×24 像素,lepton 与像素并非同一单位。距离、武器射程和路径格数之间,必须显式换算。显示与逻辑尺度常量

还有一个容易被漂亮地图掩盖的近似:Distance(COORDINATE, COORDINATE) 没有开平方,而是返回 max(|dx|,|dy|) + min(|dx|,|dy|)/2。例如横纵都相差一格时,它给出 384 lepton,欧氏距离约为 362 lepton;横向一格则正好 256。视野扫描中调用的“距离”也使用这一类逻辑度量,不能看到半径参数就推定其边界是完美圆形。这种近似用非常便宜的整数运算换取足够稳定的玩法尺度。坐标距离函数

类似地,In_Radar 的名字容易让初读者误解为“现在能在雷达上看到”,实际函数这里只判断格子是否位于可玩矩形。它没有查询电量、雷达建筑、黑幕或敌我阵营。源码里的历史命名往往保存了早期概念,必须依据输入输出确认含义,不能拿名字替代行为。可玩范围判断

2. 一格里的内容比一张地砖丰富

CellClass 不是简单的“地形枚举”。它还存放底图模板、模板内图块编号、覆盖物、污迹、所属方、触发器、移动区域编号和占位信息。

数据 主要问题 游戏中对应的现象
TType/TIcon 底层是什么图块 河流、道路、桥梁模板
Overlay/OverlayData 地面覆盖了什么 围墙、矿物等覆盖物
Smudge/SmudgeData 有什么地表痕迹 弹坑等污迹
OccupierPtr 哪些对象实际占着此格 建筑、车辆、步兵
Overlapper[] 哪些对象的图像延伸到此格 大型图像的重绘关联
Flag.Occupy 哪些位置已经占用或被预留 步兵小站位、车辆预占
Zones[] 在哪一类移动连通区域 河两岸、可碾墙区域

这里最值得区分的是占据图像重叠。一棵树的图像可以伸到邻格,但不能据此把每个被树冠盖住的格子都视为实体障碍;重绘需要的关联比碰撞需要的关联宽。步兵还有中心、西北、东北、西南、东南五个子位置,车辆另有占用位,因此同一格可以容纳多个步兵,而不是每个对象都独占一格。格子字段定义

Occupy_Down 将对象插入占据链并更新雷达及占用位;建筑必须放在链尾,源码明确说明多格建筑与单链结构的约束。这个小细节很有教育意义:场景空间结构既为玩法服务,也受对象内部 Next 指针复用方式限制。不能把它想成今天常见的“每格一个任意对象向量”。占据链维护

步兵落脚点也能继续追到具体实现。Spot_Index 先判断是否足够靠近格中心,否则用横纵坐标相对中心的位置分到四个角;Closest_Free_Spot 优先尝试想去的站位,再查预计算的候选顺序。当中心被占、四角距离相当时,函数随机选择候选顺序,避免永远先挤向同一角。由此可见,玩家看到的小兵散布不是每人任意挑一个浮点坐标,而是格子系统内的小型离散分配问题。步兵站位索引空站位选择

3. 先判断连通性,再处理眼前的障碍

Zone_Reset 分别维护普通、碾压者、破墙者、水上移动四组区域编号;每组清零,再从尚未标记的格子调用 Zone_Span。后者先沿一行找连续可通行区间、填入编号,再递归处理上下行,是扫描线式洪水填充。区域生成忽略临时步兵和车辆,以地形与移动类别确定大尺度连通关系。区域重建扫描线填充函数体

这不是为每个单位提前计算所有路径。区域编号回答“原则上属于同一片可达空间吗”,随后具体寻路才回答“现在从哪几格走”。桥断、墙变等会影响连通性,因而需要刷新区域。注意 Zone_Span 注释描述了 8 邻接意图,但相邻扫描的实际循环边界有具体历史写法;教学实现不能仅抄注释就认定它等价于标准 8 邻域 BFS。

真正的动态通行判定是各单位自己的 Can_Enter_Cell。车辆版本会查地图边界、墙能否碾压、武器能否毁墙、地雷、维修或运输通信许可、敌我关系、对向移动、隐形物体及地表移动类型。它返回一个有序类别,而非布尔值。车辆通行判定

返回值 含义 寻路的基础代价
MOVE_OK 可直接进入 1
MOVE_CLOAK 隐形对象占位 1
MOVE_MOVING_BLOCK 正在移动的障碍 3
MOVE_DESTROYABLE 可以摧毁的阻挡 8
MOVE_TEMP 暂时阻挡,如停着的友军 10
MOVE_NO 不可通行 0,拒绝

Passable_Cell 用当前容忍阈值过滤类别,并可叠加战役绕行队伍的区域威胁限制,然后从小表返回代价。这里的代价主要表示障碍处置难度,不要误称为精确预计行驶时间。阈值与代价表

4. 寻路核心是直行加沿边绕障,并非 A*

Basic_Path 先检查 NavCom 导航目标。如果目标格不可直接进入,而且距离足够远,就尝试同类移动区域中的附近位置。几个同格步兵若目标相同,还能复制已有同伴的路径,省掉一次搜索。正式寻路前把自己从占据标记中暂时提起,避免把自身当成障碍;失败时逐步放宽 PathThreshhold,直到当前策略允许的上限。路径入口与步兵复用

Find_Path 的实际流程可概括为:

text
当前位置尚未到目标,且命令数组未满:
    朝目标选一个相邻方向
    若下一格可接受:登记该方向
    否则:
        沿目标方向穿过障碍,寻找障碍后的可通行候选格
        分别沿障碍左侧、右侧追踪到候选格
        有一侧成功就用该侧;两侧成功则比较命令长度
        两侧都失败可继续越过下一段障碍,次数有上限
    从新位置继续
结束后整理和优化方向命令

源码没有 A* 的优先队列、开放集合与全局 g+h 最小选择;它显式复制左右路径控制块,分别调用 Follow_Edge。而且两边都成功时比的是 Length,不是累计 Cost。其目标是快速产出足够有用的一段路径,没有最短路保证。障碍穿透候选搜索的注释提到十次,但实际 limiter < 5,应以代码为准。主搜索与候选点左右绕行及长度选择

Follow_Edge 按顺时针或逆时针旋转检查相邻格,保持沿障碍边界的前进方向;它记录相对于源点—目标直线的位置,处理对角穿越、回头与环路,并设有追踪步数和命令容量上限。此文件第 67 行直接定义了 DIAGONAL,所以当前源码默认走对角分支;文件保留的非对角分支会改变旋转步幅与优化规则。沿边搜索与防绕圈

Optimize_Moves 对相邻方向对作局部简化:相反方向可相互抵消,某些拐角可以缩短或拉直;随后压紧命令数组、重算长度和代价。它是局部路径修整,不会重新证明全图最优。Basic_Path 使用 200 个方向的临时数组,最终仅复制到对象自身 Path 所容纳的部分;因此“寻路成功”也不等于已经安排好抵达终点的一切。命令优化路径复制与恢复占位

5. 算出了路,为什么仍会停车、绕圈或拆墙?

构造一个具体例子:坦克在建筑西边,玩家要求它到东边空地,直线会穿过建筑。搜索先找到建筑东侧的可通行候选格,左右沿边搜索分别得到若干方向,选择较短者。坦克开始行驶后,另一辆友军恰好停在下一格。此前的路径仍在数组里,但世界已经改变。

DriveClass::Start_Of_Move 再次调用 Can_Enter_Cell。若结果是临时友军,会对目标格发出 Incoming 让对象散开;若是移动障碍,可保留路线等待;若是隐形阻挡,让格子闪现;若是可摧毁对象或墙,临时覆盖成攻击任务。接近目标时还可能判定已经足够接近,清除导航。因此偶尔的停车并非一定意味着搜索器失效,而可能是动态避让、到达容差或攻击任务接管。实时检查与阻挡响应

车辆真正转弯也不是把位置线性插值到下一格中心。While_Moving 按速度累计值推进预定义转弯轨迹 RawTracks;轨迹包含坐标偏移和车身朝向,Smooth_Turn 据此更新位置。地形通过 Ground[land].Cost[speedtype] 调节速度,受伤达到黄色条件会减速,编队又可以统一移动速度。Start_Driver 预标记驶入位置,防止两辆车同时宣称同一空间。速度与轨迹地表速度与损伤预占轨迹

从制作角度看,上例包含四份不同的状态:玩家希望到达的导航目标、搜索器生成的短路径、驶入格子的预占标记、轨迹系统正在播放的转弯段。它们更新频率不同,也没有任何一份能单独代表“单位此刻完整的移动意图”。如果做调试可视化,最有效的是同时显示目标格、缓存方向、下一驶入格与实际坐标;只画一条所谓路径线,反而容易把任务切换和动态避让误判成寻路问题。这是依据现有职责划分提出的调试建议,而非仓库已经实现的工具。

6. 黑幕是探索与显示状态,不应套用现代战争迷雾模型

Sight_From 用预计算半径偏移扫描附近格,检查地图边界与距离,只揭开尚未 IsMapped 的格。增量模式跳过内圈,以降低单位移动时重复扫描的成本;此函数对大于 10 的视野参数直接返回。视野扫描

DisplayClass::Map_Cell 主要维护当前本地玩家的探索显示:它考虑被间谍渗透的雷达、普通战役盟友揭图,再设 IsMappedIsVisible 还受黑幕边缘图块组合影响,邻格甚至会递归揭开,以满足可绘制的边缘形态。不能把这两个字段机械解释为“曾探索”和“此刻有单位视线”这套现代双层迷雾。 普通揭图、特殊再遮蔽和对象显隐需要结合起来读。揭图与边缘修正

Shroud_Cell 可清除探索和可见标记并重绘邻格;裂缝产生器、全图黑幕等会调用相应地图操作。LogicClass::AI 另有由 Special.IsShadowGrowRule.ShroudRate 控制的黑幕生长分支。这说明“单位走开是否变黑”是规则与特殊设备共同决定的问题,不能从现代 RTS 经验反推。再遮蔽可选黑幕生长

7. 炸桥为什么同时改变寻路和关卡剧情

桥梁以地图模板状态表达。Destroy_Bridge_At 对经典桥模板区分完整、损伤、毁坏状态;真正断桥会减少 Scen.BridgeCount、设置 IsBridgeChanged、更换模板、刷新移动区域,随后处理相关格上的对象与攻击引用。它并不是只替换一张坏桥图片。多段桥另有分支,所以不能从单座短桥分支推广所有桥型。桥梁状态转换

下一轮逻辑会据 IsBridgeChanged 检查“所有桥已毁”事件,关卡就能发援军或宣布胜利;原有路径也必须接受新的通行条件。地图变更连接了地形、对象生命、AI 和剧情,这是理解 RTS 子系统耦合的好入口,详见关卡与触发器

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

推荐顺序:DISPLAY.H/DEFINES.H → CELL.H → CellClass::Occupy_Down → MapClass::Zone_Reset → FootClass::Basic_Path → Find_Path/Follow_Edge/Optimize_Moves → DriveClass::Start_Of_Move/While_Moving → Sight_From/Map_Cell

还需要真实地图和可运行构建才能测量:复杂迷宫的寻路成功率、拥挤地形等待时间、不同编译宏下的对角路线,以及断桥各模板对站立对象的实际结果。源码已足以确认算法结构,但不能用这些静态观察替代帧级回放验证。船舶、飞机和步兵分别有专门移动代码,本章地面车辆案例也不能直接等同于它们的全部行为。

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