使用Go ebiten 2D游戏引擎库开发一个简易的五子棋小游戏

726 阅读5分钟

【本文正在参加金石计划附加挑战赛——第二期命题】

ChatGPT Image 2026年1月6日 09_46_31.png

认识一下ebitengine

Ebitengine (Eh-Bee-Ten-Gin)(原名 Ebiten)是一款基于Go 编程语言的开源游戏引擎。Ebitengine 简单的 API 让您能够快速轻松地开发可跨多个平台部署的 2D 游戏。

image.png

在 Ebitengine 中,一切都是图像:屏幕、图像文件中的数据,甚至屏幕外的项目都表示为图像对象。大多数渲染操作都是将一幅图像绘制在另一幅图像之上。

image.png

Ebitengine 游戏可在桌面(Windows、macOS、Linux 和 FreeBSD)、Web 浏览器(通过 WebAssembly)甚至移动设备(Android 和 iOS)上运行!此外,Ebitengine 在 Windows 上采用纯 Go 实现,因此 Windows 开发人员无需安装 C 编译器。还支持 Nintendo Switch™!

image.png

尽管 Ebitengine 的绘图 API 非常简单,但 Ebitengine 游戏借助 GPU 的强大功能运行速度非常快。内部将多幅图像集成到纹理图集中,并在可能的情况下自动批量执行绘图操作。

image.png

Ebitengine 已用于开发生产级游戏。其中一个例子是Bear's Restaurant,这是一款下载量超过 1,500,000 次的移动应用程序。有关使用 Ebitengine 制作的更多生产游戏,请参阅展示页面。有关 Ebitengine 的其他商业游戏的信息,请访问展示页面

设计一款简单的五子棋游戏

在对 ebitengine 有了初步了解之后,可以尝试着做一些简单的小玩具。下面带着大家做一个非常简单的五子棋小游戏。

创建一个工程并安装依赖

首先用 Goland 创建一个 Go 应用程序工程,然后安装一下 ebitengine 库:

go get github.com/hajimehoshi/ebiten/v2

绘制出一个窗口

首先我们需要绘制一个窗口,定义 Game 结构体如下:

const (
    boardSize    = 15 // 棋盘大小
    cellSize     = 40 // 每个格子的大小
    screenWidth  = cellSize * boardSize
    screenHeight = cellSize * boardSize
)

type Player int

const (
    Player1 Player = 1 // 黑子
    Player2 Player = 2 // 白子
)

type GameWindow struct {
    board         [boardSize][boardSize]Player // 棋盘,存储每个位置的棋子
    currentPlayer Player                       // 当前玩家
    gameOver      bool                         // 游戏是否结束
    winnerMessage string                       // 胜利信息
    resetPending  bool                         // 是否等待鼠标释放
}

上面的一些常用用于定义棋盘大小还有该谁落子。

声明构造函数

func NewGame() *GameWindow {
    return &GameWindow{
        board:         [boardSize][boardSize]Player{},
        currentPlayer: Player1,
        gameOver:      false,
        winnerMessage: "",
        resetPending:  false,
    }
}

ebitengine 规定游戏窗口需要实现 ebiten.Game 接口:

type Go interface {
    // 刷新窗口
    Update() error
    // 绘制窗口的每一帧
    Draw(screen *Image)
    // 窗口布局,设置窗口的大小
    Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int)
}

实现一下这个接口:

// 更新游戏逻辑  
func (g *GameWindow) Update() error {
    return nil
}

// 绘制棋盘和棋子  
func (g *GameWindow) Draw(screen *ebiten.Image) {

}

// 设置游戏布局  
func (g *GameWindow) Layout(outsideWidth, outsideHeight int) (int, int) {  
    return screenWidth, screenHeight  
}

编写主函数运行窗口:

func main() {
    game := NewGame()

    ebiten.SetWindowSize(screenWidth, screenHeight)
    ebiten.SetWindowTitle("Gobang GameWindow")

    if err := ebiten.RunGame(game); err != nil {
        log.Fatal(err)
    }
}

可以看到已经成功绘制出了一个窗口,只不过是黑乎乎的啥也没有:

image.png

绘制棋盘格子

为了能看清棋盘,这里我们设置棋盘的底色是黄色,然后格子都是黑色的线条:

func (g *GameWindow) Draw(screen *ebiten.Image) {
    // 设置背景为黄色
    screen.Fill(color.RGBA{255, 255, 0, 255}) // 黄色背景

    // 绘制棋盘格子
    for i := 0; i < boardSize; i++ {
        ebitenutil.DrawLine(screen, float64(i*cellSize), 0, float64(i*cellSize), screenHeight, color.Black)
        ebitenutil.DrawLine(screen, 0, float64(i*cellSize), screenWidth, float64(i*cellSize), color.Black)
    }

    // 绘制棋子在交叉点上
    for i := 0; i < boardSize; i++ {
        for j := 0; j < boardSize; j++ {
            if g.board[i][j] == Player1 {
                ebitenutil.DrawCircle(screen, float64(i*cellSize), float64(j*cellSize), cellSize/3, color.Black)
            } else if g.board[i][j] == Player2 {
                ebitenutil.DrawCircle(screen, float64(i*cellSize), float64(j*cellSize), cellSize/3, color.White)
            }
        }
    }

    // 如果游戏结束,显示胜利信息在窗口顶部
    if g.gameOver {
        text.Draw(screen, g.winnerMessage, basicfont.Face7x13, screenWidth/2-50, 20, color.Black)

        // 绘制重新开始按钮
        ebitenutil.DrawRect(screen, 10, screenHeight-50, 100, 40, color.White)
        text.Draw(screen, "Restart", basicfont.Face7x13, 20, screenHeight-30, color.Black)
    }
}

可以看到棋盘的格子已经成功地被绘制出来了:

image.png

处理点击事件实现落子

下一步我们需要实现落子的逻辑,棋子要落在格子的交叉点上,重写 Update 方法:

func (g *GameWindow) Update() error {
    // 检测是否要等待鼠标释放
    if g.resetPending {
        if !ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) {
            g.resetPending = false
        }
        return nil
    }

    if g.gameOver {
        // 检查按钮点击
        if ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) {
            mouseX, mouseY := ebiten.CursorPosition()
            if mouseX >= 10 && mouseX <= 110 && mouseY >= screenHeight-50 && mouseY <= screenHeight-10 {
                g.ResetGame() // 重新开始游戏
                g.resetPending = true
            }
        }
        return nil
    }

    // 处理正常的落子逻辑
    if ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) {
        x, y := ebiten.CursorPosition()

        // 将鼠标坐标转换为棋盘交叉点的格子坐标
        gridX, gridY := (x+cellSize/2)/cellSize, (y+cellSize/2)/cellSize

        // 确保点击位置在棋盘范围内,且该位置没有棋子
        if gridX >= 0 && gridY >= 0 && gridX < boardSize && gridY < boardSize && g.board[gridX][gridY] == 0 {
            // 放置棋子
            g.board[gridX][gridY] = g.currentPlayer

            // 检查是否有玩家获胜
            if g.checkWin(gridX, gridY) {
                g.gameOver = true
                if g.currentPlayer == Player1 {
                    g.winnerMessage = "Black Wins!"
                } else {
                    g.winnerMessage = "White Wins!"
                }
            } else {
                // 切换玩家
                if g.currentPlayer == Player1 {
                    g.currentPlayer = Player2
                } else {
                    g.currentPlayer = Player1
                }
            }
        }
    }
    return nil
}

同时,我们也需要完善判负和重新开始游戏的逻辑:

// 检查是否有玩家获胜
func (g *GameWindow) checkWin(x, y int) bool {
    directions := [][]int{
        {1, 0}, {0, 1}, {1, 1}, {1, -1}, // 四个方向
    }

    for _, dir := range directions {
        count := 1
        for i := 1; i < 5; i++ {
            nx, ny := x+dir[0]*i, y+dir[1]*i
            if nx < 0 || ny < 0 || nx >= boardSize || ny >= boardSize || g.board[nx][ny] != g.currentPlayer {
                break
            }
            count++
        }
        for i := 1; i < 5; i++ {
            nx, ny := x-dir[0]*i, y-dir[1]*i
            if nx < 0 || ny < 0 || nx >= boardSize || ny >= boardSize || g.board[nx][ny] != g.currentPlayer {
                break
            }
            count++
        }
        if count >= 5 {
            return true
        }
    }
    return false
}

// 重置游戏状态  
func (g *GameWindow) ResetGame() {  
    g.board = [boardSize][boardSize]Player{}
    g.currentPlayer = Player1
    g.gameOver = false
    g.winnerMessage = ""
}

来看一下最终的实现效果:

iShot_2024-11-11_21.28.40.gif

到这一步为止,整个小项目已经算是完成了,但是可能还有许多不完善的地方,读者有兴趣可以自行完善。

技术总结

通过本篇文章,你可以初步了解到 Go 语言在 2D 游戏领域的技术以及 ebitengine 游戏引擎的基本使用,还有游戏窗口、刷新帧的概念。想要更深入地了解 ebitengine 可以去官方网站查看技术文档。