8 实战:小游戏Flappy Bird的设计与开发

综合本章所介绍内容,本节将与读者一起实现一款曾经风靡一时的小游戏Flappy Bird。这款游戏也被称为像素小鸟。玩家通过单击屏幕来控制小鸟的飞行高度穿越高低不同的管道来获取分数。这款游戏因其操作简单、难度适当,使很多玩家既不需太多时间成本来学习,就可以在三两分钟的空闲时间中挑战几局,这是其成功的重要因素。

8.1 小鸟对象的设计

Swift是一门面向对象的语言,在iOS程序开发中也要求开发者使用面向对象的编程思想,例如一个文本标签UILabel控件是一个对象,一个图片视图UIImageView控件是一个对象,通过对象间的组合与嵌套、继承与扩展创建出更加复杂的对象,由对象完成整个应用程序的界面与业务逻辑。在游戏开发中,面向对象的思想更为重要,在Flappy Bird游戏中,首先应该设计游戏的主角:像素鸟对象。

使用Xcode开发工具创建一个名为Flappy Bird的工程,将游戏中所需要使用的素材添加进工程中,读者可在本书提供的素材下载地址中找到所需素材。创建一个类取名为Bird,使其继承于UIImageView。

在编写代码之前,开发者应该思考要设计的对象具备哪些方法与属性,以像素鸟对象为例,它应该具有这样的功能或者属性:扇动翅膀动作,因重力而下落,向上飞行等。在Bird类中声明如下方法。

  //开始与结束飞行动作
    func startFlaying() {
    }
    func stopFlaying()  {
    }
    //开始与停止重力降落
    func startLand()  {
    }
    func stopLand()  {
    }
    //向上飞
    func upFlay() {
    }

在Bird类中定义一个作为游戏世界的重力加速度的常数。

let G=4

在Bird类中声明如下属性。

    //定时器
    private var timer:Timer?
    //是否应该降落
    private var couldLand = false
    //下落速度
    private var speed:Float = 0

实现Bird类对象的初始化方法如下所示。

  override init(frame: CGRect) {
        super.init(frame: CGRect(x: 0, y: 0, width: 40, height: 31))
        self.image = UIImage(named:"bird1")
        //创建飞行动画
        var array = Array<UIImage>()
        for i in 0..<3{
            let image = UIImage(named: "bird\(i+1)")
            array.append(image!)
        }
        self.animationImages = array
        self.animationDuration = 1
        self.animationRepeatCount = 0
        //初始化定时器
        timer = Timer.scheduledTimer(timeInterval: 1/60.0, target: self, selector: #selector(updateBird), userInfo: nil, repeats: true)
    }

定时器的更新方法updateBird方法实现如下所示。

   func updateBird() {
        if couldLand {
            //小鸟掉落
            self.center = CGPoint(x: self.center.x, y: self.center.y+CGFloat(speed))
            speed += Float(G)/60
            //超出边界做处理
            if self.center.y<=0 {
                self.center = CGPoint(x: self.center.x, y: 0)
            }else if(self.center.y+self.frame.height/2 >= self.superView!.frame.height){
                self.center = CGPoint(x: self.center.x, y: self.superview!.frame.height-self.frame.height/2)
            }
        }else{
            speed = 0
        }
    }

在上面的代码中做了一些边界处理,保证小鸟的坐标始终控制在屏幕内,实现Bird类中声明的相应方法如下所示。

    //开始与结束飞行动作
    func startFlaying() {
        if self.isAnimating {
            return
        }
        self.startAnimating()
    }
    func stopFlaying()  {
        if self.isAnimating {
            self.stopAnimating()
        }
    }
    //开始与停止重力降落
    func startLand()  {
        couldLand = true
    }
    func stopLand()  {
        couldLand = false
    }
    //向上飞
    func upFlay() {
        //给小鸟一个向上的初速度
        speed = -2.5
    }

通过上面的代码,基本完成了游戏主角小鸟类的设计,读者可在ViewController类中创建对象进行功能的测试。

8.2 游戏开始界面的设计

任何游戏不可能一打开就立马开始游戏,都会有一个等待开始的界面,等玩家单击某个开始按钮来触发游戏的开始。Flayyp Bird游戏也需要这样一个界面。使用Xcode开发工具新创建一个类,取名为GameStartView,使其继承于UIView。在GameStartView中声明如下属性和方法并定义一个协议。

protocol GamestartViewDelegate {
    func gameStartViewTouchStart()
}
class GameStartView: UIView {
    var delegate:GamestartViewDelegate?
    func show() {
    }
    func unShow() {
    }
}

因为开始游戏的界面总会内嵌一个开始按钮,GameStartViewDelegate协议用于按钮触发方法的回调。在GameStartView类中实现初始化方法如下所示。

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.backgroundColor = UIColor.clear
        let bg = UIImageView(frame: CGRect(x: self.frame.width/2-128, y: 100, width: 256, height: 73))
        bg.image = UIImage(named: "main")
        self.addSubview(bg)
        //开始按钮
        let btn = UIButton(type: .custom)
        btn.frame = CGRect(x: self.frame.width/2-67, y: 350, width: 135, height: 75)
        btn.setBackgroundImage(UIImage(named: "start"), for: .normal)
        btn.addTarget(self, action: #selector(touch), for: .touchUpInside)
        self.addSubview(btn)
    }

按钮的触发方法touch实现如下所示。

  func touch() {
        self.unShow()
        self.delegate?.gameStartViewTouchStart()
    }

实现用于展示和隐藏界面的show和unshow方法如下所示。

   func show() {
        UIView.animate(withDuration: 0.3) { 
            self.alpha = 1
        }
    }
    func unShow() {
        UIView.animate(withDuration: 0.3) { 
            self.alpha = 0
        }
    }

8.3 游戏结束界面的设计

Flappy Bird游戏中有这样一条规则:当小鸟落地或者碰撞到管道时,游戏即会结束。因此开发者还需要创建一个用于游戏结束后展现玩家所得分数的界面。在工程中创建一个新的类GameOverView,使其继承于UIView。在GameOverView类中声明如下属性和方法并定义一个协议。

protocol GameOverViewDelegate {
    func gameOverViewUnshow()
}
class GameOverView: UIView {
    var delegate:GameOverViewDelegate?
    func show()  {
    }
    func unShow() {
    }
    func setSource(source:Int)  {
    }
}

GameOverViewDelegate用于提供游戏结束界面将消失时的回调,GameOverView类的setSource方法用于设置玩家在游戏中获取的分数。 在GameOverView.类中声明一个UILabel标签控件作为计分板,如下所示。

private var label:UILabel?

实现GameOverView类的初始化方法,如下所示。

  override init(frame: CGRect) {
        super.init(frame: frame)
        self.backgroundColor = UIColor.clear
        let bg = UIImageView(frame: CGRect(x: self.frame.width/2-124, y: 100, width: 248, height: 56))
        bg.image = UIImage(named: "gameover2")
        self.addSubview(bg)
        label = UILabel(frame: CGRect(x: self.frame.width/2-25, y: 200, width: 50, height: 50))
        label?.backgroundColor = UIColor.clear
        label?.textAlignment = .center
        label?.font = UIFont.systemFont(ofSize: 23)
        label?.textColor = UIColor.red
        self.addSubview(label!)
        self.alpha = 0
    }

开发者还需要在游戏结束的界面实现这样的需求:当用户单击屏幕后,结束游戏界面逐渐消失,游戏开始界面重新展现。因此,在GameOverView类中实现如下方法。

  override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        self.unShow()
        self.delegate?. gameOverViewUnshow()
    }

在GameOverView类中实现声明的方法,如下所示。

   func show()  {
        UIView.animate(withDuration: 0.3) { 
            self.alpha = 1
        }
    }
    func unShow() {
        UIView.animate(withDuration: 0.3) { 
            self.alpha = 0
        }
    }
    func setSource(source:Int)  {
        label!.text = "\(source)"
    }

8.4 Flappy Bird主游戏框架的搭建

通过前面小节的准备工作,已经将游戏中需要使用的独立控件进行了设计,开发者还需搭建游戏的主功能界面并将前面的独立控件进行组合与运用。 在ViewController类中声明如下的属性并遵守相应的协议。

class ViewController: UIViewController,GamestartViewDelegate,GameOverViewDelegate{
    var bird:Bird?
    //背景
    var bg1:UIImageView?
    var bg2:UIImageView?
    //定时器
    var timer:Timer?
    //第一对管道
    var woodUp1:UIImageView?
    var woodDown1:UIImageView?
    //第二对管道
    var woodUp2:UIImageView?
    var woodDown2:UIImageView?
    //地面
    var floor1:UIImageView?
    var floor2:UIImageView?
    //游戏开始与结束视图
    var startView:GameStartView?
    var overView:GameOverView?
    //分数统计
    var sourceLabel:UILabel?
    var source:Int?
    override func viewDidLoad() {
        super.viewDidLoad()
    }
}

在ViewController类的viewDidLoad方法中编写如下初始化代码。

 override func viewDidLoad() {
        super.viewDidLoad()
        //初始化分数
        source = 0
        //创建背景
        self.creatBG()
        //创建管道
        self.creatWood()
        //创建地板
        self.creatFloor()
        //初始化像素鸟
        bird = Bird(frame: CGRect.zero)
        //设置位置
        bird?.center = CGPoint(x: 100, y: 300)
        self.view.addSubview(bird!)
        //开始飞行动画
        bird?.startFlaying()
        //初始化游戏开始与结束视图
        startView = GameStartView(frame: self.view.frame)
        overView = GameOverView(frame: self.view.frame)
        self.view.addSubview(startView!)
        self.view.addSubview(overView!)
        startView?.delegate = self
        overView?.delegate = self
        //初始化定时器
        timer = Timer.scheduledTimer(timeInterval: 1/60.0, target: self, selector: #selector(updateUI), userInfo: nil, repeats: true)
        //将定时器暂停
        timer?.fireDate = Date.distantFuture
        //创建计分板
        self.creatSourceLabel()
    }

创建背景的creatBG方法实现,如下所示。

    func creatBG()  {
        bg1 = UIImageView(frame: self.view.bounds)
        bg1?.image = UIImage(named: "bg")
        bg2 = UIImageView(frame: CGRect(x: self.view.frame.width, y: 0, width: self.view.frame.width, height: self.view.frame.height))
        bg2?.image = UIImage(named: "bg")
        self.view.addSubview(bg1!)
        self.view.addSubview(bg2!)
    }

创建管道的方法creatWood实现,如下所示。

  func creatWood()  {
        //一屏最多同时出现2组管道
        woodUp1 = UIImageView(image: UIImage(named: "04"))
        woodDown1 = UIImageView(image: UIImage(named: "05"))
        woodUp2 = UIImageView(image: UIImage(named: "04"))
        woodDown2 = UIImageView(image: UIImage(named: "05"))
        //取一个随机数作为初始高度
        let sH = self.view.frame.size.height;
        //下面柱子的高度最少为150
        //柱子间缝隙为100
        //上面柱子的高度最少为150
        let parm = sH-150-100-150
        var height = Int(arc4random())%Int(parm)+150
        woodUp1?.frame = CGRect(x: 600, y: height-325, width: 54, height: 325)
        woodDown1?.frame = CGRect(x: 600, y: height+100, width: 54, height: 325)
        height = Int(arc4random())%Int(parm)
        woodUp2?.frame = CGRect(x: 780, y: height-325, width: 54, height: 325)
        woodDown2?.frame = CGRect(x: 780, y: height+100, width: 54, height: 325)
        self.view.addSubview(woodUp1!)
        self.view.addSubview(woodUp2!)
        self.view.addSubview(woodDown1!)
        self.view.addSubview(woodDown2!)
    }

创建地板的creatFloor方法实现,如下所示。

  func creatFloor()  {
        floor1 = UIImageView(frame: CGRect(x: 0, y: self.view.frame.height-112, width: 336, height: 112))
        floor2 = UIImageView(frame: CGRect(x: self.view.frame.width, y: self.view.frame.height-112, width: 336, height: 112))
        floor1?.image = UIImage(named: "03")
        floor2?.image = UIImage(named: "03")
        self.view.addSubview(floor1!)
        self.view.addSubview(floor2!)
    }

定时器的触发方法updateUI主要有下面几个作用:

  • 控制背景的移动及进行背景复用
  • 控制管道的移动及进行管道复用
  • 控制地板的移动及进行地板复用
  • 进行分数的更新
  • 进行游戏是否结束的判定

根据如上需求,方法实现如下所示。

 func updateUI()  {
        let x = bg1!.frame.origin.x
        if x <= -self.view.frame.width  {
            bg1!.frame = self.view.bounds
            bg2!.frame = CGRect(x: self.view.frame.width, y:0 , width: self.view.frame.width, height: self.view.frame.height)
            floor1!.frame = CGRect(x: 0, y: self.view.frame.height-112, width: 336, height: 112)
            floor2!.frame = CGRect(x: self.view.frame.width, y: self.view.frame.height-112, width: 336, height: 112)
        }
        bg1?.frame = CGRect(x: bg1!.frame.origin.x-1, y: 0, width: self.view.frame.width, height: self.view.frame.height)
        bg2?.frame = CGRect(x: bg2!.frame.origin.x-1, y: 0, width: self.view.frame.width, height: self.view.frame.height)
        floor1?.frame = CGRect(x: floor1!.frame.origin.x-1, y: self.view.frame.height-112, width: 336, height: 112)
        floor2?.frame = CGRect(x: floor2!.frame.origin.x-1, y: self.view.frame.height-112, width: 336, height: 112)
        woodUp1?.frame = CGRect(x: woodUp1!.frame.origin.x-1, y: woodUp1!.frame.origin.y, width: woodUp1!.frame.width, height: woodUp2!.frame.height)
        woodUp2?.frame = CGRect(x: woodUp2!.frame.origin.x-1, y: woodUp2!.frame.origin.y, width: woodUp2!.frame.width, height: woodUp2!.frame.height)
        woodDown1?.frame = CGRect(x: woodDown1!.frame.origin.x-1, y: woodDown1!.frame.origin.y, width: woodDown1!.frame.width, height: woodDown1!.frame.height)
        woodDown2?.frame = CGRect(x: woodDown2!.frame.origin.x-1, y: woodDown2!.frame.origin.y, width: woodDown2!.frame.width, height: woodDown2!.frame.height)
        if woodUp1!.frame.origin.x+woodUp1!.frame.width <= 0 {
            //将其往后放
            let sH = self.view.frame.height
            let parm = sH-150-100-150
            let height = Int(arc4random())%Int(parm)+150
            woodUp1?.frame = CGRect(x: woodUp2!.frame.origin.x+280, y: CGFloat(height)-woodUp2!.frame.height, width: woodUp1!.frame.width, height: woodUp1!.frame.height)
            woodDown1?.frame = CGRect(x: woodUp2!.frame.origin.x+280, y: CGFloat(height)+100, width: woodUp1!.frame.width, height: woodUp1!.frame.height)
        }
        if woodUp2!.frame.origin.x+woodUp2!.frame.width <= 0 {
            //将其往后放
            let sH = self.view.frame.height
            let parm = sH-150-100-150
            let height = Int(arc4random())%Int(parm)+150
            woodUp2?.frame = CGRect(x: woodUp1!.frame.origin.x+280, y: CGFloat(height)-woodUp1!.frame.height, width: woodUp2!.frame.width, height: woodUp2!.frame.height)
            woodDown2?.frame = CGRect(x: woodUp1!.frame.origin.x+280, y: CGFloat(height)+100, width: woodUp2!.frame.width, height: woodUp2!.frame.height)
        }
        //进行分数更新
        if bird!.frame.origin.x == woodUp1!.frame.origin.x+woodUp1!.frame.width {
            source! += 1
            sourceLabel!.text = "\(source!)"
        }
        //进行死亡判定 
        self.ifDead()
    }

死亡判定的方法ifDead的实现方法如下所示。

 func ifDead() {
        //落地
        if bird!.frame.origin.y+bird!.frame.height > floor1!.frame.origin.y {
            //死亡
            bird?.stopFlaying()
            bird?.stopLand()
            timer?.fireDate = Date.distantFuture
            overView?.setSource(source: source!)
            overView?.show()
            sourceLabel?.isHidden = true
            source = 0
        }
        //碰上管道
        if bird!.frame.intersects(woodUp1!.frame) || bird!.frame.intersects(woodUp2!.frame) || bird!.frame.intersects(woodDown1!.frame) || bird!.frame.intersects(woodDown2!.frame){
            //死亡
            bird?.stopFlaying()
            bird?.stopLand()
            timer?.fireDate = Date.distantFuture
            overView?.setSource(source: source!)
            overView?.show()
            sourceLabel?.isHidden = true
            source = 0
        }
    }

创建计分板creatSourceLabel的方法实现如下所示。

 func creatSourceLabel(){
        sourceLabel = UILabel(frame: CGRect(x: self.view.frame.width/2-25, y: 100, width: 50, height: 50))
        sourceLabel?.backgroundColor = UIColor.clear
        sourceLabel?.textAlignment = .center
        sourceLabel?.font = UIFont.systemFont(ofSize: 23)
        sourceLabel?.textColor = UIColor.red
        sourceLabel?.isHidden = true
        self.view.addSubview(sourceLabel!)
    }

实现开始游戏与游戏结束界面的代理方法如下所示。

  func gameOverViewUnshow() {
        startView?.show()
        //进行初始化设置
        bird?.center = CGPoint(x: 100, y: 300)
        bird?.startFlaying()
        let sH = self.view.frame.height
        //下面柱子的高度最少为150
        //柱子间缝隙为100
        //上面柱子的高度最少为150
        let parm = sH-150-100-150
        var height = Int(arc4random())%Int(parm)+150
        woodUp1?.frame = CGRect(x: 600, y: height-325, width: 54, height: 325)
        woodDown1?.frame = CGRect(x: 600, y: height+100, width: 54, height: 325)
        height = Int(arc4random())%Int(parm)
        woodUp2?.frame = CGRect(x: 780, y: height-325, width: 54, height: 325)
        woodDown2?.frame = CGRect(x: 780, y: height+100, width: 54, height: 325)
    }
    func gameStartViewTouchStart() {
        startView?.unShow()
        timer?.fireDate = Date.distantPast
        sourceLabel?.isHidden = false
        bird?.startLand()
    }

最后还需要实现一个方法,就是当玩家单击屏幕的时候,像素小鸟会向上飞行一定高度。

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        bird?.upFlay()
    }

通过上面代码的编写,一款简易的Flappy Bird游戏就开发完成了,运行工程,其主要界面效果如图6-22~图6-24所示。