用go语言实现一个平面像素小游戏(二)

前言

上一篇教程进行了简单的角色绘制,详细:
用go语言实现一个平面像素小游戏(一)-CSDN博客
这一篇要添加键盘事件,实现角色的动画效果

键盘事件

先来介绍一下要使用到的方法

在上一篇教程中,我们绘制角色时使用到了*ebiten.ImageDrawImage方法

func (i *Image) DrawImage(img *Image, options *DrawImageOptions)

第一个参数表示要绘制的图像,第二个参数表示要怎么绘制

想要实现角色的移动,我们就需要在Draw函数中,不断改变绘制角色的位置

DrawImageOptions类型有一个GeoM属性

type DrawImageOptions struct {
	// GeoM is a geometry matrix to draw.
	// The default (zero) value is identity, which draws the image at (0, 0).
	GeoM GeoM
    ...
}

GeoM.Translate方法接收两个float64类型的参数,用于设置绘制的位置,不手动设置默认为(0, 0)

func (g *GeoM) Translate(tx, ty float64)

我们尝试使用一下它去改变角色的初始位置,将Draw方法更新如下

func (g *Game) Draw(screen *ebiten.Image) {
	screen.Fill(color.RGBA{R: 118, G: 225, B: 254, A: 255})
	imagePlayer, _, err := ebitenutil.NewImageFromFile("../Image/Characters/BasicCharakterSpritesheet.png")
	if err != nil {
		log.Fatal(err)
	}
	imageNormal := imagePlayer.SubImage(image.Rect(0, 0, 48, 48)).(*ebiten.Image)
	// 改变角色初始位置
	op := &ebiten.DrawImageOptions{}
	op.GeoM.Translate(50, 50)
	screen.DrawImage(imageNormal, op)
}

运行之后,可以发现角色的初始位置变到了(50, 50)

 好,现在想办法让角色动起来

用两个变量x和y表示角色的位置,替换掉Draw中对op.GeoM.Translate方法的手动赋值

用一个变量去表示角色的速度,增加键盘事件,每次按一下空格就让角色往右移动

ebiten.IsKeyPressed方法可以监听键盘按键

func IsKeyPressed(key Key) bool

它接收一个Key类型的值,返回一个bool类型的值,表示按键是否被按下

我们添加全局变量,并在update函数中添加键盘事件:

// 全局变量
var x float64 = 0
var y float64 = 0
var speed float64 = 1

func (g *Game) Update() error {
    // 按下空格,就让x坐标增加
	if ebiten.IsKeyPressed(ebiten.KeySpace) {
		x += speed
	}
	return nil
}

更新Draw函数,用变量x和y替换对角色位置的手动赋值

func (g *Game) Draw(screen *ebiten.Image) {
	screen.Fill(color.RGBA{R: 118, G: 225, B: 254, A: 255})
	imagePlayer, _, err := ebitenutil.NewImageFromFile("../Image/Characters/BasicCharakterSpritesheet.png")
	if err != nil {
		log.Fatal(err)
	}
	imageNormal := imagePlayer.SubImage(image.Rect(0, 0, 48, 48)).(*ebiten.Image)
	// 改变角色初始位置
	op := &ebiten.DrawImageOptions{}
	op.GeoM.Translate(x, y)
	screen.DrawImage(imageNormal, op)
}

运行之后,按下空格,角色就会向右移动

重构代码

在上面的代码中,所有的逻辑都写在了一个文件中,后续再增加功能会难以看清代码结构

在实现其他功能之前,需要对代码进行一次重构,对于目前的代码,我将逻辑拆分成了五个文件:

  • config.go:用于存放各种相关配置,例如窗口大小、标题等等
  • player.go:用于编写各种与角色相关的逻辑
  • input.go:专门用来处理输入相关逻辑
  • game.go:用于编写Game对象实现的几个方法
  • main.go:程序的入口,用于创建游戏对象、启动游戏

config.go

config.go中,我们创建Config结构体,存放窗口的宽、高、标题和图标

然后使用loadConfig函数给Config对象赋值并返回出去

config.go文件内容如下:

package main

import (
	"github.com/hajimehoshi/ebiten/v2/ebitenutil"
	"image"
	"log"
)

type Config struct {
	ScreenWidth  int
	ScreenHeight int
	Title        string
	Icon         image.Image
}

func loadConfig() *Config {
	ScreenWidth := 800
	ScreenHeight := 700
	Title := "Planting"
	_, Icon, err := ebitenutil.NewImageFromFile("../image/icon.png")
	if err != nil {
		log.Fatal(err)
	}

	return &Config{
		ScreenWidth:  ScreenWidth,
		ScreenHeight: ScreenHeight,
		Title:        Title,
		Icon:         Icon,
	}
}

player.go

player.go中,创建Player结构体,需要的属性有角色的图片、所在位置x坐标、所在位置y坐标和速度

给Player结构体写一个Draw方法,用于绘制自身,简化game.go中Draw函数的调用结构

最后通过NewPlayer函数给Player对象初始化并返回出去

package main

import (
	"github.com/hajimehoshi/ebiten/v2"
	"github.com/hajimehoshi/ebiten/v2/ebitenutil"
	"image"
	"log"
)

type Player struct {
	image *ebiten.Image
	x     float64
	y     float64
	speed float64
}

func NewPlayer() *Player {
	imagePlayer, _, err := ebitenutil.NewImageFromFile("../Image/Characters/BasicCharakterSpritesheet.png")
	if err != nil {
		log.Fatal(err)
	}
	imageNormal := imagePlayer.SubImage(image.Rect(0, 0, 48, 48)).(*ebiten.Image)
	
	return &Player{
		image: imageNormal,
		x:     0,
		y:     0,
		speed: 1,
	}
}

func (player *Player) Draw(screen *ebiten.Image) {
	op := &ebiten.DrawImageOptions{}
	op.GeoM.Translate(player.x, player.y)
	screen.DrawImage(player.image, op)
}

input.go

input.go中,创建Input结构体,暂时没有需要的属性

给Input结构体写一个Update方法,它接收一个*Player类型的参数,用于监听键盘事件改变角色的位置,按下WASD或者上下左右键代表向不同方向移动

package main

import "github.com/hajimehoshi/ebiten/v2"

type Input struct{}

func (i *Input) Update(player *Player) {
	if ebiten.IsKeyPressed(ebiten.KeyW) || ebiten.IsKeyPressed(ebiten.KeyUp) {
		player.y -= player.speed
	}
	if ebiten.IsKeyPressed(ebiten.KeyS) || ebiten.IsKeyPressed(ebiten.KeyDown) {
		player.y += player.speed
	}
	if ebiten.IsKeyPressed(ebiten.KeyA) || ebiten.IsKeyPressed(ebiten.KeyLeft) {
		player.x -= player.speed
	}
	if ebiten.IsKeyPressed(ebiten.KeyD) || ebiten.IsKeyPressed(ebiten.KeyRight) {
		player.x += player.speed
	}
}

game.go

game.go中我们需要将游戏的核心逻辑转移过来,首先在Game结构体中放入上面写好的几个模块对象*Config、*Player和*Input

通过NewGame函数给Game对象初始化并返回出去

最后在更新Update、Draw和Layout三个函数的调用逻辑

package main

import (
	"github.com/hajimehoshi/ebiten/v2"
	"image"
	"image/color"
)

type Game struct {
	cfg    *Config
	player *Player
	input  *Input
}

func (g *Game) Update() error {
	g.input.Update(g.player)
	return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
	screen.Fill(color.RGBA{R: 118, G: 225, B: 254, A: 255})
	g.player.Draw(screen)
}


func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
	return 400, 350
}

func NewGame() *Game {
	cfg := loadConfig()

	ebiten.SetWindowSize(cfg.ScreenWidth, cfg.ScreenHeight)
	ebiten.SetWindowTitle(cfg.Title)
	ebiten.SetWindowIcon([]image.Image{cfg.Icon})

	return &Game{
		cfg:    cfg,
		player: NewPlayer(),
		input:  &Input{},
	}
}

main.go

最后的main.go文件,只需要执行ebiten.RunGame方法运行游戏

package main

import (
	"github.com/hajimehoshi/ebiten/v2"
	"log"
)

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

小总结

至此重构完成,把整体逻辑分为了五个文件

因为拆分成了多个文件,现在运行程序的时候不能再使用go run main.go命令了

需要改为

go run .

运行之后,可以使用上下左右键或者WSAD去控制角色的移动

添加移动动画效果

在第一篇教程也提到过,Update会一个tick更新一次画布

tick是引擎更新的一个时间单位,默认为1/60s,tick的倒数一般称为帧,即游戏的更新频率

ebiten游戏默认是60帧,即每秒更新60次

动画的实现原理就是不断的切换图片,想实现移动动画效果,就需要在角色移动时,每隔一定的时间去切换一张图片

为此我们需要记录update函数的调用次数,每调用一定次数update函数时就根据角色的移动方向去切换对应的图片

我们在Player结构体中新增加几个属性

type Player struct {
	image *ebiten.Image
	x     float64
	y     float64
	speed float64
	// 角色是否移动
	playerMoving bool
	// 角色前进方向(用于分割精灵图)
	playerDir int
	// 角色移动方向
	playerUp, playerDown, playerRight, playerLeft bool
	// 角色帧计数
	playerFrame int
	// 精灵图的X位置
	playerScrX int
	// 精灵图的Y位置
	playerSrcY int
}
  • playerMoving是一个布尔类型的值,true代表正在移动,false代表静止
  • playrDir是一个int类型的值,它有0、1、2、3三个取值代表下、上、左、右(后面会详细解释)
  • playerUp, playerDown, playerRight, playerLeft代表移动方向,都为布尔类型
  • playerFrame代表角色帧,后面需要根据这个属性去找到要切换的图片位置
  • playerScrX、playerSrcY代表分割精灵图时的x、y坐标

 所需要的属性添加完成了,再仔细观察一下素材图

这一张素材图可以分为48*48的像素块,第一行代表向下走的四张图片、第二行代表向上走、第三行代表向左走、第四行代表向右走 

我们通过让playrDir根据移动方向取值0、1、2、3来确定需要切换的图片在哪一行

通过playerFrame取值0、1、2、3来确定需要的是一行中的哪一张图片

更新Input.Update方法

我们需要更新Input.Update方法,根据按下的键去更新对应属性的状态

func (i *Input) Update(player *Player) {
	// 重置,松开按键后恢复默认值
	player.playerMoving = false
	player.playerDir = 0
	player.playerUp = false
	player.playerDown = false
	player.playerLeft = false
	player.playerRight = false

	if ebiten.IsKeyPressed(ebiten.KeyW) || ebiten.IsKeyPressed(ebiten.KeyUp) {
		player.playerMoving = true
		player.playerDir = 1
		player.playerUp = true
	}
	if ebiten.IsKeyPressed(ebiten.KeyS) || ebiten.IsKeyPressed(ebiten.KeyDown) {
		player.playerMoving = true
		player.playerDir = 0
		player.playerDown = true
	}
	if ebiten.IsKeyPressed(ebiten.KeyA) || ebiten.IsKeyPressed(ebiten.KeyLeft) {
		player.playerMoving = true
		player.playerDir = 2
		player.playerLeft = true
	}
	if ebiten.IsKeyPressed(ebiten.KeyD) || ebiten.IsKeyPressed(ebiten.KeyRight) {
		player.playerMoving = true
		player.playerDir = 3
		player.playerRight = true
	}
}

修改后的Input.Update方法删除了player.x和player.y的变化(在game.Update方法中修改)

现在按下对应的键时会对对应的属性赋值,松开按键时恢复默认值

添加全局变量、更新Update函数

我们使用全局变量frameCount去表示Update函数调用的次数

规定角色移动且frameCount%8 == 1时让g.player.playerFrame加一,即Update函数每调用8次让图片更新一次,因为Update函数一个tick调用一次,如果让g.player.playerFrame与调用次数同步,刷新图片的速度就太快了,你可以调整取余的数字控制图片的更新速度

之所以对8取余只是因为我觉得8个tick的更新速度相对合理一点

添加全局变量frameCount并更新Update函数

// 帧计数
var frameCount int

func (g *Game) Update() error {
	g.input.Update(g.player)
	if g.player.playerMoving {
		if g.player.playerUp {
			g.player.y -= g.player.speed
		}
		if g.player.playerDown {
			g.player.y += g.player.speed
		}
		if g.player.playerLeft {
			g.player.x -= g.player.speed
		}
		if g.player.playerRight {
			g.player.x += g.player.speed
		}
		if frameCount%8 == 1 {
			g.player.playerFrame++
		}
	}

	// 大于3赋值为0,实现四张图片循环切换
	if g.player.playerFrame > 3 {
		g.player.playerFrame = 0
	}

	frameCount++
	return nil
}

playerFrame的变化需要在playerMoving为true时,所以我将角色坐标x、y和角色帧playerFrame的修改整合在一起放在了Update函数中

更新player.Draw方法

这样我们就完成了对新增属性的修改,现在我们只需要在Player.Draw方法中根据这几个属性去实现图片切换

playrDir取值0、1、2、3,表示需要切换的图片在哪一行

playerFrame取值0、1、2、3表示需要的是一行中的哪一张图片

假如现在playrDir=0,playerFrame=0,就表示角色的图片是素材图中第一行的第一张

由于图片为48*48像素,所以需要以(0, 0)、(48, 48)这两个坐标去拆分素材图

playrDir=0,playerFrame=1,就表示角色的图片是素材图中第一行的第二张

需要以(0, 48)、(48, 96)这两个坐标去拆分素材图

所以player.playerScrX、player.playerSrcY的值为:

player.playerScrX = player.playerFrame * 48
player.playerSrcY = player.playerDir * 48

将Player.Draw方法更新如下:

func (player *Player) Draw(screen *ebiten.Image) {
	player.playerScrX = player.playerFrame * 48
	player.playerSrcY = player.playerDir * 48

	// 总的素材图片
	AllImage, _, err := ebitenutil.NewImageFromFile("../Image/Characters/BasicCharakterSpritesheet.png")
	if err != nil {
		log.Fatal(err)
	}
	
	nowImage := AllImage.SubImage(image.Rect(player.playerScrX, player.playerSrcY, player.playerScrX+48, player.playerSrcY+48)).(*ebiten.Image)
	player.image = nowImage
	op := &ebiten.DrawImageOptions{}
	op.GeoM.Translate(player.x, player.y)
	screen.DrawImage(player.image, op)
}

现在,就实现了简单的移动动画效果

它看起来有点小问题,在每次停止移动时,角色的图片有时不会是初始图片

这是因为移动时会不断改变playerFrame的值,停止移动后角色帧playerFrame的取值不为0时,经过Player.Draw绘制的角色图片就不是默认图片

我们会在下面解决这个问题

添加闲置动画效果

明白了移动动画的逻辑后,添加闲置动画会变得很简单

我们只需要在角色停止移动,即player.playerMoving为false时,再做一个判定

if g.player.playerMoving {
        // 省略代码
		......
	} else if frameCount%40 == 1 {
		g.player.playerFrame++
	}

这里我选择当frameCount%40==1时让playerFrame,即40帧改变一次图片,你可以调整取余的数字调整图片的切换速度

由于静止动画只有两帧(前两张图片),所以在角色静止时需要让player.playerFrame在0和1两种取值中不断切换,实现图片的切换

    // 角色静止且大于1赋值为0,实现两张图片循环切换
	if !g.player.playerMoving && g.player.playerFrame > 1 {
		g.player.playerFrame = 0
	}

Update函数更新后:

func (g *Game) Update() error {
	g.input.Update(g.player)
	if g.player.playerMoving {
		if g.player.playerUp {
			g.player.y -= g.player.speed
		}
		if g.player.playerDown {
			g.player.y += g.player.speed
		}
		if g.player.playerLeft {
			g.player.x -= g.player.speed
		}
		if g.player.playerRight {
			g.player.x += g.player.speed
		}
		if frameCount%8 == 1 {
			g.player.playerFrame++
		}
	} else if frameCount%40 == 1 {
		g.player.playerFrame++
	}

	// 大于3赋值为0,实现四张图片循环切换
	if g.player.playerFrame > 3 {
		g.player.playerFrame = 0
	}

	// 角色静止且大于1赋值为0,实现两张图片循环切换
	if !g.player.playerMoving && g.player.playerFrame > 1 {
		g.player.playerFrame = 0
	}

	frameCount++
	return nil
}

这样我们就实现了闲置动画

还记得上面提到的“小问题”吗,实际上这里并没有着手解决它,你可以发现角色停止移动时,它会有两种图片状态,即player.playerFrame等于0和等于1时

当我们停止移动后,player.playerFrame>1时,通过新增加的if语句,playerFrame会被赋值为0,等于1时仍然为1

角色虽然会有两种图片状态,但是因为会不断切换并且不是移动状态的图片,所以在观感上是“没有问题”的

如果你想让游戏更精致一点,也可以自己尝试着去增加新的逻辑使图片切换更完美,但是我这里就先这样处理

小总结

在更新了Player.Draw方法后,实际上在NewPlayer()中不需要给Player.image赋初值了,Player.Draw方法会完成对角色各种状态的绘制处理

删除给Player.image赋初值的语句,让函数精简一点

func NewPlayer() *Player {

	return &Player{
		x:     0,
		y:     0,
		speed: 1,
	}
}

经过上面的一系列修改后

player.go文件:

package main

import (
	"github.com/hajimehoshi/ebiten/v2"
	"github.com/hajimehoshi/ebiten/v2/ebitenutil"
	"image"
	"log"
)

type Player struct {
	image *ebiten.Image
	x     float64
	y     float64
	speed float64
	// 角色是否移动
	playerMoving bool
	// 角色前进方向(用于分割精灵图)
	playerDir int
	// 角色移动方向
	playerUp, playerDown, playerRight, playerLeft bool
	// 角色帧计数
	playerFrame int
	// 精灵图的X位置
	playerScrX int
	// 精灵图的Y位置
	playerSrcY int
}

func NewPlayer() *Player {

	return &Player{
		x:     0,
		y:     0,
		speed: 1,
	}
}

func (player *Player) Draw(screen *ebiten.Image) {
	player.playerScrX = player.playerFrame * 48
	player.playerSrcY = player.playerDir * 48

	// 总的素材图片
	AllImage, _, err := ebitenutil.NewImageFromFile("../Image/Characters/BasicCharakterSpritesheet.png")
	if err != nil {
		log.Fatal(err)
	}

	nowImage := AllImage.SubImage(image.Rect(player.playerScrX, player.playerSrcY, player.playerScrX+48, player.playerSrcY+48)).(*ebiten.Image)
	player.image = nowImage
	op := &ebiten.DrawImageOptions{}
	op.GeoM.Translate(player.x, player.y)
	screen.DrawImage(player.image, op)
}

input.go文件:

package main

import "github.com/hajimehoshi/ebiten/v2"

type Input struct{}

func (i *Input) Update(player *Player) {
	// 重置,松开按键后恢复默认值
	player.playerMoving = false
	player.playerDir = 0
	player.playerUp = false
	player.playerDown = false
	player.playerLeft = false
	player.playerRight = false

	if ebiten.IsKeyPressed(ebiten.KeyW) || ebiten.IsKeyPressed(ebiten.KeyUp) {
		player.playerMoving = true
		player.playerDir = 1
		player.playerUp = true
	}
	if ebiten.IsKeyPressed(ebiten.KeyS) || ebiten.IsKeyPressed(ebiten.KeyDown) {
		player.playerMoving = true
		player.playerDir = 0
		player.playerDown = true
	}
	if ebiten.IsKeyPressed(ebiten.KeyA) || ebiten.IsKeyPressed(ebiten.KeyLeft) {
		player.playerMoving = true
		player.playerDir = 2
		player.playerLeft = true
	}
	if ebiten.IsKeyPressed(ebiten.KeyD) || ebiten.IsKeyPressed(ebiten.KeyRight) {
		player.playerMoving = true
		player.playerDir = 3
		player.playerRight = true
	}
}

game.go文件:

package main

import (
	"github.com/hajimehoshi/ebiten/v2"
	"image"
	"image/color"
)

type Game struct {
	cfg    *Config
	player *Player
	input  *Input
}

// 帧计数
var frameCount int

func (g *Game) Update() error {
	g.input.Update(g.player)
	if g.player.playerMoving {
		if g.player.playerUp {
			g.player.y -= g.player.speed
		}
		if g.player.playerDown {
			g.player.y += g.player.speed
		}
		if g.player.playerLeft {
			g.player.x -= g.player.speed
		}
		if g.player.playerRight {
			g.player.x += g.player.speed
		}
		if frameCount%8 == 1 {
			g.player.playerFrame++
		}
	} else if frameCount%40 == 1 {
		g.player.playerFrame++
	}

	// 大于3赋值为0,实现四张图片循环切换
	if g.player.playerFrame > 3 {
		g.player.playerFrame = 0
	}

	// 角色静止且大于1赋值为0,实现两张图片循环切换
	if !g.player.playerMoving && g.player.playerFrame > 1 {
		g.player.playerFrame = 0
	}

	frameCount++
	return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
	screen.Fill(color.RGBA{R: 118, G: 225, B: 254, A: 255})
	g.player.Draw(screen)
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
	return 400, 350
}

func NewGame() *Game {
	cfg := loadConfig()

	ebiten.SetWindowSize(cfg.ScreenWidth, cfg.ScreenHeight)
	ebiten.SetWindowTitle(cfg.Title)
	ebiten.SetWindowIcon([]image.Image{cfg.Icon})

	return &Game{
		cfg:    cfg,
		player: NewPlayer(),
		input:  &Input{},
	}
}

结语

至此本篇结束,实现了键盘事件、移动和静态动画效果

如果你有更好的主意去更高效的实现一些东西,例如上面添加的动画效果,欢迎在评论区提出建议

如何你有其他问题,欢迎私信我,看到都会回的

下一篇教程应该会是绘制地图,可能会加一点碰撞检测逻辑

如果这篇文章对你有帮助,请务必务必务必点赞收藏,这对我真的很重要!!!

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值