SpriteKit框架详细解析(四) —— 创建一个简单的2D游戏(二)

版本记录

版本号 时间
V1.0 2017.08.12

前言

SpriteKit框架使用优化的动画系统,物理模拟和事件处理支持创建基于2D精灵的游戏。接下来这几篇我们就详细的解析一下这个框架。相关代码已经传至GitHub - 刀客传奇,感兴趣的可以阅读另外几篇文章。
1. SpriteKit框架详细解析(一) —— 基本概览(一)
2. SpriteKit框架详细解析(二) —— 一个简单的动画实例(一)
3. SpriteKit框架详细解析(三) —— 创建一个简单的2D游戏(一)

Collision Detection and Physics: Overview - 碰撞检测和物理:概述

你的忍者真正想要做的就是打倒怪物。因此,是时候添加一些代码来检测射弹何时与目标相交。

关于SpriteKit的一个好处是它内置了一个物理引擎!物理引擎不仅非常适合模拟真实的运动,而且它们也非常适合碰撞检测。

您将设置游戏以使用SpriteKit的物理引擎来确定怪物和射弹何时发生碰撞。从高层次来看,这就是你要做的事情:

  • Set up the physics world - 建立物理世界。物理世界是运行物理计算的模拟空间。默认情况下,在场景中设置一个,您可能希望在其上配置一些属性,如重力。
  • Create physics bodies for each sprite - 为每个精灵创建物理实体。在SpriteKit中,您可以将形状与每个sprite相关联以进行碰撞检测,并在其上设置某些属性。这被称为物理体physics body。请注意,物理主体形状不必与精灵完全相同。通常它是一个更简单,近似的形状,而不是像素完美,因为这对大多数游戏和性能已经可以满足了。
  • Set a category for each type of sprite - 为每种类型的精灵设置一个类别。您可以在物理主体上设置的属性之一是类别category,该类别是指示其所属的组或组的位掩码。在这个游戏中,你将有两个类别:一个用于射弹,一个用于怪物。然后当两个物理实体碰撞时,你可以通过查看它的类别轻松地告诉你正在处理什么样的精灵。
  • Set a contact delegate - 设置联系代理。还记得早期的物理世界吗?那么,您可以在其上设置联系人委托contact delegate,以便在两个物理机构发生碰撞时得到通知。在那里你会写一些代码来检查对象的类别,如果它们是怪物和抛射物,你会让它们爆炸!

现在您已经了解了战斗计划,现在是时候将其付诸行动了!


Collision Detection and Physics: Implementation - 碰撞检测与物理:实施

GameScene.swift的顶部添加下面这个结构体

struct PhysicsCategory {
  static let none      : UInt32 = 0
  static let all       : UInt32 = UInt32.max
  static let monster   : UInt32 = 0b1       // 1
  static let projectile: UInt32 = 0b10      // 2
}

这段代码设置了你需要的物理类别的常量。

注意:您可能想知道这里有什么花哨的语法。 SpriteKit上的类别只是一个32位整数,充当位掩码。 这是一种奇特的说法,即整数中的每个32位代表一个类别(因此最多可以有32个类别)。 在这里你设置第一个位来指示一个怪物,下一个位来表示一个射弹,依此类推。

接下来,在实现SKPhysicsContactDelegate协议的GameScene.swift末尾创建一个扩展:

extension GameScene: SKPhysicsContactDelegate {

}

然后在didMove(to :)里面添加玩家到场景后添加这些行:

physicsWorld.gravity =.zero
physicsWorld.contactDelegate = self

这将物理世界设置为没有重力,并将场景设置为当两个物理体碰撞时要通知的代理。

addMonster()里面,在创建怪物精灵后立即添加这些行:

monster.physicsBody = SKPhysicsBody(rectangleOf: monster.size) // 1
monster.physicsBody?.isDynamic = true // 2
monster.physicsBody?.categoryBitMask = PhysicsCategory.monster // 3
monster.physicsBody?.contactTestBitMask = PhysicsCategory.projectile // 4
monster.physicsBody?.collisionBitMask = PhysicsCategory.none // 5

这是这样做的:

  • 1)为精灵创建一个物理主体。在这种情况下,身体被定义为与精灵相同大小的矩形,因为这对于怪物来说是一个不错的近似值。
  • 2)将精灵设置为动态dynamic。这意味着物理引擎无法控制怪物的移动。您将使用您已编写的代码来进行移动。
  • 3)将类别位掩码设置为您之前定义的monsterCategory
  • 4)contactTestBitMask指示此对象在相交时应通知联系人侦听器的对象类别。你在这里选择射弹。
  • 5)collisionBitMask指示物理引擎处理的对象的哪些类别的对象接触响应(即反弹)。你不希望怪物和抛射物互相反弹 - 在这个游戏中他们可以直接穿过对方 - 所以你把它设置为.none

接下来在touchesEnded(_:with :)中添加一些类似的代码,在设置射弹位置的线后面:

projectile.physicsBody = SKPhysicsBody(circleOfRadius: projectile.size.width/2)
projectile.physicsBody?.isDynamic = true
projectile.physicsBody?.categoryBitMask = PhysicsCategory.projectile
projectile.physicsBody?.contactTestBitMask = PhysicsCategory.monster
projectile.physicsBody?.collisionBitMask = PhysicsCategory.none
projectile.physicsBody?.usesPreciseCollisionDetection = true

作为测试,看看你是否能够理解这里的每一行以及它的作用。 如果没有,请参阅上面解释的要点!

接下来,添加一个方法,在GameScene的闭合大括号之前射弹与怪物碰撞时将被调用。 没有什么能自动调用它,你稍后会调用。

func projectileDidCollideWithMonster(projectile: SKSpriteNode, monster: SKSpriteNode) {
  print("Hit")
  projectile.removeFromParent()
  monster.removeFromParent()
}

你在这里所做的就是在碰撞时从场景中移除射弹和怪物。 很简单吧?

现在是时候实现联系委托方法了。 将以下新方法添加到您之前创建的扩展中:

func didBegin(_ contact: SKPhysicsContact) {
  // 1
  var firstBody: SKPhysicsBody
  var secondBody: SKPhysicsBody
  if contact.bodyA.categoryBitMask < contact.bodyB.categoryBitMask {
    firstBody = contact.bodyA
    secondBody = contact.bodyB
  } else {
    firstBody = contact.bodyB
    secondBody = contact.bodyA
  }
 
  // 2
  if ((firstBody.categoryBitMask & PhysicsCategory.monster != 0) &&
      (secondBody.categoryBitMask & PhysicsCategory.projectile != 0)) {
    if let monster = firstBody.node as? SKSpriteNode,
      let projectile = secondBody.node as? SKSpriteNode {
      projectileDidCollideWithMonster(projectile: projectile, monster: monster)
    }
  }
}

由于您之前将场景设置为物理世界的contactDelegate,因此只要两个物理实体发生碰撞并且相应地设置了contactTestBitMasks,就会调用此方法。

这个方法有两个部分:

  • 1)此方法将两个碰撞的实体传递给您,但不保证它们以任何特定顺序传递。 所以这段代码只是安排它们,所以它们按类别位掩码进行排序,这样你就可以稍后做出一些假设。
  • 2)这里是检查碰撞的两个物体是否是射弹和怪物,如果是这样,你之前写的方法就被调用。

Build并运行,现在当你的射弹与目标相交时,它们应该消失!


Finishing Touches - 结束点击

你现在非常接近拥有一个非常简单但可行的游戏。 你只需要添加一些音效和音乐 - 什么样的游戏没有声音? - 和一些简单的游戏逻辑。

本教程的项目资源已经有一些很酷的背景音乐和一个很棒的pew-pew声音效果。 你只需要玩它们!

为此,将这些行添加到didMove(to :)的末尾:

let backgroundMusic = SKAudioNode(fileNamed: "background-music-aac.caf")
backgroundMusic.autoplayLooped = true
addChild(backgroundMusic)

这使用SKAudioNode播放和循环播放您游戏的背景音乐。

至于声音效果,请在touchesEnded(_:withEvent :)中的guard语句后添加此行:

run(SKAction.playSoundFileNamed("pew-pew-lei.caf", waitForCompletion: false))

Build并运行,就会发现一切OK了。

注意:如果您没有听到背景音乐,请尝试在设备上运行而不是在模拟器上运行。


Game Over, Man!

现在,创建一个新场景,作为You WinYou Lose指示器。 使用iOS \ Source \ Swift File模板创建一个新文件,将文件命名为GameOverScene,然后单击Create

将以下内容添加到GameOverScene.swift

import SpriteKit

class GameOverScene: SKScene {
  init(size: CGSize, won:Bool) {
    super.init(size: size)
    
    // 1
    backgroundColor = SKColor.white
    
    // 2
    let message = won ? "You Won!" : "You Lose :["
    
    // 3
    let label = SKLabelNode(fontNamed: "Chalkduster")
    label.text = message
    label.fontSize = 40
    label.fontColor = SKColor.black
    label.position = CGPoint(x: size.width/2, y: size.height/2)
    addChild(label)
    
    // 4
    run(SKAction.sequence([
      SKAction.wait(forDuration: 3.0),
      SKAction.run() { [weak self] in
        // 5
        guard let `self` = self else { return }
        let reveal = SKTransition.flipHorizontal(withDuration: 0.5)
        let scene = GameScene(size: size)
        self.view?.presentScene(scene, transition:reveal)
      }
      ]))
   }
  
  // 6
  required init(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
}

这里有六个部分要指出:

  • 1)将背景颜色设置为白色,与主场景相同。
  • 2)根据won参数,消息设置为You WonYou Lose
  • 3)这是使用SpriteKit在屏幕上显示文本标签的方法。如您所见,它非常简单。您只需选择字体并设置一些参数即可。
  • 4)最后,这将设置并运行两个动作的序列。首先它等待3秒,然后它使用run()动作来运行一些任意代码。
  • 5)这是您在SpriteKit中转换到新场景的方法。您可以从各种不同的动画过渡中选择您想要的场景显示方式。在这里,您选择了需要0.5秒的翻转过渡。然后创建要显示的场景,并在self.view上使用presentScene(_:transition :)
  • 6)如果在场景上重写了初始值器,则还必须实现所需的init(coder :)初始化器。但是,永远不会调用此初始化程序,因此您现在只需添加一个带有fatalError(_ :)的虚拟实现。

到现在为止还挺好!现在,您只需设置主场景,在适当的时候在场景中加载游戏结束页面。

切换回GameScene.swift,在addMonster()里面,用以下内容替换monster.run(SKAction.sequence([actionMove,actionMoveDone])):

let loseAction = SKAction.run() { [weak self] in
  guard let `self` = self else { return }
  let reveal = SKTransition.flipHorizontal(withDuration: 0.5)
  let gameOverScene = GameOverScene(size: self.size, won: false)
  self.view?.presentScene(gameOverScene, transition: reveal)
}
monster.run(SKAction.sequence([actionMove, loseAction, actionMoveDone]))

这会创建一个新的lose action,当怪物离开屏幕时会在场景中显示游戏结束场景。 看看你是否理解这里的每一行,如果没有参考前面代码块的解释。

现在你也应该处理胜利的情况,不要对你的玩家残忍!在player声明之后立即将新属性添加到GameScene的顶部:

var monstersDestroyed = 0

并在projectileDidCollideWithMonster(projectile:monster:):的底部添加下面代码:

monstersDestroyed += 1
if monstersDestroyed > 30 {
  let reveal = SKTransition.flipHorizontal(withDuration: 0.5)
  let gameOverScene = GameOverScene(size: self.size, won: true)
  view?.presentScene(gameOverScene, transition: reveal)
}

在这里你可以追踪玩家摧毁的怪物数量。 如果玩家成功摧毁了超过30个怪物,则游戏结束并且玩家赢得游戏!

Build并运行。 你现在应该有胜利和失败的条件,并在适当的时候看到场景中的游戏结束场景!


源码

下面给一个具体的源码。

1. GameScene.swift
import SpriteKit

func +(left: CGPoint, right: CGPoint) -> CGPoint {
  return CGPoint(x: left.x + right.x, y: left.y + right.y)
}

func -(left: CGPoint, right: CGPoint) -> CGPoint {
  return CGPoint(x: left.x - right.x, y: left.y - right.y)
}

func *(point: CGPoint, scalar: CGFloat) -> CGPoint {
  return CGPoint(x: point.x * scalar, y: point.y * scalar)
}

func /(point: CGPoint, scalar: CGFloat) -> CGPoint {
  return CGPoint(x: point.x / scalar, y: point.y / scalar)
}

#if !(arch(x86_64) || arch(arm64))
func sqrt(a: CGFloat) -> CGFloat {
  return CGFloat(sqrtf(Float(a)))
}
#endif

extension CGPoint {
  func length() -> CGFloat {
    return sqrt(x*x + y*y)
  }
  
  func normalized() -> CGPoint {
    return self / length()
  }
}

class GameScene: SKScene {
  
  struct PhysicsCategory {
    static let none      : UInt32 = 0
    static let all       : UInt32 = UInt32.max
    static let monster   : UInt32 = 0b1       // 1
    static let projectile: UInt32 = 0b10      // 2
  }
  
  // 1
  let player = SKSpriteNode(imageNamed: "player")
  var monstersDestroyed = 0
  
  override func didMove(to view: SKView) {
    // 2
    backgroundColor = SKColor.white
    // 3
    player.position = CGPoint(x: size.width * 0.1, y: size.height * 0.5)
    // 4
    addChild(player)
    
    physicsWorld.gravity = .zero
    physicsWorld.contactDelegate = self
    
    run(SKAction.repeatForever(
      SKAction.sequence([
        SKAction.run(addMonster),
        SKAction.wait(forDuration: 1.0)
        ])
    ))
    
    let backgroundMusic = SKAudioNode(fileNamed: "background-music-aac.caf")
    backgroundMusic.autoplayLooped = true
    addChild(backgroundMusic)
  }
  
  func random() -> CGFloat {
    return CGFloat(Float(arc4random()) / 0xFFFFFFFF)
  }
  
  func random(min: CGFloat, max: CGFloat) -> CGFloat {
    return random() * (max - min) + min
  }
  
  func addMonster() {
    // Create sprite
    let monster = SKSpriteNode(imageNamed: "monster")
    
    monster.physicsBody = SKPhysicsBody(rectangleOf: monster.size) // 1
    monster.physicsBody?.isDynamic = true // 2
    monster.physicsBody?.categoryBitMask = PhysicsCategory.monster // 3
    monster.physicsBody?.contactTestBitMask = PhysicsCategory.projectile // 4
    monster.physicsBody?.collisionBitMask = PhysicsCategory.none // 5
    
    // Determine where to spawn the monster along the Y axis
    let actualY = random(min: monster.size.height/2, max: size.height - monster.size.height/2)
    
    // Position the monster slightly off-screen along the right edge,
    // and along a random position along the Y axis as calculated above
    monster.position = CGPoint(x: size.width + monster.size.width/2, y: actualY)
    
    // Add the monster to the scene
    addChild(monster)
    
    // Determine speed of the monster
    let actualDuration = random(min: CGFloat(2.0), max: CGFloat(4.0))
    
    // Create the actions
    let actionMove = SKAction.move(to: CGPoint(x: -monster.size.width/2, y: actualY), duration: TimeInterval(actualDuration))
    let actionMoveDone = SKAction.removeFromParent()
    let loseAction = SKAction.run() { [weak self] in
      guard let `self` = self else { return }
      let reveal = SKTransition.flipHorizontal(withDuration: 0.5)
      let gameOverScene = GameOverScene(size: self.size, won: false)
      self.view?.presentScene(gameOverScene, transition: reveal)
    }
    monster.run(SKAction.sequence([actionMove, loseAction, actionMoveDone]))
  }
  
  override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    // 1 - Choose one of the touches to work with
    guard let touch = touches.first else {
      return
    }
    run(SKAction.playSoundFileNamed("pew-pew-lei.caf", waitForCompletion: false))
    
    let touchLocation = touch.location(in: self)
    
    // 2 - Set up initial location of projectile
    let projectile = SKSpriteNode(imageNamed: "projectile")
    projectile.position = player.position
    
    projectile.physicsBody = SKPhysicsBody(circleOfRadius: projectile.size.width/2)
    projectile.physicsBody?.isDynamic = true
    projectile.physicsBody?.categoryBitMask = PhysicsCategory.projectile
    projectile.physicsBody?.contactTestBitMask = PhysicsCategory.monster
    projectile.physicsBody?.collisionBitMask = PhysicsCategory.none
    projectile.physicsBody?.usesPreciseCollisionDetection = true
    
    // 3 - Determine offset of location to projectile
    let offset = touchLocation - projectile.position
    
    // 4 - Bail out if you are shooting down or backwards
    if offset.x < 0 { return }
    
    // 5 - OK to add now - you've double checked position
    addChild(projectile)
    
    // 6 - Get the direction of where to shoot
    let direction = offset.normalized()
    
    // 7 - Make it shoot far enough to be guaranteed off screen
    let shootAmount = direction * 1000
    
    // 8 - Add the shoot amount to the current position
    let realDest = shootAmount + projectile.position
    
    // 9 - Create the actions
    let actionMove = SKAction.move(to: realDest, duration: 2.0)
    let actionMoveDone = SKAction.removeFromParent()
    projectile.run(SKAction.sequence([actionMove, actionMoveDone]))
  }
  
  func projectileDidCollideWithMonster(projectile: SKSpriteNode, monster: SKSpriteNode) {
    print("Hit")
    projectile.removeFromParent()
    monster.removeFromParent()
    
    monstersDestroyed += 1
    if monstersDestroyed > 30 {
      let reveal = SKTransition.flipHorizontal(withDuration: 0.5)
      let gameOverScene = GameOverScene(size: self.size, won: true)
      view?.presentScene(gameOverScene, transition: reveal)
    }
  }
}

extension GameScene: SKPhysicsContactDelegate {
  func didBegin(_ contact: SKPhysicsContact) {
    // 1
    var firstBody: SKPhysicsBody
    var secondBody: SKPhysicsBody
    if contact.bodyA.categoryBitMask < contact.bodyB.categoryBitMask {
      firstBody = contact.bodyA
      secondBody = contact.bodyB
    } else {
      firstBody = contact.bodyB
      secondBody = contact.bodyA
    }
    
    // 2
    if ((firstBody.categoryBitMask & PhysicsCategory.monster != 0) &&
      (secondBody.categoryBitMask & PhysicsCategory.projectile != 0)) {
      if let monster = firstBody.node as? SKSpriteNode,
        let projectile = secondBody.node as? SKSpriteNode {
        projectileDidCollideWithMonster(projectile: projectile, monster: monster)
      }
    }
  }
}
2. GameViewController.swift
import UIKit
import SpriteKit

class GameViewController: UIViewController {
  override func viewDidLoad() {
    super.viewDidLoad()
    let scene = GameScene(size: view.bounds.size)
    let skView = view as! SKView
    skView.showsFPS = true
    skView.showsNodeCount = true
    skView.ignoresSiblingOrder = true
    scene.scaleMode = .resizeFill
    skView.presentScene(scene)
  }
  
  override var prefersStatusBarHidden: Bool {
    return true
  }
}
3. GameOverScene.swift
import Foundation
import SpriteKit

class GameOverScene: SKScene {
  init(size: CGSize, won:Bool) {
    super.init(size: size)
    
    // 1
    backgroundColor = SKColor.white
    
    // 2
    let message = won ? "You Won!" : "You Lose :["
    
    // 3
    let label = SKLabelNode(fontNamed: "Chalkduster")
    label.text = message
    label.fontSize = 40
    label.fontColor = SKColor.black
    label.position = CGPoint(x: size.width/2, y: size.height/2)
    addChild(label)
    
    // 4
    run(SKAction.sequence([
      SKAction.wait(forDuration: 3.0),
      SKAction.run() { [weak self] in
        // 5
        guard let `self` = self else { return }
        let reveal = SKTransition.flipHorizontal(withDuration: 0.5)
        let scene = GameScene(size: size)
        self.view?.presentScene(scene, transition:reveal)
      }
      ]))
  }
  
  // 6
  required init(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
}

实现效果

下面看一下最终效果,我自己玩了一局!

后记

本篇主要讲述了创建一个简单的2D游戏,感兴趣的给个赞或者关注~~~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容