此文写在golang游戏开发学习笔记-开发一个简单的2D游戏(基础篇)之后,在这篇文章里我们要完成2D游戏场景搭建,人物动画和碰撞检测
一.创造世界
在我们的2D游戏里,游戏地图完全由方块构成,因此首先要基于前文的GameObj
派生出一个block
对象表示方块
package model
const(
BlockHeight float32 = 15
BlockWidth float32 = 20
)
//方块类
type Block struct{
GameObj
}
目前为止,方块类和普通游戏类没有任何区别,但后期可能会添加诸如方块硬度之类的属性
接下来可以开始开发地图类了
package model
import(
"game2D/resource"
"game2D/sprite"
"math"
"fmt"
"github.com/go-gl/mathgl/mgl32"
)
type GameMap struct{
Height float32
Width float32
blocks [][] *Block
heightBlockNum int
widthBlockNum int
}
//一个简单的测试用的游戏地图生成函数
func NewGameMap(width,height float32, mapFile string) *GameMap{
heightBlockNum := int(math.Ceil(float64(height / BlockHeight)))
widthBlockNum := int(math.Ceil(float64(width / BlockWidth)))
grounds := heightBlockNum / 4
xGrounds := widthBlockNum / 4
fmt.Println("map block size:",heightBlockNum,widthBlockNum)
blocks := make([][]*Block,heightBlockNum)
for i := 0;i < heightBlockNum;i++{
rowBlocks := make([]*Block,widthBlockNum)
if(i < grounds || i > grounds*3){
for j := 0; j < widthBlockNum;j++{
if(j < xGrounds || j > xGrounds * 3 || i == 0){
gameObj := NewGameObj(resource.GetTexture("soil"),float32(j) * BlockWidth,float32(i)*BlockHeight,&mgl32.Vec2{BlockWidth,BlockHeight},0,&mgl32.Vec3{1,1,1})
rowBlocks[j] = &Block{GameObj:*gameObj}
}
}
}
blocks[i] = rowBlocks
}
return &GameMap{Height:height,
Width:width,
blocks:blocks,
heightBlockNum:heightBlockNum,
widthBlockNum:widthBlockNum}
}
//将一个物体坐标转换为地图格子坐标范围
func (gameMap *GameMap) FetchBox(position,size mgl32.Vec2)(int,int,int,int){
startY := int(math.Floor(float64((position[0]) / gameMap.Width * float32(gameMap.widthBlockNum))))-1;
if(startY <= 0){
startY = 0
}
endY := int(math.Ceil(float64((position[0] + size[0]) / gameMap.Width * float32(gameMap.widthBlockNum)))) + 1
if(endY >= gameMap.widthBlockNum){
endY = gameMap.widthBlockNum - 1
}
startX := int(math.Floor(float64((position[1]) / gameMap.Height * float32(gameMap.heightBlockNum)))) -1
if(startX < 0){
startX = 0
}
endX := int(math.Ceil(float64((position[1] + size[1]) / gameMap.Height * float32(gameMap.heightBlockNum)))) +1
if(endX >= gameMap.heightBlockNum){
endX = gameMap.heightBlockNum - 1
}
return startX,endX,startY,endY
}
//渲染地图
func (gameMap *GameMap) Draw(position mgl32.Vec2, zoom mgl32.Vec2, renderer *sprite.SpriteRenderer){
startX,endX,startY,endY := gameMap.FetchBox(position,zoom)
for i:=startX;i<=endX;i++{
for j := startY;j<endY;j++{
block := gameMap.blocks[int(i)][int(j)]
if(block != nil){
block.Draw(renderer)
}
}
}
}
在上述的地图类中,我们用一个二维切片储存地图中的所有方块。用地图长宽除以方块长宽获得横向和竖向方块数量然后往二维数组中放入方块。要注意的是,虽然可以在渲染函数中简单的遍历并调用block来绘制地图,但试想一下如果地图长一千个方块宽一千个方块会怎么样?每一帧都要绘制一百万个方块!这显然是不合理的,所以在渲染函数中我们先将位置转化为方块位置,然后根据屏幕大小计算出我们能看到的方块位置和数量并对其进行渲染,这样地图无论多大我们都只用渲染屏幕内的方块,大大优化了效率
二.碰撞的艺术
单纯的碰撞检测其实并不复杂,简单的AABB模型(不发生旋转的矩形之间)只需要初中数学水平就能理解,判断两个矩形的x轴和y轴是否重叠即可,难点在于如何检测碰撞方向。
如图所示,根据xy轴是否重叠能很简单的判断出某个状态下两个物体是否已经发生了碰撞,如果我们是要做一个类似flappy bird
或者跑酷类游戏,那到这一步就可以了。但如果我们要做的是一个类似泰拉瑞亚的游戏,这样显然不符合要求。可以想一下,如果一个游戏人物向屏幕左下方移动,在某个时刻撞到了一堵垂直的墙,这时候虽然人物无法向左继续移动,但应该能向上或向下移动,如果撞到的是一堵水平的墙,那就应该反过来。我相信一定会有一个巧妙的数学方法能解决这个问题,但作者数学水平实在太次,只能想个笨办法了。
首先要明确,在游戏中,每个时刻每个物体的运动都是可预测的,在每一帧绘制之前,我们会根据物体的运动速度和方向计算好该物体在这一帧的位移,然后才会交由显卡绘制。我的笨办法是,在每个位移发生之前先获取位移方向并将物体沿着这个方向迭代,每次迭代里移动一小段的距离,如果在某次迭代中发生碰撞,那上一次的迭代位置就应该是物体最终所处的方向,明白了这一点,我们可以开始写代码了
package physic
import(
"github.com/go-gl/mathgl/mgl32"
"math"
)
//检测两个矩形是否发生碰撞
func IsCollidingAABB(thisGameObj,anotherObj React) bool{
tPosition := thisGameObj.GetPosition()
tSize := thisGameObj.GetSize()
aPosition := anotherObj.GetPosition()
aSize := anotherObj.GetSize()
return isCollidingReact(tPosition,tSize,aPosition,aSize);
}
type React interface{
GetPosition() mgl32.Vec2
GetSize() mgl32.Vec2
}
func isCollidingReact(position1,size1,position2,size2 mgl32.Vec2) bool{
// x轴方向碰撞?
collisionX := position1[0] + size1[0] >= position2[0] && position2[0] + size2[0] >= position1[0]
// y轴方向碰撞?
collisionY := position1[1] + size1[1] >= position2[1] && position2[1] + size2[1] >= position1[1]
return collisionX && collisionY
}
//检测两个矩形运动后是否会发生碰撞
func WillCollidingAABB(thisGameObj,anotherObj React,dt mgl32.Vec2) bool{
tPosition := thisGameObj.GetPosition().Sub(dt)
tSize := thisGameObj.GetSize()
aPosition := anotherObj.GetPosition()
aSize := anotherObj.GetSize()
return isCollidingReact(tPosition,tSize,aPosition,aSize);
}
//检测两个矩形的碰撞,并获取碰撞位置
func ColldingAABBPlace(thisGameObj,anotherObj React,shift mgl32.Vec2) (bool,mgl32.Vec2){
position := thisGameObj.GetPosition()
if(shift[0] == 0 && shift[1] == 0){
return false, position
}
colldingShift := mgl32.Vec2{0.0}
colldingDt := shift.Normalize()
for math.Abs(float64(colldingShift[0])) <= math.Abs(float64(shift[0])) && math.Abs(float64(colldingShift[1])) <= math.Abs(float64(shift[1])){
tempColldingShift := colldingShift.Sub(colldingDt)
if(WillCollidingAABB(thisGameObj,anotherObj,tempColldingShift)){
return true,thisGameObj.GetPosition().Sub(colldingShift)
}
colldingShift = tempColldingShift
}
return false,thisGameObj.GetPosition()
}
现在我们有了能检测两个物体是否发生碰撞和碰撞位置的检测方法,然后该怎么用到我们的应用中?物体运动之前把地图中的每个方块都检测一次显然不现实,那只检测屏幕内的的方块合理吗?答案是不合理,因为不考虑bug(比如速度太快穿过去了)的情况下,运动物体只可能和他周围的方块发生碰撞,所以我们应该只对物体周围的方块进行碰撞检测
我们将碰撞逻辑加入到地图类中,添加方法
//检测一个物体是否与地图中的方块发生碰撞
func (gameMap *GameMap) IsColl(gameObj GameObj,shift mgl32.Vec2)(bool,mgl32.Vec2){
position := gameObj.GetPosition();
size := gameObj.GetSize()
startX,endX,startY,endY := gameMap.FetchBox(mgl32.Vec2{position[0],position[1]},mgl32.Vec2{size[0],size[1]})
for i:=startX;i<=endX;i++{
for j := startY;j<endY;j++{
block := gameMap.blocks[int(i)][int(j)]
if(block != nil){
isCol,position := physic.ColldingAABBPlace(gameObj,block,shift)
if(isCol){
return isCol,position
}
}
}
}
return false,gameObj.GetPosition()
}
三.主角的诞生
现在要来创造我们的主角了,首先创建一个类代表游戏中所有可移动的物体
package model
import(
"game2D/resource"
"game2D/constant"
"github.com/go-gl/mathgl/mgl32"
)
//可移动的游戏对象
type MoveObj struct{
GameObj
//在上下左右方向是否可移动
stockUp,stockDown,stockLeft,stockRight bool
//水平移动速度
movementSpeed float32
//飞行速度
fallSpeed float32
//下坠速度
flySpeed float32
//移动时的动画纹理
moveTextures []*resource.Texture2D
//静止时的纹理
stantTexture *resource.Texture2D
//游戏地图
gameMap *GameMap
//当前运动帧
moveIndex int
//运动帧之间的切换阈值
moveDelta float32
}
func NewMoveObject(gameObj GameObj,movementSpeed,flySpeed float32, moveTextures []*resource.Texture2D,gameMap *GameMap) *MoveObj{
moveObj := &MoveObj{GameObj:gameObj,
movementSpeed:movementSpeed,
fallSpeed:100,
gameMap:gameMap,
moveTextures:moveTextures,
flySpeed:flySpeed,
moveIndex:0,
moveDelta:0,
stantTexture:gameObj.texture}
return moveObj
}
//恢复静止
func (moveObj *MoveObj) Stand(){
moveObj.texture = moveObj.stantTexture
}
//由用户主动发起的运动
func(moveObj *MoveObj) Move(direction constant.Direction, delta float32){
shift := mgl32.Vec2{0,0}
if(direction ==constant. DOWN){
if(!moveObj.stockDown && moveObj.y + moveObj.size[1] < moveObj.gameMap.Height){
shift[1] += moveObj.flySpeed * delta
}
}
if(direction == constant.UP){
if(!moveObj.stockUp && moveObj.y > 0){
shift[1] -= moveObj.flySpeed * delta
}
}
if(direction == constant.LEFT){
moveObj.ReverseX()
if(moveObj.moveIndex >= len(moveObj.moveTextures)){
moveObj.moveIndex = 0
}
moveObj.moveDelta += delta
if(moveObj.moveDelta > 0.1){
moveObj.moveDelta = 0
moveObj.texture = moveObj.moveTextures[moveObj.moveIndex]
moveObj.moveIndex += 1
}
if(!moveObj.stockLeft && moveObj.x > 0){
shift[0] -= moveObj.movementSpeed * delta
}
}
if(direction == constant.RIGHT){
moveObj.ForWardX()
if(moveObj.moveIndex >= len(moveObj.moveTextures)){
moveObj.moveIndex = 0
}
moveObj.moveDelta += delta
if(moveObj.moveDelta > 0.1){
moveObj.moveDelta = 0
moveObj.texture = moveObj.moveTextures[moveObj.moveIndex]
moveObj.moveIndex += 1
}
if(!moveObj.stockRight && moveObj.x + moveObj.size[0] < moveObj.gameMap.Width){
shift[0] += moveObj.movementSpeed * delta
}
}
isCol,position := moveObj.gameMap.IsColl(moveObj.GameObj,shift)
if(isCol){
moveObj.SetPosition(position)
}else{
moveObj.x += shift[0]
moveObj.y += shift[1]
}
}
这个类只有一个特殊的move方法,传入一个代表方向的常量和帧与帧之间的延迟,当往左或往右运动时会将自身纹理切换为运动的纹理并在度过指定时间之后切换运动帧来形成动画效果,当静止时调用Stand
方法会将纹理帧切换为静止时的图像。注意,在往左或又运动时我们只需要将纹理动画直接求镜像而不用创建新的纹理。当然这种动画的实现方式是不太优雅的,更好的方法是调用gl.SubTextImage
方法将纹理图像直接替换为指定的图像而不是切换纹理,或者将多张图片拼成为一张,在运动时调整纹理坐标来显示不同图像。不过理解了这一种,其他两种也不会有问题。
4.还不够抽象
目前为止,我们已经有了地图,游戏角色,摄像头和精灵渲染器,但在man
方法里直接创建和修改这些对象似乎不太优雅,我们创建一个Game类来进一步封装
package game
import(
"game2D/resource"
"game2D/sprite"
"game2D/camera"
"game2D/model"
"game2D/constant"
"github.com/go-gl/mathgl/mgl32"
"github.com/go-gl/gl/v4.1-core/gl"
"github.com/go-gl/glfw/v3.2/glfw"
)
type GameState int
const(
GAME_ACTIVE GameState = 0
GAME_MENU GameState = 1
)
type Game struct{
//游戏状态
state GameState
//屏幕大小
screenWidth, screenHeight float32
//世界大小
worldWidth, worldHeight float32
//精灵渲染器
renderer *sprite.SpriteRenderer
//游戏地图
gameMap *model.GameMap
//摄像头
camera *camera.Camera2D
//玩家
player *model.MoveObj
//按键状态
Keys [1024]bool
}
func NewGame(screenWidth, screenHeight, wordWidth, wordHeight float32) *Game{
game := Game{screenWidth:screenWidth,
screenHeight:screenHeight,
worldWidth:wordWidth,
worldHeight:wordHeight,
state:GAME_ACTIVE}
return &game
}
func (game *Game) Init(){
//初始化着色器
resource.LoadShader("./glsl/shader.vs", "./glsl/shader.fs", "sprite")
shader := resource.GetShader("sprite")
shader.Use()
shader.SetInt("image", 0)
//设置投影
projection := mgl32.Ortho(0, float32(game.screenWidth),float32(game.screenHeight),0, -1, 1)
shader.SetMatrix4fv("projection", &projection[0])
//初始化精灵渲染器
game.renderer = sprite.NewSpriteRenderer(shader)
//加载资源
resource.LoadTexture(gl.TEXTURE0,"./image/stone.png","stone")
resource.LoadTexture(gl.TEXTURE0,"./image/soil.png","soil")
resource.LoadTexture(gl.TEXTURE0,"./image/man-stand.png","man-stand")
resource.LoadTexture(gl.TEXTURE0,"./image/1.png","1")
resource.LoadTexture(gl.TEXTURE0,"./image/2.png","2")
resource.LoadTexture(gl.TEXTURE0,"./image/3.png","3")
resource.LoadTexture(gl.TEXTURE0,"./image/4.png","4")
resource.LoadTexture(gl.TEXTURE0,"./image/5.png","5")
resource.LoadTexture(gl.TEXTURE0,"./image/6.png","6")
//创建游戏地图
game.gameMap = model.NewGameMap(game.worldWidth, game.worldHeight,"testMapFile")
//创建测试游戏人物
gameObj := model.NewGameObj(resource.GetTexture("man-stand"),
game.worldWidth/2,
game.worldHeight/2,
&mgl32.Vec2{70,100},
0,
&mgl32.Vec3{1,1,1})
//创建摄像头,将摄像头同步到玩家位置
game.camera = camera.NewDefaultCamera(game.worldHeight,
game.worldWidth,
game.screenWidth,
game.screenHeight,
mgl32.Vec2{game.worldWidth/2 - game.screenWidth/2, game.worldHeight/2 - game.screenHeight/2})
game.player = model.NewMoveObject(*gameObj,1000,1000,[]*resource.Texture2D{resource.GetTexture("1"),
resource.GetTexture("2"),
resource.GetTexture("3"),
resource.GetTexture("4"),
resource.GetTexture("5"),
resource.GetTexture("6"),},game.gameMap)
}
//处理输入
func (game *Game) ProcessInput(delta float64){
if(game.state == GAME_ACTIVE){
playerMove := false
if(game.Keys[glfw.KeyA]){
playerMove = true
game.player.Move(constant.LEFT,float32(delta))
}
if(game.Keys[glfw.KeyD]){
playerMove = true
game.player.Move(constant.RIGHT,float32(delta))
}
if(game.Keys[glfw.KeyW]){
playerMove = true
game.player.Move(constant.UP,float32(delta))
}
if(game.Keys[glfw.KeyS]){
playerMove = true
game.player.Move(constant.DOWN,float32(delta))
}
if(!playerMove){
game.player.Stand()
}
}
}
func (game *Game) Update(delta float64){
}
//渲染每一帧
func (game *Game) Render(delta float64){
resource.GetShader("sprite").SetMatrix4fv("view",game.camera.GetViewMatrix())
//game.player.MoveBy(float32(delta))
game.player.Draw(game.renderer)
//摄像头跟随
position := game.player.GetPosition()
size := game.player.GetSize()
game.camera.InPosition(position[0] - game.screenWidth /2 + size[0], position[1] - game.screenHeight / 2 + size[1])
game.gameMap.Draw(game.camera.GetPosition(),
mgl32.Vec2{game.screenWidth,game.screenHeight},
game.renderer)
}
在game
类中我们在初始化函数里加载资源创建地图和其他需要的对象,在渲染函数中始终将摄像头的位置与主角位置同步,并在ProcessInput
方法内将按键信息转化为方向信息
5.动起来
万事俱备,现在要做的是在main方法内让程序跑起来了
package main
import(
"github.com/go-gl/glfw/v3.2/glfw"
"github.com/go-gl/gl/v4.1-core/gl"
"game2D/game"
"runtime"
)
const (
width = 800
height = 600
WORD_WIDTH float32 = 1500
WORD_HEIGHT float32 = 1000
)
var (
windowName = "æˆ‘çˆ±ä½ "
game2D = game.NewGame(width,height,WORD_WIDTH,WORD_HEIGHT)
deltaTime = 0.0
lastFrame = 0.0
)
func main(){
runtime.LockOSThread()
window := initGlfw()
defer glfw.Terminate()
initOpenGL()
game2D.Init()
for !window.ShouldClose() {
currentFrame := glfw.GetTime();
deltaTime = currentFrame - lastFrame;
lastFrame = currentFrame;
glfw.PollEvents()
game2D.ProcessInput(deltaTime)
game2D.Update(deltaTime)
gl.Clear(gl.COLOR_BUFFER_BIT);
game2D.Render(deltaTime)
window.SwapBuffers()
}
}
func initGlfw() *glfw.Window {
if err := glfw.Init(); err != nil {
panic(err)
}
glfw.WindowHint(glfw.Resizable, glfw.False)
window, err := glfw.CreateWindow(width, height, windowName, nil, nil)
window.SetKeyCallback(KeyCallback)
if err != nil {
panic(err)
}
window.MakeContextCurrent()
return window
}
func initOpenGL(){
if err := gl.Init(); err != nil {
panic(err)
}
gl.Viewport(0, 0, width, height);
gl.Enable(gl.CULL_FACE);
gl.Enable(gl.BLEND);
}
func KeyCallback(w *glfw.Window, key glfw.Key, scancode int, action glfw.Action, mods glfw.ModifierKey){
if(action == glfw.Press){
game2D.Keys[key] = true
}
if(action == glfw.Release){
game2D.Keys[key] = false
}
}
运行go main.go你将看到文章开头的图形界面
代码github地址