做2D坦克游戏,翻车率最高的功能往往不是贴图渲染,也不是敌人AI,而是最底层的实体移动。坦克大战3.0这个版本,我干的第一件大事就是把运动系统整个推翻重写,核心目标只有一个:防重叠。坦克不能穿墙、坦克之间不能互相穿过、炮弹命中时也不能从目标身体里透过去。整个工程前后折腾了一周,有差不多一半时间花在处理边界条件上,比如墙角卡位、高速子弹、两辆坦克同时挤向同一个格子的情况。这篇文章就把这套防重叠运动方案,从设计思路到代码实现再到排坑实录完整讲一遍。
想复现这套方案的朋友,只需要有一个能画矩形的2D环境,再借一套键盘输入,剩下的碰撞逻辑按文中的路子走就行。如果你是第一次写2D游戏,这篇文章能帮你少踩一大半碰撞相关的老坑;如果你已经在用Box2D这类物理引擎,也可以看看手写一个简单碰撞系统时到底在思考什么。下面的内容全部围绕一个真实项目展开,代码是C#风格的伪代码,换到C++、Java、Python都只是语法翻译的问题。
1. 项目拆解:防重叠运动到底在解决什么问题
1.1 防重叠的三个层次:检测、响应、预防
先从问题本身说起。所谓防重叠,指的是任何游戏实体在运动过程中,不出现“你中有我、我中有你”的穿插状态。拿现实生活打比方:你在走廊里迎面走过来一个人,正常情况下你俩不会叠在一起,因为你会提前绕开他。但游戏里的坦克不会绕,它只会在每一帧里按照玩家输入的方向移动固定距离,所以必须由程序主动检查“我下一步要去的地方到底能不能站人”。
这里要区分三个很容易混淆的概念:碰撞检测、碰撞响应、碰撞预防。碰撞检测回答的问题是“两个物体现在是否相交”;碰撞响应回答的是“如果相交了该怎么办”;碰撞预防则更进一步,在物体移动之前就判断“我能不能往这里走”。很多老版本游戏的运动系统是“先移动,再检测,最后把位置推回去”,属于典型的“事后纠正”。这种做法不是不能用,但在精度要求高的场景里就会露馅:高速移动时经常推不回去,或者推回去的一瞬间产生肉眼可见的抖动。坦克大战3.0改用的事前检测思路,才是防重叠运动的真正核心。
除此之外,防重叠还有一个容易被忽视的方向:同一帧内多个实体同时运动时的处理。比如两辆坦克面对面冲锋,如果各自独立检测,可能这一帧双方的新位置还没有相交,下一帧却已经互相穿过去了。这就需要在运动顺序和碰撞优先级上做文章,这一点我放到后面专门讲。
1.2 3.0版本为什么单独重写运动逻辑
坦克大战3.0这个项目,玩法是经典坦克大战的框架:地图上有砖墙和钢墙,玩家控制一辆坦克消灭敌军,敌方坦克会主动追击和射击。前两个版本虽然能跑通,但玩家反馈里出现频率最高的不是难度问题,而是“手感不对劲”——说直白一点就是穿模。两辆坦克叠在一起,子弹穿过砖墙打到玩家身上,坦克贴着墙走的时候不是卡死就是被弹飞。
这些问题归根结底都指向同一个模块:运动与碰撞。所以3.0版本我没有急着加新玩法,先把移动、碰撞、出生、子弹命中这些基础逻辑统一进一套新的运动框架里。这样做的收益是很大的:后续加新坦克、新子弹、新地图时,只要套用同一套碰撞规则,就不会再出现“新单位破坏了旧规则”的情况。对于一款小游戏而言,基础系统的一致性比功能数量重要得多。
1.3 这套方案适用的边界
在动手之前,先给这套方案画个边界。它适合的场景有几个特征:第一,实体形状基本是轴对齐的矩形(也就是长方形方向跟坐标轴平行),坦克顶多横平竖直地转向,不会任意旋转;第二,同屏实体数量在几十到几百这个量级,不需要上大规模物理引擎;第三,游戏对碰撞的精确性要求高于对物理真实感的要求,不需要反弹、摩擦、重力这类效果。如果你的游戏里有大量圆形的角色、需要像弹射游戏那样的物理手感,或者实体数量达到上千,那么下面这套AABB防重叠方案只适合作为入门版本,你需要在这个基础上继续扩展。
2. 碰撞检测方案选型:为什么是AABB而不是物理引擎
2.1 AABB的数学原理:看起来朴素,用起来扎实
AABB全称是Axis-Aligned Bounding Box,轴对齐包围盒。所谓轴对齐,指的是这个矩形的四条边分别与坐标轴平行,不会旋转。如果一个矩形斜过来了,那它就不叫AABB了。
两个AABB怎么判断是否重叠?规则其实就一句话:两个矩形在X轴方向的投影如果重叠,并且在Y轴方向的投影也重叠,那它们就相交;只要有一个轴的投影不重叠,就一定不相交。用代码写出来是这个样子:
public bool Overlaps(Rect a, Rect b) { // 只要有一个轴的投影没有重叠,就返回false if (a.Right < b.Left || a.Left > b.Right) return false; if (a.Bottom < b.Top || a.Top > b.Bottom) return false; return true; }这里我把条件分成了两行,就是为了强调“两个轴要同时满足”。见过不少新手把这里的逻辑写成“X轴或者Y轴重叠就返回真”,结果就是两个矩形明明一上一下错开着,程序却认为它们撞上了。这个低级错误我后文还会再提一次,因为哪怕是有经验的开发者也偶尔会被运算符优先级坑到。
坦克大战为什么适合AABB?因为这类游戏的坦克是四方向转向的,贴图本身跟坐标轴平行,碰撞体直接用一个矩形就能精确覆盖车身。如果用圆形碰撞体,反而会出现四个角露在外面——也就是说,一个圆形的碰撞体在靠近墙角时会提前判定碰撞,虽然更圆滑,但不够还原玩家对“坦克车身”的直觉。
2.2 为什么不直接用物理引擎
可能有朋友会问:现在项目都用Unity了,里面自带Box2D物理引擎,直接挂个碰撞体组件不就行了?确实可以,但坦克大战3.0的选择是自己写。这里把两种方案的取舍摆出来聊一聊。
物理引擎的核心能力是模拟力、速度、动量、摩擦、关节约束这些内容,它解决的问题是“物体在力的影响下如何运动”。而坦克大战需要的其实是“物体是否能够移动到某个位置”这种更简单的逻辑。用物理引擎当然能实现碰撞,但实现“坦克贴墙滑动”的时候你得去调整摩擦系数、反弹系数、碰撞响应回调,调完之后手感还未必对。相比之下,自己维护一个碰撞检测函数,几十行代码就能给出确定性的结果。
| 维度 | 自写AABB碰撞 | 物理引擎(Box2D等) |
|---|---|---|
| 代码量 | 几百行足够 | 引入完整依赖 |
| 调试难度 | 可一行行追 | 黑盒回调,需理解引擎机制 |
| 运动控制 | 完全自主,天然支持“能不能走”的判断 | 需要阻止引擎自动响应,额外配置 |
| 性能开销 | 极低,纯几何运算 | 有扫描排序等预处理开销 |
| 扩展能力 | 需要自己写旋转、多边形等高级检测 | 自带多边形、关节、传感器 |
| 适合场景 | 网格地图、矩形碰撞体、确定性强 | 物理模拟感强的游戏 |
就不说多平台移植的问题了,手写方案在任何渲染框架下都能跑,逻辑完全不依赖引擎版本。当然了,如果项目规模上去了,或者后续玩法需要诸如“炮弹击退坦克”“爆炸把坦克掀飞”这类带物理规则的玩法,再引入物理引擎也不迟。关键是想清楚当前版本的边界,不要拿大炮打蚊子,也不要等踩坑了才后悔没上引擎。
2.3 网格碰撞地图:把墙体检测变成查表
坦克大战的地图是典型的网格地图,墙体的位置可以用一张二维数组来表达,比如0代表空地,1代表砖墙,2代表钢墙。这样做的最大好处是,墙体碰撞检测从“遍历所有墙体矩形”降成了“查几个格子”。
具体做法是:取得坦克的碰撞体矩形,算出这个矩形覆盖了哪些格子,然后逐个检查这些格子的值。由于坦克的尺寸远大于地图格子,一次要查的格子数量是有限的,通常只有四到九个。这一段代码是整篇的核心之一:
public bool IntersectsWall(CollisionMap map, Rect bounds) { int cellSize = map.CellSize; // 碰撞体矩形覆盖的格子范围 int minCol = bounds.Left / cellSize; int maxCol = (bounds.Right - 1) / cellSize; int minRow = bounds.Top / cellSize; int maxRow = (bounds.Bottom - 1) / cellSize; // 越界直接视为墙,避免坦克跑出地图 if (minCol < 0 || maxCol >= map.Cols) return true; if (minRow < 0 || maxRow >= map.Rows) return true; for (int c = minCol; c <= maxCol; c++) { for (int r = minRow; r <= maxRow; r++) { if (map.Grid[c, r] != 0) return true; } } return false; }这里有一个极其容易踩坑的细节:maxCol计算的时候为什么要在bounds.Right上减一?因为如果坦克的右边恰好落在某个格子的边界线上,比如x等于32而格子大小是16,那它只占到第1格的一部分,并没有真正进入第2格。如果直接拿32除以16,会得到第2格的下标,从而把并不存在的碰撞也算进去,坦克就会莫名其妙地被卡住。这个减一的操作,本质上是把“矩形与格子的交集”处理成半开区间,保证边界情况不会判错。实测下来,大部分“坦克卡在砖墙边缘”的Bug,最后都能追到这句代码上。
地图网格除了做墙体检测,还方便做地图编辑器和出生点校验。我给每个格子都区分了碰撞属性,砖墙、钢墙、水域各有各的值,以后要加河流、冰面这类特殊地形,只需要增加对应的碰撞属性判断,完全不需要改动运动框架本身。
3. 防重叠运动核心实现:从原理到落地
3.1 先检测后移动:重塑运动流程
整个防重叠运动的第一个原则,就是“先检测后移动”。听起来像废话,但很多实现根本做不到位。正确的流程是这样的:每一帧根据方向键和速度算出目标位置,先拿着“目标位置的碰撞体”去检测墙体和其他坦克,只有确认完全不碰撞,才真正更新坦克坐标。
我给出一个完整的TryMove函数,这是整个系统的心脏:
public bool TryMove(Tank tank, float dx, float dy, CollisionMap map, List<Tank> allTanks) { // 目标位置 float newX = tank.X + dx; float newY = tank.Y + dy; // 目标位置的碰撞体 Rect newBounds = new Rect( newX - tank.HalfWidth, newY - tank.HalfHeight, tank.Width, tank.Height); // 第一道检测:地图墙体 if (IntersectsWall(map, newBounds)) return false; // 第二道检测:其他坦克 foreach (Tank other in allTanks) { if (other == tank) continue; if (Overlaps(newBounds, GetBounds(other))) return false; } // 只有通过全部检测,才真正移动 tank.X = newX; tank.Y = newY; return true; }注意,函数里所有检测用的都是“目标位置”的碰撞体,而不是当前位置。这保证了在位置更新之前,问题就已经被拦截。如果你在某个项目里看到了“先改位置、再检测、测出了重叠再把位置改回去”这种写法,趁早把它改掉,因为事后纠正方案在复杂场景里几乎必然会出抖动和穿透问题。
另外,返回值要不要提供,取决于使用场景。如果移动失败的时候需要播放撞击声或者触发动画,那么返回一个布尔值就很有用;如果只是简单阻止移动,不返回值也行。我在项目里保留了这个布尔值,方便调试时打印“为什么没走动”。
3.2 滑动响应:贴墙移动不再卡死
如果仅仅做到“撞上就禁止移动”,那手感会非常僵硬。玩家按住右下方向键,坦克碰到墙之后,如果整个移动被禁止,它就连贴着墙往前滑都做不到,游戏体验会直接崩掉。正确的做法是引入滑动响应:当完整位移被阻挡时,把位移拆成X轴和Y轴两个方向分别尝试。
实现方式非常直接,先用完整位移尝试,失败后再分别尝试两个轴向:
public void MoveWithSlide(Tank tank, float dx, float dy, ...) { // 完整位移优先 if (TryMove(tank, dx, dy, ...)) return; // 分成两个轴向,先X后Y if (TryMove(tank, dx, 0, ...)) return; if (TryMove(tank, 0, dy, ...)) return; // 两个方向都撞墙,说明卡在角落,保持原地 }这个思路的来源是分离轴思想:一个二维位移可以分解为两个一维位移,分别检测就等价于完整检测,只是“部分成功”时允许保留一个轴的方向移动。具体案例是:玩家坦克顶着一面竖直墙往左上走,完整位移被墙挡住,但X轴方向可以走过去的话,就只执行X轴位移,坦克看起来就是在贴墙滑动。反过来,如果先尝试X轴失败,再尝试Y轴,还能处理顶墙往上的情况。
这里有一个实战经验:分离轴尝试的顺序会影响手感,通常先试X轴再试Y轴,符合玩家“左右优先于上下”的操作直觉。但如果你在做一个纵向地图为主的关卡,可以考虑改成先试Y轴。另外,滑动响应在斜角输入时会有阶梯感,这是几乎所有2D游戏都会遇到的取舍,属于可接受的成本。
3.3 坦克互撞:优先级规则怎么定
防重叠不只针对墙体,坦克与坦克之间的互撞更要细抠。设想两辆坦克面对面冲锋,双方每帧各自计算出新位置,单看任何一辆,它前方另一辆坦克的位置都没有挡住它——但等两辆都移动了,结果就是重叠。这就是同一帧内“同步移动”带来的检测漏洞。
我在3.0里采用的规则是分步移动加优先级判定。具体做法是:把本帧所有需要移动的坦克排成固定顺序,依次执行TryMove。先移动的坦克会占住新位置,后移动的坦克检测时就会发现前方有障碍,从而停下来。这套逻辑很简单,但效果很稳定,永远不会出现两辆坦克互相穿过的画面。
那“固定顺序”怎么定?我用的方案是:玩家永远优先移动,敌坦克按创建顺序排序。这个规则的意义在于,当玩家坦克和敌坦克同时往同一个格子挤的时候,玩家优先,敌坦克让路。这个设计符合直觉,玩家不会因为“我明明先按了移动键却被看不见的规则挡住”而疑惑。如果你希望更公平,也可以改为按坐标先后排序,但务必保证顺序在每一帧内是确定的,不要在移动过程中动态排序,否则会出现“你让我、我让你”的抖动死循环。
这里再说一个延伸需求:坦克推挤。有些玩家反馈希望坦克能把另一辆坦克推开,但经典坦克大战里坦克是绝对碰撞的,推挤会明显破坏玩法节奏,所以我默认关掉了推挤。如果你想做推挤效果,实现方式也没那么复杂:当A坦克的目标位置被B坦克占据时,把B坦克按A的运动方向平移同样距离,然后递归检查B的新位置是否合法。递归推挤的深度要限住,不然会有连锁反应和死循环风险。
3.4 高速穿透与帧率无关性:步进切割
坦克大战3.0里子弹的移动速度比坦克快得多,如果不做特殊处理,子弹很可能在一个16毫秒的帧里从一堵薄砖墙的左边穿越到右边,碰撞检测完全来不及发现。这种情况在圈内有个专门的词叫“隧穿”(Tunneling),是高速运动碰撞的头号杀手。
最简单的解决方案是“步进切割”:把一帧内的位移人为切分成多段,逐段做碰撞检测。比如子弹速度为每帧20像素,墙厚8像素,那就把这段位移切成每段4像素,一共5段,这样第3段就能检测到墙体。核心代码是:
public void MoveWithSteps(Tank tank, float dx, float dy, ...) { // 限制单步最大位移不超过碰撞体最小尺寸的一半 float maxStep = Math.Min(tank.HalfWidth, tank.HalfHeight) * 0.5f; float distance = Mathf.Sqrt(dx * dx + dy * dy); int steps = Mathf.CeilToInt(distance / maxStep); float stepX = dx / steps; float stepY = dy / steps; for (int i = 0; i < steps; i++) { if (!TryMove(tank, stepX, stepY, ...)) break; } }这里单步最大位移的取值直接决定了防穿透的可靠性。我推荐取碰撞体最小尺寸的一半,用坦克来举例,坦克宽32像素高28像素,最小尺寸的一半是14像素,那么单步最大位移14像素,任何比14像素薄的墙体都能被准确拦截。步数越多,运动轨迹越平滑,性能开销也越低,实际调的时候留意一个平衡即可。如果将来子弹速度进一步加快,还可以把系数降到四分之一甚至更小,一帧分十几次检测在2D游戏里依然是零感知的开销。
还有一个与帧率相关的细节要一起解决:移动速度的单位应该统一成“像素/秒”,再乘以每帧的真实时间差(deltaTime),这样60帧和144帧的屏幕上坦克移动速度才能一致。但帧率很低时deltaTime会变得很大,导致一帧内移动距离非常长,所以MoveWithSteps的步进切割需要同时兜住这个风险。我的习惯是,deltaTime超过1/30秒时强制走步进逻辑,低于这个阈值时走普通的TryMove即可,省掉不必要的计算。
4. 实操过程与核心代码实现
4.1 搭建最小验证场景:一张地图两辆坦克
理论说了不少,接下来是实操。我在项目里搭了一个最小的验证场景,用来反复测试防重叠逻辑:一张8乘8的网格地图,格子大小为16像素,地图数据用二维数组直接写死;一辆玩家坦克,用方向键控制;一辆敌方坦克,设定为在固定路线上来回巡逻。这个场景虽然简单,但已经能覆盖墙体防重叠、坦克互撞、滑动响应、高速穿行这些核心场景。
地图数据大概是这样的:
0 0 0 0 0 0 0 0 0 1 0 1 0 1 0 0 0 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 0 1 0 0 0 0 0 0 0 1 0 0 0 1 0 0 0 1 0 0 0 0 0 0 0 0 0我特意在地图中间留了一条窄通道,并且布置了几个T型墙角,用来测试滑动响应的边界表现。你会发现,很多碰撞Bug只有在墙角和高低错落的墙体组合里才会暴露出来,所以测试地图一定要有意识地制造这些地形,而不是只用空旷场地。
坦克的实体信息统一存在一个Tank结构体里:中心坐标、半宽、半高、速度、朝向、阵营。这里选择用“中心点加半宽半高”而不是“左上角加宽高”来表示矩形,是有讲究的:当你要围绕中心旋转或者计算对称碰撞时,“中心点加半宽半高”的写法明显更不容易出错,四方向转向后只需要改朝向变量,不用去重算各顶点坐标。
4.2 核心代码:把防重叠逻辑串起来
整个防重叠运动框架在项目里分为几块:Tank结构体、CollisionMap类、移动函数库、主循环调用。主循环每帧做的事情非常固定:
// 主循环内的更新逻辑(伪代码) void Update(float deltaTime) { // 1. 收集所有坦克本帧的移动意图 List<MoveIntent> intents = CollectMoveIntents(); // 2. 按固定优先级排序 intents.SortByPriority(); // 3. 逐个执行带滑动和步进的移动 foreach (var intent in intents) { float dx = GetDx(intent.Direction) * intent.Speed * deltaTime; float dy = GetDy(intent.Direction) * intent.Speed * deltaTime; MoveWithSlide(intent.Tank, dx, dy, ...); } // 4. 处理子弹(与坦克走同一套碰撞规则,额外检测命中) UpdateBullets(deltaTime); // 5. 渲染 Render(); }这套分层结构的好处是,碰撞规则只存在于TryMove和IntersectsWall这两个函数里,其他代码不需要关心碰撞逻辑。无论你往游戏里加什么新角色、新移动模式,只要最后都调MoveWithSlide,就不可能出现“新增功能绕过防重叠规则”的情况。
实际开发里,我强烈建议从第一天就把“移动意图收集”和“实际位移执行”分成两步。因为一个完整的帧内,可能有多辆坦克同时产生移动意图,而它们彼此影响的判定顺序必须在统一的排序环节里定下来。如果每个坦克在自己的Update里直接改坐标,防重叠规则就永远是一盘散沙——你会发现在运动顺序上打的补丁一个比一个丑陋。
4.3 调试可视化:把碰撞体画出来
代码写得再认真,没有可视化辅助,很多碰撞问题依然查不出来。我在调试阶段给每个坦克和子弹都单独绘制了一层半透明的碰撞体矩形,并且用颜色区分状态:绿色表示本帧成功移动,黄色表示发生了滑动,红色表示完全被阻挡。
这个小小的可视化开关帮我找到了一大批逻辑问题。举个例子,坦克顶着墙角的时候,如果分离轴尝试的顺序不对,你会看到坦克的碰撞体在墙角位置来回抖动,颜色在红色和黄色之间反复跳动,一眼就能看出是移动顺序或者边界算错了。另一个非常实用的调试技巧是“预测位置绘制”:在每帧移动前,把目标位置的碰撞体用虚线画出来,这样你能直观看到坦克的前进意图,以及它是被墙体挡住的还是被其他坦克挡住的。
绘制这些辅助图形不需要额外引库,DirectX和OpenGL都有基础线段绘制接口,Unity里用Debug.DrawRect,SDL里就是SDL_RenderDrawRect。关键不是绘制本身,而是建立“先看碰撞体,再看逻辑”的排查习惯,这比任何代码审查都高效。
5. 常见问题与排查技巧实录
写这套系统的过程中,我几乎把能踩的坑都踩了一遍。为了方便后面自己查阅,也为了方便读者快速定位问题,我把高频问题整理成一张速查表,后面的小节再逐个展开讲排坑过程。
| 现象 | 根因 | 排查方向 |
|---|---|---|
| 坦克卡在墙角出不来 | 格子范围算错或移动回退逻辑有缺陷 | 打印碰撞状态,检查格子范围减一逻辑 |
| 高速子弹穿墙 | 单帧位移大于墙体厚度 | 引入步进切割,限制最大步长 |
| 坦克互相穿过 | 同帧移动顺序混乱,各自独立改坐标 | 统一收集移动意图,按固定优先级分步移动 |
| 两个矩形贴边时判定冲突 | 边界点归属约定不一致 | 统一半开区间约定,写好单元测试 |
| 同屏实体多时卡顿 | 全量两两检测组合爆炸 | 网格分区做粗检测 |
5.1 坦克卡在T型墙角里出不来
这是滑动响应最常见的Bug。现象是:玩家坦克斜向顶进一个T型墙角,松开方向键后发现坦克已经不在活动范围里了,或者按下反方向键要好几帧才能挣脱。原因通常有两个:一是碰撞体矩形在目标位置检测时,把本不该算进来的一格墙算进来了,也就是前面提到的减一问题;二是分离轴尝试的两个轴向都失败后,代码直接把坦克位置改成了“上一帧位置”,但这个上一帧位置实际上已经处于重叠状态,下一帧继续尝试移动时又被相同逻辑卡住。
排查方法是逐帧打印碰撞状态:方向、目标位置、命中的格子列表、两个轴向各自的结果。我当时遇到的是第二种情况,解决方案是把MoveWithSlide改造成“在任何移动失败时,优先回退到本帧开始前的位置,并且跳过本帧的位置更新”。另外,如果你用网格地图,还有一招很管用的预处理:在生成地图时,把所有可能产生卡位的角落格子标记一个特殊属性,调试时直接把这个格子高亮显示出来。别小看这个土办法,它比对着代码猜快得多。
5.2 子弹高速穿透薄砖墙
当子弹速度提到每帧20像素以上后,穿透现象开始出现。我一开始以为是碰撞体太小,把子弹碰撞体改大了一圈,结果更糟——子弹还没碰到墙呢就提前消失了。后来才反应过来,问题根本不在碰撞体尺寸,而在检测粒度:子弹一帧内移动的距离远大于墙体厚度,单次检测永远会在墙的左右两侧都看不到墙。
解决方案就是我前面写的步进切割。实测下来,速度300像素每秒的子弹,在60帧率下单帧位移5像素,已经小于常见墙体厚度,理论上不会穿墙;但一旦帧率掉到30,单帧位移就变成了10像素,薄一点的墙可能就会穿透。所以我的规则是:不管当前帧率多少,只要单帧位移超过碰撞体最小尺寸的一半,就强制步进。这个阈值写进框架之后,穿透问题就再也没有出现过。顺带一提,处理完子弹穿透之后,我还顺手修复了一个相关Bug:子弹命中坦克后,爆炸特效的播放位置应该是命中点,而不是子弹最终停下的位置,这两个点在高速度下可能隔了十几像素。
5.3 碰撞判断的经典低级错误:AND和OR的恩仇
写这个框架的时候,我统计了一下,AABB判断相关的Bug里,有接近一半都出在运算符上。Overlaps函数里的逻辑是“两个轴都不分离才返回true”,用代码写就是:
// 错误写法:看起来顺理成章,实际完全错误 if (a.Right < b.Left || a.Left > b.Right || a.Bottom < b.Top || a.Top > b.Bottom) return true;这个错误写法的迷惑性在于,把“条件不成立”翻译成了“条件成立”,四个不等式少一不注意就全漏了。正确的写法要么像我最开始给的那样,把所有分离条件用或连接,结果为真就代表不重叠;要么用求交集的思路,检查左右、上下的重叠区间是否都存在。我的建议是:把Overlaps函数单独封装、写好注释、加单元测试,别让它散落在业务代码里随手复制。
还有一个同类Bug是边界点归属不统一。比如矩形A的右边界是100,矩形B的左边界也是100,这时它们算不算碰撞?如果算,那就意味着两个矩形可以刚好贴住,但同一像素只能属于一个矩形;如果不算,坦克之间的缝隙可能会比预期多出一像素。这个问题没有标准答案,但对同一个项目必须保持一致。我在项目里的约定是:右边界和下边界算开区间,即x小于等于other.Left才认为分离,这样两个矩形贴边时不会判定为重叠,同时杜绝了像素缝隙。
5.4 同屏实体变多之后的性能问题
坦克大战3.0基础阶段同屏坦克最多七八辆,子弹十来发,全量两两检测也就是一百多次比较,性能完全不是问题。但等到加了不少敌方坦克,子弹数量上去之后,我还是把性能优化提前做了,原因是防患于未然。
优化手段是“网格分区”做粗检测(broad phase):把整个地图按照一定大小划分成格子,每帧把所有实体登记到它们所在的格子中,两两检测只发生在同一格子或相邻格子内的实体之间。这个思路跟墙体检测的网格化是同源的。实测在20辆坦克、60发子弹的场景下,全量两两检测每帧需要做上百的组合比较,而网格分区后只需要三四十次,收益已经很明显。如果你的实体数量更大,还能换用四叉树或者空间哈希,不过坦克大战这种规模用网格分区就足够了。这里多说一句:做出网格分区之前,一定要确保防重叠逻辑本身已经稳定,不要在错误的基础上做无谓的性能优化。
6. 个人复盘:这套方案的边界与扩展方向
写到这,核心内容基本讲完了,最后聊聊我在这套方案里的一些体会,以及后续可以怎么扩展。
先说边界。这套防重叠方案建立在“AABB加网格地图”的假设上,一旦游戏出现旋转体、圆形子弹散射或者高低差地形,这套基础逻辑就不够用了。比如你想做那种炮塔可以360度旋转的坦克,坦克本体的碰撞体仍然可以是AABB,但炮弹的碰撞体如果要做成任意角度的细长矩形,就需要引入SAT分离轴定理计算任意凸多边形的相交,代码量会上一个台阶。
再说扩展。未来如果要加道具系统,比如星星、护盾、地雷,只需要在碰撞规则里增加“碰撞属性”这个维度:每个实体带一个碰撞掩码,墙体检测时判断掩码是否包含墙属性,坦克间检测时判断阵营是否敌对。这套设计在坦克大战3.0里已经预留了接口,我后面考虑加“河流阻挡履带但允许炮弹通过”这种特殊地形,也是同样的思路。
最后一点,也是我最大的体会:在写防重叠运动之前,我花了整整一周把前两个版本的运动代码全部删掉,当时很舍不得。但重构完之后,游戏手感反而干净利落了很多。很多看似简单的游戏机制,背后的实现细节远比想象中多,而把基础系统做干净,永远是后面所有玩法开发的地基。如果你也在写类似的2D游戏,建议按这篇文章的顺序走一遍:先理清栅格地图,再写AABB检测,然后补滑动和步进,最后用可视化调试反复验证边界情况。等你把这些都跑通了,再加什么玩法都会顺手很多。