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

目录
项目架构设计
整体架构思路
扫雷游戏采用经典的MVC(Model-View-Controller)架构模式:
- Model: 游戏数据层,包括游戏板状态、地雷位置等
- View: 渲染层,负责绘制游戏界面
- Controller: 控制层,处理用户输入和游戏逻辑
Ebiten框架天然支持这种架构,通过Update()方法处理控制逻辑,Draw()方法处理视图渲染。
项目初始化
首先创建Go项目并添加Ebiten依赖:
bash
go mod init saolei
go get github.com/hajimehoshi/ebiten/v2
核心数据结构
游戏状态设计
游戏的核心是状态管理,我们设计了三个层次的状态:
- 游戏整体状态: 游戏中、胜利、失败
- 格子状态: 未揭开、已揭开、已标记
- 格子属性: 是否为地雷、周围地雷数量等
数据结构设计思路
go
// 格子结构体 - 游戏的基本单元
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. 地雷生成算法
设计思路:
- 避免在第一次点击位置及其周围生成地雷,保证玩家首次点击安全
- 使用随机算法均匀分布地雷
- 生成后立即计算每个格子周围的地雷数量
核心算法:
go
func (g *Game) placeMines(avoidX, avoidY int) {
minesPlaced := 0
for 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 = true
minesPlaced++
}
}
// 计算周围地雷数量
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)递归实现
- 避免重复揭开和无限递归
算法核心:
go
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 = true
g.revealedCount++
// 踩雷处理
if cell.IsMine {
g.state = GameStateLost
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 = GameStateWon
g.autoFlagAllMines()
}
}
算法特点:
- 使用递归实现自然的展开效果
- 通过状态检查避免重复处理
- 自动检测胜利条件
3. 邻域计算算法
设计思路:
- 计算每个格子周围8个方向的地雷数量
- 使用双重循环遍历邻域
- 边界检查防止数组越界
游戏循环与状态管理
Ebiten游戏循环
Ebiten采用Update-Draw循环模式:
- Update(): 处理游戏逻辑,每帧调用
- Draw(): 渲染游戏画面,每帧调用
- Layout(): 设置屏幕尺寸
状态管理策略
游戏状态转换:
开始游戏 → 游戏中 → 胜利/失败 → 重新开始
状态管理要点:
- 使用枚举类型定义状态,提高代码可读性
- 在状态转换时执行相应的初始化或清理工作
- 不同状态下限制不同的用户操作
时间管理
设计思路:
- 只在游戏进行时计算时间
- 首次点击时开始计时
- 游戏结束时停止计时
用户交互设计
输入处理策略
鼠标交互:
- 左键点击:揭开格子
- 右键点击:标记/取消标记地雷
- 坐标转换:屏幕坐标到游戏板坐标
键盘交互:
- R键:重新开始游戏
交互设计要点
- 首次点击保护:确保第一次点击不会踩雷
- 状态限制:游戏结束后禁止继续操作
- 视觉反馈:不同状态使用不同颜色区分
坐标转换算法
go
// 屏幕坐标转换为游戏板坐标
cellX := mouseX / CellSize
cellY := mouseY / CellSize
渲染系统设计
渲染层次
- 背景层:绘制所有格子的背景色
- 边框层:绘制格子边框
- 内容层:绘制数字、标记、地雷符号
- UI层:绘制状态信息和提示文字
颜色设计策略
- 未揭开格子:深灰色,表示未知
- 已揭开格子:浅灰色,表示安全
- 地雷格子:红色,表示危险
- 标记格子:黄色,表示警告
性能优化
渲染优化策略:
- 只重绘变化的区域
- 使用批量绘制减少API调用
- 缓存不变的渲染元素
扩展设计思路
难度系统
可以设计三个难度等级:
- 初级:9×9棋盘,10个地雷
- 中级:16×16棋盘,40个地雷
- 高级:30×16棋盘,99个地雷
数据持久化
设计思路:
- 保存最佳成绩
- 记录游戏统计信息
- 使用JSON格式存储配置
动画系统
可添加的动画效果:
- 格子揭开动画
- 地雷爆炸动画
- 胜利庆祝动画
总结
扫雷游戏虽然规则简单,但涉及了游戏开发的核心概念:
- 状态管理:合理的状态设计是游戏逻辑的基础
- 算法设计:递归展开是扫雷的核心算法
- 用户交互:直观的交互设计提升用户体验
- 渲染系统:分层的渲染架构便于维护和扩展
通过这个项目,我们学习了如何使用Ebiten框架开发2D游戏,掌握了游戏循环、状态管理、事件处理等核心概念。这些知识可以应用到更复杂的游戏开发中。
进一步学习建议
- 深入学习Ebiten:了解更高级的渲染技术和性能优化
- 游戏设计模式:学习观察者模式、状态模式等设计模式
- 算法优化:研究更高效的地雷生成和展开算法
- UI/UX设计:提升游戏的视觉效果和用户体验
游戏开发是一个不断学习和实践的过程,扫雷游戏只是一个开始。希望这个教程能为你打开Go语言游戏开发的大门!
完整代码
go
package main
import (
"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 * CellSize
ScreenHeight = BoardHeight*CellSize + 60 // 额外空间显示状态信息
)
// 格子状态
type CellState int
const (
CellStateHidden CellState = iota // 未揭开
CellStateRevealed // 已揭开
CellStateFlagged // 已标记
)
// 游戏状态
type GameState int
const (
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 := 0
for 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 = true
minesPlaced++
}
}
// 计算每个格子周围的地雷数量
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 := 0
for dy := -1; dy <= 1; dy++ {
for dx := -1; dx <= 1; dx++ {
if dx == 0 && dy == 0 {
continue
}
nx, ny := x+dx, y+dy
if 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 = true
cell.State = CellStateRevealed
g.revealedCount++
// 如果踩到地雷,游戏结束
if cell.IsMine {
g.state = GameStateLost
g.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 = GameStateWon
g.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 = true
g.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 = true
cell.State = CellStateFlagged
g.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 = false
cell.State = CellStateHidden
g.flagCount--
} else {
cell.IsFlagged = true
cell.State = CellStateFlagged
g.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 = GameStatePlaying
g.firstClick = true
g.flagCount = 0
g.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 / CellSize
cellY := y / CellSize
if 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 / CellSize
cellY := y / CellSize
if 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 * CellSize
cellY := 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 + 10
statusText := 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)
}
}