贪吃蛇是一款经典的小游戏,玩法简单却充满乐趣。本文将介绍如何使用 Go 语言和 Ebiten 游戏引擎开发一个简单的贪吃蛇游戏。通过这个项目,你可以学习到游戏开发的基本流程、Ebiten 的使用方法以及如何用 Go 实现游戏逻辑。
贪吃蛇的核心玩法是控制一条蛇在网格中移动,吃掉随机生成的食物,每吃一个食物蛇身会变长,同时得分增加。如果蛇撞到墙壁或自己的身体,游戏结束。
本项目使用 Go 语言和 Ebiten 游戏引擎实现。Ebiten 是一个轻量级的 2D 游戏引擎,非常适合开发小游戏。
安装 Ebiten:
go mod init snake
go get -u github.com/hajimehoshi/ebiten/v2
游戏的核心状态由 Game
结构体管理,包括蛇的位置、食物位置、当前方向、分数等。
type Game struct {
Head Pos // 蛇头位置
Body []Pos // 蛇身位置列表
Food Pos // 食物位置
Dir int // 当前移动方向
Score int // 当前分数
GameOver bool // 游戏是否结束
Paused bool // 游戏是否暂停
TickCount int // 更新计数器
}
Ebiten 的游戏循环由 Update
和 Draw
方法实现:
蛇的移动通过更新头部位置,并将身体各部分依次移动到前一个部分的位置实现。
func (g *Game) Next() {
// 移动蛇身
for i := len(g.Body) - 1; i > 0; i-- {
g.Body[i] = g.Body[i-1]
}
g.Body[0] = g.Head
// 移动蛇头
g.Head.X += Direction[g.Dir].X
g.Head.Y += Direction[g.Dir].Y
}
碰撞检测分为两种情况:
func (g *Game) IsDead() bool {
// 检查是否撞墙
if g.Head.X < 0 || g.Head.X >= GridSize || g.Head.Y < 0 || g.Head.Y >= GridSize {
return true
}
// 检查是否撞到自己
for _, pos := range g.Body {
if g.Head == pos {
return true
}
}
return false
}
食物需要随机生成在网格中,且不能与蛇的身体重合。
func (g *Game) SpawnFood() {
for {
x := rand.IntN(GridSize)
y := rand.IntN(GridSize)
if !g.IsOccupied(x, y) {
g.Food = Pos{x, y}
break
}
}
}
通过检测键盘输入来控制蛇的移动方向,并支持暂停和重置游戏。
func (g *Game) HandleInput() {
if ebiten.IsKeyPressed(ebiten.KeyEscape) {
os.Exit(0) // 按下 Esc 键退出游戏
}
if ebiten.IsKeyPressed(ebiten.KeyP) {
g.Paused = !g.Paused // 按下 P 键切换暂停状态
}
if !g.Paused && !g.GameOver {
// 处理方向键输入
if ebiten.IsKeyPressed(ebiten.KeyLeft) && g.Dir != RIGHT {
g.Dir = LEFT
}
if ebiten.IsKeyPressed(ebiten.KeyRight) && g.Dir != LEFT {
g.Dir = RIGHT
}
if ebiten.IsKeyPressed(ebiten.KeyUp) && g.Dir != DOWN {
g.Dir = UP
}
if ebiten.IsKeyPressed(ebiten.KeyDown) && g.Dir != UP {
g.Dir = DOWN
}
}
}
运行游戏后,你会看到一个简单的贪吃蛇界面:
R
键可以重新开始。package main
import (
"fmt"
"image/color"
"math/rand/v2"
"os"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/vector"
)
const (
GridSize int = 40 // 网格大小(每个格子的大小)
BlockSize float32 = 20 // 每个格子的像素大小
WindowWidth int = GridSize * int(BlockSize) // 窗口宽度
WindowHeight int = GridSize * int(BlockSize) // 窗口高度
InitialTPS int = 5 // 初始每秒更新次数(游戏速度)
ScorePerFood int = 10 // 每吃一个食物增加的分数
)
const (
RIGHT = iota // 右方向
DOWN // 下方向
UP // 上方向
LEFT // 左方向
)
var (
HeadColor color.Color = color.NRGBA{0, 0, 255, 255} // 蛇头颜色
BodyColor color.Color = color.NRGBA{255, 255, 255, 255} // 蛇身颜色
FoodColor color.Color = color.NRGBA{255, 0, 0, 255} // 食物颜色
)
// Pos 表示一个二维坐标
type Pos struct {
X, Y int
}
// Direction 表示四个方向的移动向量
var Direction [4]Pos = [4]Pos{{1, 0}, {0, 1}, {0, -1}, {-1, 0}}
// Game 表示游戏的状态
type Game struct {
Head Pos // 蛇头位置
Body []Pos // 蛇身位置列表
Food Pos // 食物位置
Dir int // 当前移动方向
Score int // 当前分数
GameOver bool // 游戏是否结束
Paused bool // 游戏是否暂停
TickCount int // 更新计数器
}
// Update 是游戏的主更新逻辑
func (g *Game) Update() error {
if g.GameOver {
// 如果游戏结束,检测是否按下 R 键来重置游戏
if ebiten.IsKeyPressed(ebiten.KeyR) {
g.Reset()
}
return nil
}
g.TickCount++
if g.TickCount >= 60/InitialTPS {
g.TickCount = 0
if !g.Paused {
g.Next() // 更新游戏状态
}
}
g.HandleInput() // 处理玩家输入
return nil
}
// Draw 是游戏的主绘制逻辑
func (g *Game) Draw(screen *ebiten.Image) {
DrawGameState(screen, g)
}
// Layout 设置游戏窗口的布局
func (g *Game) Layout(outerWidth, outerHeight int) (int, int) {
return WindowWidth, WindowHeight
}
func main() {
ebiten.SetWindowTitle("Snake") // 设置窗口标题
ebiten.SetWindowSize(WindowWidth, WindowHeight) // 设置窗口大小
game := &Game{}
game.Reset() // 初始化游戏状态
if err := ebiten.RunGame(game); err != nil {
panic(err)
}
}
// DrawGameState 绘制游戏状态
func DrawGameState(screen *ebiten.Image, g *Game) {
// 绘制食物
vector.DrawFilledRect(screen, float32(g.Food.X)*BlockSize, float32(g.Food.Y)*BlockSize, BlockSize, BlockSize, FoodColor, true)
// 绘制蛇头
vector.DrawFilledRect(screen, float32(g.Head.X)*BlockSize, float32(g.Head.Y)*BlockSize, BlockSize, BlockSize, HeadColor, true)
// 绘制蛇身
for _, pos := range g.Body {
vector.DrawFilledRect(screen, float32(pos.X)*BlockSize, float32(pos.Y)*BlockSize, BlockSize, BlockSize, BodyColor, true)
}
// 绘制分数
scoreText := fmt.Sprintf("Score: %d", g.Score)
ebitenutil.DebugPrint(screen, scoreText)
// 如果游戏结束,显示游戏结束信息
if g.GameOver {
ebitenutil.DebugPrintAt(screen, "Game Over! Press R to restart.", WindowWidth/2-100, WindowHeight/2)
}
}
// Next 更新游戏状态
func (g *Game) Next() {
// 检查蛇是否吃到食物
if g.Head == g.Food {
g.Body = append(g.Body, g.Body[len(g.Body)-1]) // 增加蛇身长度
g.Score += ScorePerFood // 增加分数
g.SpawnFood() // 生成新的食物
}
// 移动蛇身
for i := len(g.Body) - 1; i > 0; i-- {
g.Body[i] = g.Body[i-1]
}
g.Body[0] = g.Head
// 移动蛇头
g.Head.X += Direction[g.Dir].X
g.Head.Y += Direction[g.Dir].Y
// 检查是否碰撞
if g.IsDead() {
g.GameOver = true
}
}
// SpawnFood 生成新的食物
func (g *Game) SpawnFood() {
for {
x := rand.IntN(GridSize)
y := rand.IntN(GridSize)
if !g.IsOccupied(x, y) {
g.Food = Pos{x, y}
break
}
}
}
// IsOccupied 检查某个位置是否被蛇占据
func (g *Game) IsOccupied(x, y int) bool {
if g.Head.X == x && g.Head.Y == y {
return true
}
for _, pos := range g.Body {
if pos.X == x && pos.Y == y {
return true
}
}
return false
}
// IsDead 检查蛇是否死亡(撞墙或撞到自己)
func (g *Game) IsDead() bool {
// 检查是否撞墙
if g.Head.X < 0 || g.Head.X >= GridSize || g.Head.Y < 0 || g.Head.Y >= GridSize {
return true
}
// 检查是否撞到自己
for _, pos := range g.Body {
if g.Head == pos {
return true
}
}
return false
}
// HandleInput 处理玩家输入
func (g *Game) HandleInput() {
if ebiten.IsKeyPressed(ebiten.KeyEscape) {
os.Exit(0) // 按下 Esc 键退出游戏
}
if ebiten.IsKeyPressed(ebiten.KeyP) {
g.Paused = !g.Paused // 按下 P 键切换暂停状态
}
if !g.Paused && !g.GameOver {
// 处理方向键输入
if ebiten.IsKeyPressed(ebiten.KeyLeft) && g.Dir != RIGHT {
g.Dir = LEFT
}
if ebiten.IsKeyPressed(ebiten.KeyRight) && g.Dir != LEFT {
g.Dir = RIGHT
}
if ebiten.IsKeyPressed(ebiten.KeyUp) && g.Dir != DOWN {
g.Dir = UP
}
if ebiten.IsKeyPressed(ebiten.KeyDown) && g.Dir != UP {
g.Dir = DOWN
}
}
}
// Reset 重置游戏状态
func (g *Game) Reset() {
g.Head = Pos{2, 0} // 初始化蛇头位置
g.Body = []Pos{{1, 0}, {0, 0}} // 初始化蛇身
g.Food = Pos{rand.IntN(GridSize), rand.IntN(GridSize)} // 初始化食物位置
g.Dir = RIGHT // 初始方向向右
g.Score = 0 // 重置分数
g.GameOver = false // 重置游戏结束状态
g.Paused = false // 重置暂停状态
g.TickCount = 0 // 重置计数器
}