用 SpriteKit 和 Swift3 创建交互式儿童读物

organicbutterfly 发布于2年前
0 条问题

原文:SpriteKit Tutorial: Create an Interactive Children’s Book with SpriteKit and Swift 3
作者:Caroline Begbie
译者:kmyhy

2017-1-20 更新说明: 由 Caroline Begbie 更新至 Swift 3,iOS 10 和 Xcode 8。原文作者是 Tammy Coron,上一版本的更新者是 Jorge Jordán.

对于孩子来说,用 iPad 学习的最好时机已经到来!
使用苹果的 SpriteKit,开发者可以在 iPad 上创建精彩的儿童交互式读物,这种便利是其它媒体所不具备的。例如,请参考The Monster at the End of This BookBobo Explores Light以及Wild Fables

在本教程中,我们会用 Sprite Kit 创建一个名为 The Seasons 的儿童电子书,你会学习如何将对象添加到场景中,创建动画序列,允许读者和书中的精灵交互,以及添加声音和音乐。

如果你没有接触过 Sprite Kit,你会发现通过 SpriteKit 场景编辑器很多时候你根本不用编写任何代码。就算不得不编译一点代码,你也会发现在场景中编写代码是如此的容易。

注:The Seasons 由教程组成员 Tammy Coron 编写并插图。本文使用了这本书的开头几页。在你自己的项目中,请勿使用和复制这些内容。这个故事是 Amy Tominac 编写的,你也不能在你的项目中使用。
本教程使用的音乐由Kevin MacLeod制作,音效来自 FreeSound。你可以在项目使用这两者,但必须声明所有权。更多细节请查看项目 bundle 中的 attribution.txt 文件。

开始

首先请下载本教程的开始项目。解压缩到任意文件夹,然后用 Xcode 打开。
项目中已经包含本教程使用到的所有图片和声音。也包含了制作好的页面(场景),除了标题页面。在项目设置中,app 设置为支持 iPad 横屏模式。运行 app,你会看到一个灰色的空白页。

下图为这个 app 的结构:

app 启动后,Main.storyboard 会加载 GameViewController。打开 GameViewController.swift 在 viewDidLoad() 中找打这行:

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

这句加载了一个名为 GameScene.sks 的文件,这是一个 SpriteKit 场景文件,你可以在 SpriteKite 的可视化编辑器中编辑它。

在故事书中,每一页都是一个场景。同时,你可以用场景编辑器来加入每一页中的精灵、声音和动画。打开 GameScene.sks,在右边的面板中,找到 Custom Class 检视器,你会看到 GameScene.sks 关联的类是 GameScene。

当 GameScene.sks 加载后,会创建一个 GameScene 实例。你可以在 GameScene.swift 加入代码,和场景编辑器中的对象进行交互。

所有页都会继承 GameScene 类。每个页面的专有代码都会放在这个子类中,但所有页面都会用到的代码放在 GameScene 中.

选择准备开始。就像所有的故事书,最适合用来作为开始的就是标题页—— title scene。

创建一个页面

为 title 页创建一个新的文件。点击 File/New/File… 并选择 iOS/Resource/SpriteKit Scene 模板。点击 Next ,将文件取名为 TitlePage,点击 Create 并在场景编辑器中打开新场景。

打开 TitlePage.sks 后,缩放窗口以便看到整个场景。你可以用编辑器右下角的 + 和 - 号按钮进行放大、缩小。

当前场景处于竖屏模式,但我们的电子书只支持横屏模式。在属性检查器中将 Size 改变为:
Size: W: 1024, H: 768

注:如果你不能点击文本框改变这个值,请尝试用 Tab 或 Shift+Tab 点击上一个或下一个文本框。这样这个文本框会变成可编辑状态。

这会将场景重设为 iPad 横屏状态。iPad Pro 的大小是不一样的,但比例相同,因此页面会自动适配。

我们会在这个场景中加入一个背景图片。在属性检查器下面,在屏幕的右下角,找到 Media Libray。Assets.xcassets 中的所有图片都被列在这里。

找到图片 background-titlepage。将它拖到场景中。编辑器会用这张图片自动创建一个精灵。

选中背景图片,在属性检查器中修改下列属性:
Name: background
Position: X: 0, Y:0

这将让图片位于场景的中央。

下图显示了节点放到场景中的位置:

在编辑器中,场景的锚点默认为(0.5,0,5)。也就是说位置为 0 、锚点相同的节点,它的中心会有一半在场景中,而另一半位于屏幕下方。如果场景高度为768,Y 值 384 是场景的上边缘,Y 值 -384 是它的下边缘。

从 Media library 中将 title_text 拖到场景中。在属性检查器中,修改下列属性:

Name: titleText
Position: X: -120, Y: 150

现在将 button_read 拖到场景中,位于 title 的下方。修改属性:

Name: readButton
Position: X: -100, Y: 12

GameViewController 需要加载新场景。在 GameViewController.swift 将这句:

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

修改为:

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

这将在 app 一运行时加载新场景。

运行 app,创建新页面就是如此简单。

在上图中,你会发现 Seanons 和 Read Story 不见了。目前,所有的精灵会以随机的顺序绘制。多运行 app 几次,这两个精灵会随机地出现和消失。

精灵渲染顺序

场景中有一个节点树。每个节点都会有1到多个子节点。如果节点拥有子节点,它就是这些子节点的父节点。

节点树的最顶端是场景自身。当前场景拥有 3 个子节点,它们全都放在一个 children 数组中,这个数组是 SKNode 的属性。SKScene 和 SKSprideNode 都继承自 SKNode。

你刚才拖到场景中的每个精灵节点都放到了 children 中。数组有先后顺序,但场景在渲染的时候不使用这个顺序。

有两种解决办法。

第一种,在 GameViewController 找到这一句:

view.ignoresSiblingOrder = true

ignoresSiblingOrder 属性为 true,表明以随机顺序渲染兄弟节点。app 其实已经显示了那些“消失了的”节点,但它们渲染在背景精灵之后了。
你可以将它设置为 false:

view.ignoresSiblingOrder = false

运行 app,所有的节点都出现在场景中。

第二种方法是为每个精灵设定 z 坐标。z 坐标表示了远近深度,即你的节点距离你有多远。z 坐标小的节点会显示在 z 坐标大的节点后面。

这种方式可以让你完全控制精灵显示的层次。因此我们将采用这种方法。
将前面的代码改回去:

view.ignoresSiblingOrder = true

打开 TitlePage.sks,在场景浏览器中选中 background 对象,修改它的位置属性:

Position: Z: -10

将其他精灵的 z 坐标保持 0 不变。
运行 app,背景页面这次会显示其他两个精灵后面。

为对象添加动画

SpriteKit 很容易为场景中的对象添加动画。我们不要只是简单地在场景上显示标题,我们可以让标题滑入场景并在进入位置之前稍稍跳动。

打开 TitlePage.sks。选择 titleText。在场景下方,确保动作编辑器显示。如果不,在场景左下角,点击右边的图标以显示动作编辑器。

在 Object library 中,找到 Move 动作,并将它拖到动作比机器的 titleText 上。

在时间线中,你已经创建好一个动画块。

选中新动画块,在属性检查器中,改变下列属性:
Start Time: 0
Duration: 3
Timing Function: Linear
Offset: X: 0, Y: -300

点击动作编辑器上的 Animate 按钮,编辑器将切换到动画模式,同时标题文本动画将开始播放。标题会以 3 秒钟 300 像素的速度下移。注意时间线上的播放头会随之移动。你可以通过拖动播放头的方式慢慢查看动作的整个发生过程。

注意标题最后会落在阅读按钮下层。标题一开始应当在整个页面的最上方,并向下移动至阅读按钮的上方。点击动作编辑器上方的 Layout 按钮,退出动画模式。

从场景浏览器中选中 titleText,修改它的属性:

Position: X: -120, Y: 450

这会将 The Seasons 移动到页面的上方页面以外的位置。

运行 app,注意 The Seasons 向下运动进入指定位置。

这只是一个简单的动画的例子,你可能看到各种可能。现在我们加一个弹跳的动作到这个动画中,并在标题动画之后为阅读按钮增加一个渐入效果。

在时间线中选择 move 动作。在属性检查器中,将时间函数从 linear 改变为:

Timing Function: Ease In

linear 表示动画在整个动画期间以匀速播放。Ease In 则表示动画一开始慢然后逐渐加快。

在 titleText 动作时间线上拖入第2、3 个 Move 动作,顺序摆放。

选中第二个 Move 动作,在属性检查器中,修改属性:

Start Time: 3
Duration: 0.25
Offset: X: 0, Y: 5

注:如果你无法输入值,请确认你处于布局模式。你左下角应当显示 Animate 而不是 Layout。

选中第3 个 Move动作,在属性检查器中,修改属性:

Start Time: 3.25
Duration: 0.25
Offset: X: 0, Y: -5

运行 app,你会看到标题动画一开始是缓慢的,然后逐渐加速。在动画块结束的时候有一个抖动。

现在来为阅读按钮增加渐入效果。
在场景浏览器中选择 readButton。在属性检查器中,修改属性:
Alpha: 0

这样 readButton 会消失。

从 Objects library 中多一个 FadeIn 动作到 Action 编辑器的 readButton 时间线中。
选中 FadeIn 动作,在属性检查器中修改属性:

Start Time: 3.25
Duration: 0.75

在 readButton s时间线的 FadeIn 动作后边拖一个 PlaySoundFileNamed 动作。
在属性检查器中,修改属性:

Start Time: 4.0
Filename: thompsonman_pop

运行 app,当标题动画完成时会播放一个声音。点击阅读按钮,什么也不会发生。

注:模拟器在 SpriteKit 动画时帧率会变低。要获得真实效果,请在真机即 iPad 上运行。帧率显示在 app 的右下角。

App 的架构

电子书的每一页都有同样的元素:

  • 背景声音
  • 文本语音 (大部分页面)
  • 底部可触摸的工具条,用于页面导航
  • 页面间的转换动画

现在 TitlePage.sks 已经能够加载了,它创建了一个普通的 SKScene 实例,但你需要在 SKScene 子类 GameScene 中和这些元素打交道。每个页都继承自 GameScene。

新建一个 Swift 文件。点击 File/New/File…, 选择 iOS/Source/Cocoa Touch Class 并点击 Next。给类取名为 TitlePage 并继承 GameScene 类。点击 Next、Create。

在 TitlePage.swift, 将这句

import UIKit

替换为:

import SpriteKit

当我们导入 SpriteKit 框架是,与之有关的框架比如 UIKit 和 Foundation 框架会自动导入。

在 TitlePage.sks 中,从场景浏览器中选择 Scene,在自定义类检查器中,修改属性:

Custom Class: TitlePage

这将 SpriteKit 场景和刚刚我们创建的类绑定起来。

引用节点

每一页都会有带有导航按钮的底部工具栏(footer)。因为所有页都有,我们可以将它单独作为一个场景,然后在所有页面中引用这个 footer。

我已经创建了一个空的场景叫做 Footer.sks,并且除了 TitlePage.sks 外,所有的页面中都引用了这个场景。

首先,来看一眼 Scene01.sks。在场景浏览器中,你会看到一个对 footer 的引用。但 footer 的图片没有显示在上面。

现在我们来为 Footer.sks 添加精灵节点,以了解创建引用节点的好处。所有引用了 Footer 的场景都会自动更新显示新加的节点。

打开 Footer.sks,从 Media library 中将 footer 图片拖到场景中。

在属性检查器中,修改属性:

Name: footerBackground
Position: X: 0, Y: 0, Z: -5

将 button_previous, button_next 和 button_sound_on 拖到 footer background 中,并将它们的位置放到工具栏的右边:

当用户点击左边的 footer 背景图片时,他会回到标题页。

这里我们不准备使用任何按钮图片,因此从 Object library 中,拖一个颜色精灵到工具栏左边,并调整它的位置大小以覆盖住背景上的文字:

修改下列属性:
Position: Z: -20

这会将颜色精灵放在其他对象之后,因此它是不会显示的。
在属性检查器中,给每个精灵一个名字。从左至右依次是:

buttonHome
buttonSound
buttonPrevious
buttonNext

打开 Scene01.sks,仿佛被施加了魔法,footer 现在的显示和你在 Footer.sks 中设计的一样。

这就是引用节点的威力。如果你改变了一个场景,这些改变会自动同步到所有别的场景,当然这些场景都引用了前者。

现在可以将 footer 添加到标题页中。打开 TitlePage.sks, 从 Object library 中拖一个引用节点到场景中。

选中这个引用节点,修改属性:

Name: footer
Reference: Footer
Position: X: 0, Y: -345, Z: 100

运行 app,你会在标题页下方看到 footer。

目前所有的 footer 按钮还有任何动作。因为这些精灵还没有任何点击事件的处理器。在下一节,我们将深入到代码中去,并将我们在场景中创建的属性和代码联系起来。

检测触摸事件

当 SpriteKit 加载 TitlePage.sks 时,场景的层次类似下图:

我们会和这个层次结构联系起来,并通过我们在编辑器中设定的名字将节点和代码中的变量连接起来。

因为 footer 按钮在所有的场景中都是同样的动作,我们首先需要在基类 GameScene 中修改代码。这个类中现在有几个方法。这些方法是一些简单的触摸时间处理方法和页面场景需要覆盖的方法的存根。

在 GameScene.swift, 为 GameScene 添加几个属性:

var footer:SKNode!
var btnNext: SKSpriteNode!
var btnPrevious: SKSpriteNode!
var btnSound: SKSpriteNode!
var btnHome: SKSpriteNode!

这些属性和 Footer.sks 中的精灵一一匹配。要连接它们,在文件底部添加这个方法:

override func sceneDidLoad() {
  super.sceneDidLoad()
  footer = childNode(withName: "footer")
}

SpiteKit 在场景被加载到内存之后调用 sceneDidLoad() 方法,你可以通过场景编辑器中的名字很容易地找到这些子节点。
但是 childNode(withName:) 方法只能找到父节点的直系子节点。要遍历更远的层级,你需要在查找字符串中添加两个斜杠 //。这会在整个节点树中地柜。

在 sceneDidLoad() 方法最后加入这几句:

btnNext = childNode(withName: "//buttonNext") as! SKSpriteNode
btnPrevious = childNode(withName: "//buttonPrevious") as! SKSpriteNode
btnSound = childNode(withName: "//buttonSound") as! SKSpriteNode
btnHome = childNode(withName: "//buttonHome") as! SKSpriteNode

现在我们已经在代码中引用了 footer 的所有导航按钮。

新建一个方法,用于跳到其他场景:

func goToScene(scene: SKScene) {
  let sceneTransition = SKTransition.fade(with: UIColor.darkGray, duration: 1)
  scene.scaleMode = .aspectFill
  self.view?.presentScene(scene, transition: sceneTransition)
}

这个方法创建了一个渐入渐出变换,然后让 View 用指定的变换呈现这个场景。

在 GameScene 中有两个存根方法:getNextScene() 和 getPreviousScene()。这两个方法会在每个页面覆盖,以便返回对应的上一页和下一页。

在 TitlePage.swift 中,用这个方法返回下一页:

override func getNextScene() -> SKScene? {
  return SKScene(fileNamed: "Scene01") as! Scene01
}

在 Scene01.swift 新增方法:

override func getPreviousScene() -> SKScene? {
  return SKScene(fileNamed: "TitlePage") as! TitlePage
}

然后,你可以用这个方法在 GameScene.swift 中覆盖触摸处理器:

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
  guard let touch = touches.first else { return }

  // 1
  let touchLocation = touch.location(in: self)

  // 2
  if footer.contains(touchLocation) {
    let location = touch.location(in: footer)

    // 3
    if btnNext.contains(location) {
      goToScene(scene: getNextScene()!)
    } else if btnPrevious.contains(location) {
      goToScene(scene: getPreviousScene()!)
    } else if btnHome.contains(location) {
      goToScene(scene: SKScene(fileNamed: "TitlePage") as! TitlePage)
    }

  } else {

    // 4
    touchDown(at: touchLocation)
  }
}

代码解释如下:

  1. 找出第一个触摸点在屏幕上的坐标。
  2. 如果触摸位于 footer 上,获取触摸在 footer 上的坐标。
  3. 判断触摸位于哪个按钮,并调用对应的处理。

如果触摸位于 footer 之外,调用 touchDown(at:) 方法。每个页面都会有不同的交互功能,因此每个 GameScene 子类中需要覆盖这个方法。

运行 app,点击每个按钮。下一页按钮会跳到下一页面,上一页按钮会跳到前一个页面。
你可以阅读整本书了!

但是,如果你在最后一页的时候点了下一页会发生什么?或者在标题页点击了上一页呢?因为这些页面没有提供下一页和上一页的方法,app 会崩溃。

标题页上有一个阅读按钮,用于开始阅读电子书,因此它并不需要上一页按钮和下一页按钮。同样,最后一页也不需要显示下一页按钮。

在 TitlePage.swift,添加一个属性,用于引用阅读按钮:

var readButton: SKSpriteNode!

覆盖 sceneDidLoad() :

override func sceneDidLoad() {
  super.sceneDidLoad()
  readButton = childNode(withName: "readButton") as! SKSpriteNode

  btnNext.isHidden = true
  btnPrevious.isHidden = true
}

这里,我们获得了阅读按钮节点引用并隐藏了上一页和下一页按钮。

覆盖 touchDown(at:) 方法以作为阅读按钮的处理方法:

override func touchDown(at point: CGPoint) {
  if readButton.contains(point) {
    goToScene(scene: getNextScene()!)
  }
}

当阅读按钮被点击,下一场景加载。

总之,当触摸发生时,GameScene 的 touchesBegan 方法被调用。这个方法会判断是否触摸点位于 footer 上,如果不在,它会调用 touchDown(at:)。每个 GameScene 子类都会覆盖这个方法以便根据需要实现自己的动作。

运行 app,标题页上的导航按钮被隐藏了。点击阅读按钮,电子书的第一页显示。

点击 footer 的左半部分,标题页显示。这个地方我们在 footer 下面隐藏了一个返回按钮。

你的故事书已经初步成型了。

语音是这本故事书的一个功能,SpriteKit 使这个功能的实现超级简单!

加入声音

通过前面添加爆破声的例子,你已经明白添加简单声效是何等的简单了。每一页都会有一个单独的背景音乐。用户通过点击声音按钮,也可以关闭背景音乐。

在 TitlePage.sks 中,从 Object library 拖一个 Audio 节点到场景中。无所谓放在哪里——只需要拖到页面中就行了。

在属性检查器中,修改属性:
Name: backgroundMusic
Filename: title_bgMusic
Autoplay: unchecked

在 GameScene.swift,添加如下属性:

var backgroundMusic: SKAudioNode?
var textAudio: SKAudioNode?
var soundOff = false

在 GameScene 中,在 sceneDidLoad() 的最后一句添加:

backgroundMusic = childNode(withName: "backgroundMusic") as? SKAudioNode
textAudio = childNode(withName: "textAudio") as? SKAudioNode

这样就将编辑器中节点和代码关联起来了。

backgroundMusic 用于播放背景音乐,textAudio 用于播放语音。音乐是否播放取决于用户。如果用户点击了声音按钮,音乐会被关闭,这个按钮的图片将显示为禁音状态。

将这个条件添加到 touchesBegan(_:with:)方法:

else if btnSound.contains(location) {
  soundOff = !soundOff
}

简单地切换这个布尔值。

将 soundOff 的属性声明由:

var soundOff = false

修改为:

var soundOff = false {
  didSet {
    // 1
    let imageName = soundOff ? "button_sound_off" : "button_sound_on"
    btnSound.texture = SKTexture(imageNamed: imageName)

    // 2
    let action = soundOff ? SKAction.pause() : SKAction.play()
    backgroundMusic?.run(action)
    backgroundMusic?.autoplayLooped = !soundOff

    // 3
    UserDefaults.standard.set(soundOff, forKey: "pref_sound")
    UserDefaults.standard.synchronize()
  }
}

这段代码在 soundOff 被改变时执行。代码解释如下:

  1. 根据 soundOff 的值切换按钮图片。
  2. 根据 soundOff 的值创建并执行 SKAction。如果声音是打开的,这个动作就是播放,否则是暂停。如果声音是打开的,将 autoplayLooped 设为循环播放。
  3. 将用户偏好保存到 UserDefaults。这样哪怕程序退出,app 的设置也不会丢失。

当场景加载时,我们需要从 UserDefaults 加载用户偏好。在 sceneDidLoad() 方法最后添加:

soundOff = UserDefaults.standard.bool(forKey: "pref_sound")

运行 app,点击声音按钮。背景音乐会停止/播放,同时图片会切换。关闭声音,退出 app,再次运行 app。因为设置是保存在 UserDefaults 中的,app 会仍然记住背景音乐已被关闭。

要朗读文字,每一页都需要有一个文本语音文件。我已经添加了每一页的语音文件了。

当每一页加载时,会有一点轻微的延迟,然后播放语音文件。
在 GameScene.swift 中添加一个方法:

override func didMove(to view: SKView) {
  if let textAudio = textAudio {
    let wait = SKAction.wait(forDuration: 0.2)
    let play = SKAction.play()
    textAudio.run(SKAction.sequence([wait, play]))
  }
}

SpriteKit 在加载完场景后会调用 didMove(to:) 方法。在这个方法中,我们判断该页是否有语音文件,如果有,九车间一系列动作。首先创建一个等待(wait)动作,然后是一个播放(play)动作。然后将动作添加到一个动作序列中并在 textAudio 上执行。

运行 app,让故事书开始朗读吧!

所有声音设置都在 GameScene 中完成,而每一页的场景都继承了 GameScene,每一页的 footer,声音设置都能正确工作。

物理引擎介绍

我们可以添加一些互动以增加电子书的吸引力。主角是一个小男孩,在这一节,你将为他创建一顶帽子以便读者能够将帽子拖到男孩的头上。

我已经在 Scene01.sks 中添加了帽子和男孩(有一个闪烁动作)。但帽子的物理属性需要修改。在编辑器中加入物理特性是一件非常轻易和有趣的事情。

在 Scene01.sks 中,从场景浏览器中选中帽子精灵。

在属性检查器中,在 Physics Defintion(可能需要向下滚动才能看见这个选项)下面,改变下列属性:

Body Type: bounding rectangle
Dynamic: checked
Allows Rotation: unchecked
Restitution: 0.5

有大量物理属性可以修改,比如 mass(体积)、gravity(重力) 和 friction effects(摩擦系数)。

当前你还没有什么对象能够让帽子与之碰撞。点击 Animate,你可以看到帽子掉出了屏幕底部。

解决这个问题的最简单方式就是用代码创建一个物理边界。
在 Scene01.swift,在 Scene01 中添加属性:

var hat: SKSpriteNode!

重新在 sceneDidLoad() ,连接这个属性:

override func sceneDidLoad() {
  super.sceneDidLoad()
  hat = childNode(withName: "hat") as! SKSpriteNode
}

在 sceneDidLoad() 方法后面添加代码:

var bounds = CGRect.zero
bounds.origin.x = -size.width/2
bounds.origin.y = -size.height/2 + 110
bounds.size = size
physicsBody = SKPhysicsBody(edgeLoopFrom: bounds)

这里我们创建了一个边界矩形,大小等于整个屏幕,底部稍微保留了一点空白。你将场景的物理体的封闭边,大小和矩形相同,这样帽子就会停留在物理体内。

运行 app,帽子会停留在距离屏幕底部 110 像素的上方。

好,你为帽子添加了几个物理属性——但如何添加交互呢?

处理触摸和移动帽子

这一节实现帽子的触摸的处理,以便你能在屏幕上移动它。
在 Scene01.swift中,添加一个用于保存触摸位置:

var touchPoint: CGPoint?

touchPoint 将用于保存最近用户触摸位置,如果用户没有触摸过屏幕,则它是 nil。

在 Scene01 中新增一个方法:

override func touchDown(at point: CGPoint) {
  if hat.contains(point) {
    touchPoint = point

    hat.physicsBody?.velocity = CGVector.zero
    hat.physicsBody?.angularVelocity = 0
    hat.physicsBody?.affectedByGravity = false
  }
}

当用户第一次触摸到帽子,代码会将位置保存到 touchPoint。同时对帽子的物理属性进行了修改。如果没有这些修改,就不可能在屏幕上拖动帽子,因为你会不停地和物理引擎较劲。

在屏幕上移动时我们需要时刻记录触摸位置,因此在 Scene01 中添加这个方法:

override func touchMoved(to point: CGPoint) {
  if touchPoint != nil {
    touchPoint = point
  }
}

这样,我们不断刷新最新触摸位置并将它保存在 touchPoint 里。

当用户手指离开屏幕,我们需要重新设置和帽子相关的变量。添加这个方法:

override func touchUp(at point: CGPoint) {
  if let touchPoint = touchPoint {
    hat.physicsBody?.affectedByGravity = true
  }
  touchPoint = nil
}

这段代码会报错,待会我们会解决它。

还有一件事情,让帽子能够跟随用户手指在屏幕上移动。在 Scene01 中添加一个方法:

override func update(_ currentTime: TimeInterval) {
  if let touchPoint = touchPoint {
    hat.position = touchPoint
  }
}

SpriteKit 在绘制每一帧时调用 update(_:) 方法。在这里,我们检查用户是否正在拖拽帽子,如果是,将帽子移动到 touchPoint 所指定的位置。帽子并不会跑到屏幕以外,因为有物理边界的存在。

运行 app,点击阅读按钮,在屏幕上随意拖动帽子。放下帽子,你会看到它弹起来了!

移动帽子确实很爽,但无论是否放在男孩的头上都不会有任何反应。我们来加上这个处理。

就像你创建了一个假的返回按钮用于回到标题页面,我也在 Scene01.sks 中加了一个”仿制的”精灵 hatPosition,放在所有其他节点的下层,用于代表帽子的位置。你可以用这个精灵的位置来检测帽子是否被拖到了正确的位置。

在 Scene01.swift,在 touchUp(at:) 的这句:

hat.physicsBody?.affectedByGravity = true

之后加入这些代码:

if let hatPosition = childNode(withName: "hatPosition") {
  if hatPosition.contains(touchPoint) {
   hat.position = hatPosition.position
   hat.physicsBody?.affectedByGravity = false
   hat.run(SKAction.playSoundFileNamed("thompsonman_pop.mp3",
                                       waitForCompletion: false))
  }
}

这里,我们通过检查 touchPoint 是否位于我们的帽子“仿制品”精灵之内,判断帽子是否到达了正确的位置。如果是,帽子已经足够近了,说明帽子正位于孩子的头顶上。你可以播放一个爆破音,通知用户帽子已经放到主角的头上了——还有什么?看到窗外的雪了吗?

运行 app,抓起帽子放在孩子头上:

[

除了故事和叙述方式,动作和声音这些交互元素对于体验来说也非常重要,真正利用了 iOS 和 SpriteKit 便利。

粒子

关于冬天和冰雪的故事怎能缺少最关键的因素——雪呢?

在我原先的想法里,没有交互的书是完全没有粒子效果的。有一些元素比如火焰、雨、爆炸和雪。粒子通常是小图片。这个图片会被多次复制,但颜色、大小和方向会不同。

SpriteKit 粒子用粒子编辑器其实很容易创建。点击 File/New/File…,选择 iOS/Resource/SpriteKit Particle File 模板然后点击 Next。

在粒子模板列表中,选择 Snow。我建议你在后面尝试一下其它粒子。
点击 Next 并为文件取名为 Snow,然后点 Create。粒子文件会打开,一个”真实的“雪会出现在你面前。

每个粒子都是一个小贴图的实例,但每个粒子的大小都不相同。雪花下降的速度和方向也不相同。不过粒子系统不属于本文的内容,但在属性编辑器中,你可以修改全部的属性。

打开 Scene01.sks,你会看到有两个背景。 backgrounds 和 backgroundAlpha 除了后者有一个透明的窗口区域外完全相同。我们需要将雪花加在两个背景之间。

将 Snow.sks 文件从项目浏览器中拖进 background 图片的窗子的上方居中。修改下列属性 :

Name: snow
Position: X: 263, Y: 200, Z: -8

我们将 z 坐标设置为 -8,这样例子发射器会放在两个背景节点之间。
点击 Animate 查看窗外下雪的样子!这才有点冬天的样子嘛!

结束

我希望你喜欢这篇教程。我喜欢使用 Tammy Coron’s 的插图 :]

这个故事就讲到这里了!你可以从这里下载完成后的示例项目。

如果你想进一步了解 SpriteKit,请阅读我们的2D Apple Games by Tutorials

有任何问题和建议,请加入下面的讨论。

查看原文: 用 SpriteKit 和 Swift3 创建交互式儿童读物

 

需要 登录 后回复方可回复, 如果你还没有账号你可以 注册 一个帐号。