Go Ebiten小游戏开发:扫雷
本教程将介绍如何使用Go语言和Ebiten游戏库开发一个经典的扫雷游戏。我们将重点关注设计思路和核心算法,而不是简单地堆砌代码。

目录
- 项目架构设计
- 核心数据结构
- 关键算法实现
- 游戏循环与状态管理
- 用户交互设计
- 渲染系统设计
项目架构设计
整体架构思路
扫雷游戏采用经典的MVC(Model-View-Controller)架构模式:
- Model: 游戏数据层,包括游戏板状态、地雷位置等
- View: 渲染层,负责绘制游戏界面
- Controller: 控制层,处理用户输入和游戏逻辑
Ebiten框架天然支持这种架构,通过Update()方法处理控制逻辑,Draw()方法处理视图渲染。
项目初始化
首先创建Go项目并添加Ebiten依赖:
go mod init saolei
go get github.com/hajimehoshi/ebiten/v2
核心数据结构
游戏状态设计
游戏的核心是状态管理,我们设计了三个层次的状态:
- 游戏整体状态: 游戏中、胜利、失败
- 格子状态: 未揭开、已揭开、已标记
- 格子属性: 是否为地雷、周围地雷数量等
数据结构设计思路
// 格子结构体 - 游戏的基本单元
type Cell struct {IsMine bool // 是否是地雷IsRevealed bool // 是否已揭开IsFlagged bool // 是否已标记NeighborMines int // 周围地雷数量State CellState // 格子状态
}// 游戏主结构体 - 管理整个游戏状态
type Game struct {board [][]Cell // 二维数组表示游戏板state GameState // 游戏整体状态firstClick bool // 是否第一次点击(影响地雷生成)startTime time.Time // 游戏开始时间elapsedTime int // 游戏经过时间flagCount int // 标记数量revealedCount int // 已揭开格子数量
}
设计要点:
- 使用二维数组存储游戏板,便于坐标计算和邻域访问
- 布尔字段表示状态,直观且高效
- 计数字段用于游戏逻辑判断和UI显示
关键算法实现
1. 地雷生成算法
设计思路:
- 避免在第一次点击位置及其周围生成地雷,保证玩家首次点击安全
- 使用随机算法均匀分布地雷
- 生成后立即计算每个格子周围的地雷数量
核心算法:
func (g *Game) placeMines(avoidX, avoidY int) {minesPlaced := 0for minesPlaced < MineCount {x := rand.Intn(BoardWidth)y := rand.Intn(BoardHeight)// 关键:避开首次点击区域if abs(x-avoidX) <= 1 && abs(y-avoidY) <= 1 {continue}if !g.board[y][x].IsMine {g.board[y][x].IsMine = trueminesPlaced++}}// 计算周围地雷数量for y := 0; y < BoardHeight; y++ {for x := 0; x < BoardWidth; x++ {if !g.board[y][x].IsMine {g.board[y][x].NeighborMines = g.countNeighborMines(x, y)}}}
}
2. 递归展开算法
设计思路:
- 当玩家点击一个周围没有地雷的格子时,自动展开相邻的所有空白区域
- 使用深度优先搜索(DFS)递归实现
- 避免重复揭开和无限递归
算法核心:
func (g *Game) revealCell(x, y int) {// 边界检查和状态检查if x < 0 || x >= BoardWidth || y < 0 || y >= BoardHeight {return}cell := &g.board[y][x]if cell.IsRevealed || cell.IsFlagged {return}// 揭开当前格子cell.IsRevealed = trueg.revealedCount++// 踩雷处理if cell.IsMine {g.state = GameStateLostreturn}// 关键:递归展开空白区域if cell.NeighborMines == 0 {for dy := -1; dy <= 1; dy++ {for dx := -1; dx <= 1; dx++ {if dx == 0 && dy == 0 {continue}g.revealCell(x+dx, y+dy) // 递归调用}}}// 胜利条件检查if g.revealedCount == BoardWidth*BoardHeight-MineCount {g.state = GameStateWong.autoFlagAllMines()}
}
算法特点:
- 使用递归实现自然的展开效果
- 通过状态检查避免重复处理
- 自动检测胜利条件
3. 邻域计算算法
设计思路:
- 计算每个格子周围8个方向的地雷数量
- 使用双重循环遍历邻域
- 边界检查防止数组越界
游戏循环与状态管理
Ebiten游戏循环
Ebiten采用Update-Draw循环模式:
- Update(): 处理游戏逻辑,每帧调用
- Draw(): 渲染游戏画面,每帧调用
- Layout(): 设置屏幕尺寸
状态管理策略
游戏状态转换:
开始游戏 → 游戏中 → 胜利/失败 → 重新开始
状态管理要点:
- 使用枚举类型定义状态,提高代码可读性
- 在状态转换时执行相应的初始化或清理工作
- 不同状态下限制不同的用户操作
时间管理
设计思路:
- 只在游戏进行时计算时间
- 首次点击时开始计时
- 游戏结束时停止计时
用户交互设计
输入处理策略
鼠标交互:
- 左键点击:揭开格子
- 右键点击:标记/取消标记地雷
- 坐标转换:屏幕坐标到游戏板坐标
键盘交互:
- R键:重新开始游戏
交互设计要点
- 首次点击保护:确保第一次点击不会踩雷
- 状态限制:游戏结束后禁止继续操作
- 视觉反馈:不同状态使用不同颜色区分
坐标转换算法
// 屏幕坐标转换为游戏板坐标
cellX := mouseX / CellSize
cellY := mouseY / CellSize
渲染系统设计
渲染层次
- 背景层:绘制所有格子的背景色
- 边框层:绘制格子边框
- 内容层:绘制数字、标记、地雷符号
- UI层:绘制状态信息和提示文字
颜色设计策略
- 未揭开格子:深灰色,表示未知
- 已揭开格子:浅灰色,表示安全
- 地雷格子:红色,表示危险
- 标记格子:黄色,表示警告
性能优化
渲染优化策略:
- 只重绘变化的区域
- 使用批量绘制减少API调用
- 缓存不变的渲染元素
扩展设计思路
难度系统
可以设计三个难度等级:
- 初级:9×9棋盘,10个地雷
- 中级:16×16棋盘,40个地雷
- 高级:30×16棋盘,99个地雷
数据持久化
设计思路:
- 保存最佳成绩
- 记录游戏统计信息
- 使用JSON格式存储配置
动画系统
可添加的动画效果:
- 格子揭开动画
- 地雷爆炸动画
- 胜利庆祝动画
总结
扫雷游戏虽然规则简单,但涉及了游戏开发的核心概念:
- 状态管理:合理的状态设计是游戏逻辑的基础
- 算法设计:递归展开是扫雷的核心算法
- 用户交互:直观的交互设计提升用户体验
- 渲染系统:分层的渲染架构便于维护和扩展
通过这个项目,我们学习了如何使用Ebiten框架开发2D游戏,掌握了游戏循环、状态管理、事件处理等核心概念。这些知识可以应用到更复杂的游戏开发中。
进一步学习建议
- 深入学习Ebiten:了解更高级的渲染技术和性能优化
- 游戏设计模式:学习观察者模式、状态模式等设计模式
- 算法优化:研究更高效的地雷生成和展开算法
- UI/UX设计:提升游戏的视觉效果和用户体验
游戏开发是一个不断学习和实践的过程,扫雷游戏只是一个开始。希望这个教程能为你打开Go语言游戏开发的大门!
完整代码
package mainimport ("fmt""image/color""log""math/rand""time""github.com/hajimehoshi/ebiten/v2""github.com/hajimehoshi/ebiten/v2/ebitenutil""github.com/hajimehoshi/ebiten/v2/inpututil""github.com/hajimehoshi/ebiten/v2/vector"
)// 游戏常量
const (BoardWidth = 10 // 游戏板宽度BoardHeight = 10 // 游戏板高度MineCount = 15 // 地雷数量CellSize = 30 // 每个格子的大小(像素)ScreenWidth = BoardWidth * CellSizeScreenHeight = BoardHeight*CellSize + 60 // 额外空间显示状态信息
)// 格子状态
type CellState intconst (CellStateHidden CellState = iota // 未揭开CellStateRevealed // 已揭开CellStateFlagged // 已标记
)// 游戏状态
type GameState intconst (GameStatePlaying GameState = iota // 游戏中GameStateWon // 胜利GameStateLost // 失败
)// 格子结构
type Cell struct {IsMine bool // 是否是地雷IsRevealed bool // 是否已揭开IsFlagged bool // 是否已标记NeighborMines int // 周围地雷数量State CellState // 格子状态
}// 游戏结构
type Game struct {board [][]Cell // 游戏板state GameState // 游戏状态firstClick bool // 是否第一次点击startTime time.Time // 游戏开始时间elapsedTime int // 游戏经过时间(秒)flagCount int // 标记数量revealedCount int // 已揭开格子数量
}// 创建新游戏
func NewGame() *Game {// 初始化游戏板board := make([][]Cell, BoardHeight)for y := range board {board[y] = make([]Cell, BoardWidth)for x := range board[y] {board[y][x] = Cell{State: CellStateHidden,}}}return &Game{board: board,state: GameStatePlaying,firstClick: true,flagCount: 0,revealedCount: 0,}
}// 放置地雷
func (g *Game) placeMines(avoidX, avoidY int) {minesPlaced := 0for minesPlaced < MineCount {x := rand.Intn(BoardWidth)y := rand.Intn(BoardHeight)// 避免在第一次点击的位置及其周围放置地雷if abs(x-avoidX) <= 1 && abs(y-avoidY) <= 1 {continue}if !g.board[y][x].IsMine {g.board[y][x].IsMine = trueminesPlaced++}}// 计算每个格子周围的地雷数量for y := 0; y < BoardHeight; y++ {for x := 0; x < BoardWidth; x++ {if !g.board[y][x].IsMine {g.board[y][x].NeighborMines = g.countNeighborMines(x, y)}}}
}// 计算周围地雷数量
func (g *Game) countNeighborMines(x, y int) int {count := 0for dy := -1; dy <= 1; dy++ {for dx := -1; dx <= 1; dx++ {if dx == 0 && dy == 0 {continue}nx, ny := x+dx, y+dyif nx >= 0 && nx < BoardWidth && ny >= 0 && ny < BoardHeight {if g.board[ny][nx].IsMine {count++}}}}return count
}// 揭开格子
func (g *Game) revealCell(x, y int) {if x < 0 || x >= BoardWidth || y < 0 || y >= BoardHeight {return}cell := &g.board[y][x]if cell.IsRevealed || cell.IsFlagged {return}cell.IsRevealed = truecell.State = CellStateRevealedg.revealedCount++// 如果踩到地雷,游戏结束if cell.IsMine {g.state = GameStateLostg.revealAllMines()return}// 如果周围没有地雷,自动揭开周围的格子if cell.NeighborMines == 0 {for dy := -1; dy <= 1; dy++ {for dx := -1; dx <= 1; dx++ {if dx == 0 && dy == 0 {continue}g.revealCell(x+dx, y+dy)}}}// 检查是否获胜if g.revealedCount == BoardWidth*BoardHeight-MineCount {g.state = GameStateWong.autoFlagAllMines()}
}// 揭开所有地雷
func (g *Game) revealAllMines() {for y := 0; y < BoardHeight; y++ {for x := 0; x < BoardWidth; x++ {if g.board[y][x].IsMine {g.board[y][x].IsRevealed = trueg.board[y][x].State = CellStateRevealed}}}
}// 自动标记所有地雷
func (g *Game) autoFlagAllMines() {for y := 0; y < BoardHeight; y++ {for x := 0; x < BoardWidth; x++ {cell := &g.board[y][x]if cell.IsMine && !cell.IsFlagged {cell.IsFlagged = truecell.State = CellStateFlaggedg.flagCount++}}}
}// 切换标记状态
func (g *Game) toggleFlag(x, y int) {if x < 0 || x >= BoardWidth || y < 0 || y >= BoardHeight {return}cell := &g.board[y][x]if cell.IsRevealed {return}if cell.IsFlagged {cell.IsFlagged = falsecell.State = CellStateHiddeng.flagCount--} else {cell.IsFlagged = truecell.State = CellStateFlaggedg.flagCount++}
}// 重新开始游戏
func (g *Game) restart() {g.board = make([][]Cell, BoardHeight)for y := range g.board {g.board[y] = make([]Cell, BoardWidth)for x := range g.board[y] {g.board[y][x] = Cell{State: CellStateHidden,}}}g.state = GameStatePlayingg.firstClick = trueg.flagCount = 0g.revealedCount = 0
}// 辅助函数
func abs(x int) int {if x < 0 {return -x}return x
}// Ebiten接口实现
func (g *Game) Update() error {// 更新游戏时间if g.state == GameStatePlaying && !g.firstClick {g.elapsedTime = int(time.Since(g.startTime).Seconds())}// 处理输入if inpututil.IsKeyJustPressed(ebiten.KeyR) {g.restart()return nil}if g.state != GameStatePlaying {return nil}// 处理鼠标点击if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {x, y := ebiten.CursorPosition()cellX := x / CellSizecellY := y / CellSizeif cellX >= 0 && cellX < BoardWidth && cellY >= 0 && cellY < BoardHeight {if g.firstClick {g.placeMines(cellX, cellY)g.startTime = time.Now()g.firstClick = false}g.revealCell(cellX, cellY)}}if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonRight) {x, y := ebiten.CursorPosition()cellX := x / CellSizecellY := y / CellSizeif cellX >= 0 && cellX < BoardWidth && cellY >= 0 && cellY < BoardHeight {if g.firstClick {g.placeMines(cellX, cellY)g.startTime = time.Now()g.firstClick = false}g.toggleFlag(cellX, cellY)}}return nil
}func (g *Game) Draw(screen *ebiten.Image) {// 绘制游戏板for y := 0; y < BoardHeight; y++ {for x := 0; x < BoardWidth; x++ {cell := g.board[y][x]cellX := x * CellSizecellY := y * CellSize// 绘制格子背景if cell.IsRevealed {if cell.IsMine {// 地雷 - 红色vector.FillRect(screen, float32(cellX), float32(cellY), CellSize, CellSize, colorRed, true)} else {// 已揭开的格子 - 浅灰色vector.FillRect(screen, float32(cellX), float32(cellY), CellSize, CellSize, colorLightGray, true)}} else if cell.IsFlagged {// 标记的格子 - 黄色vector.FillRect(screen, float32(cellX), float32(cellY), CellSize, CellSize, colorYellow, true)} else {// 未揭开的格子 - 深灰色vector.FillRect(screen, float32(cellX), float32(cellY), CellSize, CellSize, colorDarkGray, true)}// 绘制格子边框vector.FillRect(screen, float32(cellX), float32(cellY), CellSize, 1, colorBlack, true)vector.FillRect(screen, float32(cellX), float32(cellY), 1, CellSize, colorBlack, true)vector.FillRect(screen, float32(cellX+CellSize-1), float32(cellY), 1, CellSize, colorBlack, true)vector.FillRect(screen, float32(cellX), float32(cellY+CellSize-1), CellSize, 1, colorBlack, true)// 绘制数字或标记if cell.IsRevealed && !cell.IsMine && cell.NeighborMines > 0 {text := fmt.Sprintf("%d", cell.NeighborMines)ebitenutil.DebugPrintAt(screen, text, cellX+CellSize/2-4, cellY+CellSize/2-8)} else if cell.IsFlagged {ebitenutil.DebugPrintAt(screen, "F", cellX+CellSize/2-4, cellY+CellSize/2-8)} else if cell.IsRevealed && cell.IsMine {ebitenutil.DebugPrintAt(screen, "M", cellX+CellSize/2-4, cellY+CellSize/2-8)}}}// 绘制状态信息statusY := BoardHeight*CellSize + 10statusText := fmt.Sprintf("landmine: %d mark: %d time: %ds", MineCount-g.flagCount, g.flagCount, g.elapsedTime)ebitenutil.DebugPrintAt(screen, statusText, 10, statusY)// 绘制游戏状态switch g.state {case GameStateWon:ebitenutil.DebugPrintAt(screen, "You Win! Click R please", 10, statusY+20)case GameStateLost:ebitenutil.DebugPrintAt(screen, "Game Over! Click R please", 10, statusY+20)default:ebitenutil.DebugPrintAt(screen, "Left-click to uncover, right-click to mark", 10, statusY+20)}
}func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {return ScreenWidth, ScreenHeight
}// 颜色定义
var (colorBlack = color.RGBA{0, 0, 0, 255}colorRed = color.RGBA{255, 0, 0, 255}colorYellow = color.RGBA{255, 255, 0, 255}colorDarkGray = color.RGBA{128, 128, 128, 255}colorLightGray = color.RGBA{192, 192, 192, 255}
)func main() {game := NewGame()ebiten.SetWindowSize(ScreenWidth, ScreenHeight)ebiten.SetWindowTitle("扫雷游戏")if err := ebiten.RunGame(game); err != nil {log.Fatal(err)}
}