用Go语言来做一个小游戏吧!
GitHub 仓库: goTraining
前言
2024 年夏天用 Go 做了个东西:一个能跑能玩的陨石射击游戏。最初动机很单纯——学了 Go 的语法,想做点不是命令行输出 Hello World 的东西。游戏是很好的选择:有状态管理、有实时循环、有碰撞检测、有资源加载。一个几十兆的小程序,碰了工程里的许多问题。
引擎选的是 Ebitengine(当时还叫 Ebiten),Go 原生的 2D 游戏库。没有 Unity 那么庞大的编辑器,库只要引进来就能写 main(),跑起来。对于做个小游戏来说,刚刚好。
这篇文章是一次完整的技术复盘。不是教程,是我做完之后回头看:每一块怎么做的,为什么这么做,有什么坑,以及如果再写一次会怎么改。
架构总览
整个项目只有 8 个 .go 文件,没有第三方依赖(除了 Ebitengine 本身和 Go 标准库):
1 | ├── game.go # 游戏主循环,碰撞检测,分数管理 |
核心依赖关系很简单:game.go 持有 *Player 和 *Meteors,Player 持有 []*Bullet,Meteors 持有 []*Meteor。没有接口,没有抽象层,没有任何”面向对象设计模式”。就是数据的组合和函数的调用。
这就是 Go 的风格。
游戏循环:Update / Draw / Layout
Ebitengine 的核心是一个 Game 接口:
1 | type Game interface { |
我的实现:
1 | func (g *Game) Update() error { |
Update 做逻辑,Draw 做渲染。每一帧,Ebitengine 先调 Update() 更新所有游戏状态,再调 Draw() 画一帧。Layout 只返回固定的 1600 × 900。
这个分离非常关键。如果你在 Draw 里改状态,帧率和逻辑会耦合。但这里每一帧的逻辑是确定的:玩家先动 → 陨石移动/生成 → 碰撞检测 → 计时器推进。这个顺序本身就是一个隐含的”帧顺序谱”——谁先谁后有实际影响,比如如果先做碰撞检测再更新陨石位置,你会看到子弹从陨石身边”滑过去”。
物理系统:为什么是加速度+摩擦力
初版代码用的是老式街机的方案——固定速度,对角移动时做向量归一化。这个方案的代码现在还注释在 player.go 里:
1 | // 被废弃的向量归一化方案 |
后来换成了加速度+摩擦力:
1 | const friction = 0.8 |
这个改动背后是一个设计思考:游戏手感的本质是物理参数。
固定速度的归一化方案有一个问题——玩家按方向键,飞船立刻开始匀速移动,松手立刻停。这种手感是”开关式”的,适合硬核弹幕游戏,但对我来说,太僵硬了,我不满意。
加速度方案给了飞船惯性。Ax/Ay 是玩家”想去的方向”,Vx/Vy 是飞船只”实际上”的速度。friction = 0.8 意味着每帧速度衰减 20%,所以松手后不是立刻停,而是滑行一段——手感和马里奥的惯性一样。而 a = 1.2 决定了”推背感”——加速度太小会感觉飞船很肉,太大又会觉得飘。
三个数值(加速度 1.2、摩擦力 0.8、子弹速度 300px/s)调了一个多小时。每次改一个参数,编译跑起来飞一圈,感受一下。这个”编译-飞一圈-调参”的循环就是最原始的游戏设计反馈环。
旋转与子弹发射:三角函数终于用上了
玩家用鼠标左右键旋转飞船。这个简单——每帧根据按下的鼠标按钮加一个角度增量:
1 | func (p *Player) rotate() { |
有意思的是子弹的生成位置。子弹不是从飞船中心射出的,而是从船头方向偏移 50 像素:
1 | pos := Vector{ |
这里的 math.Sin 和 math.Cos 做的事情是:已知旋转角度,求方向上的一定距离的偏移坐标。bulletOffset = 50 是偏移距离,rotation 是飞船头朝向的角度。因为这艘飞船的初始朝向是上方(0 度),所以 Y 轴偏移用的是 -Cos(向上是 Y 负方向)。
子弹飞行同样用 sin/cos:
1 | func (b *Bullet) Update() { |
注意 + b.moveSpeed.X/2——子弹继承了飞船发射时的速度。这意味着如果你在高速飞行中射击,子弹会有向前的初速度叠加。这不是 bug,是一个刻意的设计:飞船的惯性传递给子弹,视觉上子弹是从运动的飞船中射出的,不是凭空出现在屏幕坐标上。
旋转的绘制也有一个经典技巧——旋转中心调整。图片的旋转默认以左上角为原点,但我们需要以中心旋转:
1 | options.GeoM.Translate(-halfW, -halfH) // 把旋转中心移到图片中心 |
三步变换:平移 → 旋转 → 平移。绕任意点旋转”的标准解法。

碰撞检测:撞的是方块
游戏需要检测两种碰撞:
- 子弹打中陨石 → 双方消失,加分
- 陨石撞到玩家 → 游戏重置
用的都是同一种碰撞检测——AABB(Axis-Aligned Bounding Box,轴对齐矩形):
1 | type Rect struct { |
判断两个矩形是否重叠,正着算很麻烦。两个矩形有 4 种重叠姿态:左上、右上、左下、右下,还要考虑边缘相切。所以我用了取反法:
两个矩形不重叠 → 一个在另一个的左边、右边、上边、或下边
不重叠的条件:
1 | r1 在 r2 左边 → r1.MaxX < r2.X |
四个条件用 OR 连接。取反后,重叠的条件是:
1 | r1.MaxX >= r2.X AND r1.X <= r2.MaxX AND r1.MaxY >= r2.Y AND r1.Y <= r2.MaxY |
这就是 Intersect 里的四个比较。原理上等价于力扣 836. 矩形重叠,只是我把每个矩形的宽高存了而不是用对角坐标。

当然,这是一个近似。圆形陨石用方形碰撞盒,边缘会有”明明没碰到却判定命中”的虚假碰撞。但在这个阶段,AABB 的性能和实现复杂度是最优的。对于一个学习项目来说,”够用”比”精确”更重要。
计时器:不靠真实时间,靠 Tick
游戏中需要两个 “冷却”:
- 玩家射击间隔:1 秒
- 陨石生成间隔:5 秒
做冷却有两种思路:用真实时间,还是用帧数。
方案一:真实时间
最简单的做法是记录”上次射击的时间”,每帧用 time.Since() 算出时间差,大于间隔就允许射击:
1 | if time.Since(p.lastShot) >= p.coolDown { |
这个方案的问题不在”正常情况下”。正常情况下,帧率稳定 60fps,每帧约 16.6ms,时间差平滑增长,冷却判断精准。
问题出在异常帧上。 假设某一帧因为 GC 暂停或系统调度卡了 200ms。在这一帧里 time.Since() 返回的不是 16ms 而是 216ms——远超 1 秒冷却的阈值。冷却被”跨过去”了。更麻烦的是:如果 200ms 的卡顿恰好让两段逻辑都跨过去了(比如射击冷却和陨石生成都判定为就绪),这一帧里玩家的感受会是”卡了一下,然后屏幕突然多了一个陨石和我射不出的子弹”。
真实时间的另一个隐患是浮点累积误差。time.Duration 是纳秒精度,但多次加减后误差会累积。虽然对于秒级别的冷却影响微乎其微,但在需要高精度同步的场景(比如格斗游戏的帧数据)里,这是致命问题。
方案二:帧数(Tick)
Tick 计时器不认时间,只认”过了几帧”:
1 | type Timer struct { |
构造时把时间换算成帧数:duration_ms × TPS / 1000。Ebitengine 的 TPS(Ticks Per Second)默认是 60,所以 1 秒 = 60 tick,5 秒 = 300 tick。
每帧 UpdateTicks() 只加一次——不溢出(if currentTicks < targetTicks 保证了上限)。RestTicks() 重置为 0。IsReadyAttack() 只是比较两个整数。
**这个方案的好处是”可预测”**。不管某一帧卡了多久,计数只加 1。冷却进度的推进严格跟随逻辑帧,不受渲染性能影响。卡顿不会导致”跳过”冷却——在 tick 的世界里,时间是均匀流逝的。
trade-off:Tick 的代价
Tick 计时器有一个隐含假设:每一帧的逻辑步长与目标 TPS 一致。但在实际硬件上,这不一定成立。
如果你的显示器是 60Hz 且开了垂直同步,Ebitengine 稳定跑 60fps,一切完美——1 秒冷却 = 60 tick ≈ 1 秒。但如果跑在 144Hz 显示器上且没有帧率限制,游戏可能跑到 144fps,此时 60 tick 只用了 0.4 秒就完成了——冷却”加速”了。
这个问题的标准解法是用固定时间步长(fixed timestep)配合可变渲染帧率,这是 Gaffer on Games 那篇经典文章的核心思想。但它的实现复杂度对于这个项目来说不值得——引入一个累加器、一个固定步长的 Update 循环、还要处理螺旋阶梯(spiral of death)。
为什么 Tick 对这个项目是对的
- 目标平台是 PC + 60fps。帧率波动存在,但不会到 “144fps 冷却加速” 的程度。如果未来移植到移动端(高刷屏常见),这确实是个隐患,但现在不是。
- 冷却精度要求低。玩家感觉不到射击冷却差了几十毫秒。
tick vs 真实时间的误差 < 1/60 ≈ 16ms,在 1 秒基数上可以忽略。 - 代码量极少。整个 Timer 只有 20 行。如果引入 delta time 管理,需要跟踪累计时间、处理重置逻辑、考虑浮点误差,代码量至少翻倍。
- 不会被卡顿”吃掉”冷却。这是选 tick 最直接的动机。宁可冷却被略微延长(因为 200ms 卡顿的那一帧只加了一个 tick),也不愿意冷却在卡顿中被跳过。
做技术的本能之一就是”用更精确的方案”。但工程上更重要的是知道什么时候精度不重要。一个射击游戏的冷却系统不需要纳秒级别的同步,它需要的是 “不会莫名其妙跳过冷却” 和 “我从头到尾看得懂自己的代码”。
陨石生成:极坐标 + 随机方向
陨石从屏幕外围生成,向屏幕中心移动。用极坐标(角度 + 半径)比用四边各抽一个坐标简洁得多:
1 | func newMeteor() *Meteor { |
生成点在以屏幕中心为圆心、半屏宽为半径的圆上随机一个角度。然后从生成点指向中心的向量做归一化后乘以随机速度。这样每个陨石都有一个不同的移动速度,视觉上不会看起来像”一群同步飞行的陨石”。
旋转速度也随机化:-0.02 + rand.Float64()*0.04,让每个陨石的转速不同。
Go Embed:把资源吃进二进制
游戏需要图片和字体。
Go 1.16 的 embed 解决了这个:
1 | //go:embed assets |
这一行会让编译器在构建时把整个 assets/ 目录的内容嵌入二进制。运行时通过 data.Open() 读取 。
加载图片也简单:
1 | func mustLoadImage(n string) *ebiten.Image { |
mustLoad 这种命名是 Go 社区的约定,意味着初始化阶段的错误直接编译时失败,不返回 error。
架构设计:Go 的”不面向对象”
整个项目没有任何 interface(除了实现 Ebitengine 的 Game 接口),没有任何 abstract class。玩家、子弹、陨石,都是结构体 + 方法。
1 | type Player struct { |
Player 拥有 []*Bullet,不是用继承而是用组合。子弹的 Update 由 Player 的 Update 触发——这是一种”自上而下传递控制权”的风格。Meteors 持有 []*Meteor,同理。
这种设计的好处是显式。在只有玩家一个射击源的场景下,就不过度设计一个”子弹池管理器”了。
缺陷
学习做小游戏,做完回头看不完美的地方很多。记下来:
死亡处理太粗暴:
reset()直接清空一切重新开始。如果加上三秒倒计时和”分数保留显示”,体验会好很多。碰撞检测是矩形 → 圆形:大陨石的碰撞体验不对,矩形盒比实际图形大。圆形碰撞检测只多两行代码——圆心距 < 半径和。没写的唯一原因是当时没搜到公式。
没有音效:
assets/Bonus/里有 ogg 文件(sfx_laser1.ogg、sfx_lose.ogg等),但没有引入音频库。那周觉得”先做完再说”,然后就再也没有回来加。子弹清理:子弹飞出屏幕后不会被清理。它会在坐标 (99999, 99999) 继续存活,只是看不见。理论上如果你打得够久,
bullets数组会无限增长。可以加一个remove。没有关卡系统:陨石 5 秒一波永远不变。最自然的扩展是随时间加速生成——
spawnInterval -= elapsedTime * factor。但当时我只想做个”能跑的”就止步了。
结语
做这个项目之前,没想到这么简单的一个小游戏也能用到那些数学知识,还是挺有意思的。
下一篇还没想好。可能继续做游戏,也可能回到二游分析的线。


