先说个挺有反差感的事:很多人一聊到Go语言,第一反应就是写接口、写微服务、处理高并发,压根不会把它和“游戏”这两个字联系在一起。但Go生态里其实藏着一个相当成熟的2D游戏引擎,就是Ebiten。我最近用Golang配上Ebiten做了一款带碰撞检测的小游戏,整个过程下来最大的感受是:用Go写游戏并没有想象中那么“玩具”,而碰撞检测这个概念,也没有很多教程说得那么玄乎。这篇就把我的项目思路、核心实现、踩过的坑一次讲清楚,给想做游戏又不想离开Go生态的同学一个参考。
碰撞检测玩法需要的语文描述:
- 说人话版本:AABB矩形碰撞,就是判断两个“不旋转的方框”有没有重叠。玩家是一个方块,金币是一个方块,只要方块边缘交叉,就视为碰撞。判断方式就是比较坐标往左往右往上往下有没有“穿过去”。
- 标准公式:假设
a.x是左边、a.x+a.w是右边,a.y是上边、a.y+a.h是下边,同理b也这样。碰撞条件是a.x < b.x+b.w && a.x+a.w > b.x && a.y < b.y+b.h && a.y+a.h > b.y。 - 为什么要用这个:计算只有四次比较,非常适合成百上千个对象的批量检测,而且不需要考虑物体旋转,老游戏、格斗游戏里大量在用。
圆形碰撞的代码我这么写,注意平方距离比较,避免开根号,减少每帧的无效计算:
func CircleCollide(x1, y1, r1, x2, y2, r2 float64) bool { dx := x1 - x2 dy := y1 - y2 distSq := dx*dx + dy*dy minDist := r1 + r2 return distSq <= minDist*minDist }2.2 如何判断碰撞发生方向和分离方向
很多教程只教你判断“有没有碰撞”,但游戏里真正需要的是“碰撞了该怎么办”。比如撞墙之后玩家应该被挡在墙外面,而不是黏在墙上或者穿过去。这里要用重叠量来推断碰撞方向。
两个矩形一旦碰撞,x方向和y方向会产生两个重叠距离——overlapX和overlapY。比较这两个值,谁小,碰撞就优先发生在哪个轴上。举例子:如果玩家从左边撞向一堵竖直的墙,x方向的重叠量大约只有一步的距离,y方向则大到几乎完全不重叠,那么overlapX远小于overlapY,就能判断这是左右方向的碰撞,把玩家的x坐标修正到墙的左边。
func ResolveCollision(px, py, pw, ph float64, ex, ey, ew, eh float64) (newX, newY float64) { overlapX := math.Min(px+pw, ex+ew) - math.Max(px, ex) overlapY := math.Min(py+ph, ey+eh) - math.Max(py, ey) if overlapX < overlapY { if px < ex { px = ex - pw } else { px = ex + ew } } else { if py < ey { py = ey - ph } else { py = ey + eh } } return px, py }这段代码就是碰撞响应里的“推挤分离”法:不返回True或False,而是直接修正坐标,碰撞就变成了一种物理约束。如果你只想判断是否碰撞而不想改变物体位置,那就别调用这个函数,用上一节那两个Collide函数就够了。实际项目中,这两种函数要分开设计,因为判断和响应本来就应该是两个解决方案。
2.3 循环碰撞还是逐个碰撞
常见的菜鸟思路是:敌人、金币、障碍物全部丢进一个数组,然后玩家每一帧跟每个对象做碰撞检测。这在演示Demo里没什么问题,但对象一多就扛不住。假设场上有一百个敌人,玩家逐个检测只要一百次,还可以接受;但如果敌人之间也要互相检测,复杂度就是O(n^2),一百个就是一万次,一千个就是一百万次,一帧一百万次比较,Ebiten也撑不住。
解决方案是空间划分。用最简单的网格法:把游戏世界按格子划分,比如每个格子64x64像素,每个对象根据坐标放入对应格子,每帧检测时只检查自己所在格子以及邻居格子里的对象即可。这样复杂度能降到接近O(n)。小型游戏完全够用,也能顺便减少很多误碰撞,比如屏幕两端毫不相干的两个对象就不需要互相比了。
网格做法这里给个简化方向:
type SpatialGrid struct { cellSize float64 cells map[[2]int][]int // 格子的坐标 -> 对象ID列表 } func (g *SpatialGrid) Insert(id int, x, y, w, h float64) { // 先根据对象边界算出它跨了哪些格子,把id加入对应格子 } func (g *SpatialGrid) Query(x, y, w, h float64) []int { // 查询一个矩形范围内跨了哪些格子,返回里面的对象id }你不需要把所有碰撞对象都纳入网格,通常只把数量多、密集的敌人和子弹放进去,玩家和金币少,可以继续直接用逐个检测。哪种便宜用哪种,这是实际项目里非常实用的优化态度。
3. 实操过程与核心环节实现
前两节把理论和框架讲清楚之后,接下来的重头戏就是真正把代码写出来。从环境准备,到搭建游戏循环,到把玩家、敌人、金币做成一个完整可玩的小关卡,每个步骤我都会贴上可以直接跑的代码片段,并解释为什么要这么写。
3.1 环境准备与项目初始化
Ebiten对Go版本要求不高,通常Go 1.18以上就没什么问题。拉依赖这块不同平台有细微区别,macOS要装一些Xcode的命令行工具,Windows和Linux相对省事。装好之后建一个项目目录,初始化模块:
mkdir collision-demo cd collision-demo go mod init collision-demo然后拉取Ebiten依赖:
go get github.com/hajimehoshi/ebiten/v2这里如果你运行的环境是Linux,可能还需要安装一些系统级的图形库,比如OpenGL的驱动、X11开发库。如果build的时候缺少某些依赖,可以先解决系统包再继续,具体命令根据自己发行版来定,这属于环境问题,和代码逻辑无关。