Go + Ebiten 实现 2D 游戏碰撞检测:AABB、圆形与圆矩形混合判定
用 Go 写 2D 游戏最顺手的方式我试了一圈之后还是觉得 Ebiten 最香。之前为了验证一个碰撞检测游戏的想法我先在纸上画了草图屏幕底部有一块板子上方不断掉下落球玩家左右移动板子躲避它们一旦碰到就结束。听起来简单但真要把 Demo 跑起来还是要认真处理几件事游戏主循环怎么组织、对象怎么管理、碰撞检测到底用哪种数学模型。这篇文章记录的就是这个项目的完整实现过程最终产物是一个约 200 行的 Go 程序依赖只有 Ebiten 一家却能稳定演示 AABB 碰撞、圆形碰撞、圆与矩形混合碰撞三种检测方式。它适合两类读者一类是刚开始用 Go 写游戏、想找一条低门槛路径的开发者另一类是对碰撞检测公式一知半解、想通过可运行代码彻底搞懂的人。读完你不仅能跑起来一个游戏还能把公式改造成自己的玩法。1. 项目拆解先想清楚这个碰撞检测游戏要做什么1.1 玩法循环每一帧里都在发生什么游戏名的重点是“碰撞检测”所以玩法可以非常简单。我这里设定为玩家控制屏幕底部一块长条形板子左右移动躲避从顶部持续掉落的圆球板子只要碰到任意一个圆球游戏立刻结束屏幕右上角显示存活时间。这个规则的每一帧都在做同一件事读取输入、移动玩家、生成或更新圆球、检测碰撞、绘制画面。别小看这个循环它是所有游戏共用的骨架只不过 Ebiten 用三个方法帮你限定了边界Update 负责逻辑、Draw 负责渲染、Layout 负责窗口尺寸。我把这个循环拆开想了一下其实碰撞检测不是“一次判断”而是和移动更新耦合在一起的系统。比如圆球每帧移动 2 到 4 个像素玩家板子一帧最多移动 6 个像素那碰撞检测必须在移动之后立刻执行否则就会出现“已经重叠但没被检测到”的窗口期。对于本项目的物体尺寸和速度离散检测完全够用这一点我在后文会专门展开。1.2 为什么选 Ebiten三种方案的对比结论选择 Ebiten 之前我试过另外两条路线。第一条是 SDL2 的 Go 绑定这套方案能力很强但建窗口、处理事件、加载图像、管理主循环全得自己来写一天还不一定能看到画面。第二条是用标准库的 image 包加上自绘循环优点是零依赖缺点是没有游戏循环框架、没有输入封装、没有字体绘制一切从零开始适合教学演示但并不适合快速做原型。Ebiten 把这三块的体验平衡得最好窗口、输入、图像、音频、字体全都内置游戏循环被限定成 Update、Draw、Layout 三个方法你只需要填充逻辑。更关键的是它是纯 Go 实现不依赖外部引擎和运行时编译产物是一个单一可执行文件跨平台部署非常方便。如果拿游戏引擎类比Ebiten 更像一个“游戏开发库”而不是“完整编辑器”。它不做场景编辑器、不做组件可视化串联但正是这种克制让初学者不被工具链吓跑。1.3 一个项目同时演示三种碰撞检测既然标题核心是碰撞检测我希望这个 Demo 不只跑一个 if 判断。于是我把三类常见情况都放进了代码里圆球和玩家矩形板子之间用圆与矩形碰撞如果将来玩家也用矩形碰撞框可以直接用 AABB如果落球之间要做互动可以用圆与圆。这三种检测的实现思路完全不同但又能共享同一套坐标系统。把它们放在一个项目里比单独写三个程序更能看出差异。实际开发中很多 2D 游戏用的是混合方案角色和地形用 AABB子弹和子弹用圆形角色与交互物体用圆与矩形。因为 AABB 对旋转敏感、圆形对高速物体更友好而圆与矩形碰撞函数本身就是在求“点到矩形最短距离”理解一个就能串起另外两个。能在一个项目里把接口对齐这三种情况后面接任何玩法都不慌。2. 环境准备与项目骨架搭建2.1 创建模块并安装依赖实操第一步永远是初始化模块。我在项目目录里执行下面两条命令go mod init collision-demo go get github.com/hajimehoshi/ebiten/v2latest第一条命令生成 go.mod第二条把 Ebiten 拉进依赖。如果你在 Linux 上运行可能需要先装几个系统库常见的是 libgl1-mesa-dev、libxrandr-dev、libxcursor-dev 等安装命令因发行版而异。Windows 和 macOS 一般不需要额外处理。我遇到过一种很典型的翻车在无桌面显示的服务器上用go run启动窗口程序Ebiten 会直接报错或者黑屏这不是代码问题是你根本没有可用的显示环境开发调试还是老老实实在本地跑。Ebiten 对 Go 版本有要求建议使用较新的 Go 版本。我自己的习惯是每隔几周跑一次go get -u因为 Ebiten 迭代比较快新版 API 偶尔会微调。初学者很容易忽略这一点照着旧教程写结果发现RunGame的签名变了或者某个函数被挪了包问题排查半天。这个项目用的 API 相对稳定主流版本都能跑。2.2 理解 Update、Draw、Layout 三方法Ebiten 的Game接口只有三个方法但理解深浅直接决定你后面写代码顺不顺。Update以每秒 60 次TPS 默认 60被调用所有逻辑都放这里读键盘、改坐标、生成对象、检查碰撞。Draw负责把每一帧画面画到屏幕上它通常在垂直同步后触发可能不是严格的 60 帧但 Ebiten 会保证 Update 与 Draw 的节奏协调。Layout比较特殊它接收窗口实际尺寸返回游戏逻辑坐标尺寸比如我总是返回 640x480这样窗口无论放大到多大游戏内部仍然以 640x480 的逻辑坐标系运行高分屏上也不会出现物体尺寸崩坏。我之前犯过一个经典错误试图在 Draw 里改游戏状态。比如判断碰撞后直接改变对象位置结果画面出现奇怪闪烁。后来才意识到Ebiten 的 Draw 只该做“读状态并绘制”所有写操作必须留在 Update。养成这个习惯后很多诡异问题都消失了。2.3 数据结构和全局状态的组织方式这个 Demo 里的实体不多我仍然坚持用结构体组织而不是一堆平行变量。Player 结构体保存坐标和宽高Ball 保存圆心坐标、半径、下落速度和颜色。Game 结构体保存玩家、圆球列表、存活时间、游戏结束标记和生成计数器。type Player struct { X, Y float64 Width, Height float64 } type Ball struct { X, Y, Radius float64 Speed float64 Color color.RGBA } type Game struct { player Player balls []*Ball survivalSeconds float64 gameOver bool frameTick int }为什么用指针切片[]*Ball因为 Ball 会被频繁创建和回收用指针可以避免复制较大的结构体而且后续做对象池、复用都比较直观。对于这个体量值切片也完全能跑但我更愿意让代码具备直接扩展的倾向。实际项目里如果圆球数量上百上千最重要的其实是减少无意义的分配这一点我在性能章节还会提。还有一点不要忽略Ebiten 的坐标系统和数学课上的坐标系不同原点在窗口左上角x 向右增大、y 向下增大。第一次写的时候很容易把 y 方向搞反导致板子跑到屏幕顶部去。3. 碰撞检测公式推导与代码落地3.1 AABB轴对齐包围盒的两步判定AABB 全称 Axis-Aligned Bounding Box轴对齐包围盒。两个矩形不重叠只有四种情况A 在 B 左边、A 在 B 右边、A 在 B 上边、A 在 B 下边。把这四种情况取反就是重叠的判定条件。func aabbCollide(ax, ay, aw, ah, bx, by, bw, bh float64) bool { return ax bxbw axaw bx ay bybh ayah by }这个函数含义是A 的左边界必须在 B 的右边界左侧并且 A 的右边界必须在 B 的左边界右侧y 方向同理。四个条件同时成立说明两个矩形存在交集。边界触点要特别注意。如果两个矩形边缘刚好贴合axaw bx时函数返回 false视为不碰撞。游戏里通常要求“肉眼看到接触但没重叠不算碰撞”所以用严格小于/大于如果换成和贴边就会被判定为碰撞视觉上会提前一帧触发。具体取舍要看玩法但多数情况下我推荐严格比较。用一个生活类比两个礼品盒一个盒子的右边缘正好挨着另一个盒子的左边缘你没有叠上去盒子就不算碰撞。AABB 的优势是计算量极小只需要比较大小缺点是矩形不能旋转旋转后包围盒会偏大。3.2 圆与圆平方距离的妙处两个圆碰撞判定是最直观的圆心距离小于等于半径之和。但为了避开开方比较平方func circleCollide(x1, y1, r1, x2, y2, r2 float64) bool { dx : x1 - x2 dy : y1 - y2 return dx*dxdy*dy (r1r2)*(r1r2) }开方走的是浮点指令单个消耗虽然不大但游戏每帧可能要处理几十上百次检测项目一大会有差别。平方距离在这个场景完全够用误差可控。这里有个容易踩的细节半径或者坐标用 float64 时平方后的数量级通常只有四位到六位数字完全在浮点精度范围内。如果硬要用 float32 压缩内存可能出现两个圆边缘反复“抖动”的问题因为精度不足导致距离在临界值附近跳变。3.3 圆与矩形最近点法一步到位圆与矩形碰撞是这次 Demo 的主角。思路是找到矩形内离圆心最近的点再看这个点与圆心的距离是否小于圆半径。数学上叫“点到矩形的最近距离”。实现里用math.Max和math.Min做夹取func circleRectCollide(cx, cy, cr, rx, ry, rw, rh float64) bool { nearestX : math.Max(rx, math.Min(cx, rxrw)) nearestY : math.Max(ry, math.Min(cy, ryrh)) dx : cx - nearestX dy : cy - nearestY return dx*dxdy*dy cr*cr }这个逻辑可以类比成把鼠标指针限制在一个窗口区域内时系统会把指针坐标钳制到窗口边界上得到的就是“可移动范围内离鼠标最近的点”。无论是圆心在矩形内部、上方、左侧还是四个角的方向夹取公式都能给出正确的最近点所以边角碰撞也能准确捕捉不会出现“圆角没判上”的漏检。这个方法本质上把圆缩成一个点、矩形保持原尺寸去判断也可以反过来理解成把矩形向外扩展一个圆半径判断圆心是否落在膨胀后的矩形里。两种视角得到同一套判断结果理解哪一个都行。我更喜欢最近点的视角因为后续如果要计算碰撞方向和反弹向量这个最近点可以直接用来生成法线方向扩展很方便。3.4 离散检测的局限隧道效应速览我们每帧做一次快照判定叫离散碰撞检测。它有个经典问题叫隧道效应一个物体速度太快上一帧还在矩形左侧下一帧已经到了右侧中间没有一次快照落在重叠区间于是物体直接穿模。本项目中圆球一帧最多移动 4 像素左右圆半径最小 10 像素玩家板子厚 20 像素不存在穿越风险。如果你的游戏有高速子弹或角色瞬移就需要连续碰撞检测CCD或者把更新拆成多个子步长或者限制最大速度不超过物体最小尺寸的一半。最省事的是限制速度高速物体本来就不符合肉眼观察逻辑游戏设计上就能规避。这个原则在做任何原型时都值得先想一遍别一上来就上复杂算法。4. 完整 Demo 代码把碰撞检测接进游戏4.1 玩家控制按键与边界钳制玩家移动用方向键控制水平方向只有左右两个键。为了让板子不冲出屏幕我直接用math.Max和math.Min做钳制比写两串 if 更简洁if ebiten.IsKeyPressed(ebiten.KeyLeft) { g.player.X - playerSpeed } if ebiten.IsKeyPressed(ebiten.KeyRight) { g.player.X playerSpeed } g.player.X math.Max(0, math.Min(screenWidth-g.player.Width, g.player.X))这里screenWidth-g.player.Width是板子允许到达的最大 x 坐标。为什么用math.Max(0, math.Min(max, x))而不是写“如果小于 0 就赋 0如果大于 max 就赋 max”因为后者要两个 if而前者表达的是一个数学上的区间映射一眼就能看出含义。后续如果改成上下左右都能动只需要把 y 方向也写一遍同样的钳制。4.2 落球生成与回收帧计数与切片复用落球不能用无限循环一次性生成必须按节奏慢慢冒出来。我用一个帧计数器控制生成间隔每 60 帧生成一个球也就是大约一秒一个。生成时随机决定半径、速度和颜色。g.frameTick if g.frameTick%baseSpawnInt 0 { radius : 10 rand.Float64()*18 g.balls append(g.balls, Ball{ X: rand.Float64()*(screenWidth-2*radius) radius, Y: -radius, Radius: radius, Speed: 2 rand.Float64()*2.5, Color: randomColor(), }) }这里 X 的随机公式很多人会写错。如果直接写rand.Float64()*screenWidth当一个球半径很大时球的圆心可能贴到屏幕边缘导致球一半在窗口外面出生视觉上很不自然。所以要留出半径的余量rand.Float64()*(screenWidth-2*radius)radius保证整个圆周都在屏幕水平范围以内。球的回收我用了切片原地过滤的技巧alive : g.balls[:0] for _, b : range g.balls { b.Y b.Speed if circleRectCollide(b.X, b.Y, b.Radius, g.player.X, g.player.Y, g.player.Width, g.player.Height) { g.gameOver true } if b.Y screenHeightb.Radius { continue } alive append(alive, b) } g.balls alive这个写法的原理是复用底层数组。g.balls[:0]创建一个长度 0 但容量不变的切片然后遍历原切片把需要保留的球追加到alive。由于底层是同一块内存这不会产生额外的大分配。为什么要这么写因为频繁append和新建切片在球数量变大后会造成大量垃圾回收压力而原地过滤是典型的 Go 切片操作惯性写法。掉出屏幕的球用continue跳过自然就消失了。4.3 碰撞响应与游戏状态处理这个 Demo 里用gameOver bool管理状态就够了。Update 开头直接判断if g.gameOver { return nil }一旦碰撞发生游戏逻辑停摆但 Draw 还会继续绘制最后一帧的画面这样玩家能看到被击中的瞬间。我这里没有引入枚举状态机因为一个布尔够用加了反而徒增理解成本。不过我有意把碰撞响应写在每次检测之后而不是全盘遍历完成后这样如果后面想改成“碰到即得分并移除球”只需要在同一个位置调整分支扩展点很清晰。存活时间的累计也不该放在 Draw 里而是在 Update 每帧累加固定值g.survivalSeconds 1.0 / 60.0因为 Update 默认以 60 次每秒的节奏推进这个值就是真实时间流逝的近似。如果后面要支持暂停需要在暂停时停止累加否则玩家切后台回来会发现自己“凭空存活”了很久。4.4 渲染不加载图片直接用 vector 绘图为了让项目零素材依赖绘制全部用 Ebiten 的 vector 包完成。玩家板子用vector.DrawFilledRect圆球用vector.DrawFilledCircle每种颜色直接构造color.RGBA传入。vector.DrawFilledRect(screen, float32(g.player.X), float32(g.player.Y), float32(g.player.Width), float32(g.player.Height), playerColor, false) for _, b : range g.balls { vector.DrawFilledCircle(screen, float32(b.X), float32(b.Y), float32(b.Radius), b.Color, false) }注意坐标参数是float32而不是float64所以要做一次显式转换。这不是 bug而是 v2 版本 vector API 的设计选择。如果你之前写过其他语言可能觉得这里多此一举但在 Go 里强制类型转换反而能避免浮点宽度来回变化造成的精度问题。用 vector 绘制还有一个好处颜色可以随时改不用准备精灵图。实际游戏里如果要用角色立绘只需要把 Ball 结构体里加一个*ebiten.Image字段再在 Draw 里用DrawImage替换绘图调用碰撞检测部分完全不用动。4.5 完整代码与运行效果把上面的逻辑组装起来就是一个能直接运行的程序。完整代码我贴在下面注释保留在关键位置package main import ( fmt image/color log math math/rand github.com/hajimehoshi/ebiten/v2 github.com/hajimehoshi/ebiten/v2/ebitenutil github.com/hajimehoshi/ebiten/v2/vector ) const ( screenWidth 640 screenHeight 480 playerSpeed 6.0 baseSpawnInt 60 ) var ( bgColor color.RGBA{R: 0x0f, G: 0x0f, B: 0x1a, A: 0xff} playerColor color.RGBA{R: 0x4f, G: 0xd1, B: 0xff, A: 0xff} ) type Player struct { X, Y float64 Width, Height float64 } type Ball struct { X, Y, Radius float64 Speed float64 Color color.RGBA } type Game struct { player Player balls []*Ball survivalSeconds float64 gameOver bool frameTick int } func (g *Game) Update() error { if g.gameOver { return nil } g.survivalSeconds 1.0 / 60.0 if ebiten.IsKeyPressed(ebiten.KeyLeft) { g.player.X - playerSpeed } if ebiten.IsKeyPressed(ebiten.KeyRight) { g.player.X playerSpeed } g.player.X math.Max(0, math.Min(screenWidth-g.player.Width, g.player.X)) g.frameTick if g.frameTick%baseSpawnInt 0 { radius : 10 rand.Float64()*18 g.balls append(g.balls, Ball{ X: rand.Float64()*(screenWidth-2*radius) radius, Y: -radius, Radius: radius, Speed: 2 rand.Float64()*2.5, Color: randomColor(), }) } alive : g.balls[:0] for _, b : range g.balls { b.Y b.Speed if circleRectCollide(b.X, b.Y, b.Radius, g.player.X, g.player.Y, g.player.Width, g.player.Height) { g.gameOver true } if b.Y screenHeightb.Radius { continue } alive append(alive, b) } g.balls alive return nil } func (g *Game) Draw(screen *ebiten.Image) { screen.Fill(bgColor) vector.DrawFilledRect(screen, float32(g.player.X), float32(g.player.Y), float32(g.player.Width), float32(g.player.Height), playerColor, false) for _, b : range g.balls { vector.DrawFilledCircle(screen, float32(b.X), float32(b.Y), float32(b.Radius), b.Color, false) } status : fmt.Sprintf(Survival: %.1fs Balls: %d, g.survivalSeconds, len(g.balls)) if g.gameOver { status fmt.Sprintf(Game Over! Survival: %.1fs, g.survivalSeconds) } ebitenutil.DebugPrint(screen, status) } func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { return screenWidth, screenHeight } func circleRectCollide(cx, cy, cr, rx, ry, rw, rh float64) bool { nearestX : math.Max(rx, math.Min(cx, rxrw)) nearestY : math.Max(ry, math.Min(cy, ryrh)) dx : cx - nearestX dy : cy - nearestY return dx*dxdy*dy cr*cr } func aabbCollide(ax, ay, aw, ah, bx, by, bw, bh float64) bool { return ax bxbw axaw bx ay bybh ayah by } func circleCollide(x1, y1, r1, x2, y2, r2 float64) bool { dx : x1 - x2 dy : y1 - y2 return dx*dxdy*dy (r1r2)*(r1r2) } func randomColor() color.RGBA { return color.RGBA{ R: uint8(100 rand.Intn(155)), G: uint8(80 rand.Intn(175)), B: uint8(120 rand.Intn(135)), A: 0xff, } } func main() { ebiten.SetWindowSize(screenWidth, screenHeight) ebiten.SetWindowTitle(Collision Demo with Ebiten) game : Game{ player: Player{ X: screenWidth/2 - 50, Y: screenHeight - 40, Width: 100, Height: 20, }, } if err : ebiten.RunGame(game); err ! nil { log.Fatal(err) } }运行go run .之后屏幕上会出现一个 640x480 的窗口深色背景底部一块蓝色板子彩色圆球从顶部陆续掉落左上角显示存活时间和场上球数。只要操作板子躲开所有圆球存活时间就会一直增加碰到任意一个球画面立刻定格并显示 Game Over。代码里的三个碰撞函数虽然只用了circleRectCollide一个但另外两个在文件里已经实现你可以直接拿过去测试把玩家矩形改成两个矩形就是把玩家碰撞调成aabbCollide给球之间加互斥效果就遍历两两调用circleCollide。这也是我刻意保留三份函数的原因抄作业的人不用再翻文档。4.6 参数调节与手感经验想让游戏变难或者变简单只需要调几个常量。baseSpawnInt是生成间隔从 60 改成 30圆球生成频率翻倍playerSpeed从 6 改成 8玩家反应更灵活rand.Float64()*18里的 18 控制半径上限改成 30 后大球更多命中判定区域更大游戏难度自然上升。我实测下来一批比较舒服的初始参数是生成间隔 50玩家速度 6.5下落速度在 2 到 4.5 之间随机。这个参数组合下新手能存活二十秒左右不会一开始就被秒杀也不会出现“站着不动也能活很久”的枯燥局面。参数调整没有绝对标准最好让随机范围产生明显差异玩家才会感受到每次开局的新鲜感。5. 调试心得与性能优化5.1 把碰撞区域可视化调试碰撞检测时我最推荐的做法不是打印日志而是把碰撞框画出来。在 Draw 里临时加一个半透明辅助层给每个圆球画一个外切正方形再给玩家矩形描一圈边这样你能用肉眼直接看到“圆心距矩形边缘多远时触发了判定”。Ebiten 的 vector 包里有StrokeRect可以给矩形描边。颜色上我会选择与游戏主体差异很大的青色或亮红色并且用常量开关控制const debugDraw true if debugDraw { for _, b : range g.balls { vector.StrokeRect(screen, float32(b.X-b.Radius), float32(b.Y-b.Radius), float32(b.Radius*2), float32(b.Radius*2), 1, color.RGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff}, false) } }上线时把debugDraw改成 false这些辅助绘制就全部消失不会污染正式画面。我见过不少人遇到“明明感觉碰到了却没判定”的 bug用这个办法基本五分钟内就能定位要么是碰撞框画错位置要么是坐标已经漂移。5.2 对象数量上来后怎么办几十个圆球和一块板子碰撞检测耗时可以忽略。但如果圆球上千每个球都要和板子做一次检测复杂度就是 O(N)再加上球与球之间的两两检测就是 O(N²)很快会碰到性能瓶颈。2D 游戏通常用空间划分来优化把屏幕划分成 32x32 或 64x64 的网格每个圆球只检查相邻网格内的对象。再进阶就是用四叉树不过对 Demo 来说网格足够轻代码量也少。我的建议是先别惦记优化把功能跑通然后用 Debug 工具看 Draw 和 Update 的耗时确认确实卡了再动手。过早优化反而让代码难懂尤其对一个教学向的项目来说线性扫描恰恰是最容易读懂的实现。5.3 FPS 与逻辑步调的真相Ebiten 里 Update 的默认节奏是每秒 60 次和屏幕刷新率不一定相等。如果窗口被拖动或机器性能不足Draw 可能掉帧但逻辑依然按固有节奏推进这一点大多数时候是优势你的碰撞检测不会因为渲染卡顿而变慢或变快。但要注意这些逻辑更新不是“真实时间流逝”的绝对度量所以我的存活时间直接按1/60秒累加。如果你要做暂停、后台恢复这个方案会出问题玩家切出去五分钟再回来程序如果一直跑 Update存活时间会疯涨。正确的做法是用真实时间戳计算 delta或者检测到窗口失去焦点时暂停累加。实时对战类游戏强烈建议引入 delta time否则不同刷新率的机器上游戏手感会明显不一致。6. 常见问题与避坑清单实录6.1 高频问题速查表现象可能原因解决办法圆球穿过板子直接掉到屏幕外离散检测失效速度过快或物体太小限制下落速度或者把更新拆成多个子步长板子能按出屏幕一半没做 clamp或钳制公式写反用math.Max(0, math.Min(maxX, x))圆球刚出生就显示在屏幕外随机坐标范围没考虑半径用rand*(screenWidth-2r)r生成 XLinux 启动报 GLFW 相关错误缺少系统依赖库安装 mesa、xrandr、xcursor 等开发包窗口尺寸变化后物体比例变形Layout 返回了变化值始终返回固定逻辑尺寸例如 640x480球全部挤到屏幕左边随机数种子固定或生成公式错误Go 1.20 前记得显式rand.Seed检查半径余量高分屏文字发虚DebugPrint 使用逻辑坐标渲染使用更精细的字体方案或提高逻辑分辨率6.2 碰撞边界选择的细节前面提过和的区别这里再展开说说。大多数游戏把“刚好贴合”视为不碰撞因为玩家看到两个物体边缘接触但没重叠不应该判定为命中。如果你的玩法需要“碰到就算”可以把条件统一改成和但要接受视觉上可能早一帧触发的观感。更稳的做法是把碰撞检测拆成“移动前预测”和“移动后确认”。比如平台跳跃游戏里角色想向上跳先计算目标位置是否会与平台重叠如果会就只移动到平台边缘不再执行这次跳跃。这样能避免“先移动再检测”导致的卡墙抖动。本 Demo 不需要这种精度我提起它是因为很多人做到这里后会习惯性地把碰撞检测当成一个“事后裁决器”实际上在处理角色与地形交互时提前预测才是主流。6.3 从 Demo 到小游戏可以怎么扩展这个项目提供了非常好的扩展起点。想改成接水果玩法就把碰撞后的响应从“gameOver”改成“score 并移除球”漏接的球照样落出屏幕想加多级难度就在每存活 10 秒后调小生成间隔、调大下落速度想加音效Ebiten 的 audio 库可以直接播放 wav 文件。我后来做过一个有意思的改动把玩家的矩形换成圆形再把落球改成矩形碰撞函数从circleRectCollide换成circleCollide或aabbCollide整条逻辑链只调整了参数和函数名。这说明实体结构和碰撞函数解耦后换玩法比预期省力得多。建议你按这个思路自己改一版哪怕只是把板子从矩形变圆形也会对“碰撞算法是服务游戏设计的”这句话有更深体会。这个项目做到最后我最大的感受是碰撞检测看起来只是两个坐标公式但把它放进帧循环里组织好才是游戏开发的真正门槛。之前总觉得要先把算法学完再写游戏实际动手之后才发现在 Update 里调一次碰撞函数、观察它如何影响 gameOver比背十遍公式都管用。后来我把这个 Demo 的玩家矩形换成圆形、把落球改成矩形整套碰撞系统只改了几个参数就完成了这种重构效率正是 Ebiten 轻量设计带来的红利。如果你也打算用 Go 做点小游戏建议从这样一个“单屏 移动 碰撞”的起点开始不用一上来就搞资源管理、场景切换先让一个碰撞判断在自己屏幕上真实地发生一次剩下的想法自然会长出来。

相关新闻

Android 12以太网静态IP配置全攻略:从设置界面到adb命令与固化方案

Android 12以太网静态IP配置全攻略:从设置界面到adb命令与固化方案

简介:这份资源聚焦 Android 12 系统设置中以太网静态 IP 的配置实现,面向系统开发人员、框架层源码研究者以及需要定制有线网络行为的工程师。资源包内含 8 个文件,其中 6 个 Java 源码文件覆盖以太网配置存储、连接状态追踪、网络工厂与服务…

2026/10/18 2:10:56 阅读更多 →
alpha-shape 算法实践:用 JavaScript 重建散点凹边界与三维轮廓

alpha-shape 算法实践:用 JavaScript 重建散点凹边界与三维轮廓

简介:alpha-shape是计算几何方向的JavaScript工具库,用于求取任意维度点集的阿尔法形状,适合在点云轮廓提取、形状拟合与空间数据分析中快速得到清晰边界。面向有一定前端或Node.js基础的开发者,核心算法封装成调用简单的函数&…

2026/10/18 2:10:56 阅读更多 →
数字藏品系统源码全解析:架构、部署与二次开发实践

数字藏品系统源码全解析:架构、部署与二次开发实践

简介:这是一套全开源的壹牛NFT数字艺术藏品系统源码,面向有数字藏品平台搭建与二次开发需求的开发者、创业团队及技术学习者。源码同时覆盖后台管理与H5/APP端展示逻辑,新版UI全面改版,并加入宝盒抽奖、多种材料合成宝石及3D模型显…

2026/10/18 2:10:56 阅读更多 →

最新新闻

C#+OpenCV二维码识别:图像预处理与透视矫正实战解析

C#+OpenCV二维码识别:图像预处理与透视矫正实战解析

这个项目看起来很直接,但实际落地时坑不少。标题是“C#OpenCV识别图片二维码”,拆开看就是三件事:C#做宿主程序、OpenCV做图像处理、二维码识别作为最终目标。很多人一上来就找OpenCV的二维码识别模块,结果发现识别率惨不忍睹&…

2026/10/18 2:59:06 阅读更多 →
Flutter多因子排序:动态权重与概率统计在鸿蒙适配中的实践

Flutter多因子排序:动态权重与概率统计在鸿蒙适配中的实践

如果你维护过带排序列表的App,大概率在某个版本上被多因子业务混战折磨过:用户要综合分,运营要热度加权,产品要新品加权,风控要防刷降权,几个团队各提各的需求,最后全压在ranking模块上。我最初…

2026/10/18 2:59:06 阅读更多 →
Qwen-Agent实战:从对话到代码执行的智能体自动化框架

Qwen-Agent实战:从对话到代码执行的智能体自动化框架

如果你的工作里有一半时间是用来“搬运数据、整理表格、复制粘贴、改格式”,那么Qwen-Agent值得你花半个小时来试一下。这不是那种“跟我对话聊天”的玩具型AI工具,而是一个真正能接手干活的全能助手——给它一个任务描述,它能自动拆解步骤、…

2026/10/18 2:59:06 阅读更多 →
视频流分段返回实战:HTTP Range 原理、实现与避坑指南

视频流分段返回实战:HTTP Range 原理、实现与避坑指南

1. 视频流分段返回到底在解决什么问题第一次接触“视频流分段返回”这个需求,是在一个后台管理系统里。前端要预览一段监控回放,视频文件放在对象存储上,单个文件动辄两三百兆。最开始的做法很粗暴:后端一次性把整个文件读进内存&…

2026/10/18 2:59:06 阅读更多 →
Moonfire NVR:Rust 编写的轻量级本地 IP 摄像机录像系统

Moonfire NVR:Rust 编写的轻量级本地 IP 摄像机录像系统

简介:Moonfire NVR 是一款轻量高效、开源的网络视频录像机(NVR)系统,面向嵌入式安防开发人员、边缘视频存储方案设计者及 Rust/JavaScript 全栈工程师,解决 IP 摄像机 RTSP 流的低开销持久化存储与按需回放问题。资源包…

2026/10/18 2:59:06 阅读更多 →
开源Newsletter系统多租户安全设计:MailCheap从账户隔离到API Key撤销全链路指南

开源Newsletter系统多租户安全设计:MailCheap从账户隔离到API Key撤销全链路指南

【免费下载链接】mailcheap Newsletters where you pay for the emails you send, not for dormant subscribers, at a fraction of the price of beehiiv and the like. Open source: self-host it, or use mailcheap.ai. 项目地址: https://gitcode.com/gh_mirror…

2026/10/18 2:58:05 阅读更多 →

日新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/18 0:00:55 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/18 0:00:55 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/18 0:00:55 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/18 0:00:55 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/18 0:00:55 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/18 0:00:55 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/18 0:55:02 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/17 17:22:31 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/16 14:23:36 阅读更多 →