GitHub 仓库: goTraining


前言

2024 年夏天用 Go 做了个东西:一个能跑能玩的陨石射击游戏。最初动机很单纯——学了 Go 的语法,想做点不是命令行输出 Hello World 的东西。游戏是很好的选择:有状态管理、有实时循环、有碰撞检测、有资源加载。一个几十兆的小程序,碰了工程里的许多问题。

引擎选的是 Ebitengine(当时还叫 Ebiten),Go 原生的 2D 游戏库。没有 Unity 那么庞大的编辑器,库只要引进来就能写 main(),跑起来。对于做个小游戏来说,刚刚好。

这篇文章是一次完整的技术复盘。不是教程,是我做完之后回头看:每一块怎么做的,为什么这么做,有什么坑,以及如果再写一次会怎么改。


架构总览

整个项目只有 8 个 .go 文件,没有第三方依赖(除了 Ebitengine 本身和 Go 标准库):

1
2
3
4
5
6
7
8
├── game.go      # 游戏主循环,碰撞检测,分数管理
├── player.go # 玩家飞船:移动、旋转、射击
├── bullet.go # 子弹:生成、飞行、绘制
├── meteor.go # 单颗陨石:运动逻辑
├── meteors.go # 陨石管理器:定时生成、批量更新
├── timer.go # Tick 计时器
├── rect.go # 矩形碰撞盒
└── sprite.go # 资源加载(embed + 字体)

核心依赖关系很简单:game.go 持有 *Player*MeteorsPlayer 持有 []*BulletMeteors 持有 []*Meteor。没有接口,没有抽象层,没有任何”面向对象设计模式”。就是数据的组合和函数的调用。

这就是 Go 的风格。


游戏循环:Update / Draw / Layout

Ebitengine 的核心是一个 Game 接口:

1
2
3
4
5
type Game interface {
Update() error
Draw(screen *ebiten.Image)
Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int)
}

我的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (g *Game) Update() error {
g.player.Update()
g.meteors.Update(g.gameTimer)
g.BulletCollisions()
g.MeteorCollisions()
g.UpdateGameTimer()
return nil
}

func (g *Game) Draw(s *ebiten.Image) {
g.player.Draw(s)
g.meteors.Draw(s)
text.Draw(s, fmt.Sprintf("%06d", g.score), ScoreFont, ScreenWidth/2-100, 50, color.White)
}

Update 做逻辑,Draw 做渲染。每一帧,Ebitengine 先调 Update() 更新所有游戏状态,再调 Draw() 画一帧。Layout 只返回固定的 1600 × 900

这个分离非常关键。如果你在 Draw 里改状态,帧率和逻辑会耦合。但这里每一帧的逻辑是确定的:玩家先动 → 陨石移动/生成 → 碰撞检测 → 计时器推进。这个顺序本身就是一个隐含的”帧顺序谱”——谁先谁后有实际影响,比如如果先做碰撞检测再更新陨石位置,你会看到子弹从陨石身边”滑过去”。


物理系统:为什么是加速度+摩擦力

初版代码用的是老式街机的方案——固定速度,对角移动时做向量归一化。这个方案的代码现在还注释在 player.go 里:

1
2
3
4
// 被废弃的向量归一化方案
// factor := speed / math.Sqrt(move.Vx*move.Vx + move.Vy*move.Vy)
// move.Vx *= factor
// move.Vy *= factor

后来换成了加速度+摩擦力:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const friction = 0.8

// 每帧:读取输入 → 给加速度
if ebiten.IsKeyPressed(ebiten.KeyUp) || ebiten.IsKeyPressed(ebiten.KeyW) {
move.Ay += -1.2 // Y 轴向上是负的
}

// 加速度累积到速度
move.Vx += move.Ax
move.Vy += move.Ay

// 速度累积到位置
move.X += move.Vx
move.Y += move.Vy

// 摩擦力衰减速度
move.Vx *= friction
move.Vy *= friction

这个改动背后是一个设计思考:游戏手感的本质是物理参数

固定速度的归一化方案有一个问题——玩家按方向键,飞船立刻开始匀速移动,松手立刻停。这种手感是”开关式”的,适合硬核弹幕游戏,但对我来说,太僵硬了,我不满意。

加速度方案给了飞船惯性Ax/Ay 是玩家”想去的方向”,Vx/Vy 是飞船只”实际上”的速度。friction = 0.8 意味着每帧速度衰减 20%,所以松手后不是立刻停,而是滑行一段——手感和马里奥的惯性一样。而 a = 1.2 决定了”推背感”——加速度太小会感觉飞船很肉,太大又会觉得飘。

三个数值(加速度 1.2、摩擦力 0.8、子弹速度 300px/s)调了一个多小时。每次改一个参数,编译跑起来飞一圈,感受一下。这个”编译-飞一圈-调参”的循环就是最原始的游戏设计反馈环。


旋转与子弹发射:三角函数终于用上了

玩家用鼠标左右键旋转飞船。这个简单——每帧根据按下的鼠标按钮加一个角度增量:

1
2
3
4
5
6
7
8
9
func (p *Player) rotate() {
speed := math.Pi / float64(ebiten.TPS())
if ebiten.IsMouseButtonPressed(ebiten.MouseButton0) {
p.rotation -= speed
}
if ebiten.IsMouseButtonPressed(ebiten.MouseButton2) {
p.rotation += speed
}
}

有意思的是子弹的生成位置。子弹不是从飞船中心射出的,而是从船头方向偏移 50 像素

1
2
3
4
5
pos := Vector{
p.playPosition.X + halfW + math.Sin(p.rotation)*bulletOffset,
p.playPosition.Y + halfH - math.Cos(p.rotation)*bulletOffset,
0, 0, 0, 0,
}

这里的 math.Sinmath.Cos 做的事情是:已知旋转角度,求方向上的一定距离的偏移坐标bulletOffset = 50 是偏移距离,rotation 是飞船头朝向的角度。因为这艘飞船的初始朝向是上方(0 度),所以 Y 轴偏移用的是 -Cos(向上是 Y 负方向)。

子弹飞行同样用 sin/cos:

1
2
3
4
5
func (b *Bullet) Update() {
shotSpeed := bulletSpeedPerSecond / float64(ebiten.TPS())
b.position.X += math.Sin(b.rotation)*shotSpeed + b.moveSpeed.X/2
b.position.Y += -math.Cos(b.rotation)*shotSpeed + b.moveSpeed.Y/2
}

注意 + b.moveSpeed.X/2——子弹继承了飞船发射时的速度。这意味着如果你在高速飞行中射击,子弹会有向前的初速度叠加。这不是 bug,是一个刻意的设计:飞船的惯性传递给子弹,视觉上子弹是从运动的飞船中射出的,不是凭空出现在屏幕坐标上。

旋转的绘制也有一个经典技巧——旋转中心调整。图片的旋转默认以左上角为原点,但我们需要以中心旋转:

1
2
3
4
options.GeoM.Translate(-halfW, -halfH)  // 把旋转中心移到图片中心
options.GeoM.Rotate(p.rotation) // 旋转
options.GeoM.Translate(halfW, halfH) // 复位
options.GeoM.Translate(p.playPosition.X, p.playPosition.Y) // 移到实际位置

三步变换:平移 → 旋转 → 平移。绕任意点旋转”的标准解法。

旋转示意图


碰撞检测:撞的是方块

游戏需要检测两种碰撞:

  1. 子弹打中陨石 → 双方消失,加分
  2. 陨石撞到玩家 → 游戏重置

用的都是同一种碰撞检测——AABB(Axis-Aligned Bounding Box,轴对齐矩形)

1
2
3
4
5
6
7
8
type Rect struct {
X, Y, Width, Height float64
}

func (r Rect) Intersect(Other *Rect) bool {
return r.X <= Other.MaxX() && Other.X <= r.MaxX() &&
r.Y <= Other.MaxY() && Other.Y <= r.MaxY()
}

判断两个矩形是否重叠,正着算很麻烦。两个矩形有 4 种重叠姿态:左上、右上、左下、右下,还要考虑边缘相切。所以我用了取反法:

两个矩形重叠 → 一个在另一个的左边、右边、上边、或下边

不重叠的条件:

1
2
3
4
r1 在 r2 左边 → r1.MaxX < r2.X
r1 在 r2 右边 → r1.X > r2.MaxX
r1 在 r2 上边 → r1.MaxY < r2.Y
r1 在 r2 下边 → r1.Y > r2.MaxY

四个条件用 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
2
3
4
if time.Since(p.lastShot) >= p.coolDown {
p.shoot()
p.lastShot = time.Now()
}

这个方案的问题不在”正常情况下”。正常情况下,帧率稳定 60fps,每帧约 16.6ms,时间差平滑增长,冷却判断精准。

问题出在异常帧上。 假设某一帧因为 GC 暂停或系统调度卡了 200ms。在这一帧里 time.Since() 返回的不是 16ms 而是 216ms——远超 1 秒冷却的阈值。冷却被”跨过去”了。更麻烦的是:如果 200ms 的卡顿恰好让两段逻辑都跨过去了(比如射击冷却和陨石生成都判定为就绪),这一帧里玩家的感受会是”卡了一下,然后屏幕突然多了一个陨石和我射不出的子弹”。

真实时间的另一个隐患是浮点累积误差time.Duration 是纳秒精度,但多次加减后误差会累积。虽然对于秒级别的冷却影响微乎其微,但在需要高精度同步的场景(比如格斗游戏的帧数据)里,这是致命问题。

方案二:帧数(Tick)

Tick 计时器不认时间,只认”过了几帧”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type Timer struct {
currentTicks int
targetTicks int
}

func NewTimer(duration time.Duration) *Timer {
return &Timer{
currentTicks: 0,
targetTicks: int(duration.Milliseconds()) * ebiten.TPS() / 1000,
}
}

func (t *Timer) UpdateTicks() {
if t.currentTicks < t.targetTicks {
t.currentTicks++
}
}

func (t *Timer) IsReadyAttack() bool {
return t.currentTicks >= t.targetTicks
}

构造时把时间换算成帧数: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 对这个项目是对的

  1. 目标平台是 PC + 60fps。帧率波动存在,但不会到 “144fps 冷却加速” 的程度。如果未来移植到移动端(高刷屏常见),这确实是个隐患,但现在不是。
  2. 冷却精度要求低。玩家感觉不到射击冷却差了几十毫秒。tick vs 真实时间的误差 < 1/60 ≈ 16ms,在 1 秒基数上可以忽略。
  3. 代码量极少。整个 Timer 只有 20 行。如果引入 delta time 管理,需要跟踪累计时间、处理重置逻辑、考虑浮点误差,代码量至少翻倍。
  4. 不会被卡顿”吃掉”冷却。这是选 tick 最直接的动机。宁可冷却被略微延长(因为 200ms 卡顿的那一帧只加了一个 tick),也不愿意冷却在卡顿中被跳过。

做技术的本能之一就是”用更精确的方案”。但工程上更重要的是知道什么时候精度不重要。一个射击游戏的冷却系统不需要纳秒级别的同步,它需要的是 “不会莫名其妙跳过冷却” 和 “我从头到尾看得懂自己的代码”。


陨石生成:极坐标 + 随机方向

陨石从屏幕外围生成,向屏幕中心移动。用极坐标(角度 + 半径)比用四边各抽一个坐标简洁得多:

1
2
3
4
5
6
7
8
9
10
11
12
13
func newMeteor() *Meteor {
r := ScreenWidth / 2.0 // 半径:半屏宽
angle := rand.Float64() * 2 * math.Pi // 随机角度
p := Vector{
X: target.X + r*math.Cos(angle),
Y: target.Y + r*math.Sin(angle), // 注意这里 target.Y 是正数
}
velocity := 1.0 + rand.Float64()*1.5
direction := Vector{X: target.X - p.X, Y: target.Y - p.Y}
normalized := direction.Normalize()
move := Vector{X: normalized.X * velocity, Y: normalized.Y * velocity}
// ...
}

生成点在以屏幕中心为圆心、半屏宽为半径的圆上随机一个角度。然后从生成点指向中心的向量做归一化后乘以随机速度。这样每个陨石都有一个不同的移动速度,视觉上不会看起来像”一群同步飞行的陨石”。

旋转速度也随机化:-0.02 + rand.Float64()*0.04,让每个陨石的转速不同。


Go Embed:把资源吃进二进制

游戏需要图片和字体。

Go 1.16 的 embed 解决了这个:

1
2
//go:embed assets
var data embed.FS

这一行会让编译器在构建时把整个 assets/ 目录的内容嵌入二进制。运行时通过 data.Open() 读取 。

加载图片也简单:

1
2
3
4
5
6
func mustLoadImage(n string) *ebiten.Image {
p, err := data.Open(path.Join("assets", n))
// ...
img, _, err := image.Decode(p)
return ebiten.NewImageFromImage(img)
}

mustLoad 这种命名是 Go 社区的约定,意味着初始化阶段的错误直接编译时失败,不返回 error。


架构设计:Go 的”不面向对象”

整个项目没有任何 interface(除了实现 Ebitengine 的 Game 接口),没有任何 abstract class。玩家、子弹、陨石,都是结构体 + 方法

1
2
3
4
5
6
7
8
type Player struct {
playPosition Vector
sprite *ebiten.Image
rotation float64
bullets []*Bullet
coldTimer Timer
speed float64
}

Player 拥有 []*Bullet,不是用继承而是用组合。子弹的 Update 由 Player 的 Update 触发——这是一种”自上而下传递控制权”的风格。Meteors 持有 []*Meteor,同理。

这种设计的好处是显式。在只有玩家一个射击源的场景下,就不过度设计一个”子弹池管理器”了。


缺陷

学习做小游戏,做完回头看不完美的地方很多。记下来:

  1. 死亡处理太粗暴reset() 直接清空一切重新开始。如果加上三秒倒计时和”分数保留显示”,体验会好很多。

  2. 碰撞检测是矩形 → 圆形:大陨石的碰撞体验不对,矩形盒比实际图形大。圆形碰撞检测只多两行代码——圆心距 < 半径和。没写的唯一原因是当时没搜到公式。

  3. 没有音效assets/Bonus/ 里有 ogg 文件(sfx_laser1.oggsfx_lose.ogg 等),但没有引入音频库。那周觉得”先做完再说”,然后就再也没有回来加。

  4. 子弹清理:子弹飞出屏幕后不会被清理。它会在坐标 (99999, 99999) 继续存活,只是看不见。理论上如果你打得够久,bullets 数组会无限增长。可以加一个remove。

  5. 没有关卡系统:陨石 5 秒一波永远不变。最自然的扩展是随时间加速生成——spawnInterval -= elapsedTime * factor。但当时我只想做个”能跑的”就止步了。


结语

做这个项目之前,没想到这么简单的一个小游戏也能用到那些数学知识,还是挺有意思的。


下一篇还没想好。可能继续做游戏,也可能回到二游分析的线。