版本记录
版本号 | 时间 |
---|---|
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 Win
或You 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 Won
或You 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游戏,感兴趣的给个赞或者关注~~~