手记

我用Ebiten在40分钟内制作了一个2D游戏作品。

这张照片由 Leon PauleikhoffUnsplash 上发布。

为这个项目我为什么决定使用Golang?

让我们开始,

虽然我不是开发者,也不会在电视上扮演开发者。但我曾领导过几个开发团队,并且知道如何分阶段构建产品。我知道何时该去征求反馈,从而确保我们的产品能够满足用户需求。并且帮助推出过让用户兴奋的产品。

对于这个业余项目,我从一个不算革命性的想法开始。即如果我能避免所有这些不必要的复杂性,我可以更快地学习编程概念。我曾经为了好玩建过一些安卓应用,但我想开发一个可以直接编译到Windows上的东西,不需要用模拟器,也不用太担心跨平台的问题。

所以我选择了Golang。它被设计为简单、快速且符合惯用。我想要一种打字感觉自然的语言。同样重要的是,我希望我的代码能够用于橡胶鸭调试(通俗来说,就是对着一个不会动的东西讲代码细节)。这样,我就能向别人清晰地解释我的程序中的结构体、函数等内容。

我到底建了什么东西?

我制作了一款名为《蝎子与法拉利》的游戏,这个名字灵感来源于Frogger及其游戏机制。这个概念很荒唐,但我很快就学到了不少游戏开发的知识。在这个项目中,我使用了一个名为Ebiten的库,这是一个专为Go语言开发的开源游戏引擎。通过Ebiten,我利用了它简单易用的API和各种函数,能够迅速且轻松地开发出一个可以在多个平台上运行的游戏。

现在我又在部署到Windows上,但你也可以利用macOS和Linux。说到底,当我们编译程序时,会在和代码相同的目录下生成一个可执行文件。这意味着我们甚至可以在Windows上构建程序,之后可以在Linux上运行相同的程序,这真是太酷了。

我们从哪里开始呢?
1) 在您的操作系统上安装 Go 语言 (Golang)

Go语言下载页面: https://go.dev/dl/

2) 运行 Ebiten 的示例作为 PoC(概念验证)

为了快速开始,我实际上推荐Ebiten的hello world示例: https://ebitengine.org/en/tour/hello_world.html

3) 请跟着下面的内容走!
新建Go项目

照片由 Kelly Sikkema 拍摄,来自 Unsplash

我推荐使用VS Code作为这个项目的IDE。这是因为VS Code自带一个Go插件,提供了诸如IntelliSense(智能感知)、代码导航、符号查找、测试、调试以及其他诸多功能,这些功能在Go开发中非常有用。

打开 Git Bash 或者命令提示符

mkdir yourgame && cd yourgame go mod init foo # 使用 go mod init foo 初始化模块(你可以用 foo 替换为 github.com/yourname/yourgame 或其他名称)
cd yourgame  
在终端中输入 'code .' 来打开 VS Code(或者在任何 IDE 中打开该目录)

我们需要安装ebiten模块

**您的go.mod文件所在的目录中,在终端运行的命令是:**

     go get github.com/hajimehoshi/ebiten/v2 // 获取hajimehoshi的ebiten库的最新版本
新建一个 Main.go 文件

如果你现在已经猜到了这一点,也就是存储你程序核心逻辑的地方。

我们先从添加这些 main 包和导入相关库开始:

在创建可重用代码时,您将创建一个包,它旨在作为共享库使用。然而,在构建可执行程序时,应使用“main”包,这会告诉Go编译器该包是用于编译成可执行程序,而不是共享库。在“main”包中的main函数是可执行程序的入口点。

到目前为止,在你的 Main.go 文件中,我们需要加入以下代码,如下:

package main  

import (  
 "fmt"  
 "image"  
 _ "image/png"  
 "log"  
 "math/rand"  
 "os"  
 "time"  

 "github.com/hajimehoshi/ebiten/v2"  
 "github.com/hajimehoshi/ebiten/v2/ebitenutil"  
 "github.com/hajimehoshi/ebiten/v2/vector"  
 "image/color"  
)

如果你已经熟悉编程,你会看到一些熟悉的库,这些库在其他编程语言中也有类似的功能。fmt 包实现了格式化的输入输出功能,其中包含的功能类似于 C 语言中的 printfscanf 函数。

你将看到图像部分,image/png 用于加载和解码图像,math/rand 用于生成随机数。

不过,理解编程逻辑目前这些都不太重要。

如果你是编程新手,你需要知道的是,我们从其他地方获取授权重用的代码,并在程序的开头引入这些功能。这样,当程序执行时,当它需要解码一张图片时,它就可以做到,因为之前我们已经引入了相应代码。

整型常量定义

在这一部分,我定义了一系列常量值:这包括屏幕尺寸、网格大小、玩家的移动速度、车道数以及汽车的参数(速度、车距)。

现在记得这一点,我做了一个类似Frogger那样的游戏。

玩家需要控制一个角色(一只蝎子)来避开高速穿越水面公路的移动的汽车。因此,目标是不要被像素化的法拉利车撞到,并在规定时间内到达屏幕的另一端。

所以第一步是创建常数。不用太在意具体的数值。你可以简化为你的程序设定任何数值。我只是随便选了一些我认为合适的数字,这部分可能需要一些调整,


    const (  
     screenWidth     = 640  
     screenHeight    = 480  
     gridSize        = 32  
     gridWidth       = screenWidth / gridSize  
     gridHeight      = screenHeight / gridSize  
     playerSpeed     = 5 // 每帧移动单位数  
     numLanes        = 5  
     numCarsPerLane  = 6 // 减少的数字以增加间距的清晰度  
     carSpeedMin     = 1.5  
     carSpeedMax     = 3.0  
     minCarGap       = 3   // 最小车辆间隔(以网格单位计算)  
     maxCarGap       = 6   // 最大车辆间隔(以网格单位计算)  
    )
结构

Go 并不是一种纯粹的对象导向语言,它不提供类,而是提供结构体(structs)。可以将方法关联到结构体上,使数据及其操作方法能够捆绑在一起,类似于类的概念。

GameObject 结构体 : 表示游戏中的实体,具有位置、速度、图像、大小和朝向等属性。

游戏结构体:包括玩家、背景图、对象图、车辆、当前时间、上次更新时间以及游戏状态。

type GameObject struct {  
    // 游戏对象结构体
    x, y    float64  // x, y坐标
    speed   float64  // 速度
    image   *ebiten.Image  // 图像
    width   int  // 宽度
    height  int  // 高度
    isRight bool  // 是否向右
}  

type Game struct {  
    // 游戏结构体
    player         *GameObject  // 玩家对象
    background     *ebiten.Image  // 背景图像
    objects        map[string]*ebiten.Image  // 物体集合
    cars           []*GameObject  // 汽车集合
    currentTime    int  // 当前时间
    lastUpdateTime time.Time  // 上次更新时间
    gameState      string  // 游戏状态
}
新游戏功能:

新游戏初始化函数:使用默认设置初始化新游戏,加载图片,并设置游戏的初始状态。

func NewGame() *游戏 {
 g := &游戏{
  当前时间戳:    60,
  最后更新时间戳: time.Now(),
  映射:        make(map[string]*ebiten.Image),
  游戏状态字符串:      "playing",
  玩家实例:         &游戏对象{}, // 初始化玩家
 }
 g.加载图片()
 g.初始化游戏状态()
 return g
}
开始游戏

此方法用于设定玩家的初始位置,并随机分配车辆在车道中的位置和速度。

这花费了我们最多的时间来确定我的蝎子角色在给定的车流密度和速度下,在车辆之间所需的具体空间是多少。

func (g *Game) 初始化游戏函数() {  
 // 设置玩家初始位置为  
 g.player.x = float64(gridWidth / 2 * gridSize)  
 设置玩家的x坐标为 float64(gridWidth / 2 * gridSize)  
 g.player.y = float64((gridHeight - 1) * gridSize)  
 设置玩家的y坐标为 float64((gridHeight - 1) * gridSize)  

 // 清除现有的车辆  
 g.cars = []*GameObject{}  

 // 在每条车道中初始化更多车辆  
 for lane := 0; lane < numLanes; lane++ {  
  lastCarX := -float64(gridSize) // 起始位置设置在屏幕左边界之前  
  for i := 0; i < numCarsPerLane; i++ {  
   // 计算车辆之间的最小间隔和最大间隔  
   minGap := lastCarX + float64(minCarGap*gridSize)  
   maxGap := lastCarX + float64(maxCarGap*gridSize)  
   carX := minGap + 随机.Float64()*(maxGap-minGap)  

   g.cars = append(g.cars, &GameObject{  
    x:       carX,  
    y:       float64(5+lane) * gridSize,  // 不同车道中的车辆位置  
    speed:   carSpeedMin + 随机.Float64()*(carSpeedMax-carSpeedMin),  
    image:   g.objects["car"],  
    width:   gridSize * 2,  
    height:  gridSize,  
    isRight: 随机.Intn(2) == 0,  // 是否向右  
   })  

   lastCarX = carX  
  }  
 }  
}
更新方式

负责游戏更新,涵盖玩家和车辆移动以及时间变化。

func (g *Game) Update() error {  
 if g.gameState != "playing" {  
  if ebiten.IsKeyPressed(ebiten.KeySpace) {  
   g.initializeGame()  
   g.currentTime = 60  
   g.gameState = "playing"  
  }  
  return nil  
 }  

 now := time.Now()  
 elapsed := now.Sub(g.lastUpdateTime).Seconds()  
 g.lastUpdateTime = now  

 if ebiten.IsKeyPressed(ebiten.KeyArrowLeft) {  
  g.player.x -= gridSize * elapsed * playerSpeed  
 }  
 if ebiten.IsKeyPressed(ebiten.KeyArrowRight) {  
  g.player.x += gridSize * elapsed * playerSpeed  
 }  
 if ebiten.IsKeyPressed(ebiten.KeyArrowUp) {  
  g.player.y -= gridSize * elapsed * playerSpeed  
 }  
 if ebiten.IsKeyPressed(ebiten.KeyArrowDown) {  
  g.player.y += gridSize * elapsed * playerSpeed  
 }  

 g.player.x = 限制(g.player.x, 0, 屏幕宽度-格子大小)  
 g.player.y = 限制(g.player.y, 0, 屏幕高度-格子大小)  

 // 更新车辆  
 for _, car := range g.cars {  
  if car.在右侧 {  
   car.x += car.speed * elapsed * gridSize  
   if car.x > 屏幕宽度 {  
    car.x = -float64(car.width)  
   }  
  } else {  
   car.x -= car.speed * elapsed * gridSize  
   if car.x < -float64(car.width) {  
    car.x = 屏幕宽度  
   }  
  }  
 }  
}

检查碰撞函数

检查玩家是否撞到任何汽车,然后相应更新游戏状态。

少了它,这只蝎子将毫无阻力,并会在这条虚拟路上轻松超越法拉利,就像。

    func (g *Game) checkCollisions() {  
     playerRect := image.Rect(int(g.player.x), int(g.player.y), int(g.player.x)+g.player.width, int(g.player.y)+g.player.height)  

     // 检查是否撞到车  
     for _, car := range g.cars {  
      carRect := image.Rect(int(car.x), int(car.y), int(car.x)+car.width, int(car.y)+car.height)  
      if playerRect.Overlaps(carRect) {  
       g.gameState = "lose"  
       return  
      }  
     }  
    }
绘图方法

渲染游戏,包括背景、车辆、玩家界面、时间显示,以及游戏状态消息。

要更深入地理解,请阅读Ebiten的几何矩阵文档

func (g *Game) Draw(screen *ebiten.Image) {  
 // 绘制背景图像  
 op := &ebiten.DrawImageOptions{} // 新建绘图选项  
 screen.DrawImage(g.background, op)  

 // 绘制汽车  
 for _, car := range g.cars {  
  op := &ebiten.DrawImageOptions{} // 新建绘图选项  
  op.GeoM.Translate(car.x, car.y)  
  screen.DrawImage(car.image, op)  
 }  

 // 绘制玩家  
 if g.player.image != nil {  
  op := &ebiten.DrawImageOptions{}  
  op.GeoM.Translate(g.player.x, g.player.y)  
  screen.DrawImage(g.player.image, op)  
 } else {  
  log.Println("玩家图像为空,无法绘制玩家")  
  // 绘制玩家的占位红色矩形  
  vector.DrawFilledRect(screen,  
   float32(g.player.x),  
   float32(g.player.y),  
   float32(g.player.width),  
   float32(g.player.height),  
   color.RGBA{255, 0, 0, 255},  
   false)  
 }  

 // 绘制当前时间  
 ebitenutil.DebugPrint(screen, fmt.Sprintf("当前时间: %d", g.currentTime))  

 // 绘制游戏状态  
 if g.gameState == "win" {  
  ebitenutil.DebugPrint(screen, "\n\n你赢了!按空格键重新开始游戏")  
 } else if g.gameState == "lose" {  
  ebitenutil.DebugPrint(screen, "\n\n游戏结束!按空格键重新开始游戏")  
 }  
}
布局方式

这仅仅是为了获取屏幕的大小。

定义了一个函数Layout,该函数接收外部宽度和高度作为参数,并返回屏幕的宽度和高度。
func (g *Game) Layout(externalWidth, externalHeight int) (int, int) {  
 return screenWidth, screenHeight  
}
游戏中最重要的是:主功能!

设置好窗口大小和标题后,使用Ebiten的RunGame函数(启动游戏的函数)来开始游戏。

func main() {  
 ebiten.SetWindowSize(screenWidth, screenHeight)  
 ebiten.SetWindowTitle("蝎子和法拉利")  
 if err := ebiten.RunGame(NewGame()); err != nil {  
  log.Fatal(err)  
 }  
}
限幅功能

确保值处于指定范围

// 返回值在最小值和最大值之间进行限制
func clamp(value, min, max float64) float64 {  
    if value < min {  
        return min  
    }  
    if value > max {  
        return max  
    }  
    return value  
}
最后的代码

在我的GitHub仓库里,你可以找到完整的代码和readme.md!

这份教程中唯一缺少的是图像文件,你可以将它们下载并存放在自己的文件夹中。但我建议你下面去找找自己的背景图、物件图和玩家图,因为这样你可以学到一些关于像素、图像编辑、碰撞检测等方面的知识。

选择您的图片并使用您自己的Paint.NET风格

当然,这一步可选

照片由 Illán Riestra Nava 拍摄,来自 Unsplash

我推荐从他们的github releases页面安装Paint.net

发布页面 · paintdotnet/releaseDownloads:例如安装程序EXE和便携ZIP包 - 发布页面 · paintdotnet/releasegithub.com

这是一款让你可以修改位图的免费软件

我们需要这样做,因为我们将会把图像缩小到像素级别,并将其作为像素处理,然后将它们作为二维对象来使用。

对于我的背景图片,我选用了下面的空白的青蛙图案。

现在在 Paint.net(一个图像编辑软件)中,背景的尺寸至少要和我们定义的窗口一样大。

屏幕宽度 = 640  
屏幕高度 = 480

在我的程序里,我把它设置成这样。

只有你能决定你选择的背景感觉是否自然。

玩家大小及车辆尺寸

根据我们程序中定义的窗口大小,玩家和物体的大小应该按比例调整,以适应网格大小和屏幕尺寸,以便游戏平衡且视觉上协调。

玩家尺寸
宽度和高度:为了保持良好的可见性和机动性,玩家的尺寸应与网格大小相同。由于网格大小为32像素,玩家的宽度和高度可以为32x32像素。
定位:确保玩家的初始位置和移动遵循网格,以避免意外与其他游戏对象重叠。

汽车:
宽度:汽车宽度可以超过网格大小,以此来营造规模感和增加难度。64像素(2个网格单元)的宽度是合适的,你已经在代码中这么设置了。
高度:高度可以是32像素,与网格大小相同,以确保它适合车道。

推荐使用Paint.NET软件

使用上方看到的缩放按钮,把您的角色和汽车物件缩放到推荐尺寸。

在我的游戏中,你可以看到我没有完全清理干净对象周围的所有空白区域。

这已经是我的极限了。Paint.net有许多出色的文档和教程,但我觉得要去除空白,你需要使用套索工具或矩形选框工具,然后使用“通过选区裁剪”功能。这样可以最好地清理图片。

但有时候试试你选的图和尺寸搭配起来怎么样,最好的方法就是运行程序看看效果如何——结果可能看起来很滑稽!

谢谢您的阅读,可以在LinkedIn上与我联系,链接如下:,https://www.linkedin.com/in/samuel-armentrout/

0人推荐
随时随地看视频
慕课网APP