游戏世界里有成百上千个”东西”在飞:子弹、坦克、咖啡杯、下落的方块。玩家永远不会问”碰撞检测是怎么写的”,但如果碰撞错了——子弹穿身而过、坦克卡进砖墙、小人踩空掉落——游戏立刻显得廉价。这篇讲清楚街机厅里的三样碰撞武器:圆形、矩形、瓦片地图。它们没有一样超出”两个数字的比较”,但组合起来足够撑起六台游戏。
一、圆形碰撞:一次勾股定理
坦克大战里,判断”子弹打中坦克了吗”,用的是两个物体中心点的距离:
function rectsHit(a, b, r) {
var dx = a.x - b.x, dy = a.y - b.y;
return dx * dx + dy * dy < r * r;
}
把两个物体的坐标看成一横一竖两条直角边,距离就是斜边——勾股定理。代码里不开平方,直接比较平方值(dx*dx + dy*dy < r*r),省掉一次 Math.sqrt,还能规避负数方向问题。
调用处长这样(tank.js):
if (rectsHit(bl, en, 10)) { // 子弹中心与坦克中心距离小于 10px → 命中
bl.dead = true;
en.hp--;
}
这里的
10 不是坦克的真实尺寸,而是设计出来的"判定半径":调小,玩家觉得子弹总擦身而过,不公平;调大,隔空掉血,莫名其妙。游戏手感很大程度上就是这些半径的调参——街机厅里敌我子弹互消用的半径是 4,坦克互撞阻挡用的是 14,各自不同,全是试出来的。
二、矩形碰撞:四个比较打天下
对于”方块对方块”(比如点击了哪张手牌、小人踩在哪块地板上),标准武器是 AABB(Axis-Aligned Bounding Box,轴对齐包围盒):两个矩形相交,当且仅当——
A 的左边 < B 的右边,且 A 的右边 > B 的左边,且 A 的上边 < B 的下边,且 A 的下边 > B 的上边。
小丑杯里判断”玩家点中了哪张牌”用的就是它:
function inRect(x, y, rx, ry, rw, rh) {
return x >= rx && x <= rx + rw && y >= ry && y <= ry + rh;
}
// 点击是否落在第 c 张手牌上(手牌从 HAND_X 开始,每张间隔 CARD_W + GAP)
for (var c = 0; c < hand.length; c++) {
var cy = sel[c] ? HAND_Y_SEL : HAND_Y;
if (inRect(x, y, HAND_X + c * (CARD_W + GAP), cy, CARD_W, CARD_H)) {
toggleSel(c); // 点中了!
}
}
四个比较,一次乘法都没有。游戏开发的碰撞,90% 的场景靠圆形和矩形就够——不需要任何物理引擎。
三、瓦片地图:像素换格子
前两招处理”物体 vs 物体”。还有一类问题是”物体 vs 世界”:坦克撞墙、小人踩地板。街机厅的做法是把世界切成 20×20 像素的瓦片(tile),整张地图就是一个二维数组:
var EMPTY = 0, BRICK = 1, STEEL = 2, BASE = 9; // tank.js
// map[行][列] 的值决定这个格子里是什么
查询”某个像素点是否是实心的”,只需要一次除法换算坐标,再查表:
function solidAt(px, py) {
var tx = Math.floor(px / T), ty = Math.floor(py / T); // 像素 → 格子坐标
if (tx < 0 || ty < 0 || tx >= COLS || ty >= ROWS) return true; // 出界即实心
var v = map[ty][tx];
return v === BRICK || v === STEEL || v === BASE;
}
坦克是 16×16 的方块,比瓦片大不了一圈,怎么判断”整辆坦克没撞墙”?四角采样:只检查坦克包围盒的四个角(各向内缩 1 像素)落在什么格子上:
function hitsWall(x, y, w, h) {
var l = x - w / 2, t = y - h / 2;
return solidAt(l + 1, t + 1) || solidAt(l + w - 1, t + 1) ||
solidAt(l + 1, t + h - 1) || solidAt(l + w - 1, t + h - 1);
}
四角都在空地上,中间必然也是空地(坦克尺寸小于两格),四次查询代替上百次——这就是”采样”的思想。
四、踩坑实录:判定窗口必须大于移动步长
碰撞最阴险的坑是隧穿(tunneling):物体移动太快,一步跨过判定区,碰撞”根本没发生”。反过来的坑是判定窗口太窄:下 100 层里,小人落地判定要求脚的位置落进地板顶部的一个 7 像素窗口:
if (under && man.vy >= 0 && man.y + 8 <= under.y + 6) { /* 落地成功 */ }
小人的下落速度上限是每帧 4.4 像素——必须小于 7 像素的窗口,否则会有一帧从”窗口上方”直接跳到”窗口下方”,看起来就是小人穿地板摔了下去。后来修「最后几层必死」的物理死锁时,把滚动速度从 5.1 压到 4.3,压到下落上限之下,判定窗口从此永远够用。先定判定窗口,再让所有速度向它低头——这是那次事故教会我的顺序。
隧穿的标准解法叫"连续碰撞检测":把一帧的移动切成多个小步分别检测,或者用射线算出这一帧会扫过哪些格子。街机厅的物体速度都被刻意控制在"每帧位移 < 最小物体尺寸"以内,从源头回避了这个问题——好的约束比复杂的算法更划算。
五、可视化:拖一个小球撞墙玩玩
按住小球拖动,撞向中间的砖块。松手后小球飞出边界会被拉回。绿色高亮 = 圆形碰撞生效,砖块变红 = AABB 命中,右侧实时显示两个判定式的原始数值:
这里用了比"四角采样"更优雅的一招:把圆心坐标逐轴夹逼(clamp)进矩形范围,得到矩形上离圆心最近的点;这个点落在圆内(距离 < 半径)就是命中。绿色小点就是那个"最近点",观察它在你拖动时始终贴在矩形边缘——这就是整个判定的工作原理。
六、跟着复现:30 行的拖拽碰撞
上面演示的核心逻辑抽出来只有这些(完整版直接右键查看本页演示的源码):
var ball = { x: 60, y: 130, r: 12 };
var brick = { x: 220, y: 100, w: 70, h: 60 };
// 圆 vs 矩形:把圆心夹进矩形 → 得到最近点 → 比较距离平方
var nx = Math.max(brick.x, Math.min(ball.x, brick.x + brick.w));
var ny = Math.max(brick.y, Math.min(ball.y, brick.y + brick.h));
var hit = (ball.x-nx)*(ball.x-nx) + (ball.y-ny)*(ball.y-ny) < ball.r*ball.r;
// 矩形 vs 点(鼠标点中了哪张牌)
var onCard = mx >= cx && mx <= cx+cw && my >= cy && my <= cy+ch;
复现建议:把砖块改成”被打中 3 次后消失”(加个 hp 计数器),再放三块砖排成一排——你就拥有了一个打砖块游戏的碰撞内核。街机厅里的打砖块(COFFEE BREAKOUT)正是从这两个判定式长出来的。
七、小结
- 圆形碰撞:中心距比较,
dx²+dy² < r²,判定半径是手感参数 - AABB:四个比较,用于方块对方块、点击命中测试
- 瓦片地图:像素除以格宽查表,四角采样判断整块物体
- 铁律:判定窗口必须大于移动步长,否则隧穿;约束优先于算法
下一篇是全系列最有故事的一篇:Flappy 咖啡杯的第 29 根管道,为什么 27% 的玩家必死?——一次教科书级的”随机数不是背锅侠”排查。
本篇所属系列:博客开发指南 · 上一篇:(八)一行注册一台机 · 下一篇:(十)随机不等于坑