Go Ebiten小游戏开发:扫雷

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

目录

  1. 项目架构设计
  2. 核心数据结构
  3. 关键算法实现
  4. 游戏循环与状态管理
  5. 用户交互设计
  6. 渲染系统设计

项目架构设计

整体架构思路

扫雷游戏采用经典的MVC(Model-View-Controller)架构模式:

  • Model: 游戏数据层,包括游戏板状态、地雷位置等
  • View: 渲染层,负责绘制游戏界面
  • Controller: 控制层,处理用户输入和游戏逻辑

Ebiten框架天然支持这种架构,通过Update()方法处理控制逻辑,Draw()方法处理视图渲染。

项目初始化

首先创建Go项目并添加Ebiten依赖:

bash 复制代码
go mod init saolei
go get github.com/hajimehoshi/ebiten/v2

核心数据结构

游戏状态设计

游戏的核心是状态管理,我们设计了三个层次的状态:

  1. 游戏整体状态: 游戏中、胜利、失败
  2. 格子状态: 未揭开、已揭开、已标记
  3. 格子属性: 是否为地雷、周围地雷数量等

数据结构设计思路

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键:重新开始游戏

交互设计要点

  1. 首次点击保护:确保第一次点击不会踩雷
  2. 状态限制:游戏结束后禁止继续操作
  3. 视觉反馈:不同状态使用不同颜色区分

坐标转换算法

go 复制代码
// 屏幕坐标转换为游戏板坐标
cellX := mouseX / CellSize
cellY := mouseY / CellSize

渲染系统设计

渲染层次

  1. 背景层:绘制所有格子的背景色
  2. 边框层:绘制格子边框
  3. 内容层:绘制数字、标记、地雷符号
  4. UI层:绘制状态信息和提示文字

颜色设计策略

  • 未揭开格子:深灰色,表示未知
  • 已揭开格子:浅灰色,表示安全
  • 地雷格子:红色,表示危险
  • 标记格子:黄色,表示警告

性能优化

渲染优化策略

  • 只重绘变化的区域
  • 使用批量绘制减少API调用
  • 缓存不变的渲染元素

扩展设计思路

难度系统

可以设计三个难度等级:

  • 初级:9×9棋盘,10个地雷
  • 中级:16×16棋盘,40个地雷
  • 高级:30×16棋盘,99个地雷

数据持久化

设计思路

  • 保存最佳成绩
  • 记录游戏统计信息
  • 使用JSON格式存储配置

动画系统

可添加的动画效果

  • 格子揭开动画
  • 地雷爆炸动画
  • 胜利庆祝动画

总结

扫雷游戏虽然规则简单,但涉及了游戏开发的核心概念:

  1. 状态管理:合理的状态设计是游戏逻辑的基础
  2. 算法设计:递归展开是扫雷的核心算法
  3. 用户交互:直观的交互设计提升用户体验
  4. 渲染系统:分层的渲染架构便于维护和扩展

通过这个项目,我们学习了如何使用Ebiten框架开发2D游戏,掌握了游戏循环、状态管理、事件处理等核心概念。这些知识可以应用到更复杂的游戏开发中。

进一步学习建议

  1. 深入学习Ebiten:了解更高级的渲染技术和性能优化
  2. 游戏设计模式:学习观察者模式、状态模式等设计模式
  3. 算法优化:研究更高效的地雷生成和展开算法
  4. 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)
	}
}
相关推荐
程序猿_极客2 小时前
【2025】16届蓝桥杯 Java 组全题详解(省赛真题 + 思路 + 代码)
java·开发语言·职场和发展·蓝桥杯
老夫的码又出BUG了2 小时前
分布式Web应用场景下存在的Session问题
前端·分布式·后端
玉树临风江流儿3 小时前
C++左值、右值、move移动函数
开发语言·c++
研究司马懿3 小时前
【ETCD】ETCD——confd配置管理
数据库·golang·自动化·运维开发·etcd·argocd·gitops
拾荒的小海螺3 小时前
JAVA:Spring Boot3 新特性解析的技术指南
java·开发语言·spring boot
程序猿20233 小时前
Python每日一练---第二天:合并两个有序数组
开发语言·python
椰羊sqrt3 小时前
CVE-2025-4334 深度分析:WordPress wp-registration 插件权限提升漏洞
android·开发语言·okhttp·网络安全
Js_cold3 小时前
Verilog任务task
开发语言·fpga开发·verilog
njxiejing3 小时前
Numpy一维、二维、三维数组切片实例
开发语言·python·numpy