Go Ebiten小游戏开发:2048
项目简介
这是一个使用Go语言和Ebiten游戏引擎开发的2048游戏。2048是一款经典的数字滑块游戏,玩家通过上下左右移动合并相同数字,目标是获得2048数字方块。
核心算法实现
游戏数据结构
const (
gridSize = 4 // 4x4网格
)
var (
grid [gridSize][gridSize]int // 游戏主网格
score int // 游戏分数
gameOver bool // 游戏结束标志
)
游戏使用一个4x4的二维数组存储每个格子的数值,0表示空格子。
随机生成方块算法
func addRandomTile() {
var emptyTiles [][2]int
for y := 0; y < gridSize; y++ {
for x := 0; x < gridSize; x++ {
if grid[y][x] == 0 {
emptyTiles = append(emptyTiles, [2]int{x, y})
}
}
}
if len(emptyTiles) > 0 {
pos := emptyTiles[rand.Intn(len(emptyTiles))]
value := 2
if rand.Float32() < 0.1 {
value = 4
}
grid[pos[1]][pos[0]] = value
}
}
算法实现原理:
- 扫描整个网格,找出所有空格子(值为0)的坐标
- 从空格子列表中随机选择一个位置
- 90%的概率生成数字2,10%的概率生成数字4
- 在选定的位置放置新数字
移动和合并算法
移动和合并是2048游戏的核心,通过一个通用函数实现四个方向的操作:
func moveAndMerge(transform func(x, y int) (int, int)) {
// 合并相同数字
// 移动数字
}
实现原理:
- 使用转换函数将四个方向的操作抽象为统一的逻辑
- 先处理合并:查找相邻的相同数字并合并,更新分数
- 再处理移动:将非空格子向特定方向移动,填补空格
- 如果有移动或合并操作,则添加一个随机数字
转换函数的巧妙设计使得四个方向的操作可以共用一套逻辑:
- 向上移动:
func(x, y int) (int, int) { return x, y }
- 向下移动:
func(x, y int) (int, int) { return x, gridSize - 1 - y }
- 向左移动:
func(x, y int) (int, int) { return y, x }
- 向右移动:
func(x, y int) (int, int) { return gridSize - 1 - y, x }
游戏结束判断
游戏结束的条件是:棋盘已满且无法进行任何合并操作。
func isGridFull() bool {
// 检查网格是否已满
}
func canMerge() bool {
// 检查是否有可以合并的相邻数字
}
实现原理:
- 首先检查网格是否已满(没有值为0的格子)
- 然后检查是否存在相邻的相同数字(可以合并)
- 两个条件都满足时,游戏才结束
UI渲染
游戏使用Ebiten的绘图API实现界面渲染:
func (g *Game) Draw(screen *ebiten.Image) {
// 绘制背景和网格
// 绘制每个格子和数字
// 显示分数和游戏状态
}
特点:
- 使用不同颜色区分不同数值的方块
- 根据数字位数自动调整字体大小,保证美观
- 文字居中显示,增强视觉体验
总结
2048游戏的关键在于数据结构和移动合并算法的实现。通过抽象转换函数,代码实现了简洁统一的移动逻辑。使用Ebiten引擎提供的图形API,使游戏具有良好的视觉效果和交互体验。
这个项目展示了Go语言在游戏开发中的应用,以及如何使用函数式编程思想简化游戏逻辑实现。
完整代码
需要在该文件夹添加一个字体文件font.oft
才能运行
package main
import (
"fmt"
"image/color"
"log"
"math/rand"
"os"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/hajimehoshi/ebiten/v2/text"
"github.com/hajimehoshi/ebiten/v2/vector"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
)
const (
screenWidth = 400
screenHeight = 500
gridSize = 4
tileSize = 100
fontSize = 48 // 更大的字体大小
)
var (
grid [gridSize][gridSize]int
score int
gameOver bool
colors = map[int]color.RGBA{
0: {240, 240, 240, 255}, // 空白格子
2: {255, 255, 204, 255}, // 2
4: {255, 204, 153, 255}, // 4
8: {255, 153, 102, 255}, // 8
16: {255, 102, 102, 255}, // 16
32: {255, 51, 51, 255}, // 32
64: {255, 0, 0, 255}, // 64
128: {255, 255, 102, 255}, // 128
256: {255, 255, 51, 255}, // 256
512: {255, 255, 0, 255}, // 512
1024: {255, 204, 0, 255}, // 1024
2048: {255, 153, 0, 255}, // 2048
}
face font.Face
fontFaces map[int]font.Face // 不同位数的数字使用不同大小的字体
fontTT *opentype.Font // 保存字体对象以供重复使用
)
type Game struct{}
func (g *Game) Update() error {
if gameOver {
return nil
}
if inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) {
moveUp()
} else if inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) {
moveDown()
} else if inpututil.IsKeyJustPressed(ebiten.KeyArrowLeft) {
moveLeft()
} else if inpututil.IsKeyJustPressed(ebiten.KeyArrowRight) {
moveRight()
}
if isGridFull() && !canMerge() {
gameOver = true
}
return nil
}
func (g *Game) Draw(screen *ebiten.Image) {
screen.Fill(color.RGBA{187, 173, 160, 255}) // 背景色
// 绘制棋盘
for y := 0; y < gridSize; y++ {
for x := 0; x < gridSize; x++ {
value := grid[y][x]
tileColor := colors[value]
drawTile(screen, x, y, tileColor, fmt.Sprintf("%d", value))
}
}
// 绘制分数
text.Draw(screen, fmt.Sprintf("Score: %d", score), face, 10, screenHeight-20, color.White)
// 游戏结束提示
if gameOver {
text.Draw(screen, "Game Over!", face, screenWidth/2-120, screenHeight/2, color.White)
}
}
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
return screenWidth, screenHeight
}
func main() {
initGrid()
// 加载字体
var err error
tt, err := opentype.Parse(embeddedFont)
if err != nil {
log.Fatal(err)
}
fontTT = tt // 保存字体对象以供创建不同大小的字体
// 创建默认字体和分数显示用字体
face, err = opentype.NewFace(tt, &opentype.FaceOptions{
Size: fontSize,
DPI: 72,
Hinting: font.HintingFull,
})
if err != nil {
log.Fatal(err)
}
// 初始化不同大小的字体
initFontFaces()
game := &Game{}
ebiten.SetWindowSize(screenWidth, screenHeight)
ebiten.SetWindowTitle("2048")
if err := ebiten.RunGame(game); err != nil {
log.Fatal(err)
}
}
// 初始化不同大小的字体
func initFontFaces() {
fontFaces = make(map[int]font.Face)
// 不同位数对应的字体大小
fontSizes := map[int]float64{
1: 48, // 个位数 (1-9)
2: 40, // 两位数 (10-99)
3: 34, // 三位数 (100-999)
4: 28, // 四位数 (1000-9999)
}
// 创建不同大小的字体
for digits, size := range fontSizes {
f, err := opentype.NewFace(fontTT, &opentype.FaceOptions{
Size: size,
DPI: 72,
Hinting: font.HintingFull,
})
if err != nil {
log.Printf("创建字体大小 %f 失败: %v", size, err)
continue
}
fontFaces[digits] = f
}
}
// 获取数字的位数
func getDigits(n int) int {
if n == 0 {
return 1
}
digits := 0
for n > 0 {
digits++
n /= 10
}
return digits
}
// 绘制一个格子
func drawTile(screen *ebiten.Image, x, y int, tileColor color.RGBA, textStr string) {
vector.DrawFilledRect(screen, float32(x*tileSize+10), float32(y*tileSize+10), tileSize-20, tileSize-20, tileColor, true)
if textStr != "0" {
// 根据数字位数选择合适的字体
digits := len(textStr)
tileFace := face // 默认使用标准字体
// 如果有对应位数的字体,则使用对应的字体
if f, ok := fontFaces[digits]; ok {
tileFace = f
}
// 计算文本的宽度和高度
bounds, _ := font.BoundString(tileFace, textStr)
textWidth := (bounds.Max.X - bounds.Min.X).Ceil()
textHeight := (bounds.Max.Y - bounds.Min.Y).Ceil()
// 计算居中的位置
textX := x*tileSize + (tileSize-textWidth)/2
textY := y*tileSize + (tileSize+textHeight)/2
// 绘制文本
text.Draw(screen, textStr, tileFace, textX, textY, color.Black)
}
}
// 初始化棋盘
func initGrid() {
addRandomTile()
addRandomTile()
}
// 在随机位置添加一个 2 或 4
func addRandomTile() {
var emptyTiles [][2]int
for y := 0; y < gridSize; y++ {
for x := 0; x < gridSize; x++ {
if grid[y][x] == 0 {
emptyTiles = append(emptyTiles, [2]int{x, y})
}
}
}
if len(emptyTiles) > 0 {
pos := emptyTiles[rand.Intn(len(emptyTiles))]
value := 2
if rand.Float32() < 0.1 {
value = 4
}
grid[pos[1]][pos[0]] = value
}
}
// 移动和合并逻辑(上下左右)
func moveUp() {
moveAndMerge(func(x, y int) (int, int) { return x, y })
}
func moveDown() {
moveAndMerge(func(x, y int) (int, int) { return x, gridSize - 1 - y })
}
func moveLeft() {
moveAndMerge(func(x, y int) (int, int) { return y, x })
}
func moveRight() {
moveAndMerge(func(x, y int) (int, int) { return gridSize - 1 - y, x })
}
// 通用的移动和合并函数
func moveAndMerge(transform func(x, y int) (int, int)) {
moved := false
for x := 0; x < gridSize; x++ {
// 合并相同数字
for y := 0; y < gridSize-1; y++ {
tx, ty := transform(x, y)
if grid[ty][tx] == 0 {
continue
}
for yy := y + 1; yy < gridSize; yy++ {
txx, tyy := transform(x, yy)
if grid[tyy][txx] == 0 {
continue
}
if grid[ty][tx] == grid[tyy][txx] {
grid[ty][tx] *= 2
score += grid[ty][tx]
grid[tyy][txx] = 0
moved = true
}
break
}
}
// 移动数字
for y := 0; y < gridSize; y++ {
tx, ty := transform(x, y)
if grid[ty][tx] == 0 {
for yy := y + 1; yy < gridSize; yy++ {
txx, tyy := transform(x, yy)
if grid[tyy][txx] != 0 {
grid[ty][tx] = grid[tyy][txx]
grid[tyy][txx] = 0
moved = true
break
}
}
}
}
}
if moved {
addRandomTile()
}
}
// 检查棋盘是否已满
func isGridFull() bool {
for y := 0; y < gridSize; y++ {
for x := 0; x < gridSize; x++ {
if grid[y][x] == 0 {
return false
}
}
}
return true
}
// 检查是否可以合并
func canMerge() bool {
for y := 0; y < gridSize; y++ {
for x := 0; x < gridSize; x++ {
if y < gridSize-1 && grid[y][x] == grid[y+1][x] {
return true
}
if x < gridSize-1 && grid[y][x] == grid[y][x+1] {
return true
}
}
}
return false
}
// 嵌入一个字体文件(例如 TTF 文件)
var embeddedFont, _ = os.ReadFile("font.otf") // 这里需要嵌入一个字体文件,例如 TTF 文件