SpriteKit之飞机大战

6,537 阅读4分钟

plan.gif

我正在参加掘金社区游戏创意投稿大赛个人赛,详情请看:游戏创意投稿大赛

前言

记得很多年以前玩过一款很好玩的手游叫雷霆战机,那时候玩的很疯狂。那么今天我们使用SpriteKit来实现一个简易版的雷霆战机

安装

1、iOS端安装有点难受,不像安卓直接给一个apk就好了,我这边的话上了一个TestFlight,可以下载一个TestFlightAPP,使用TestFlight安装

使用微信打开这个地址按提示安装就可以了:

testflight.apple.com/join/YCDnN4…

打开后大概是这样的: IMG_0980.PNG

2、可以运行代码安装,demo地址在这

玩法

1、该游戏为闯关游戏,玩家拖动飞机消灭敌人来获取分数
2、初始的时候玩家有3条生命,每一关会随机掉落一个血包和增加飞机的攻击力。敌人的生命值也是随着闯关级数逐级增加的,每一关都会有一个boss,打败boss会获得大量分数

下面我们就开始一步一步的实现吧。

实现

1、新建项目,在项目里创建一个PlanScene场景

1.1、在didMove(to view: SKView)方法里设置重力加速度为0,0

override func didMove(to view: SKView) {
    super.didMove(to: view)

    //设置重力加速度
    physicsWorld.gravity = .zero
}
2、背景循环滚动

2.1、要让背景循环滚动的话,我们需要创建两个一样的背景精灵,bgNode1position设置成CGPoint(x: 0, y: 0)bgNode2position设置成CGPoint(x: 0, y: size.height)

private lazy var bgNode1: SKSpriteNode = {
    let view = SKSpriteNode(imageNamed: "plan_bg")
    view.position = CGPoint(x: 0, y: 0)
    view.size = size
    view.anchorPoint = CGPoint(x: 0, y: 0)
    view.zPosition = 0
    view.name = "bgNode"
    return view
}()

private lazy var bgNode2: SKSpriteNode = {
    let view = SKSpriteNode(imageNamed: "plan_bg")
    view.position = CGPoint(x: 0, y: size.height)
    view.size = size
    view.anchorPoint = CGPoint(x: 0, y: 0)
    view.zPosition = 0
    view.name = "bgNode"
    return view
}()

2.2、在override func update(_ currentTime: TimeInterval)方法里重新设置bgNode1bgNode2position

override func update(_ currentTime: TimeInterval) {
    super.update(currentTime)
    
    backgroudScrollUpdate()
}

private func backgroudScrollUpdate() {
    bgNode1.position = CGPoint(x: bgNode1.position.x, y: bgNode1.position.y - 4)
    bgNode2.position = CGPoint(x: bgNode2.position.x, y: bgNode2.position.y - 4)
    if bgNode1.position.y <= -size.height {
        bgNode1.position = CGPoint(x: 0, y: 0)
        bgNode2.position = CGPoint(x: 0, y: size.height)
    }
}

这样的话背景就可以无限滚动起来了

plan_bg.gif

3、添加飞机并拖动飞机移动

3.1、如果想要拖动飞机,要给当前场景的view添加pan手势,因为SKSpriteNode是不能添加手势的,所以,我们要把手势添加到当前场景的view上,然后在touchesBegan方法里判断当前触摸的是不是飞机,所以我们先定义两个变量

// 触摸的是否是飞机
private var isTouchPlan: Bool = false
// 当前飞机的point
private var planPoint: CGPoint = .zero

3.2、创建飞机精灵

// 飞机
private lazy var planNode: SKSpriteNode = {
    let view = SKSpriteNode(imageNamed: "plan02")
    view.position = CGPoint(x: size.width / 2, y: size.height / 2)
    view.anchorPoint = CGPoint(x: 0.5, y: 0.5)
    view.size = CGSize(width: 70, height: 54)
    view.zPosition = 2
    view.name = "plan"
    view.physicsBody = SKPhysicsBody(rectangleOf: view.size)
    //物理体是否受力
    view.physicsBody?.isDynamic = false
    //设置物理体的标识符
    view.physicsBody?.categoryBitMask = 1
    //设置可与哪一类的物理体发生碰撞
    view.physicsBody?.contactTestBitMask = 2
    return view
}()

3.3、在touchesBegan方法里面判断触摸的是不是飞机

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    isTouchPlan = false
    guard let touch = (touches as NSSet).anyObject() as? UITouch else { return }
    let point = touch.location(in: self)
    let node = atPoint(point)
    switch node.name {
    case "plan":
        // 如果点击的是飞机,则将isTouchPlan置为true
        isTouchPlan = true
    default:
        break
    }
}

3.4、给当前场景添加一个pan手势来实现飞机拖动

private func addPanGestureRecognizer(_ view: SKView) {
    let pan = UIPanGestureRecognizer(target: self, action: #selector(panAction(_:)))
    view.addGestureRecognizer(pan)
}

@objc private func panAction(_ sender: UIPanGestureRecognizer) {
    if isTouchPlan {
        var position = sender.location(in: sender.view)
        // 因为SpriteKit的坐标原点在左下角,所以需要转换一下
        position = CGPoint(x: position.x, y: size.height - position.y)
        planNode.position = position
        planPoint = position
    }
}

plan_move.gif

4、飞机发射子弹

4.1、发射子弹的话我们创建一个定时器,在定时器方法里面生成子弹,由于我们是关卡制,所以间隔时间根据关卡来定,关卡越高时间越短

private func startBulletTimer() {
    var ti = 0.2 - TimeInterval(leve) * 0.02
    if ti <= 0.05 {
        ti = 0.05
    }
    bulletTimer = Timer.scheduledTimer(timeInterval: ti, target: self, selector: #selector(createBullet), userInfo: nil, repeats: true)
}

// 创建子弹
@objc private func createBullet() {
    let bulletNode = SKSpriteNode(imageNamed: "plan_bullet")
    bulletNode.position = planPoint
    bulletNode.anchorPoint = CGPoint(x: 0.5, y: 1)
    bulletNode.size = CGSize(width: 10, height: 10)
    bulletNode.zPosition = 1
    bulletNode.name = "bullet"
    addChild(bulletNode)
    var ti = 3 - TimeInterval(leve) * 0.5
    if ti <= 0.5 {
        ti = 0.5
    }
    // 让子弹向上移动
    bulletNode.run(SKAction.moveTo(y: size.height, duration: ti)) {
        bulletNode.removeAllActions()
        bulletNode.removeFromParent()
    }
    bulletNode.physicsBody = SKPhysicsBody(rectangleOf: bulletNode.size)
    //物理体是否受力
    bulletNode.physicsBody?.isDynamic = true
    bulletNode.physicsBody?.allowsRotation = false
    bulletNode.physicsBody?.collisionBitMask = 0
    //设置物理体的标识符
    bulletNode.physicsBody?.categoryBitMask = 1
    //设置可与哪一类的物理体发生碰撞
    bulletNode.physicsBody?.contactTestBitMask = 2
}

plan_bullet.gif

5、分别添加返回按钮以及显示关卡、得分、生命的精灵,代码比较简单,就不贴出来了
6、创建敌机、血包和增加攻击力包

6.1、创建敌机我们还是使用定时器

// 敌机定时器
private func startEnemyTimer() {
    var ti = 0.5 - TimeInterval(leve) * 0.05
    if ti <= 0.1 {
        ti = 0.1
    }
    enemyTimer = Timer.scheduledTimer(timeInterval: ti, target: self, selector: #selector(createEnemy), userInfo: nil, repeats: true)
}

// 创建敌机
@objc private func createEnemy() {
    let enemyNode = SKSpriteNode(imageNamed: "plan01")
    // 随机敌机出现的位置
    let pointX = CGFloat(arc4random_uniform(UInt32(size.width - 40)))
    enemyNode.position = CGPoint(x: pointX + 20, y: size.height)
    enemyNode.anchorPoint = CGPoint(x: 0.5, y: 0.5)
    enemyNode.size = CGSize(width: 40, height: 40)
    enemyNode.zPosition = 1
    enemyNode.name = "enemy"
    addChild(enemyNode)
    var ti = 6 - TimeInterval(leve) * 0.3
    if ti <= 1 {
        ti = 1
    }
    // 敌机向下移动,移动速度根据关卡来
    enemyNode.run(SKAction.moveTo(y: 0, duration: ti)) {
        enemyNode.removeAllActions()
        enemyNode.removeFromParent()
    }
    enemyNode.physicsBody = SKPhysicsBody(rectangleOf: enemyNode.size)
    //物理体是否受力
    enemyNode.physicsBody?.isDynamic = true
    enemyNode.physicsBody?.allowsRotation = false
    //设置物理体的标识符
    enemyNode.physicsBody?.categoryBitMask = 2
    //设置可与哪一类的物理体发生碰撞
    enemyNode.physicsBody?.contactTestBitMask = 1
    enemyNode.physicsBody?.collisionBitMask = 0
    enemyNode.physicsBody?.mass = CGFloat(enemyLife)
}

6.2、创建血包和增加攻击力包,这两个每个关卡分别只会出现一次,每个关卡时长里随机出现

private func createPotionOrAttack() {
    // 创建药水
    DispatchQueue.main.asyncAfter(deadline: .now() + TimeInterval(arc4random_uniform(5))) {
        self.createNode("plan_potion")
    }

    // 增加攻击力
    DispatchQueue.main.asyncAfter(deadline: .now() + TimeInterval(arc4random_uniform(5))) {
        self.createNode("plan_attack")
    }
}

private func createNode(_ name: String) {
    let node = SKSpriteNode(imageNamed: name)
    let pointX = CGFloat(arc4random_uniform(UInt32(self.size.width - 40)))
    node.position = CGPoint(x: pointX + 20, y: self.size.height)
    node.anchorPoint = CGPoint(x: 0.5, y: 0.5)
    node.size = CGSize(width: 40, height: 40)
    node.zPosition = 1
    node.name = name
    self.addChild(node)
    var ti = 6 - TimeInterval(leve) * 0.3
    if ti <= 1 {
        ti = 1
    }
    node.run(SKAction.moveTo(y: 0, duration: ti)) {
        node.removeAllActions()
        node.removeFromParent()
    }
    node.physicsBody = SKPhysicsBody(rectangleOf: node.size)
    //物理体是否受力
    node.physicsBody?.isDynamic = true
    node.physicsBody?.allowsRotation = false
    //设置物理体的标识符
    node.physicsBody?.categoryBitMask = 2
    //设置可与哪一类的物理体发生碰撞
    node.physicsBody?.contactTestBitMask = 1
    node.physicsBody?.collisionBitMask = 0
}

plan_enemy.gif

7、创建Boss与Boss发射子弹

7.1、创建boss我们还是使用定时器,没错还是定时器。每个关卡多长时间,这个boss的定时器就设置多长时间,为了方便测试,暂时设定5s

// boss出现定时器
private func startBossTimer() {
    bossTimer = Timer.scheduledTimer(timeInterval: 5, target: self, selector: #selector(createBoss), userInfo: nil, repeats: true)
}

// 创建boss
@objc private func createBoss() {
    bossNode = SKSpriteNode(imageNamed: "boss01")
    bossNode?.position = CGPoint(x: size.width / 2, y: size.height - 50)
    bossNode?.anchorPoint = CGPoint(x: 0.5, y: 1)
    bossNode?.size = CGSize(width: 98, height: 127)
    bossNode?.zPosition = 1
    bossNode?.name = "boss"
    addChild(bossNode!)
    // 让boss左右移动
    let wait = SKAction.wait(forDuration: 2)
    let action1 = SKAction.moveTo(x: 64, duration: 1.5)
    let action2 = SKAction.moveTo(x: size.width - 64, duration: 3)
    bossNode?.run(wait) {
       self.bossNode?.run(SKAction.repeatForever(SKAction.sequence([action1, action2])))
    }
    bossNode?.physicsBody = SKPhysicsBody(rectangleOf: bossNode?.size ?? .zero)
    //物理体是否受力
    bossNode?.physicsBody?.isDynamic = true
    bossNode?.physicsBody?.allowsRotation = false
    //设置物理体的标识符
    bossNode?.physicsBody?.categoryBitMask = 2
    //设置可与哪一类的物理体发生碰撞
    bossNode?.physicsBody?.contactTestBitMask = 1
    bossNode?.physicsBody?.collisionBitMask = 0
    bossNode?.physicsBody?.mass = CGFloat(leve) * 1000
    // 生命值
    bossLife = leve * 1000
    bossLifeNode = SKLabelNode(text: "\(bossLife)/\(bossLife)")
    bossLifeNode?.fontColor = .green
    bossLifeNode?.fontSize = 20
    bossLifeNode?.position = bossNode?.position ?? .zero
    bossLifeNode?.horizontalAlignmentMode = .center
    bossLifeNode?.zPosition = 1
    addChild(bossLifeNode!)
    bossLifeNode?.run(SKAction.wait(forDuration: 2)) {
        self.bossLifeNode?.run(SKAction.repeatForever(SKAction.sequence([
            SKAction.moveTo(x: 64, duration: 1.5),
            SKAction.moveTo(x: self.size.width - 64, duration: 3)
        ])))
    }
    // 停止敌机创建定时器
    stopEnemyTimer()
    // 停止Boss定时器
    stopBossTimer()
    // boss发射子弹
    startBossBulletTimer()
}

7.2、Boss发射子弹,这里我们开启一个boss发射子弹的定时器, 并在createBoss()方法里调用

// boss发射子弹定时器
private func startBossBulletTimer() {
    var ti = 1 - TimeInterval(leve) * 0.05
    if ti <= 0.1 {
        ti = 0.1
    }
    bossBulletTimer = Timer.scheduledTimer(timeInterval: ti, target: self, selector: #selector(createBossBullet), userInfo: nil, repeats: true)
}

// 创建boss Bullet
@objc private func createBossBullet() {
    let bossBulletNode = SKSpriteNode(imageNamed: "boss_bullet")
    bossBulletNode.position = bossNode?.position ?? CGPoint(x: size.width / 2, y: size.height)
    bossBulletNode.anchorPoint = CGPoint(x: 0.5, y: 1)
    bossBulletNode.size = CGSize(width: 12, height: 25)
    bossBulletNode.zPosition = 1
    bossBulletNode.name = "bossBullet"
    addChild(bossBulletNode)
    var ti = 6 - TimeInterval(leve) * 0.3
    if ti <= 1 {
        ti = 1
    }
    bossBulletNode.run(SKAction.moveTo(y: 0, duration: ti)) {
        bossBulletNode.removeAllActions()
        bossBulletNode.removeFromParent()
    }
    bossBulletNode.physicsBody = SKPhysicsBody(rectangleOf: bossBulletNode.size)
    //物理体是否受力
    bossBulletNode.physicsBody?.isDynamic = true
    bossBulletNode.physicsBody?.allowsRotation = false
    //设置物理体的标识符
    bossBulletNode.physicsBody?.categoryBitMask = 2
    //设置可与哪一类的物理体发生碰撞
    bossBulletNode.physicsBody?.contactTestBitMask = 1
    bossBulletNode.physicsBody?.collisionBitMask = 0
}

plan_boss.gif

该创建的创建好了,该动的动起来了,下面我们来实现各个节点的碰撞,来消灭敌机与boss

8、消灭敌机与boss

8.1、消灭敌机与boss我们就使用物理引擎的碰撞来实现,主要是使用SKPhysicsContactDelegate中的didBegin(_ contact: SKPhysicsContact)这个代理方法实现的。

8.2、首先,我们要对场景的physicsWorld属性设置代理对象为场景自身

physicsWorld.contactDelegate = self

8.3、遵守SKPhysicsContactDelegate并实现didBegin(_ contact: SKPhysicsContact)方法

extension PlanScene: SKPhysicsContactDelegate {
    func didBegin(_ contact: SKPhysicsContact) {
    
    }
}

didBegin(_ contact: SKPhysicsContact)方法中返回了一个SKPhysicsContact对象

open class SKPhysicsContact : NSObject {
    // 碰撞体A
    open var bodyA: SKPhysicsBody { get }
    // 碰撞体B
    open var bodyB: SKPhysicsBody { get }
    // 在场景坐标中,两个物理体之间的接触点。
    open var contactPoint: CGPoint { get }
    // 指定碰撞方向的法向量。
    open var contactNormal: CGVector { get }
    // 这两个物体在牛顿秒内相互撞击的强度。
    open var collisionImpulse: CGFloat { get }
}

我们主要使用SKPhysicsContact中的bodyAbodyB来获取两个碰撞的节点。那么我们怎么知道bodyAbodyB是敌机还是我们飞机呢?查看文档我们会发现有这么一段描述,我们可以通过给场景中的每个物理体,设置categoryBitMask(物理体的标识符)contactTestBitMask(标记可与哪一类的物理体发生碰撞)属性

截屏2022-04-01 09.30.17.png

8.4、在整个游戏中,我们创建了有飞机机发射的子弹敌机bossboss发射的子弹攻击力包血包这几个物理体

其中飞机飞机发射的子弹我们设置categoryBitMask1contactTestBitMask2
那么对应的敌机bossboss发射的子弹攻击力包血包我们设置categoryBitMask2contactTestBitMask1

8.5、判断出bodyAbodyB分别代表什么

extension PlanScene: SKPhysicsContactDelegate {
    func didBegin(_ contact: SKPhysicsContact) {
        // 飞机、机发射的子弹
        var planeNode: SKSpriteNode?
        // 敌机、boss、boss发射的子弹、攻击力包和血包
        var enemyNode: SKSpriteNode?
        if contact.bodyA.categoryBitMask == 1 && contact.bodyB.categoryBitMask == 2 {
            planeNode = contact.bodyA.node as? SKSpriteNode
            enemyNode = contact.bodyB.node as? SKSpriteNode
        } else {
            planeNode = contact.bodyB.node as? SKSpriteNode
            enemyNode = contact.bodyA.node as? SKSpriteNode
        }
    }
}

区分好bodyAbodyB分别代表什么之后,我们就开始做相应处理

8.5、处理节点碰撞

extension PlanScene: SKPhysicsContactDelegate {

    func didBegin(_ contact: SKPhysicsContact) {
        var planeNode: SKSpriteNode?
        var enemyNode: SKSpriteNode?
        if contact.bodyA.categoryBitMask == 1 && contact.bodyB.categoryBitMask == 2 {
            planeNode = contact.bodyA.node as? SKSpriteNode
            enemyNode = contact.bodyB.node as? SKSpriteNode
        } else {
            planeNode = contact.bodyB.node as? SKSpriteNode
            enemyNode = contact.bodyA.node as? SKSpriteNode
        }
        guard let planeNode = planeNode, let enemyNode = enemyNode else { return }
        // 增加生命
        if enemyNode.name == "plan_potion", planeNode.name == "plan" {
            ownLife += 1
            enemyNode.removeAllActions()
            enemyNode.removeFromParent()
            return
        }
        // 增加攻击力
        if enemyNode.name == "plan_attack", planeNode.name == "plan" {
            aggressivity += 10
            enemyNode.removeAllActions()
            enemyNode.removeFromParent()
            return
        }
        // 处理飞机
        switch planeNode.name {
        case "bullet" where (enemyNode.name != "bossBullet" &&
                             enemyNode.name != "plan_potion" &&
                             enemyNode.name != "plan_attack"):
            planeNode.removeAllActions()
            planeNode.removeFromParent()
            if enemyNode.name == "boss" {
                bossLife -= aggressivity
                bossLifeNode?.text = "\(bossLife)/\(leve*1000)"
            }
        case "plan":
            ownLife -= 1
            if ownLife <= 0 {
                gameOver()
                planeNode.removeAllActions()
                planeNode.removeFromParent()
            }
        default:
            break
        }
        
        // 如果是boss发射的子弹,则不做处理
        if enemyNode.name == "bossBullet" {
            return
        }
        if enemyNode.name == "plan_potion" {
            return
        }
        if enemyNode.name == "plan_attack" {
            return
        }

        enemyNode.physicsBody?.mass -= CGFloat(aggressivity)
        if enemyNode.physicsBody?.mass ?? 0 <= CGFloat(aggressivity) {
            enemyNode.removeAllActions()
            enemyNode.removeFromParent()
            switch enemyNode.name {
            case "enemy":
                score += enemyLife
            case "boss":
                score += leve * 1000
                leve += 1
                enemyLife += leve * 10
                stopBossBulletTimer()
                bossNode?.removeAllActions()
                bossNode?.removeFromParent()
                bossLifeNode?.removeAllActions()
                bossLifeNode?.removeFromParent()
                startBulletTimer()
                startEnemyTimer()
                startBossTimer()
            default:
                break
            }
        }
    }
}

plan_11.gif

基本功能已经完成了,下面我们就来添加碰撞效果

8.6、添加碰撞效果
碰撞效果我们使用SKEmitterNode来做,然后在碰撞的节点处显示出来。我们选择SpriteKit Particle File来新建一个目标销毁时的效果Blast.sks和击打boss产生的效果Strike.sks文件 截屏2022-04-01 10.11.35.png 然后分别选中两个文件,设置相应参数,我这边是随便设置的就不多说了。
根据这两个文件名称来初始化,并在相应的位置加载

private func blast(_ position: CGPoint, fileName: String) {
    if let blast = SKEmitterNode(fileNamed: fileName) {
        blast.zPosition = 2
        blast.position = position
        addChild(blast)
        // 0.3秒后消失
        blast.run(SKAction.sequence([
            SKAction.wait(forDuration: 0.3),
            SKAction.run {
                blast.removeAllActions()
                blast.removeFromParent()
            }
        ]))
    }
}

最后效果就入顶部展示的那样

最后

整个游戏到此就结束了,快来秀出你的战绩吧