Swift-SpriteKit 游戏开发探索

加入人物

人物共有11张图片,连起来就是动态走路,由于图片略大,这里把xy方向同时缩小到0.4倍,人物的物理体设置为纹理(非透明部分),且不允许旋转,采用精确碰撞检测,即纹理边缘,人物受各种力作用。最后设置11张图片循环播放

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
func knightInit(){
knight = SKSpriteNode(imageNamed: "sister.altas/sister0")
knight.xScale = 0.4
knight.yScale = 0.4
knight.position = CGPoint(x: 500, y: 200)
knight.physicsBody = SKPhysicsBody(texture: SKTexture(imageNamed: "sister.altas/sister0"), size: knight.size)
knight.physicsBody?.allowsRotation = false
knight.physicsBody?.density = 1
knight.physicsBody?.usesPreciseCollisionDetection = true
knight.physicsBody?.isDynamic = true
knight.physicsBody?.friction = 0
knight.physicsBody?.affectedByGravity = true
//knight.addChild(blingbling)
addChild(knight)
knight.physicsBody?.categoryBitMask = knightCategory
knight.physicsBody?.contactTestBitMask = floorCategory | ballCategory
knight.physicsBody?.collisionBitMask = floorCategory | ballCategory | platformCategory
for i in 0...10 {
let dbTexture = SKTexture(imageNamed: "sister.altas/sister" + String(i))
frames.append( dbTexture )
}
//播放动画
let actionAnimation = SKAction.repeatForever(SKAction.animate(with: frames, timePerFrame: 0.08, resize: true, restore: false))
knight.run(actionAnimation)
}

虚拟摇杆

类似王者荣耀,在游戏左下角设置虚拟摇杆,但是位置不固定,可以随着手指改变位置

先构造摇杆UI,一个是外部圆圈circleBar,一个是内部手指按动的点位centerPoint

1
2
3
4
5
6
7
8
9
10
11
12
13
func jointBar(){
circleBar = SKShapeNode.init(rectOf: CGSize.init(width: 106, height: 106), cornerRadius: 53)
circleBar.position = CGPoint(x: 100, y: 100)
circleBar.lineWidth = 2
circleBar.strokeColor = .green
circleBar.name = "circleBar"
addChild(circleBar)
centerPoint = SKShapeNode.init(circleOfRadius: 6)
centerPoint.fillColor = SKColor.blue
centerPoint.position = CGPoint(x: 100, y: 100)
centerPoint.name = "centerPoint"
addChild(centerPoint)
}

然后分别重写下面三个函数,获取手指在屏幕上滑动动作,进行响应,手指触碰的第一个点作为虚拟摇杆当前的初始点位,内圈centerPoint始终限制在外圈里,滑动结束后,摇杆归位

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
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let beginPosition = touches.first else {
return
}
circleBar.position = beginPosition.location(in: self)
centerPoint.position = beginPosition.location(in: self)
}

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
for t in touches {
let position = t.location(in: self)
moveX = position.x-circleBar.position.x
moveY = position.y-circleBar.position.y
let length = moveX * moveX + moveY * moveY
let lg = sqrt(length)
if lg<50 {
centerPoint.position = position
}
else{
let outY = (50*moveY)/lg
let outX = (50*moveX)/lg
centerPoint.position = CGPoint(x: outX+circleBar.position.x, y: outY+circleBar.position.y)
}
}
}

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
centerPoint.position = CGPoint(x: 100, y: 100)
circleBar.position = CGPoint(x: 100, y: 100)
moveX = 0
moveY = 0
knight.physicsBody?.velocity.dx = 0
}

对于2D游戏,虚拟摇杆只能控制左右的移动,因此内圈在左半边就是向左,在右半边就是向右,可以重写update函数加入对精灵的左右移动的控制

1
2
3
4
5
6
7
8
9
override func update(_ currentTime: TimeInterval) {
// Called before each frame is rendered
if moveX < 0 {
knight.physicsBody?.velocity.dx = -80
}
if moveX > 0 {
knight.physicsBody?.velocity.dx = 80
}
}

跳跃

首先在右下角放置一个跳跃的按键

1
2
3
4
5
6
7
func jumpBarInit(){
jumpBar = SKShapeNode.init(circleOfRadius: 20)
jumpBar.fillColor = .orange
jumpBar.position = CGPoint(x: 700, y: 100)
jumpBar.name = "jumpBar"
addChild(jumpBar)
}

改写touchesBegan函数如下,如果点击区域在跳跃键位置,则给人物一个向上的脉冲力,人物受重力影响,会按照正常物理规则上升再降落,此时也可操纵摇杆,一边跳跃一边移动

1
2
3
4
5
6
7
8
9
10
11
12
13
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let beginPosition = touches.first else {
return
}
let touchLocation = beginPosition.location(in: self)
if jumpBar.contains(touchLocation) {
knight.physicsBody?.applyImpulse(CGVector(dx: 0, dy: 40))
}
else{
circleBar.position = touchLocation
centerPoint.position = touchLocation
}
}

冲刺

水平冲刺就是给人物一个水平方向的脉冲力

先在右下角加入一个冲刺的按钮

1
2
3
4
5
6
7
func sprintBarInit(){
sprintBar = SKShapeNode.init(circleOfRadius: 20)
sprintBar.fillColor = .green
sprintBar.position = CGPoint(x: 800, y: 100)
sprintBar.name = "sprintBar"
addChild(sprintBar)
}

然后修改touchesBegan函数,响应按下冲刺按钮的动作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let beginPosition = touches.first else {
return
}
let touchLocation = beginPosition.location(in: self)
if jumpBar.contains(touchLocation) {
knight.physicsBody?.applyImpulse(CGVector(dx: 0, dy: 40))
}
if sprintBar.contains(touchLocation){
if direction == true {
knight.physicsBody?.applyImpulse(CGVector(dx: 50, dy: 0))
}
else{
knight.physicsBody?.applyImpulse(CGVector(dx: -50, dy: 0))
}
}
if touchLocation.x < 300 {
circleBar.position = touchLocation
centerPoint.position = touchLocation
}
}

其中direction为类成员变量,为false方向是左,true向右,在update函数中加入判断,实时更新方向

1
2
3
4
5
6
7
8
9
10
override func update(_ currentTime: TimeInterval) {
if moveX < 0 {
knight.physicsBody?.velocity.dx = -80
direction = false
}
if moveX > 0 {
knight.physicsBody?.velocity.dx = 80
direction = true
}
}

1.14更新

以上都是探索阶段,下面的是实际用的代码

玩家状态机

上面写的代码已经很复杂了,对于状态的转换,采用SpriteKit自带的类“状态机”很方便。需要先拟定玩家的几种状态,和它们之间的转换关系,第i行第j列表示是否能从状态i转换到状态j,是为1,否为0。这部分好难搞,花了好几天时间才弄明白。

\ 站立 跳跃 下落 冲刺 走路 着陆
站立 0 1 0 1 1 1
跳跃 0 0 1 0 0 0
下落 0 0 0 1 0 1
冲刺 1 0 1 0 1 0
走路 1 1 1 1 0 0
着陆 1 0 0 0 0 0

代码如下:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
class PlayerState: GKState {
unowned var playerNode: SKSpriteNode
var StandTexture = SKTexture(imageNamed: "sister.altas/sister0")
var JumpTexture = SKTexture(imageNamed: "sister.altas/sister4")
var SprintTexture = SKTexture(imageNamed: "sister.altas/sister7")
init(playerNode: SKSpriteNode) {
self.playerNode = playerNode
super.init()
}
}

class JumpingState: PlayerState {
var validFlag: UInt8 = 2
var timer: Timer = Timer()

override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is FallingState.Type:
return true
default:
return false
}
}

override func didEnter(from previousState: GKState?) {
print("enter jumpingState")
validFlag = 0
playerNode.physicsBody?.applyImpulse(CGVector(dx: 0, dy: 40))
playerNode.removeAction(forKey: "walk")
playerNode.texture = JumpTexture
stateMachine?.enter(FallingState.self)
}
}


class SprintState: PlayerState {
var validFlag: Bool = true
var hasFinishedSprint: Bool = false

override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is JumpingState.Type, is SprintState.Type, is LandingState.Type:
return false
default:
return true
}
}
override func didEnter(from previousState: GKState?) {
print("enter sprintState")
validFlag = false
hasFinishedSprint = false
if playerNode.xScale > 0 {
playerNode.run(SKAction.moveBy(x: -50, y: 0, duration: 0.1))
}
else{
playerNode.run(SKAction.moveBy(x: 50, y: 0, duration: 0.1))
}
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: false, block: {(timer) in
self.hasFinishedSprint = true
self.stateMachine?.enter(FallingState.self)
})
playerNode.removeAction(forKey: "walk")
playerNode.texture = SprintTexture
}
}


class WalkingState: PlayerState {
var frames: [SKTexture] = []
var actionAnimation: SKAction!
override init(playerNode: SKSpriteNode) {
for i in 0...10 {
let dbTexture = SKTexture(imageNamed: "sister.altas/sister" + String(i))
frames.append(dbTexture)
}
//播放动画
actionAnimation = SKAction.repeatForever(SKAction.animate(with: frames, timePerFrame: 0.08, resize: true, restore: false))
super.init(playerNode: playerNode)
}
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is WalkingState.Type, is LandingState.Type:
return false
default:
return true
}
}
override func didEnter(from previousState: GKState?) {
print("enter walkingState" , playerNode.xScale)
if !(previousState is WalkingState){
playerNode.run(actionAnimation, withKey: "walk")
}
if playerNode.xScale > 0 {
playerNode.physicsBody?.velocity.dx = -80
}
else {
playerNode.physicsBody?.velocity.dx = 80
}
}
}

class FallingState: PlayerState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is SprintState.Type, is LandingState.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
print("enter FallingState")
return
}
}

class StandState: PlayerState{
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is StandState.Type, is FallingState.Type, is LandingState.Type:
return false
default:
return true
}
}
override func didEnter(from previousState: GKState?) {
print("enter standState")
playerNode.removeAction(forKey: "walk")
playerNode.texture = StandTexture
playerNode.physicsBody?.velocity = CGVector(dx: 0, dy: 0)
}
}

class LandingState: PlayerState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
if stateClass is StandState.Type{
return true
}
else {
return false
}
}

override func didEnter(from previousState: GKState?) {
print("enter LandingState")
stateMachine?.enter(StandState.self)
}
}

在GameScene中,添加GKStateMachine,与玩家绑定作为状态机,初始进入下落状态

1
2
playerStateMachine = GKStateMachine(states: [JumpingState(playerNode: knight), SprintState(playerNode: knight), StandState(playerNode: knight), WalkingState(playerNode: knight), FallingState(playerNode: knight), LandingState(playerNode: knight)])
playerStateMachine.enter(FallingState.self)

多点触控

最开始编码的时候,发现了个问题:同时移动摇杆,按跳跃键时不好使,原来是没有开启多点触控,导致touch的各个函数参数只有一个触摸位置,需要在GameScene的构造函数中加入

1
view.isMultipleTouchEnabled = true

美化-按钮点击动效

用户按下按钮之后需要加个动效,表示按钮已经按下,这里是用了简单的透明度渐变来做的,代码如下:

1
2
3
4
5
6
7
8
9
extension GameScene {
func pressJumpAnimation() {
jumpBar.run(SKAction.sequence([SKAction.fadeAlpha(to: 0.5, duration: 0.05), SKAction.fadeAlpha(to: 1.0, duration: 0.05)]))
}

func pressSprintAnimation() {
sprintBar.run(SKAction.sequence([SKAction.fadeAlpha(to: 0.5, duration: 0.05), SKAction.fadeAlpha(to: 1.0, duration: 0.05)]))
}
}

视角跟随

玩家左右移动时,需要进行视角跟随,避免丢失视野,建立SKNode作为世界坐标系,将场景中的物体全部添加到该节点的子节点,在物理渲染全部结束后,改变此节点的位置。下面的代码可以使得玩家永远处于屏幕正中央。

1
2
3
override func didSimulatePhysics() {
worldNode.position = CGPoint(x: -(knight.position.x-(self.size.width/2)), y: -(knight.position.y-(self.size.height/2)))
}

深度值设置

完成视角跟随之后,发现了另一个问题,如果地板与按钮重叠时,地板在上层,这样很不合理,因此改变节点的zPosition属性,将背景的节点设置为-1,其余默认为0,就可以使按钮正常显示在顶层了。

改进

当我希望向场景中添加多个同样的物体时,需要写很多重复的代码,我想到了一个方法,为一些不同类别的物体,设计SKSpriteNode的子类,他们的物体性质固定,但是可以放置在不同位置,改变大小。

比如下面的代码将游戏中经常出现的跳跃平台、玩家角色分别封装。需要注意的是对父类的super.init()必须采用非convenience的构造函数,否则会报错

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
37
38
39
40
41
class PlatformProperty: SKSpriteNode {
init(widthRatio: CGFloat, positionInit: CGPoint) {
let texture = SKTexture(imageNamed: "platform")
let textureSize = texture.size()
super.init(texture: texture, color: .black, size: textureSize)
position = positionInit
physicsBody = SKPhysicsBody(texture: texture, size: textureSize)
physicsBody?.isDynamic = false
physicsBody?.usesPreciseCollisionDetection = true
physicsBody?.restitution = 0
xScale = 0.5 * widthRatio
yScale = 0.5
}

required init? (coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
}

class PlayerProperty: SKSpriteNode {
public var scaleValue: CGFloat = 0.3
init(positionInit: CGPoint) {
let texture = SKTexture(imageNamed: "sister.altas/sister0")
super.init(texture: texture, color: .black, size: texture.size())
position = positionInit
physicsBody = SKPhysicsBody(texture: texture, size: texture.size())
physicsBody?.allowsRotation = false
physicsBody?.density = 1
physicsBody?.usesPreciseCollisionDetection = true
physicsBody?.isDynamic = true
physicsBody?.friction = 0.0
physicsBody?.restitution = 0.0
physicsBody?.affectedByGravity = true
xScale = scaleValue
yScale = scaleValue
}

required init? (coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
}

目前,基本上把逻辑调通了

update函数中也删去了一些不必要的代码,简化为只判断LandingState条件

1
2
3
4
5
override func update(_ currentTime: TimeInterval) {
if knight.physicsBody!.velocity.dy.magnitude < 1e-10 && playerStateMachine.currentState is FallingState {
playerStateMachine.enter(LandingState.self)
}
}

SpriteKit Rendering Loop

The rendering loop is tied to the SKScene object. Rendering runs only when the scene is presented. SpriteKit only renders the scene when something changed so its efficient. Here’s what goes on in the rendering loop each frame:

  • update - make changes to nodes, evaluate nodes
  • SKScene -> evaluates actions
  • didEvaluateActions -
  • SKScene -> simulates physics
  • didSimulatePhysics -
  • SKScene -> applies constraints
  • didApplyConstraints
  • didFinishUpdate
  • SKView renders the scene

官网上给的流程图如下:

avatar

使用纹理集

今天犯了一个及其愚蠢的错误,纹理集的文件夹后缀应该是.atlas,我给写成了.altas,调了半天,就是不好使,今晚浪费了…

1
2
let dbAtlas = SKTextureAtlas(named: "enemy.atlas")
let texture = dbAtlas.textureNamed("enemyWalkingLeft0")

把玩家纹理集也弄成atlas了,这样比单个图片加载效率更高

加入敌人

在素材网站下了几张图,凑合用了下,能左右行走

构造了一个敌人SKSpriteNode子类,加入了敌人的固定移动动作(keepWalking函数),保持重复的左右巡逻。

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
class EnemyNode : SKSpriteNode {
let dbAtlas = SKTextureAtlas(named: "enemy.atlas")
var perceptionDistance: CGFloat = 120
var frameWalkingLeft = [SKTexture]()
var frameWalkingRight = [SKTexture]()
init(positionInit: CGPoint) {
let texture = dbAtlas.textureNamed("enemyWalkingLeft0")
frameWalkingLeft.append(texture)
let size = texture.size()
super.init(texture: texture, color: .black, size: size)
position = positionInit
physicsBody = SKPhysicsBody(texture: texture, size: size)
physicsBody?.isDynamic = true
physicsBody?.allowsRotation = false
physicsBody?.affectedByGravity = true
physicsBody?.usesPreciseCollisionDetection = true
physicsBody?.restitution = 0
frameWalkingLeft.append(dbAtlas.textureNamed("enemyWalkingLeft1"))
frameWalkingRight.append(dbAtlas.textureNamed("enemyWalkingRight0"))
frameWalkingRight.append(dbAtlas.textureNamed("enemyWalkingRight1"))
keepWalking()
}

required init? (coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}

func keepWalking() {
let walkingLeftAnimation = SKAction.repeat(SKAction.animate(with: frameWalkingLeft, timePerFrame: 0.2, resize: true, restore: false), count: 10)
let walkingRightAnimation = SKAction.repeat(SKAction.animate(with: frameWalkingRight, timePerFrame: 0.2, resize: true, restore: false), count: 10)
let walkingLeftAction = SKAction.group([walkingLeftAnimation, SKAction.moveBy(x: -70, y: 0, duration: 4)])
let walkingRightAction = SKAction.group([walkingRightAnimation, SKAction.moveBy(x: 70, y: 0, duration: 4)])
run(SKAction.repeatForever(SKAction.sequence([walkingLeftAction, walkingRightAction])))
}
}

之前玩家状态机存在一个bug,就是下落状态时无法跳跃,可是忽略了从平台上下落的情况,因此在PlayerState类中添加成员变量hasJumped,初始化为false,如果进入JumpingState,则将其设为true,进入LandingState再设为false


1月24日更新:修复了若干bug,优化了代码结构

Protocol的使用

今天把代码全部重构,重头戏就是Protocol的妙用,现学现卖

最直观的理解就是类A在某些情况下需要类B做一些事情,但是他们的成员变量并不共享,无法触发一些连续的事件,这个时候类A作为委托方,写一个协议,类B中实现协议的接口,类A就可以调用类B的接口,实现一系列响应。实际应用的时候分为以下步骤:

  1. 先声明Protocol,作为class来定义,例如

    1
    2
    3
    protocol OperatorBarPlayerDelegate : class{
    func updateDirection(direction: Direction)
    }
  2. 作为被委托方的类继承该Protocol;
    class PlayerNode: SKSpriteNode, OperatorBarPlayerDelegate {}

  3. 在委托方类中加入代理成员 weak var delegatePlayer: OperatorBarPlayerDelegate?
  4. 在被委托方中实现Protocol中的接口
  5. 创建委托方和被委托方的实例,设置代理 A.delegatePlayer = B

代码实际架构

包含三个主要类:按钮和用户交互类、人物类、玩家状态机类;

按钮和用户交互类作为控制源头,设置为委托方,在另外两个类中分别设计协议,更新玩家状态,赋予人物相应动作。

Thank you for every coin~