SpriteKit 入门

引语

作为一个 iOS 开发,你应该知道一个 app 是怎样从无到有,但对于游戏,却不一定。所以,本文可以你了解游戏开发方面的知识。

为什么是 SpriteKit?

首先,我们需要了解目前手机游戏开发的现状。目前,手机游戏引擎用的最多的应该是 Cocos2d-x 和 Unity,它们都是垮平台的引擎,但这两者对于初学者来说,需要额外学习的东西太多。
SpriteKit 是由 Apple 在 WWDC 2013 发布 2D 游戏引擎,目前可用于 iOS, macOS tvOS 和 watchOS。它有着良好的设计、简单易用、API 和 Cocos2D 非常像。
对于已经熟悉 iOS 开发的人来说,其用法和 UIKit 差不多,而且,他并非只能用来开发游戏。本文后面就会用 SpriteKit 实现类似 iTunes Music 的风格选择交互。

SpriteKit Framework 介绍

首先,我们来看 SpriteKit 主要类结构。

ScreenShot1.png
  • SKView 用来管理和渲染 SKScene, 继承自 UIView。你甚至可以将 SKViewUIKit 里的其他 View 结合使用,比如在其之上加一个 UIButton
  • SKNode 是基础类,和 UIKit 类似,SpriteKitnode trees 的概念,实际中一般和其子类打交道。SKNode 定义了一些基础属性和方法,如 position, frame, alpha, physicBody, addChild(), runAction() 等。
  • SKAction 用来实现位移、缩放等效果,调用 SKNode.runAction()Action 添加到 Node,可以自由组合 Action 实现复杂效果。
  • SKPhysicBody 定义了一个 Node 的物理属性,设置 node.physicBody 后 node 就可以进行物理计算、碰撞检测。SKPhysicBody 包含了质量、速度、弹性、摩擦力等属性。
  • SKSceneroot node,定义了 SKView 显示的具体内容, physicsWorld 属性可设置重力等全局属性,通过 SKPhysicsContactDelegate 获得碰撞通知。就游戏来说,其内容是动态变化的,所以 SKScene 有一个 rendering loop (见下图)
ScreenShot2.png

每一帧都会执行这个 loop,loop 执行完之后被改变的内容才会重新绘制。子类化 SKScene 时可以重写 update(_:)didXXX 方法获得回调。

结合 Magnetic 进行代码讲解

上面介绍了 SpriteKit 的一些基本概念,下面我们通过一个示例来介绍如何在普通 app 中结合 SpriteKit 实现一些优雅的交互。此示例受 Github 上的 Magnetic 项目启发,用 SpriteKit 实现 iTunes Music 的「个人喜好定制」功能,源码见 https://github.com/iblacksun/Artists, 最后效果如下:

ScreenShot4.png

开始实现

第一步,使用 Xcode 新建一个 Game 类型的项目,Game Technology 选 SpriteKit。简单起见,修改 Deployment Info,使其只支持 iPhone 和 Portrait 方向。Build & Run 之后你会看到我们熟悉的 Hello World。

ScreenShot3.png

Hello World 项目中很多模板代码在我们项目中并不需要,删除 GameScene.sks, Actions.sks, GameScene.swift 几个文件;修改 Main.storyboard 将 SKView 的 backgroudColor 修改成白色;修改 GameViewController 替换成如下:

1
2
3
4
5
6
7
8
9
10
11
class GameViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
guard let skView = self.view as? SKView else{
return
}
skView.ignoresSiblingOrder = true
skView.showsFPS = true
skView.showsNodeCount = true
}
}

此时如果运行项目的话应该是一个灰色空白界面,底部显示了 nodes 数量和 fps。

构建 Node

现在该主角们登场了,新建 ArtistsScene.swift ArtistNode.swift,分别继承 SKSceneSKShapeNode。修改 GameViewControllerviewDidLoad 方法,在末尾加入代码:
let scene = ArtistsScene(size: skView.bounds.size)
skView.presentScene(scene)
调用 presentScene(_:) 方法呈现 Scene,此时 ArtistsScene 就是 root node,因 SKScene 的默认背景色是黑色,所以现在运行项目的话会看到一个黑色空白界面。
接下来开始实现功能,重写 ArtistsScenedidMove(to:) 的方法,此方法在 Scene 被 present 时会被调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
override func didMove(to view: SKView) {
super.didMove(to: view)
//1.禁用重力
physicsWorld.gravity = CGVector.zero
backgroundColor = .white
let viewWidth = view.frame.size.width
let viewHeight = view.frame.size.height
let radius = max(viewWidth, viewHeight)
//2.添加一个具有向心力的特殊 SKFieldNode.
let fieldNode = SKFieldNode.radialGravityField()
fieldNode.region = SKRegion(radius: Float(radius))
fieldNode.minimumRadius = Float(radius)
fieldNode.strength = 50
addChild(fieldNode)
//3.修改坐标原点
anchorPoint = CGPoint(x: 0.5, y: 0.5)
//4. 添加所有 Artist nodes,初始随机分配在左右两侧,受向心力作用,会自动汇聚到中心点
for (index, artistName) in artists.enumerated() {
let x = (index % 2 == 0) ? -viewWidth/2 : viewWidth/2
let y = CGFloat.random(-viewHeight/2, viewHeight/2)
let node = ArtistNode(circleOfRadius: 40)
node.fillColor = .red
node.position = CGPoint(x: x, y: y)
addChild(node)
}
}
  1. physicsWorld.gravity = CGVector.zero 禁用重力作用,否则所有设置过 physicBody 的 node 都会受重力影响自动坠落;
  2. 添加一个具有向心力的特殊 SKFieldNode,在其 region 内的所有 node 都会受影响,自动向中心移动;
  3. SpriteKit 的坐标原点在左下角,这点和 UIKit 不一样。设置 anchorPoint = (0.5, 0.5),方便计算后面 ArtistNode 的 position;
  4. 循环添加 ArtistNode,设置其 position,其中 x 平均分配到左右两侧,y 则取顶部和底部间的随机值。

此时如果运行的话会发现所有 ArtistNode 都停留在屏幕两侧并不会想中心靠拢,猜猜原因?
打开 ArtistNode,实现一个 convenience init 方法,传入 artistName

1
2
3
4
5
6
7
8
9
10
11
12
convenience init(artistName :String) {
self.init()
self.init(circleOfRadius: 40)
fillColor = .red
physicsBody = SKPhysicsBody(circleOfRadius: frame.size.width / 2)
physicsBody?.allowsRotation = false
physicsBody?.friction = 0
physicsBody?.linearDamping = 3
addMultilineTextNode(artistName, radius: 40)
}

init 中创建一个半径为 40 的圆形 node,填充色是红色;创建一个大小和自身相等的圆形 physicsBody。
接下来的问题是如何将 artistName 添加到 ArtistNode?SpriteKit 提供了 SKLabelNode,但它不支持文字换行。addMultilineTextNode(artistName, radius: 40) 里的代码是将文字转换成图片,然后往 ArtistNode 添加一个 SKSpriteNode。

1
2
3
4
5
6
if let image = UIGraphicsGetImageFromCurrentImageContext(){
let texture = SKTexture(image: image)
let spriteNode = SKSpriteNode(texture: texture)
addChild(spriteNode)
}
UIGraphicsEndImageContext()

添加交互

完成上面代码并运行之后,所有 ArtistNode 会自动从左右两侧缓慢的向中心移动,但并不能拖拽和点击。
SKNode 继承自 UIResponder (NSResponder),其事件处理和 UIView 一样:通过 touchesBegan touchesMoved touchesEnded touchesCancelled 几个方法处理。
因我们的 root node 是 ArtistsScene,所以我们可以重写 ArtistsScene 的这几个方法来处理拖拽和点击事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else {
return
}
let previous = touch.previousLocation(in: self)
let location = touch.location(in: self)
if location.distance(from: previous) == 0{
return
}
isMoving = true
let x = location.x - previous.x
let y = location.y - previous.y
for node in children{
let distance = node.position.distance(from: location)
let acceleration: CGFloat = 3 * pow(distance, 1/2)
let direction = CGVector(dx: x * acceleration, dy: y * acceleration)
node.physicsBody?.applyForce(direction)
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first, !isMoving else {
isMoving = false
return
}
isMoving = false
let location = touch.location(in: self)
guard let artistNode = artistNodeAt(location) else{
return
}
artistNode.isSelected = !artistNode.isSelected
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
isMoving = false
}
  1. touchesMoved 方法中处理拖拽事件,如果发生拖拽,则向所有 node 施加一个作用力,使之随着手指位置一起移动;
  2. touchesEnded 中处理点击事件。通过 isMoving 区分这个事件是拖拽还是点击,点击的话查找出被点击的 ArtistNode,设置其 isSelected 属性。

接下来看看 ArtistNode.isSelected 的实现:

1
2
3
4
5
6
7
8
9
10
var isSelected = false {
didSet {
guard oldValue != isSelected else {
return
}
removeAction(forKey: "scale")
let scaleAction = SKAction.scale(to: (isSelected ? 1.5 : 1.0), duration: 0.2)
run(scaleAction, withKey: "scale")
}
}

didSet 获得设值之后的回调,通过 run(_ action:),选中缩放至 1.5,取消选择还原至 1.0。

如何深入

以上通过示例的方式介绍了 SpriteKit 的基础,以及如何将 SpriteKit 运用到普通 App 中。如果还想继续深入的话,可以参考以下资源: