iOS SpriteKit 小游戏开发实例 - Flappy Bird

由于很多小伙伴要demo我就不一一发了,直接丢在github上自己下载吧:https://github.com/sideslash/FlappyBird

最近利用业余时间根据官方文档和网上的资料学习了苹果官方推出的2D游戏开发引擎Spritekit基本知识,模仿做了一个前两年火了一火的小游戏flappy bird练练手,现在就来一步一步讲讲这个游戏我的实现方法。

因为Apple推行Swift开发语言,Swift也将是以后iOS方面开发的主力语言,所有这篇实例我们也就先抛弃Objective-C,而使用Swift3开发语言,如果你还不熟悉Swift的基本语法那赶快去学学吧,如果你已经了解了那么就跟着我继续吧!

先看看最后最后做完的样子


1.准备工作


新建工程

先新建一个工程项目,模板选择Game



语言选择Swift,开发库选择SpriteKit



删除示例文件和代码

然后我们的工程就建立好了。接着我们就先把那些Xcode自动创建的示例文件和代码都删除掉,先看文件目录栏,把GameScene.sks和Actions.sks两个文件删除掉,然后进入Assets.xcassets把里面的那张飞机图片删除掉。这样我们就把用不到的文件都删掉了,接下来继续删除没用的代码


首先进入GameViewController.swift文件,找到那个viewDidLoad()方法,看到里面下面内容。

注意看这一句

if let scene = SKScene(fileNamed: "GameScene")  {

........

}

这一句是通过一个GameScene的sks文件来创建一个场景实例对象,由于咱们刚刚把GameScene.sks文件删除了,所以我们现在是创建不出来场景的,所以我们需要把viewDidLoad()里面的代码改成下面的内容


super.viewDidLoad()

if let view = self.view as! SKView? {

    let scene = GameScene(size: view.bounds.size)  //通过代码创建一个GameScene类的实例对象

    scene.scaleMode = .aspectFill

    view.presentScene(scene)

    view.ignoresSiblingOrder = true

    view.showsFPS = true

    view.showsNodeCount = true

}


现在我们就把通过sks文件创建场景对象改成了通过代码直接创建一个叫做GameScene类的实例对象了。

到这里我们的GameViewController文件就改完了。接下来我们进入GameScene.swift我们最终要的场景类的文件看一看

。。。。我勒个去。。。。你会发现Xcode给我们自动添加了这么多示例的代码,然并卵,都删掉!删到跟下图一样只留下didMove()和update()两个空方法即可!


我们在didMove方法里先加上一句代码,设置场景的背景色为淡蓝色,现在我们就可以运行一下程序看看显示的是不是一个淡蓝色的界面

self.backgroundColor = SKColor(red: 80.0/255.0, green: 192.0/255.0, blue: 203.0/255.0, alpha: 1.0)

didMove()方法会在当前场景被显示到一个view上的时候调用,你可以在里面做一些初始化的工作

这样看来一切正常,我们自己的场景终于显示在玩家面前了。


导入资源文件

我自己选用了3张小鸟的png图片,一张翅膀上抬、一张翅膀放平、一张翅膀下坠,这样我们一会就可以做出小鸟在飞的效果。图片大小都是50*43,你也可以自己在网上找几张类似的图片来使用,尺寸别太大,虽然你可以通过代码改变小鸟的小大,但是如果你的图片本身很大,你实际需要它显示的比较小,那么对性能其实有点浪费,不过对于这种小游戏来说你想怎么弄都没问题的。


PS:稍后如果我把我的工程放上网你们也可以直接下载我的工程,直接用里面的图片素材

导入图片注意:先新建一个叫player.atlas的文件夹,然后我们把这三张图片放到这个文件夹下,然后再将这个文件夹拖到工程里面,注意要勾选copy item if need。

为什么要这样做?

因为当你把一类相关的贴图图片素材放在一个.atlas文件夹里,编译程序的时候Xcode会把这个文件夹里的图片都导入“纹理图集”里,相对于只用独立的图片文件而言,使用纹理图集会非常显著地提升游戏的渲染性能

然后我们再将另外三张图片丢入工程的Asserts.xcasserts里即可,分别是地面(floor),上水管(topPipe)和下水管(bottomPipe)



至此准备工作全部完成,我们终于可以开始敲代码了!




2.布置场景和游戏状态


PS:由于这个游戏比较小也不复杂,所以咱们也就不设计什么高级的开发模式来开发这个游戏了,全部的布局逻辑代码全部都写在GameScene.swift文件里。


布置地面

我们先进入GameScene.swift,给GameScene这个类添加两个地面的变量

var floor1: SKSpriteNode!

var floor2: SKSpriteNode!

然后再在didMove()方法里添加下面的代码

// Set floors

floor1 = SKSpriteNode(imageNamed: "floor")

floor1.anchorPoint = CGPoint(x: 0, y: 0)

floor1.position = CGPoint(x: 0, y: 0)

addChild(floor1)

floor2 = SKSpriteNode(imageNamed: "floor")

floor2.anchorPoint = CGPoint(x: 0, y: 0)

floor2.position = CGPoint(x: floor1.size.width, y: 0)

addChild(floor2)

可以看到为什么我弄了两个floor?因为我们一会要让floor向左移动,使得看起来小鸟在向右飞,所以我弄了两个floor头尾两连地放着,等会我们就让两个floor一起往左边移动,当左边的floor完全超出屏幕的时候,就马上把左边的floor移动凭借到右边的floor后面然后继续向左移动,如此循环下去。

我将anchorPoint设置为(0,0),即SpriteNode的左下角的点作为这个node的锚点,是为了方便定位floor,如果不熟悉锚点是什么的朋友赶快去搜一搜!

SKScene场景的默认锚点为(0,0)即左下角,SKSpriteNode的默认锚点为(0.5,0.5)即它的中心点。

另外SpriteKit的坐标系是向右x增加,向上y增加。而不像做iOS应用开发时候UIKit是向右x增加,向下y增加!

现在让我们运行一下程序就可以看到我们的地面出现了!




放置小鸟

我们来讲我们的游戏主角小鸟显示出来,同样给GameScene类增加一个小鸟的变量

var bird: SKSpriteNode!

然后在didMove()方法里,在添加floor的后面添加下面代码

bird = SKSpriteNode(imageNamed: "player1")

addChild(bird)

这样我们就将我们的主角小鸟添加到场景上了。等等!你还没给小鸟设置position呢,不是应该把小鸟放到屏幕中间开始么?


游戏状态

没错,但是在我们设置它位置之前,我们先构想一下我们这个游戏整个运行的流程:

1.一开始小鸟在屏幕中间飞,地面也在移动,但是这个时候还没有真的开始,所以还不会有水管出现。

2.当玩家准备好了点了一下屏幕,游戏正式开始,小鸟会受重力作用往下坠落,水管开始出现,此时玩家每点击一次屏幕小鸟就有会受一次上升的力。

3.如果小鸟碰到水管或者小鸟碰到地面了,则游戏结束,小鸟停止飞的动作,场景里的水管和地面都停住不动。此时玩家再点击屏幕则回到上面1初始状态。

可以看到,玩家的操作和场景内容的移动与否都与当前游戏的进程状态有关系,我们也可以看出有三个状态:1初始状态 2游戏进行中状态 3游戏结束状态

那我们现在GameScene类里面定义一个枚举来表示不同的状态,同时给GameScene增加一个游戏状态的变量

enum GameStatus {

    case idle    //初始化

    case running    //游戏运行中

    case over    //游戏结束

}

var gameStatus: GameStatus = .idle  //表示当前游戏状态的变量,初始值为初始化状态

现在我们知道了整个游戏会有三个进程状态,那么我们就给GameScene增加三个对应的方法,分别来处理这个三个状态。

func shuffle()  { 

//游戏初始化处理方法

gameStatus = .idle

}

func startGame()  { 

//游戏开始处理方法

gameStatus = .running

}

func gameOver()  {

//游戏结束处理方法

gameStatus = .over

}


可以看到目前我们只在这三个方法里分别修改了当前游戏的进程状态变量。

接下来大家再想想上面那个没解决的问题,设置小鸟初始化的位置该放在那里呢?当然是初始化shuffle()方法里啦,添加下面代码内容到shuffle()方法里

bird.position = CGPoint(x: self.size.width * 0.5, y: self.size.height * 0.5)


那么我们应该什么时候来调用这三个方法呢?

首先在场景初始化完成的时候,肯定要先调用一下shuffle()初始化,所有我们在didMove()方法里的最后面添加一句

shuffle()

然后再给GameScene添加下面这个方法

override func touchesBegan(_ touches: Set, with event: UIEvent?) {

    switch gameStatus {

       case .idle:

          startGame()  //如果在初始化状态下,玩家点击屏幕则开始游戏

       case .running:

          print("给小鸟一个向上的力")   //如果在游戏进行中状态下,玩家点击屏幕则给小鸟一个向上的力(暂时用print一句话代替)

      case .over:

         shuffle()  //如果在游戏结束状态下,玩家点击屏幕则进入初始化状态

      }

}

touchesBegan()是SKScene自带的系统方法,当玩家手指点击到屏幕上的时候会调用,可以看到我们用switch语句来处理了三种不同的游戏状态下,玩家点击屏幕后做出的不同响应

现在让我们来运行一下程序,可以看到小鸟也正常的出现在屏幕中间了





3.让内容动起来


我们目前可以看到虽然我们看到了小鸟和地面,但是怎么都是死的,这也太假了,那么接下来我们要让他们都动起来,让小鸟好像真的在飞

移动地面

我们先来移动地面,我们给GameScene添加一个叫做moveScene()的方法,用来使场景内的物体向左移动起来,暂时我们先让地面移动,稍后还会在这个方法里添加让水管移动的代码

func moveScene() {

    //make floor move

    floor1.position = CGPoint(x: floor1.position.x - 1, y: floor1.position.y)

    floor2.position = CGPoint(x: floor2.position.x - 1, y: floor2.position.y)

    //check floor position

    if floor1.position.x < -floor1.size.width {

        floor1.position = CGPoint(x: floor2.position.x + floor2.size.width, y: floor1.position.y)

    }

    if floor2.position.x < -floor2.size.width {

        floor2.position = CGPoint(x: floor1.position.x + floor1.size.width, y: floor2.position.y)

    }

}

我们在这个方法里先让两个floor向左移动1的位置,然后检查两个floor是否已经完全超出屏幕的左边,超出的floor则移动到另一个floor的右边。

那我们该什么时候调用这个方法呢?我们可以在update()方法里调用moveScene()方法。

还记得update()方法么?我们最开始留下的两个空方法,一个是didMove()另一个就是update()呀!

update()方法为SKScene自带的系统方法,在画面每一帧刷新的时候就会调用一次

那么就在update()方法里添加一下内容代码

if gameStatus != .over {

    moveScene()

}

如果当前游戏状态不是结束的,则每次调用update()的时候都调用moveScene()方法,回想一下我们上面提高的游戏流程是不是应该这样呢?

运行一下程序,看我们的地面是不是东西来,就想鸟在向右飞一样



小鸟动起来

现在我们让鸟也飞起来吧!

先给GameScene添加两个新的方法,一个是让小鸟开始飞,一个是让小鸟停止飞(游戏结束,小鸟坠地了就要停止飞)

//开始飞

func birdStartFly() {

    let flyAction = SKAction.animate(with: [SKTexture(imageNamed: "player1"),

                                                                       SKTexture(imageNamed: "player2"),

                                                                       SKTexture(imageNamed: "player3"),

                                                                       SKTexture(imageNamed: "player2")],

                                                           timePerFrame: 0.15)

    bird.run(SKAction.repeatForever(flyAction), withKey: "fly")

}

//停止飞

func birdStopFly() {

    bird.removeAction(forKey: "fly")

}

在birdStartFly()方法里

我们用了准备的3张小鸟的图片生成了四个SKTexture纹理对象,他们四个连起来就是小鸟的翅膀从上->中->下->中这样一个循环过程

然后用这一组纹理创建了一个飞的动作(flyAction),同时设置纹理的变化时间为0.15秒

然后让小鸟重复循环执行这个飞的动作,同时给这个动作使用了一个叫"fly"的key来标识

在birdStopFly()方法里只有一句代码,就是把fly这个动作从小鸟身上移除掉


接下来我们分别在shuffle()方法里添加一句让小鸟开始飞,

birdStartFly()

在gameOver()方法里添加一句让小鸟停止飞

birdStopFly()

现在运行程序就能看到小鸟像是真的在往右边飞!




4.随机创造水管

现在我们地面有了,小鸟也有了,该要让水管上场了。

我们先想想水管出现有什么特点

1.成对的出现,一个在上一个在下,上下两个水管中间留有一定的高度的距离让小鸟能通过

2.上下水管之间的高度距离是随机的,但是有个最小值和最大值

3.一对水管出现之后向左移动,移动出了屏幕左侧就要把它移除掉

4.一对水管出现之后,间隔一定的时间,再产生另一对水管,间隔的时间也是随机数,也要设一个最大和最三小值

5.在游戏初始化状态下要停止重复创建水管,同时要移除掉场景里上一句残留的水管。在游戏进行中状态下才重复创建水管。在游戏结束状态下,停止创建水管,如果场景里还有存在水管,则停止左移

那么我准备了四个方法来实现水管功能(5个方法不是跟上面5个特点一一对应喔!)

1.方法startCreateRandomPipesAction()    开始重复创建水管的动作方法

2.方法stopCreateRandomPipesAction()     停止创建水管的动作方法    

3.方法createRandomPipes()    具体某一次创建一对水管方法,在此方法里计算上下水管大小随机数

4.方法addPipes(topSize: CGSize, bottomSize: CGSize)  添加一对水管到场景里,这个方法有两个参数分别是上水管和下水管的大小,在此方法里仅仅做的是创建两个SKSpriteNode对象,然后将他们加到场景里

5.方法removeAllPipesNode()  移除所有正在场景里的水管

我们一个方法一个方法的来

首先添加下面addPipes(topSize: CGSize, bottomSize: CGSize)方法到GameScene里面

func addPipes(topSize: CGSize, bottomSize: CGSize) {

        //创建上水管

        let topTexture = SKTexture(imageNamed: "topPipe")      //利用上水管图片创建一个上水管纹理对象

        let topPipe = SKSpriteNode(texture: topTexture, size: topSize)  //利用上水管纹理对象和传入的上水管大小参数创建一个上水管对象

        topPipe.name = "pipe"   //给这个水管取个名字叫pipe

        topPipe.position = CGPoint(x: self.size.width + topPipe.size.width * 0.5, y: self.size.height - topPipe.size.height * 0.5) //设置上水管的垂直位置为顶部贴着屏幕顶部,水平位置在屏幕右侧之外


        //创建下水管,每一句方法都与上面创建上水管的相同意义

        let bottomTexture = SKTexture(imageNamed: "bottomPipe")

        let bottomPipe = SKSpriteNode(texture: bottomTexture, size: bottomSize)

        bottomPipe.name = "pipe"

        bottomPipe.position = CGPoint(x: self.size.width + bottomPipe.size.width * 0.5, y: self.floor1.size.height + bottomPipe.size.height * 0.5)  //设置下水管的垂直位置为底部贴着地面的顶部,水平位置在屏幕右侧之外


        //将上下水管添加到场景里

        addChild(topPipe)

        addChild(bottomPipe)

}


现在你有个一个helper方法可以添加两个真实的水管到场景里了,我们继续讲下面createRandomPipes()方法代码添加到GameScene里面

func createRandomPipes() {

        //先计算地板顶部到屏幕顶部的总可用高度

        let height = self.size.height - self.floor1.size.height

        //计算上下管道中间的空档的随机高度,最小为空档高度为2.5倍的小鸟的高度,最大高度为3.5倍的小鸟高度

        let pipeGap = CGFloat(arc4random_uniform(UInt32(bird.size.height))) + bird.size.height * 2.5

        //管道宽度在60

        let pipeWidth = CGFloat(60.0)

        //随机计算顶部pipe的随机高度,这个高度肯定要小于(总的可用高度减去空档的高度)

        let topPipeHeight = CGFloat(arc4random_uniform(UInt32(height - pipeGap)))

         //总可用高度减去空档gap高度减去顶部水管topPipe高度剩下就为底部的bottomPipe高度

        let bottomPipeHeight = height - pipeGap - topPipeHeight

        //调用添加水管到场景方法

        addPipes(topSize: CGSize(width: pipeWidth, height: topPipeHeight), bottomSize: CGSize(width: pipeWidth, height: bottomPipeHeight))

}

现在我们只要调用一次这个createRandomPipes()方法,就能真的创建一个一堆随机的上下水管并且把他们添加到场景里面了!

创建随机数通常使用以下两个方法

arc4random() -> UInt32 

这个方法会随机床身给一个无符号Int32以内的整数

arc4random_uniform(_ __upper_bound: UInt32) -> UInt32

这个方法比上面那个方法多一个参数,这个参数就是设置这个能产生随机数的最大值,也就是限定了一个范围


PS:可以看到我们在这个方法里面计算了好几个随机数,最后的目的就是为了计算出上下水管的大小。这里具体的随机数的大小范围是可以根据你自己的喜好更改的!比如上下水管的空档随机高度,如果你想游戏容易一点就让这个随机数最小值变大一点,如果你想游戏难一点就让随机数最小值变小。另外我们水管的宽度是写死60,你也可以让这个宽度也是一个随机数。。。



现在我们能创建一对水管了,那我想重复创建该怎么办呢?那就需要将下面这个方法startCreateRandomPipesAction()添加到GameScene

func startCreateRandomPipesAction() {

        //创建一个等待的action,等待时间的平均值为3.5秒,变化范围为1秒

        let waitAct = SKAction.wait(forDuration: 3.5, withRange: 1.0)  

       //创建一个产生随机水管的action,这个action实际上就是调用一下我们上面新添加的那个createRandomPipes()方法

        let generatePipeAct = SKAction.run {  

                self.createRandomPipes()

        }

        //让场景开始重复循环执行"等待" -> "创建" -> "等待" -> "创建"。。。。。

        //并且给这个循环的动作设置了一个叫做"createPipe"的key来标识它

        run(SKAction.repeatForever(SKAction.sequence([waitAct, generatePipeAct])), withKey: "createPipe")

}

现在我们只要调用一次startCreateRandomPipesAction()方法后,场景就会每隔一段时间就创建一堆水管添加到场景里了。那我们应该在哪里调用这个方法呢?明显是在startGame()游戏开始方法里啦

所以在startGame()方法里面最后加上下面这一句

startCreateRandomPipesAction()  //开始循环创建随机水管


既然有个开始循环创建,那么就把停止循环创建的方法也加进来吧,添加下面stopCreateRandomPipesAction()方法到GameScene里

func stopCreateRandomPipesAction() {

        self.removeAction(forKey: "createPipe")

}

可以看到这个方法很简单,仅仅是通过一个action的key将场景的重复创建水管的action移除掉即可。

接下来我我们在gameOver()方法里最后添加上下面这一句,就能让游戏结束的时候也停止创建水管了

stopCreateRandomPipesAction()


还有最后一个方法要添加的就是移除掉场景里的所有水管,添加下面方法到GameScene

func removeAllPipesNode() {

        for pipe in self.children where pipe.name == "pipe" {  //循环检查场景的子节点,同时这个子节点的名字要为pipe

                pipe.removeFromParent()  //将水管这个节点从场景里移除掉

        }

}

然后我们在shuffle()方法里的gameStatus = . idle后面加上下面这一句,这样我们就能在每一局新开始初始换的时候将上一句可能残留在场景里的旧水管清空

removeAllPipesNode()


好的!现在我们运行一下我们的游戏,记得游戏一开始是初始化状态,要点击一下屏幕才会游戏开始,看到了么每隔几秒就会有一对水管天添加到场景里


等等!!!说好的水管呢????没有看到呀!!!!!

没错你肯定看不到,因为你记得我们创建了两个水管SpriteNode之后把他们的位置放在哪里么?我们把他们放在了屏幕右侧之外了,你当然看不到啦。但是虽然你看不到你也知道它已经在场景了!注意看右下角那个黑色小条的内容node 和 fps,这是方便我们调试时候用的,显示游戏场景里的实时的node数量和刷新率,最开始node是4,当你点击了一下屏幕游戏开始了之后,每隔几秒node就会加2,这个2就是我们的上下水管了!

所以我们还要让水管动起来,找到之前写的moveScene()方法,在移动地面代码后面加上下面的代码

//循环检查场景的子节点,同时这个子节点的名字要为pipe

for pipeNode in self.children where pipeNode.name == "pipe" { 

        //因为我们要用到水管的size,但是SKNode没有size属性,所以我们要把它转成SKSpriteNode

        if let pipeSprite = pipeNode as? SKSpriteNode { 

                //将水管左移1

                pipeSprite.position = CGPoint(x: pipeSprite.position.x - 1, y: pipeSprite.position.y)

                //检查水管是否完全超出屏幕左侧了,如果是则将它从场景里移除掉

                if pipeSprite.position.x < -pipeSprite.size.width * 0.5 {

                      pipeSprite.removeFromParent()

               }

        }

}

因为moveScene()方法会在游戏进行中时,每一帧更新的update()方法里调用,所以你现在你再运行程序就会看到了水管跟着地面一起往左边移动了!




5.物理世界


到此我们已经完成了这个游戏很大一部分了,但是这个游戏还有最重要一部分现在才出场,这就是模拟物理世界!

可以看到我们现在运行程序,小鸟没有收到重力作用,不会下坠,点击屏幕小鸟也不会向上飞,小鸟碰到水管也不会死掉,这就是因为缺少了物理世界的模拟。

我觉得物理的模拟是游戏引擎很重要的一个功能,它给了游戏的玩法和开发更多的可能性。那么什么是模拟物理世界?

比如你可以把一个场景当成我们生活的真实物理环境,里面会有重力,会有磁场会有引力场等等。场景里面的物理体会受各种场的影响,还能跟其他物理体有交互,比如物理体直接碰撞了会互相弹开,物理体有自己的质量密度体积等等。是不是很神奇!而且这些物理的计算完全有游戏引擎做好了,你只要会用就行了!

我们这个游戏其实用不到多复杂的物理模拟,仅仅是场景里会有重力,小鸟会受到重力影响自由落体,然后小鸟会跟水管和地面产生碰撞,整个场景有个边界,小鸟不能一直往上飞出屏幕。


配置场景的物理体

找到didMove()方法,在设置场景背景色代码后面加上下面内容

// Set Scene physics

self.physicsBody = SKPhysicsBody(edgeLoopFrom: self.frame)  //给场景添加一个物理体,这个物理体就是一条沿着场景四周的边,限制了游戏范围,其他物理体就不会跑出这个场景

self.physicsWorld.contactDelegate = self //物理世界的碰撞检测代理为场景自己,这样如果这个物理世界里面有两个可以碰撞接触的物理体碰到一起了就会通知他的代理

加完这两句之后你会发现第二句代码报错了!那是因为你让GameScene成为了物理场景的碰撞检测代理,但是你并没有遵守这个代理的协议,所以赶快让GameScene这个类遵守下面这个协议吧

SKPhysicsContactDelegate

现在就不会报错了,你可以看到GameScene因为是继承自SKScene,SKScene是自带了个物理世界的,有兴趣你现在也可以试试打印一下当前物理世界的重力看看 -> print(self.physicsWorld.gravity),结果是不是(x:0,y:-9.8),表示重力是沿着屏幕向下的方向,重力大小是9.8,是不是跟高中物理学的是一样的呢!

最后先做一个准备工作,在GameScene类的外面加上下面内容

let birdCategory: UInt32 = 0x1 << 0

let pipeCategory: UInt32 = 0x1 << 1

let floorCategory: UInt32 = 0x1 << 2

设置三个常量来表示小鸟、水管和地面物理体,稍后我们后面会用到


配置地面物理体

找到didMove()方法,在添加地面的带面后面加上下面内容

//配置地面1的物理体

floor1.physicsBody = SKPhysicsBody(edgeLoopFrom: CGRect(x: 0, y: 0, width: floor1.size.width, height: floor1.size.height))

floor1.physicsBody?.categoryBitMask = floorCategory

//配置地面2的物理体

floor2.physicsBody = SKPhysicsBody(edgeLoopFrom: CGRect(x: 0, y: 0, width: floor2.size.width, height: floor2.size.height))

floor2.physicsBody?.categoryBitMask = floorCategory

这里要说明的是物理体的categoryBitMask,这个用来表示当前物理体是哪一个物理体,我们用我们刚刚准备好的floorCategory来表示他,等会碰撞检测的时候需要通过这个来判断。


配置小鸟物理体

找到didMove()方法,在添加小鸟的代码后面,shuffle()方法前面加入下面代码

bird.physicsBody = SKPhysicsBody(texture: bird.texture!, size: bird.size)

bird.physicsBody?.allowsRotation = false  //禁止旋转

bird.physicsBody?.categoryBitMask = birdCategory //设置小鸟物理体标示

bird.physicsBody?.contactTestBitMask = floorCategory | pipeCategory  //设置可以小鸟碰撞检测的物理体

上面我们就设置好了小鸟的物理体了,contactTestBitMask是来设置可以与小鸟碰撞检测的物理体,我们设置了地面和水管,所以通常物理体的categoryBitMask用二进制移位方式来表示,这样在设置contactTestBitMask的时候就可以直接多个移位的标识做按位取或的运算即可


配置水管物理体

找到addPipes(topSize: CGSize, bottomSize: CGSize)方法,在addChild(topPipe),addChild(bottomPipe)代码之前加入下面的代码内容

//配置上水管物理体

topPipe.physicsBody = SKPhysicsBody(texture: topTexture, size: topSize)

topPipe.physicsBody?.isDynamic = false

topPipe.physicsBody?.categoryBitMask = pipeCategory

//配置下水管物理体

bottomPipe.physicsBody = SKPhysicsBody(texture: bottomTexture, size: bottomSize)

bottomPipe.physicsBody?.isDynamic = false

bottomPipe.physicsBody?.categoryBitMask = pipeCategory


选在我们来运行一下游戏吧,你可以看到游戏一开始在初始化状态小鸟就受到重力的影响而掉到地面上了,这不是我们想要的,我们希望是玩家点击了屏幕游戏开始了小鸟才会下落

那么请在shuffle()方法里,设置小鸟的position的代码后面加上下面这句

bird.physicsBody?.isDynamic = false

然后再在startGame()方法里,开始创建水管代码之前加上下面这句

bird.physicsBody?.isDynamic = true

isDynamic的作用是设置这个物理体当前是否会受到物理环境的影响,默认是true,我们在游戏初始化的时候设置小鸟不受物理环境影响,但是在游戏开始的时候才会受到物理环境的影响

现在再运行游戏就可以看到初始化的时候小鸟停在屏幕中间,点击了屏幕游戏开始了,小鸟才会掉下来


给小鸟一个速度

现在这游戏简直就是没法玩,小鸟一下就掉到地上,怎么点屏幕他都不会网上飞

现在找到touchesBegan()方法,看到这个写好的switch语句里,.running情况只有一句print("给小鸟一个向上的力"),打印一句话可不会让小鸟往上飞,现在请将这句print替换为下面这句代码

bird.physicsBody?.applyImpulse(CGVector(dx: 0, dy: 20))

这个句代码可以给小鸟的物理体施加一个向上的冲量,让小鸟获得一定的向上速度,但是由于小鸟还受重力影响,所以你得经常点击屏幕才能保持小鸟不掉下去。

Impluse是什么?Impulse在物理上就是冲量的意思,冲量=质量 * (结束速度 - 初始速度),即I = m * (v2 - v1),如果物体的质量为1,那么冲量i = v2 - v1。当一个质量为1的物理体applyImpulse(CGVector(dx: 0, dy: 20))的意思就是让他在y的方向上叠加20m/s的速度。当然如果物理体质量m不为1,那叠加的速度就不是刚好等于冲量的字面量了,而是要除以m了。如一个质量为2的物理体同样applyImpulse(CGVector(dx: 0, dy: 20)),结果就是它在y的方向上叠加了10m/s的一个速度


检测碰撞

现在我们的游戏已经基本能玩了,但是小鸟碰到水管或者掉到地面上小鸟没有死掉,游戏还在继续,现在我们就来完善这个问题

记得我们将当前的GameScene设置为了物理世界的碰撞检测的代理么?接下来我们只要实现检测到碰撞产生的代理方法即可

在GameScene里添加下面这个方法代码,didBegin()会在当前物理世界有两个物理体碰撞接触了则回调用,这两个碰撞了的物理体的信息都在contact这个参数里面,分别是bodyA和bodyB

func didBegin(_ contact: SKPhysicsContact) {

        //先检查游戏状态是否在运行中,如果不在运行中则不做操作,直接return

        if gameStatus != .running { return }

      //为了方便我们判断碰撞的bodyA和bodyB的categoryBitMask哪个小,小的则将它保存到新建的变量bodyA里的,大的则保存到新建变量bodyB里

        var bodyA : SKPhysicsBody

        var bodyB : SKPhysicsBody

        if contact.bodyA.categoryBitMask < contact.bodyB.categoryBitMask {

            bodyA = contact.bodyA

            bodyB = contact.bodyB

       }else {

            bodyA = contact.bodyB

            bodyB = contact.bodyA

       }

       接下来判断bodyA是否为小鸟,bodyB是否为水管或者地面,如果是则游戏结束,直接调用gameOver()方法

       if (bodyA.categoryBitMask == birdCategory && bodyB.categoryBitMask == pipeCategory) ||

           (bodyA.categoryBitMask == birdCategory && bodyB.categoryBitMask == floorCategory) {

               gameOver()

       }

}


现在我们运行游戏就可以正常玩耍了,小鸟碰到地面或者水管游戏就会结束,小鸟就会落地,水管会停住,如果再点击一次屏幕就会回到初始状态,小鸟回到中间,残留的水管都消失了


但是这个游戏结束有点突兀,最好能给个提示告诉玩家游戏结束了。

我们先给GameScene这个类添加一个变量,来表示游戏结束提示的label

lazy var gameOverLabel: SKLabelNode = {

         let label = SKLabelNode(fontNamed: "Chalkduster")

         label.text = "Game Over"

         return label

}()

注意这个变量我们用了一个lazy来标示,标示这个label是懒加载的,也就是只有在gameOverLabel第一次被调用的时候才会创建,它的创建代码用一个大括号包住,结尾要带一对()表示马上执行意思。这样我们就通过懒加载创建一个gameOverLabel,他的text内容Game Over提示语。

接下来找到gameOver()方法,在此方法的最后加上下面的代码,这样在gameOver的时候就会有一个提示语从天而降了

//禁止用户点击屏幕

isUserInteractionEnabled = false

//添加gameOverLabel到场景里

addChild(gameOverLabel)

//设置gameOverLabel其实位置在屏幕顶部

gameOverLabel.position = CGPoint(x: self.size.width * 0.5, y: self.size.height)

//让gameOverLabel通过一个动画action移动到屏幕中间

gameOverLabel.run(SKAction.move(by: CGVector(dx:0, dy:-self.size.height * 0.5), duration: 0.5), completion: {

        //动画结束才重新允许用户点击屏幕

        self.isUserInteractionEnabled = true

})

不过要记住在游戏回到初始化状态下的时候,要把gameOverLabel从场景里移除掉,所以找到shuffle()方法,然后在removeAllPipesNode()方法后面加上下面这一句

gameOverLabel.removeFromParent()


现在我们再来运行一下游戏,就能发现一切正常了,可以愉快的玩耍了!




6.补充提示

虽然游戏能玩了,但是你不觉得少点什么么?

没错游戏一般都有分数,表示玩家这句玩的成绩怎么样,所以这个游戏里我们可以添加一个表示玩家小鸟飞了多远距离的提示。

我们先给GameScene添加一个metersLabel,用它来展示用户走了多远的距离,添加下面代码到你的GameScene

lazy var metersLabel: SKLabelNode = {

        let label = SKLabelNode(text: "meters:0")

        label.verticalAlignmentMode = .top

        label.horizontalAlignmentMode = .center

        return label

}()

可以看到我们同样使用了懒加载的方式来创建这个metersLabel变量



PS:这里稍微多介绍一点SKLabelNode这个类,如果做过iOS应用开发的朋友应该都知道UILabel这个控件,跟UILabel类似SKLabelNode就是SpriteKit中显示一段文字的空间,首先他是继承自SKNode,所以它可以被添加到场景里面,它也可以执行各种Action动作。

另外可能还有一个你不适应的地方就是他的位置布局问题,在做iOS应用时候UILabel有大小,UILabel的原点在它自己左上角,你自然知道怎么放置它了。但是SKLabelNode是没有size这个属性的,他的frame属性也只是readonly的,这怎么办?

SKLabelNode有两个新的属性叫做verticalAlignmentMode和horizontalAlignmentMode,表示这个label在水平和垂直方向上如何布局,他们是枚举类型。比如你把的SKLabelNode的postion位置设置在(50,100)这个点,然后把他的verticalAlignmentMode 设置为.top,则表示这段文字的顶部是position所在位置的y的水平高度上,如果设置为.bottom,则这段文字的底部水平线高度就是position的y的水平高度。所以horizontalAlignmentMode属性也是同理,只是它是设置水平方向上的布局。可能等我迟点补充一个图表示会比较清晰,容易理解



现在我们有了这个label的变量,要将他加到场景上

找到didMove()方法,然后在设置场景的物理体的代码后面加上下面的代码内容

// Set Meter Label

metersLabel.position = CGPoint(x: self.size.width * 0.5, y: self.size.height)

metersLabel.zPosition = 100

addChild(metersLabel)

我们把metersLabel放在了屏幕的顶部中间,然后注意第二句metersLabel.zPosition = 100,我们把label在z轴上的位置设置在了100,你可能会问这不是2D游戏么怎么会有z轴,这里的z轴你也可以理解为图层的层次顺序轴,zPosition越大就越靠近玩家,就是说如果两个场景里的node某一部分重叠了,那么就是zPosition大的那个node会覆盖住小的那个node,zPosition默认值是0,如果两个都是0的node重叠了那就要看谁是先被添加进场景的,先被添加进的会被后添加进的覆盖住。

那么为什么metersLabel要设置一个大一些的zPosition?因为metersLabel是在didMove方法里就添加到场景了,我们又希望它始终不被遮住,但是那些出现的水管是后添加进场景的node,他们移动到metersLabel上面的时候就会覆盖住它,所以我们才要做这样的一个操作。


现在我们有一个用来显示小鸟飞了多远的label了,该要让它显示变化的值了

我们给GameScene添加多一个记录飞行米数的变量,添加下面代码到GameScene

var meters = 0 {

    didSet  {

         metersLabel.text = "meters:\(meters)"

    }

}

meters是一个Int值就可以了,初始设置为0,可以看到我们写了个didSet{...},表示这个变量每次当被设置了一个新的值就会执行一次didSet里面的代码,我们在这里重新设置了一个metersLabel现实的内容。

接下来我们要在游戏运行时候不断增加meters的值,简单点的方法就是在每一帧刷新的update()方法里去改变

我找到update()方法,然后添加下面的内容到方法里

if gameStatus == .running {

      meters += 1

}

现在你运行游戏就会看到一旦点击一下屏幕游戏开始了,飞行的米数就会不断的刷刷刷的飞涨。

但是还有一件事情别忘了,就是找到shuffle()方法,在里面添加一句下面的代码,每次回到游戏初始化状态下时,要把上一局的飞行米数重新清零

meters = 0


现在你再运行游戏就会得到跟文章开篇时候的动图一样的效果了!



7.还有什么可以完善的?

至此对于此游戏的基本实现算是写完了,不过你也可以继续完善这个游戏,或者用不同的方法来实现试试

1.比如说这里我们的场景内容移动(地面和水管)是直接在update()方法里改变position来实现,那么能不能换成用SKAction的方法来做到呢?

2.虽然游戏能玩了,但是那些会影响到游戏的一些关键参数是否已经是最优的选择了?如果你觉得小鸟的自由落体下坠的太快或者每次小鸟上升的速度太小等等,这些可能都要开发者自己去玩玩尝试找到最优的参数配置

3.是否可以增加渐进的难度?比如说随机的产生水管的间隔时间能不能随游戏进行时间越来越短?等等

4.是否小鸟可以能吃到一些道具让它在一定时间内不惧怕水管?

5.是否可以添加玩家成绩的记录?

等等等等。。。。。这些就看你想不想去完善了试一试,这里就不做一一实现了,谢谢

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容