前言
上一篇教程进行了简单的角色绘制,详细:
用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{},
}
}
结语
至此本篇结束,实现了键盘事件、移动和静态动画效果
如果你有更好的主意去更高效的实现一些东西,例如上面添加的动画效果,欢迎在评论区提出建议
如何你有其他问题,欢迎私信我,看到都会回的
下一篇教程应该会是绘制地图,可能会加一点碰撞检测逻辑
如果这篇文章对你有帮助,请务必务必务必点赞收藏,这对我真的很重要!!!