WWDC2014大会中的Playground大炮气球示例

在WWDC14上我们通过Playground展示了Swift这门语言,很多人会问关于在Playground中展示出的那个气球的示例是如何实现的。从气球这个示例我们可以看到我们写的代码具有很强的交互性,而且非常有趣,并且也从该示例中展示了Playground的许多特性。现在,你可以通过这篇文章,学习如何实现气球示例中的各种有趣的效果,在我们提供的Balloons.playground文件中有详细的文档说明和代码示例。

由于Balloons.playground文件中运用了SpriteKit的新特性,所以要求在最新的Xcode 6 Beta版本环境和OS X Yosemite系统下运行。

Balloons.playground

// Requires Xcode 6 (beta 4) running on Yosemite (developer preview 4) or later.
import SpriteKit
import XCPlayground

Playground文件中包含了探索SpriteKit新特性的示例代码。该示例来自WWDC2014大会上演示Swift语言时使用的示例,该示例描述的是两个大炮,间隔任意一段时间就会向天上的气球开火,当两个气球相撞时,会“砰”的一下消失。在该示例中,你会了解到创建如此炫酷的场景、自定义动作和效果是多么简单。

当你更改示例中的代码时,你会在Playground的右侧Timeline区域即时的看到更改后的效果,所以你可以尝试按照你自己的想法创造出更多有趣的效果。

注意:如果你看不到气球大炮的场景,那么你可以通过View > Assistant Editor > Show Assistant Editor打开Timeline区域,或者也可以使用Option-Command-Return快捷键打开。

来让我们开始吧!

在Timeline区域呈现我们想要的效果

SpriteKit的内容用SKView对象来表示,它负责将要显示的内容提供给模拟器。所有的效果都通过SKScene对象来呈现,它相当于SKNode对象树的根节点。在这个场景中,你将添加一些节点并且创建游戏的内容。

我们在Xcode中通过iOS > Application > SpriteKit Game创建一个SpriteKit Game模板,模板中会提供MyScene文件,这就是我们游戏场景,该文件就是场景文件。所有的资源,包括场景文件、图片资源都全部绑定在Playground包中供我们使用,你也可以在Playground包种添加你自己的图片资源。你可以在Balloons.playground文件上点击鼠标右键,然后选择显示包内容,将资源文件添加进去即可。

let sceneView = SKView(frame: NSRect(x: 0, y: 0, width: 850, height: 638))
let scene = SKScene(fileNamed: "GameScene")
scene.scaleMode = .AspectFill
sceneView.presentScene(scene)

XCPShowView("Balloons", sceneView)

尝试实验:因为重力是在场景中由程序虚拟构建的物理世界(physicsWorld属性)定义的,所以在SpriteKit的物理世界中,我们可以不按照现实世界的定律,并且可以改变定律。
我们可以通过改变scene.physicsWorld.gravity矢量值让重力颠倒。

我们要想在Playground的场景中真正看到希望的效果,那么我们就要调用XCPlayground类的XCPShowView函数。该函数的功能是将场景真实的显示在Timeline区域中,通过该函数,你对代码做的每一个改动都会实时的显示在Timeline区域中。

让大炮开火

当大炮要开火时,让我们在场景中添加一些气球,并让它们在场景中来回穿梭。每一个气球其实就是一个Sprite节点或者叫Sprite元素,为了让气球看起来更真实、更有质感,我们会从气球图片集合中随机的取出气球图片用于显示在场景中。

这里我们先创建一个Swift数组对象,内容是气球图片的名称,然后用数组的map函数创建一个SKTexture对象的数组。这样我们就可以很方便的通过该数组随机的创建Sprite元素。

let images = [
    "blue", "heart-blue", "star-blue",
    "green", "star-green", "heart-pink",
    "heart-red", "orange", "red",
    "star-gold", "star-pink", "star-red",
    "yellow"
]
let textures: [SKTexture] = images.map { SKTexture(imageNamed: "balloon-\($0)") }

var configureBalloonPhysics: ((balloon: SKSpriteNode) -> Void)?
func createRandomBalloon() -> SKSpriteNode {
    let choice = Int(arc4random_uniform(UInt32(textures.count)))
    var balloon = SKSpriteNode(texture: textures[choice])
    configureBalloonPhysics?(balloon: balloon)

    return balloon
}

尝试实验:你可以点击代码编辑区右侧的小圆圈将代码片段的结果显示在Timeline区域,这样可以便于我们检查。
如果将SKTexture添加到Timeline后内容显示不全,你可以拖动Timeline滚动条来显示全部内容。

现在我们就已经创建好了气球,是时候让它们动起来了。我们首先要赋予它们真实的物理身体,因为当模拟物理形态和行为时,只会对有物理身体的元素起作用。

SpriteKit中,每个场景最多可包含32个类别。在众多的元素中,你可以用物理身体的类别来区分它们。这里要注意的是,我们将气球类别赋值为位移掩码,原因是为了当两个气球碰撞时能触发通知。

let BalloonCategory: UInt32 = 1 << 1
configureBalloonPhysics = { balloon in
    balloon.physicsBody = SKPhysicsBody(texture: balloon.texture, size: balloon.size)
    balloon.physicsBody.linearDamping = 0.5
    balloon.physicsBody.mass = 0.1
    balloon.physicsBody.categoryBitMask = BalloonCategory
    balloon.physicsBody.contactTestBitMask = BalloonCategory
}

尝试实验:将把BalloonCategory赋值给contactTestBitMask这行移除掉,看看会发生什么?

更新物理身体的属性是很有意思的实验,因为你的修改会即时的现实在Timeline区域,会给即时给你呈现反馈。并且你还可以在Timeline区域检查并调试当前代码段的结果是否正确。

尝试实验:尝试增加或减少物理身体的masslinearDamping属性的值。气球会有什么变化?再尝试修改其他属性,看看气球会有什么变化?

我们还需要设定气球在场景中的位置。并且我们希望气球被击中时正对着大炮的炮口。

let displayBalloon: (SKSpriteNode, SKNode) -> Void = { balloon, cannon in
    balloon.position = cannon.childNodeWithName("mouth").convertPoint(CGPointZero, toNode: scene)
    scene.addChild(balloon)
}

这里要注意一下,我们通过调用名为mouth子元素的convertPoint:toNode方法给气球设置位置。这样做是没问题的,因为我们在每个大炮中都增加了名为mouth的子元素,用来标示或预定位气球可能出现的位置。这样做的好处是能避免我们去计算气球的位置,并且如果我们想改变气球第一次出现的位置时,根本不用修改代码,因为气球的位置是随机取mouth子元素的位置的。

在气球被击中时,我们应该让气球表现出有一股冲击力作用于它,其实就是瞬间改变了气球的速度。我们可以用SpriteKit提供的推力的行为,可以作用于指定的方向来做出冲击力的效果。当然我们也要根据大炮发出炮弹的旋转方向设置气球被冲击后的方向。

最后,我们将创建、显示已经向气球射击的代码封装成一个单独的函数,用于后续调用。

let fireBalloon: (SKSpriteNode, SKNode) -> Void = { balloon, cannon in
    let impulseMagnitude: CGFloat = 70.0

    let xComponent = cos(cannon.zRotation) * impulseMagnitude
    let yComponent = sin(cannon.zRotation) * impulseMagnitude
    let impulseVector = CGVector(dx: xComponent, dy: yComponent)

    balloon.physicsBody.applyImpulse(impulseVector)
}

func fireCannon(cannon: SKNode) {
    let balloon = createRandomBalloon()

    displayBalloon(balloon, cannon)
    fireBalloon(balloon, cannon)
}

为了能够方便的访问大炮元素,我们在Xcode对他们进行明确的命名。这样我们就可以不需要知道元素树的结构或者说是大炮节点的位置,我们甚至可以不需要修改代码就可以更改大炮的位置。

let leftBalloonCannon = scene.childNodeWithName("//left_cannon")
let rightBalloonCannon = scene.childNodeWithName("//right_cannon")

SpriteKit通过SKAction对象改变节点的位置、旋转、缩放或因我们设置的其他情况而等待(也就是在特定的时间内什么也不做)。你可以在一系列或一组行为中单独指定执行某个行为,并且可以让该行为自动的重复执行任意次数(或者一直执行)。而这并不需要修改节点的属性,它完全可以由一个简单的Block来执行。

let wait = SKAction.waitForDuration(1.0, withRange: 0.05)
let pause = SKAction.waitForDuration(0.55, withRange: 0.05)

let left = SKAction.runBlock { fireCannon(leftBalloonCannon) }
let right = SKAction.runBlock { fireCannon(rightBalloonCannon) }

let leftFire = SKAction.sequence([wait, left, pause, left, pause, left, wait])
let rightFire = SKAction.sequence([pause, right, pause, right, pause, right, wait])

当大炮开火时,我们需要为它创建一系列的行为,让它在开火和停止开火之间交替执行。我们将开火/停止这一系列的行为添加到另外一个行为中,并且让他们一直重复执行。

尝试实验:增加大炮开火的间隔,改变大炮的火力,并让大炮不停的开火。

要想让某个元素执行相应的动作,我们只需要调用runAction函数,并将我们想执行的动作作为参数传入即可。每个节点可以同时执行多个行为动作,这就使得我们可以在SpriteKit中让节点执行自定义的,复杂的行为动作。

leftBalloonCannon.runAction(SKAction.repeatActionForever(leftFire))
rightBalloonCannon.runAction(SKAction.repeatActionForever(rightFire))

尝试实验:SKAction类有一个rotateByAngle函数,该函数可以让节点根据某个角度旋转。
创建一个让大炮在开火时按角度旋转的行为动作,并执行。

气球的撞击

当两个气球碰撞时,我们希望让其中一个气球爆炸。爆炸的效果就是我们创建的一个行为,所以就可以使用该行为创建出一个动画效果,作用在气球相互碰撞时,并将爆炸的气球元素移除场景。当有两个行为合并为一个系列行为时,它们会按顺序一个一个执行。

let balloonPop = (1...4).map {
    SKTexture(imageNamed: "explode_0\($0)")
}

let removeBalloonAction: SKAction = SKAction.sequence([
    SKAction.animateWithTextures(balloonPop, timePerFrame: 1 / 30.0),
    SKAction.removeFromParent()
])

虽然两个元素在场景中碰撞的行为由SpriteKit自动处理,但是我们也必须要为其提供一个符合我们游戏场景的逻辑。这包括定义碰撞时触发的通知。在之前,我们将所有气球元素的物理身体类别都设定为气球类别,但是场景中的地面也是一个元素,并且它的类别是默认类别,这里我们需要知道,默认类别为所有类别。

let GroundCategory: UInt32 = 1 << 2
let ground = scene.childNodeWithName("//ground")
ground.physicsBody.categoryBitMask = GroundCategory

尝试实验:如果不给地面设置类别(也就是将上述三行代码注释掉),那么当气球碰撞或者说落到地面时会发生什么?

接触通知由SpriteKit的物理世界中的接触代理处理,这是一个遵循SKPhysicsContactDelegate协议的类。每当碰撞发生时,物理世界会通知接触代理(即遵循SKPhysicsContactDelegate协议的类),所以我们才能做出碰撞后正确的反应。

class PhysicsContactDelegate: NSObject, SKPhysicsContactDelegate {
    func didBeginContact(contact: SKPhysicsContact) {
        let categoryA = contact.bodyA.categoryBitMask
        let categoryB = contact.bodyB.categoryBitMask

        if (categoryA & BalloonCategory != 0) && (categoryB & BalloonCategory != 0) {
            contact.bodyA.node.runAction(removeBalloonAction)
        }
    }
}

let contactDelegate = PhysicsContactDelegate()
scene.physicsWorld.contactDelegate = contactDelegate

在接触代理的didBeginContact函数中,我们通过物理身体的类别位掩码来确保这次接触或者说碰撞是两个气球发生的(也就是说确保元素的类别是BalloonCategory)。用按位运算来确定两个元素是否是BalloonCategory类别,并执行该类别正确的行为。

尝试实验:允许气球和大炮碰撞。
提示:大炮元素没有赋予物理身体。

总结

Playground为你提供了一种与代码互动的有趣的方式。使用Playground对你也有很大的帮助,因为你的学习过程和调试错误过程都在一个可控的环境中。更重要的一点是,Playground能挑起你的好奇心,鼓励你不断的来测试你的代码。

希望你们在不断的修改代码、实验中找到乐趣,并且永远不要害怕重头开始!