本次项目实战所用的资源均来自保卫萝卜官方App 保卫萝卜官网
保护知识产权,人人有责!
前言:从事iOS应用开发好几年了,跟着Swift语言的发展过来的,这些年来没有接触过游戏开发,感觉挺可惜的,所以决定开始学习用SpriteKit来开发游戏。写文章把自己的开发过程记录下来,希望能够和其他开发者更多进行交流学习。
创建项目
开发环境:Xcode12、Swift 5
我们在Xcode上选择File → New → Project
或者使用快捷键Shift + Command + N
来打开创建项目面板,填入项目名称和组织名称,游戏语言Swift
,游戏框架选择SpriteKit
,点击Next
开始自己的新项目。
项目配置
新项目中有些文件我们暂时用不到,先把GameScene.sks
和Actions.sks
这两个文件删除了吧,这两个文件是为了方便管理SKNode(节点)
和SKAction(动作)
的。我们先从代码开始学习暂时就不需要这种方式来构建场景。
然后我们创建三个新的目录Utils
、Nodes
和Scenes
,分别用来管理通用、节点和场景类的文件。我们在Utils
目录下创建Functions.swift
、Constants.swift
和Extensions.swift
三个文件,接着在Nodes
目录下创建BaseNode.swift
文件,然后把GameScene.swift
文件移动到Scenes
目录下,最后项目的文件结构如下:
AppDelegate.swift // 应用入口
GameViewController.swift // 视图控制器
Main.storyboard // 故事板
|-- Utils // 通用目录
|-- Constants.swift // 通用常量
|-- Extensions.swift // 通用拓展
|-- Functions.swift // 通用方法
|-- Nodes // 节点目录
|-- BaseNode.swift // 自定义的 SKSpriteNode 节点
|-- Scenes // 场景目录
|-- GameScene.swift // 应用主场景
添加资源
正所谓兵马未动粮草先行,项目要开发了当然要准备图片和音频等资源啦,把资源文件添加进项目里面吧,注意勾选Copy items if needed
,选择Create groups
,然后点击Add
,这些资源包括了应用图标、启动图、散图、纹理集、音频、数值设定等文件。
设置应用图标和启动图
使用快捷键 Command + R
开始编译项目
开始的项目没有应用图标、启动图,我们需要自己设置。把资源文件里的应用图标对应地拖进项目吧,然后给应用起一个新的名称
因为游戏项目基本是横屏的,所以需要修改项目支持的屏幕方向,在项目设置中的Device Orientation
把 Portrait
勾选项给取消掉。
应用启动页推荐使用 LaunchScreen.storyboard
, 创建一个 LaunchScreen
文件,然后在该文件上添加自己想要的启动页视图就好了,这里我直接放了一张图片,只需要一张横屏图就可以适配绝大部分机型了。
配置场景
创建场景
新项目的的文件入口是 Main.storyboard → GameViewController.swift → GameScene.swift
因为我们删除了默认生成的场景文件GameScene.sks
,里面的场景也无法获取了,我们需要通过代码来创建新的场景,我们需要在GameViewController.swift
文件的viewDidLoad()
方法中修改成以下代码:
override func viewDidLoad() {
super.viewDidLoad()
if let view = self.view as! SKView? {
let scene = GameScene(size: CGSize(width: 1334, height: 750))
// 设置锚点位置在中心
scene.anchorPoint = CGPoint(x: 0.5, y: 0.5)
// 设置场景的缩放模式
scene.scaleMode = .aspectFill
// 展示场景
view.presentScene(scene)
// 设置忽略同一z轴上的节点添加顺序
view.ignoresSiblingOrder = true
// 显示fps
view.showsFPS = true
// 显示节点数量
view.showsNodeCount = true
}
}
关于anchorPoint
的部分我们下面会进行补充,我们接着进入 GameScene.swift
文件中,删除系统自动生成的代码,剩下的代码如下:
import SpriteKit
import GameplayKit
class GameScene: SKScene {
override func sceneDidLoad() {
}
}
接下来我们要添加首页背景图片,开始之前需要解释一下纹理集 SKTextureAtlas,这个和其他开发框架中的图集概念差不多,比如 cocoa2d-x
中经常使用的 .pvr.ccz
文件(支持加密),纹理集或图集可以把多张图片集合到一张图片中,用 plist
文件描述每张图片的尺寸等信息,可以更方便地管理图片资源。
项目中的图片资源我根据 plist
文件编译生成了对应的获取纹理集图片的 struct
。
纹理集的目录结构如下:
|-- Mainscene1-hd.atlasc
|-- Mainscene1-hd.plist
|-- Mainscene1-hd.png
|-- Mainscene1-hd.swift
-
Mainscene1-hd.plist
→ 图片描述文件 -
Mainscene1-hd.png
→ 合成的大图片 -
Mainscene1-hd.swift
→ 根据plist
文件编译的struct
其中 Mainscene1-hd.swift
的代码示例如下:
import SpriteKit
struct Mainscene1_hd {
private static let textureAtlas = SKTextureAtlas(named: "Mainscene1-hd")
static let mainbg = textureAtlas.textureNamed("mainbg")
}
添加首页背景图片
我们先创建一个自定义的精灵节点,方便管理后面添加的所有节点,在�BaseNode.swift
文件中添加下面的代码:
import SpriteKit
class BaseNode: SKSpriteNode {
init(texture: SKTexture?) {
let size: CGSize = texture != nil ? CGSize(width: texture!.size().width, height: texture!.size().height) : .zero
super.init(texture: texture, color: .clear, size: size)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
来添加首页的背景图片吧,在sceneDidLoad()
方法中添加下面的代码就可以了:
let background = BaseNode(texture: Mainscene1_hd.mainbg)
background.zPosition = -1
addChild(background)
-
sceneDidLoad()
→ 场景加载完成,场景初始化后仅会调用一次 -
didMove(to view: SKView)
→ 场景入口,每次进入场景都会调用 -
addChild(_ node: SKNode)
→ 尾部添加子节点SKNode,常见的节点有:
SKSpriteNode → 精灵节点,最常用的节点,管理纹理实例(即图片)
SKVideoNode → 视频节点
SKAudioNode → 音频节点
SKLabelNode → 文本节点
SKShapeNode → 形状节点
SKEmitterNode → 粒子节点
SKCropNode → 裁剪节点
SKEffectNode → 核心图像过滤节点,SKScene继承自该类
SKTileMapNode → 瓦片地图节点,常用于构建地图
SK3DNode → 3D节点 -
texture:
SKTexture → 纹理实例,每个纹理实例代表一张可复用的图片 -
zPosition = -1
→ 设置背景图的图层在最底层,避免出现同层的图片被背景遮盖的情况
运行项目,效果如下:
图片正常显示出来了,可喜可贺,鼓掌啪啪啪!但是,其实这里隐藏着一个问题!因为缩放显示的原因,场景左右都出现了空白区域,如果场景中添加的其他节点超出了背景图的左右边界,就会显示成这样:
这可不符合我们的效果预期,我都可以想到后面的炮塔子弹超出背景的时候出现能打穿屏幕的滑稽场面了,我们需要想想办法了。
裁剪显示区域
考虑到项目资源中的背景图片比例是(960,640)
,再参考如下设备横屏宽高比例:
- (4/3 - 1.33) ,设备:iPad 所有型号
- (16/9 - 1.77) ,设备:iPhone 6/7/8/SE s系列、Plus系列
- (448/207 - 2.16),设备:iPhone X/XR/XS/11/12 Max系列、Pro系列、mini系列
如果我们使用默认的图片比例1.5
的话,在大部分设备上都会出现空白区域,我用了一个笨方法,动态修正场景的尺寸,使其和显示区域一致。
我们在Constants.swift
文件添加以下常量和变量:
public let kImageWidth: CGFloat = 960
public let kImageHeight: CGFloat = 640
public let kImageRatio: CGFloat = kImageWidth/kImageHeight
public var kImageScale: CGFloat = 1.0
然后在Functions.swift
文件添加以下方法:
public func sceneSize() -> CGSize {
var deviceWidth: CGFloat = 0, deviceHeight: CGFloat = 0
// 获取当前设备的长边和短边
let maxBound: CGFloat = max(UIScreen.main.bounds.width, UIScreen.main.bounds.height)
let minBound: CGFloat = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height)
// 判断当前设备是不是iPhone,如果是,默认横屏显示
if UIDevice.current.userInterfaceIdiom == .phone {
deviceWidth = maxBound
deviceHeight = minBound
} else {
// 用switch语句判断当前设备的方向
switch UIDevice.current.orientation {
case .unknown:
// 未知即初次启动未改变屏幕方向,之间设置当前设备的宽高
deviceWidth = UIScreen.main.bounds.width
deviceHeight = UIScreen.main.bounds.height
case .portrait, .portraitUpsideDown:
// 竖屏
deviceWidth = minBound
deviceHeight = maxBound
default:
// 默认横屏
deviceWidth = maxBound
deviceHeight = minBound
}
}
// 计算设备宽高比
let ratio = deviceWidth / deviceHeight
var width: CGFloat = 0, height: CGFloat = 0
// 通过对比设备的宽高比和图片的宽高比来确定场景的宽高
if ratio < kImageRatio {
kImageScale = deviceWidth/kImageWidth
width = deviceWidth
height = width / kImageRatio
} else {
kImageScale = deviceHeight/kImageHeight
height = deviceHeight
width = height * kImageRatio
}
return CGSize(width: width, height: height)
}
让我们回到GameViewController.swift
文件,修改viewDidLoad()
中的代码:
override func viewDidLoad() {
super.viewDidLoad()
if let skView = view as? SKView {
let scene = GameScene(size: sceneSize())
scene.backgroundColor = .black
scene.anchorPoint = CGPoint(x: 0.5, y: 0.5)
scene.scaleMode = .aspectFit
skView.presentScene(scene)
skView.ignoresSiblingOrder = true
skView.showsFPS = true
skView.showsNodeCount = true
}
}
接着还需要在BaseNode.swift
中修改初始化方法:
init(texture: SKTexture?) {
let size: CGSize = texture != nil ? CGSize(width: texture!.size().width * kImageScale, height: texture!.size().height * kImageScale) : .zero
super.init(texture: texture, color: .clear, size: size)
}
运行项目,效果如下:
场景的缩放模式
场景有一个属性是scaleMode
,是一个枚举值,其值如下:
-
aspectFit
此模式可以保持场景比例不变,而且全部显示在视图中 -
aspectFill
此模式可以保持场景比例不变,会填充整个视图,但是可能只有部分场景显示出来 -
fill
此模式可以让场景全部显示出来,但是会导致场景变形 -
resizeFill
此模式可以保证场景与视图的尺寸相匹配
我们使用的场景拉伸模式是aspectFit
,在iPad等大型显示设备上也有不俗的效果,如下所示:
添加节点和动画
前面我们添加了首页的背景图片,那么就接着在GameScene
中添加其他节点吧。
let carrot = SKSpriteNode(texture: Mainscene1_hd.carrot)
addChild(carrot)
效果如下:
场景坐标系
仔细一看,小萝卜的位置需要调整啊!此时我们需要先了解 SKScene
场景中的坐标系。
SKScene
场景的坐标系默认以屏幕的中心点为原点(实际是SKScene的锚点,可以通过修改值来改变原点位置)
,分别有x轴、y轴、z轴。我们添加的子节点如果没有进行设置,默认都在原点的位置。
那么我们怎么修改节点的位置呢,首先要了解节点的两个属性 anchorPoint(锚点)
和 position(位置)
。
-
anchorPoint(锚点)
→ 锚点是一个节点的真正核心位置,其概念和现实中物体的重心差不多,网上有很多详尽的资料,我这里就简单介绍好了。锚点的值在0~1
的范围内,赋予其他值会被修正,默认是在中心点(0.5,0.5)
,其位置如下所示:
(0,1)—————————(1,1)
| |
| (0.5,0.5) |
| |
(0,0)—————————(1,0)
-
position(位置)
→ 自身锚点相对于父节点锚点的位置
请务必注意,如果先设置 position
调整好节点的位置,之后又修改了 anchorPoint
会导致节点的坐标出现偏移,当然也可以通过拓展节点来优化坐标计算。
在Constants.swift
文件中添加新的常量:
// 默认锚点,通过这个值统一子节点的位置计算
public let kAnchorPoint = CGPoint(x: 0.5, y: 0.5)
在Extensions.swift
文件中添加新的拓展:
public extension SKSpriteNode {
var autoPosition: CGPoint {
set {
let realWidth = frame.width / kImageScale
let realHeight = frame.height / kImageScale
position = CGPoint(x: (newValue.x + (realWidth * (anchorPoint.x - kAnchorPoint.x))) * kImageScale, y: (newValue.y + (realHeight * (anchorPoint.y - kAnchorPoint.y))) * kImageScale)
} get {
return position
}
}
var autoAnchorPoint: CGPoint {
set {
anchorPoint = CGPoint(x: limit(newValue.x), y: limit(newValue.y))
position = CGPoint(x: (position.x + (frame.width * (anchorPoint.x - kAnchorPoint.x))), y: (position.y + (frame.width * (anchorPoint.y - kAnchorPoint.y))))
} get {
return anchorPoint
}
}
// 限制值的范围在0~1之间
private func limit(_ num: CGFloat) -> CGFloat {
return max(0, min(num, 1))
}
}
之后对节点的 autoPosition
和 autoAnchorPoint
进行设置,无论怎么怎么改变锚点的位置,统一以锚点在(0.5,0.5)
来计算位置。
给节点添加动画
上面我们介绍了 anchorPoint
,如果我们给节点添加动作 SKAction
的话,需要注意动作是基于anchorPoint
来进行的,如果默认的锚点位置不能达成想要的动画效果,就需要通过修改锚点来调整动画效果了。
我们在Functions.swift
文件中添加通用角度转换的方法:
public func angle(_ angle: CGFloat) -> CGFloat {
return angle * CGFloat.pi / 180
}
接着在GameScene.swift
文件中添加setupCarrot()
方法并进行调用
func setupCarrot() {
// 加载纹理
let carrot = SKSpriteNode(texture: Mainscene1_hd.carrot)
carrot.autoPosition = CGPoint(x: 0, y: 45)
let leaf1 = SKSpriteNode(texture: Mainscene1_hd.leaf_1)
leaf1.autoPosition = CGPoint(x: -65, y: 130)
let leaf2 = SKSpriteNode(texture: Mainscene1_hd.leaf_2)
leaf2.autoPosition = CGPoint(x: 5, y: 160)
let left3 = SKSpriteNode(texture: Mainscene1_hd.leaf_3)
left3.autoPosition = CGPoint(x: 60, y: 140)
// 修改锚点,添加动画
leaf2.autoAnchorPoint = CGPoint(x: 0.4, y: 0)
leaf2.run(.sequence([
.rotate(toAngle: angle(-10), duration: 0),
.wait(forDuration: 2),
.repeatForever(rotateAction())
]))
// 修改锚点,添加动画
left3.autoAnchorPoint = CGPoint(x: 0.2, y: 0)
left3.run(.sequence([
.wait(forDuration: 6),
.repeatForever(rotateAction())
]))
// 添加节点
addChild(leaf1)
addChild(left3)
addChild(leaf2)
addChild(carrot)
// 添加上层标题节点
let codebg = SKSpriteNode(texture: Mainscene1_hd.mainbg_cn)
codebg.position = CGPoint(x: -14, y: -54)
addChild(codebg)
}
/// 根据已有角度生成摇晃SKAction
func rotateAction() -> SKAction {
return .sequence([
.rotate(byAngle: angle(10), duration: 0.1),
.rotate(byAngle: angle(10), duration: 0.1),
.rotate(byAngle: angle(10), duration: 0.1),
.rotate(byAngle: angle(-10), duration: 0.1),
.wait(forDuration: 8)
])
}
因为萝卜的图层在上方,所以需要修改添加顺序,也可以通过修改节点的zPosition
属性改变精灵的层级来达到效果,zPosition
默认是0,数值越大,节点层级越高
动作效果如下:
关于动作SKAction
在前面我们给节点添加了动作, SKAction 是我们生成动画的核心类,下面简单介绍一下它的几个重要属性和方法:
duration
→ 动作时长,通常在初始化动作的时候设置,动作组会自动计算时长speed
→ 动作速度,默认值是1.0,像游戏中的二倍速、三倍速可以通过调节这个属性了达成效果move(to location: CGPoint, duration: TimeInterval) -> SKAction
→ 节点位置移动方法,创建一个移动到坐标点location
的动作,动作时间为duration
。该动作不可逆,动作结束后保持移动的位置。还有其他单独移动x轴moveTo(x: CGFloat, duration: TimeInterval)
和单独移动y轴moveTo(y: CGFloat, duration: TimeInterval)
的方法。在本次项目中经常应用的就是怪物和炮塔子弹的移动了。move(by delta: CGVector, duration: TimeInterval) -> SKAction
→ 节点位置移动方法,动作可逆,创建一个x轴和y轴偏移的动作,delta
包含了x轴和y轴的偏移量。rotate(byAngle radians: CGFloat, duration: TimeInterval) -> SKAction
→ 节点旋转方法,基于原有角度进行相对角度旋转,每次旋转不改变节点的原有角度,动作结束后回到原有角度。rotate(toAngle radians: CGFloat, duration: TimeInterval) -> SKAction
→ 节点旋转方法,基于传入角度进行绝对角度旋转,每次旋转都改变节点的原有角度,动作结束后保持旋转角度。
根据上面的介绍,大家应该能够观察到 SKAction
各种动作的规律了,使用 by
都是基于现有状态的相对变化,使用 to
是一种绝对变化,根据自己的需求使用对应的方法吧。
关于 SKAction
的其他动作还有很多,这里就最终介绍已经使用了的剩余相关动作:
sequence(_ actions: [SKAction]) -> SKAction
→ 根据动作组创建一个串行队列动作,如果了解过线程概念的应该都比较懂,串行队列中的动作会按顺序来运行,如果使用动作的reversed()
方法会会使串行队列的动作变成逆向运行,例如:[1,2,3] -> [3R,2R,1R]
。group(_ actions: [SKAction]) -> SKAction
→ 根据动作组创建一个并行队列动作,并行队列中的动作会同时进行,总动作时长是由队列中动作的最长时间决定的。wait(forDuration duration: TimeInterval) -> SKAction
→ 等待一定时间。repeatForever(_ action: SKAction) -> SKAction
→ 动作一直循环。repeat(_ action: SKAction, count: Int) -> SKAction
→ 动作循环一定次数。
总结
在这次开发过程中,我们创建了新项目,也学习了纹理集和简单动画的使用,对于简单的页面布置应该没什么问题了,接下来就是时间的活啦,让我们来把首页铺满吧!
在下次的学习中,我们将学习节点的点击事件和场景切换,也会开始制作选关界面!
在学习过程中,我学习了很多文章,也搬运了一个流程比较清晰的游戏项目开发教程,大家可以先参考下面的文章,加深对SpriteKit的了解
- 如何在 Swift 3 中用 SpriteKit 框架编写游戏 (Part 1)
- 如何在 Swift 3 中用 SpriteKit 框架编写游戏 (Part 2)
- 如何在 Swift 3 中用 SpriteKit 框架编写游戏 (Part 3)