在ios7,苹果引入了SpriteKit,一个高性能渲染2D的框架。不像中心库(专注于画图)或中心动画(专注于动画过度),SpriteKit专注于不同领域-video games,它是苹果首次涉足ios的图形游戏编程的时代。在发布ios7盒OS X10.9(Mavericks. 2013年WWDC发布)的同时,为了写程序更为简单提供了相同的API在两个平台,尽管苹果从未像SpriteKit提供了一个框架,它有明显的相似之处是Cocos2D等各种开源库。如果你使用的是Cocos2D或类似的过去,你会感觉很熟悉。
现在创建一个工程并选择Game template名为:TextShooter
(sks文件只是标准的归档文件,你可以用NSKeyedUnarchiver和NSKeyedArchiver类来写和读)
)
xcode会为你初始化一些方法例如:
override func viewDidLoad() { super.viewDidLoad() if let view = self.view as! SKView? { // 初始化'GameScene.sks' if let scene = SKScene(fileNamed: "GameScene") { // 让缩放比例填充整个窗口Set the scale mode to scale to fit the window scene.scaleMode = .aspectFill // 加载这个场景(新场景取代旧场景) view.presentScene(scene) } //当运行时,忽视父子类的关系 view.ignoresSiblingOrder = true //在右下角显示FPS的值 view.showsFPS = true
//在右下角显示结点(node)的个数 view.showsNodeCount = true } }
了解完xcode自动初始化的代码,接下来我们自己手动初始化我们自己想要的,选择GameScence.swift,我们不需要didMoveToView()这个方法,现在改成:
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { for touch in touches { let location = touch.location(in: self)//获取当前位置 } }
xcode自带一个GameScene.sks,里面没有我们想要的属性,所以得自己创建,需要添加属性为当前游戏等级数,生活玩家的数量,一个标志,让我们知道等级是否完成,修改GameScene.swift: private var levelNumber: Int //等级制度 private var playerLives: Int //玩家血 private var finished = false //当前游戏是否结束 class func scene(size:CGSize, levelNumber:Int) -> GameScene { return GameScene(size: size, levelNumber: levelNumber)}
override convenience init(size:CGSize) { self.init(size: size, levelNumber: 1) }
/*
创建名为SKLabelNode类的两个实例,并选择一个字体,设置一个文本值,指定一些对齐
*/ init(size:CGSize, levelNumber:Int) { self.levelNumber = levelNumber self.playerLives = 5 super.init(size: size) backgroundColor = SKColor.lightGray() let lives = SKLabelNode(fontNamed: "Courier")//指定字体 lives.fontSize = 16 lives.fontColor = SKColor.black() lives.name = "LivesLabel" lives.text = "Lives: (playerLives)" lives.verticalAlignmentMode = .top lives.horizontalAlignmentMode = .right lives.position = CGPoint(x: frame.size.width, y: frame.size.height) addChild(lives) let level = SKLabelNode(fontNamed: "Courier") level.fontSize = 16 level.fontColor = SKColor.black() level.name = "LevelLabel" level.text = "Level (levelNumber)" level.verticalAlignmentMode = .top level.horizontalAlignmentMode = .left level.position = CGPoint(x: 0, y: frame.height) addChild(level) } required init?(coder aDecoder: NSCoder) { levelNumber = aDecoder.decodeInteger(forKey: "level") playerLives = aDecoder.decodeInteger(forKey: "playerLives") super.init(coder: aDecoder) }
/*
required的使用规则:required
修饰符只能用于修饰类初始化方法
当子类含有异于父类的初始化方法时(初始化方法参数类型和数量异于父类),子类必须要实现父类的required
初始化方法,并且也要使用required
修饰符而不是override
当子类没有初始化方法时,可以不用实现父类的required
初始化方法
*/
override func encode(with aCoder: NSCoder) { aCoder.encode(Int(levelNumber), forKey: "level") aCoder.encode(playerLives, forKey: "playerLives") }
/*
我们给每个label命名,是因为init(coder:)和encode(with aCoder:)方法需要,所有SpriteKit结点,包括SKScene都遵循NSCoding协议
*/
我们配置了两个SKLabelNode,是时候让它们现身了,选择GameView.swift并添加以下代码:
override func viewDidLoad() { super.viewDidLoad() let scene = GameScene(size: view.frame.size, levelNumber: 1) //configure the view let skView = self.view as! SKView skView.showsFPS = true skView.showsNodeCount = true //Sprite Kit applies additional optimizations to improve rendering performance skView.ignoresSiblingOrder = true //Set the scale mode to scale to fit the window scene.scaleMode = .aspectFill skView.presentScene(scene) }
现在你可以顺便了解下override var prefersStatusBarHidden,return true就是状态栏隐藏,反之。现在可以运行下,正常的结果:
背景有了,接下来可以添加一些互动了,毕竟是游戏,我们先添加一个发射子弹的头部,创建Cocoa Touch class 并以SKNode为父类,命名为playerNode,添加一下代码:
import SpriteKit class PlayerNode: SKNode { override init() { super.init() name = "Player (self)" initNodeGraph() //初始化一个结点,内容为"^"(将V旋转180度)作为发射子弹的头部 } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) } private func initNodeGraph() { let label = SKLabelNode(fontNamed: "Courier") //指定字体 label.fontColor = SKColor.blue label.fontSize = 40 label.text = "v" label.zRotation = CGFloat(Double.pi) //绕z轴旋转180度 label.name = "label" self.addChild(label) } }
跟刚才添加level,lives一样,在GameScene,swift里实例化(实现)playerNode:
在addChild(level)后面加上这两行:
playerNode.position = CGPoint(x: frame.midX, y: frame.height * 0.1)
addChild(playerNode)
现在运行你就会看到如下场景:
现在我们来讨论如何用手指来移动它,这边插个题外话,在web前端里面坐标轴是已左上角为基准,但在SpriteKit我测试了下,添加一个结点并设置position(x:0,y:0),效果如下图所示:
所以这里的坐标是以左下角为基准。
我们假设当手指在屏幕下方的0.2部分(以下)滑动的时候就是有意要让发射器移动,下面这段代码就是这个意思:
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { for touch in touches { let location = touch.location(in: self) if location.y < frame.height * 0.2 { let target = CGPoint(x: location.x, y: playerNode.position.y) playerNode.moveToward(target) //移动到手指当前位置 } } }
并且在playerNode类添加:
func moveToward(_ location: CGPoint) { removeAction(forKey: "movement") let distance = pointDistance(position, location) //计算当前位置和发射器位置的直线距离 let screenWidth = UIScreen.main.bounds.size.width let duration = TimeInterval(2 * distance/screenWidth) //转换为时间间隔专用的单位,例如:毫秒 run(SKAction.move(to: location, duration: duration), withKey:"movement")//duration:指定移动过程需要的时间,可以自己指定 }
可以发现上面的pointDistance()并没有定义,这边可以创建一个swift文件专门放置计算点或向量之类的算法,我的算法代码如下:
import UIKit // Takes a CGVector and a CGFLoat. // 返回一个新向量(旧向量的x,y分量乘以参数CGPoint) func vectorMultiply(_ v: CGVector, _ m: CGFloat) -> CGVector { return CGVector(dx: v.dx * m, dy: v.dy * m) } // Takes two CGPoints. // Returns a CGVector representing a direction from p1 to p2. func vectorBetweenPoints(_ p1: CGPoint, _ p2: CGPoint) -> CGVector { return CGVector(dx: p2.x - p1.x, dy: p2.y - p1.y) } // Takes a CGVector. // Returns a CGFloat containing the length of the vector, calculated using // Pythagoras' theorem. //√(x^2+y^2) func vectorLength(_ v: CGVector) -> CGFloat { return CGFloat(sqrtf(powf(Float(v.dx), 2) + powf(Float(v.dy), 2))) } // Takes two CGPoints. Returns a CGFloat containing the distance between them, // calculated with Pythagoras' theorem. //√(x^2+y^2) func pointDistance(_ p1: CGPoint, _ p2: CGPoint) -> CGFloat { return CGFloat( sqrtf(powf(Float(p2.x - p1.x), 2) + powf(Float(p2.y - p1.y), 2))) }
现在运行可以用手指轻触屏幕下方可以移动发射器了(当然在屏幕下方的0.2部分),并且移动速度也还不错,但在移动的过程中这个发射器什么都不会做,我们可以给它添加一些动作,翻转什么的:
func moveToward(_ location: CGPoint) { removeAction(forKey: "movement") let distance = pointDistance(position, location) let screenWidth = UIScreen.main.bounds.size.width let duration = TimeInterval(2 * distance/screenWidth) //转换为时间间隔专用的单位,例如:毫秒 run(SKAction.move(to: location, duration: duration), withKey:"movement") //duration:指定移动过程需要的时间,可以自己指定 let wobbleTime = 0.3 let halfWobbleTime = wobbleTime/2 let wobbling = SKAction.sequence([ SKAction.scaleX(to: 0.2, duration: halfWobbleTime), SKAction.scaleX(to: 1.0, duration: halfWobbleTime) ])//接收一个action队列(数组) let wobbleCount = Int(duration/wobbleTime) //当duration大于wobbleTime时才会大于1,才会执行,所以当距离比较近的时候是不会旋转的,这个可以自由发挥 run(SKAction.repeat(wobbling, count: wobbleCount), withKey: "wobbling") }
现在运行的效果就比较好看一点,现在改添加一些敌人了,创建一个父类为SKNode,命名为:EnemyNode,并添加以下代码:
import SpriteKit class EnemyNode: SKNode { override init() { super.init() name = "Enemy (self)" initNodeGraph() } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) } private func initNodeGraph() { let topRow = SKLabelNode(fontNamed: "Courier-Bold") topRow.fontColor = SKColor.brown topRow.fontSize = 20 topRow.text = "x x" topRow.position = CGPoint(x: -20, y: 15)//为了不显示在屏幕所以设定-20 addChild(topRow) let middleRow = SKLabelNode(fontNamed: "Courier-Bold") middleRow.fontColor = SKColor.brown middleRow.fontSize = 20
middleRow.position = CGPoint(x: -20, y: 0) middleRow.text = "x" addChild(middleRow) let bottomRow = SKLabelNode(fontNamed: "Courier-Bold") bottomRow.fontColor = SKColor.brown bottomRow.fontSize = 20 bottomRow.text = "x x" bottomRow.position = CGPoint(x: -20, y: -15) addChild(bottomRow)
//三个SKLabelNode构成一个敌人
}
}
跟刚才一样在GameScene.swift加载该结点,并为这个结点设置一个函数随机生成x,y坐标来来生成敌人:
在addChild(playerNode)后面添加
spawnEnemies()//随机生成敌人
addChild(enemies)
private func spawnEnemies() { let count = Int(log(Float(levelNumber))) + levelNumber for _ in 0..<count { let enemy = EnemyNode() let size = frame.size; let x = arc4random_uniform(UInt32(size.width * 0.8)) + UInt32(size.width * 0.1) //随机生成x坐标,范围0.1屏幕宽度~0.8屏幕宽度 let y = arc4random_uniform(UInt32(size.height * 0.5)) + UInt32(size.height * 0.5) //随机生成y坐标,范围0.5屏幕高度~0.5屏幕高度 enemy.position = CGPoint(x: CGFloat(x), y: CGFloat(y)) enemies.addChild(enemy) } }
发射器有了,敌人也有了,现在该弄子弹了,创建一个BulletNode继承于SKNode:
// // BulletNode.swift // otherGame // // Created by 陈金伙 on 2017/4/8. // Copyright © 2017年 cjh. All rights reserved. // import SpriteKit class BulletNode: SKNode { var thrust:CGVector = CGVector(dx: 0, dy: 0) override init() { super.init() let dot = SKLabelNode(fontNamed: "Courier") dot.fontColor = SKColor.black dot.fontSize = 40 dot.text = "." addChild(dot) let body = SKPhysicsBody(circleOfRadius: 1) body.isDynamic = true body.categoryBitMask = PlayerMissileCategory //用于定义物理主体所属的类别 body.contactTestBitMask = EnemyCategory //一个掩码,定义哪些类别的物体引起与这个物理体的交集通 body.collisionBitMask = EnemyCategory //定义哪些类别的物理机构可以与这个物理体碰撞 body.fieldBitMask = GravityFieldCategory //定义哪些类别的物理领域可以施加力量在这个物理机构 body.mass = 0.01 //以千克为单位的物体 physicsBody = body name = "Bullet (self)" } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) let dx = aDecoder.decodeFloat(forKey: "thrustX") let dy = aDecoder.decodeFloat(forKey: "thrustY") thrust = CGVector(dx: CGFloat(dx), dy: CGFloat(dy)) } override func encode(with aCoder: NSCoder) { super.encode(with: aCoder) aCoder.encode(Float(thrust.dx), forKey: "thrustX") aCoder.encode(Float(thrust.dy), forKey: "thrustY") } class func bullet(from start: CGPoint, toward destination: CGPoint) -> BulletNode { let bullet = BulletNode() bullet.position = start let movement = vectorBetweenPoints(start, destination) //差的向量 let magnitude = vectorLength(movement) //两点之间的距离 let scaledMovement = vectorMultiply(movement, 1/magnitude) //缩放向量 let thrustMagnitude = CGFloat(100.0) bullet.thrust = vectorMultiply(scaledMovement, thrustMagnitude)//扩大向量,无论屏幕多大都能发射到 bullet.run(SKAction.playSoundFileNamed("shoot.wav", waitForCompletion: false)) return bulle } func applyRecurringForce() { physicsBody!.applyForce(thrust) //对物理体的重心施加力量,如果没有这个函数,发射的子弹会向下滑 } }
同理的向GameScene.swift添加
private let playerBullets = SKNode()
addChild(playerbullets)
并在
touchesBegan()中添加else(默认不移动发射器就是发射子弹)
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { for touch in touches { let location = touch.location(in: self) if location.y < frame.height * 0.2 { let target = CGPoint(x: location.x, y: playerNode.position.y) playerNode.moveToward(target) //移动到手指当前位置 } else { //没有移动发射器就默认发射子弹 let bullet = BulletNode.bullet(from: playerNode.position, toward: location)//从发射器当前位置发射到手指的位置 playerBullets.addChild(bullet) } } }
现在我们考虑当子弹飞出屏幕时可以让它消失(从内存中撤销)在GameScene的update()添加:
override func update(_ currentTime: TimeInterval) { updatebullets() } private func updatebullets() { var bulletsToRemove:[BulletNode] = [] for bullet in playerBullets.children as! [BulletNode] { if !frame.contains(bullet.position) { // 当子弹离开屏幕时放入一个数组 bulletsToRemove.append(bullet) continue } // 对物理体的重心施加力量 bullet.applyRecurringForce() } playerBullets.removeChildren(in: bulletsToRemove) }
现在的子弹遇到敌人并没有攻击性,因为我们只给BulletNode指定物理性质,现在该轮到PlayerNode,EnemyNode了,选择EnemyNode,并添加以下代码:
private func initPhysicsBody() { let body = SKPhysicsBody(rectangleOf: CGSize( 40, height: 40)) body.affectedByGravity = false body.categoryBitMask = EnemyCategory body.contactTestBitMask = PlayerCategory | EnemyCategory //发射器和子弹可以与之碰撞 body.mass = 0.2 //本身重量 body.angularDamping = 0 //阻力 body.linearDamping = 0 body.fieldBitMask = 0 physicsBody = body }
并在init()里添加刚才我们加的initPhysicsBody(),同理,在PlayerNode添加以下代码:
private func initPhysicsBody() { let body = SKPhysicsBody(rectangleOf: CGSize( 20, height: 20)) body.affectedByGravity = false body.categoryBitMask = PlayerCategory body.contactTestBitMask = EnemyCategory body.collisionBitMask = 0 body.fieldBitMask = 0 physicsBody = body }
并在init()添加initPhysicsBody(),现在可以运行试试看效果,当你把屏幕唯一的敌人打掉之后,你就应该想到下一步该设定升级了,当我们把敌人打出屏幕时,也应该像子弹那样从内存中移除,在GameScene添加以下代码:
private func updateEnemies() { var enemiesToRemove:[EnemyNode] = [] for node in enemies.children as! [EnemyNode] { if !frame.contains(node.position) { enemiesToRemove.append(node) continue } } enemies.removeChildren(in: enemiesToRemove) }
并更新update()函数的内容:
override func update(_ currentTime: TimeInterval) { if finished { return } updatebullets() updateEnemies() checkForNextlevel() } private func checkForNextlevel() { //查看是否还有敌人存活 if enemies.children.isEmpty { goToNextLevel() } } private func goToNextLevel() { //进入下一级 finished = true let label = SKLabelNode(fontNamed: "Courier") label.text = "Level Complete!" label.fontColor = SKColor.blue label.fontSize = 32 label.position = CGPoint(x: frame.size.width * 0.5, y: frame.size.height * 0.5) addChild(label) let nextLevel = GameScene(size: frame.size, levelNumber: levelNumber + 1) //等级不断增加 nextLevel.playerLives = playerLives //生命值不变 view!.presentScene(nextLevel, transition: SKTransition.flipHorizontal(withDuration: 1.0)) }
这个游戏里的每个结点都是模拟现实物理的,所以我们还要考虑当我们用子弹打到第一个敌人时,敌人会被打飞,被打飞的过程中或许会碰撞到另一个敌人又或许会随重力下滑,掉落到发射器上,即玩家生命值减一操作,这就需要委托了,因为委托事件里面有contact事件对当前阶段很好用,添加
class GameScene: SKScene, SKPhysicsContactDelegate {
physicsWorld.gravity = CGVector(dx: 0, dy: -1) //设置重力向下
physicsWorld.contactDelegate = self
这边我们可以先想想碰撞的特效xcode提供自带的文件,创建SpriteKit partical file命名MissleExplosion,并在inspector属性进行调整,这边是我的(随便调的,自由发挥):
同理再创建一个命名为EnemyExplosion,并在inspector属性进行调整(自由发挥)
选择GameScene添加以下代码:
func didBegin(_ contact: SKPhysicsContact) { if contact.bodyA.categoryBitMask == contact.bodyB.categoryBitMask { //一样的种类 let nodeA = contact.bodyA.node! let nodeB = contact.bodyB.node! } else { var attacker: SKNode var attackee: SKNode if contact.bodyA.categoryBitMask > contact.bodyB.categoryBitMask {//种类的大小,下面有给图说明 // A attack B attacker = contact.bodyA.node! attackee = contact.bodyB.node! } else { //B attack A attacker = contact.bodyB.node! attackee = contact.bodyA.node! } if attackee is PlayerNode { playerLives -= 1 } //What do we do with the attacker and the attackee? attackee.receiveAttacker(attacker, contact: contact)//扩展类的方法,下面有给 playerBullets.removeChildren(in: [attacker]) enemies.removeChildren(in: [attacker]) } }
四种大小分别代表不同的种类,在我们给他们的physicBody初始化时就有给他们指定,接下来扩展SKnode类,为什么要扩展SKNode?,因为在SpriteKit每个对象都是一个结点,所以扩展SKNode,可以对敌人,发射器,子弹都好操作,新建一个swift file命名SKNode+Extra并添加以下代码:
import SpriteKit extension SKNode { func receiveAttacker(_ attacker: SKNode, contact: SKPhysicsContact) { // Default implementation does nothing physicsBody!.affectedByGravity = true let force = vectorMultiply(attacker.physicsBody!.velocity, contact.collisionImpulse) let myContact = scene!.convert(contact.contactPoint, to: self) physicsBody!.applyForce(force, at: myContact) let path = Bundle.main.path(forResource: "MissileExplosion", ofType: "sks") let explosion = NSKeyedUnarchiver.unarchiveObject(withFile: path!) as! SKEmitterNode explosion.numParticlesToEmit = 20 //默认为0,无限粒子,这边指定20颗粒子 explosion.position = contact.contactPoint //在子弹击中的部位出现粒子 scene!.addChild(explosion) } func friendlyBumpFrom(_ node: SKNode) { // Default implementation does nothing physicsBody!.affectedByGravity = true } }
现在运行你会发现一切都良好,就是尽管敌人掉落到发射器上,玩家的血是没有扣的,我记得明明有添加
if attackee is PlayerNode {
playerLives -= 1
}
可是不起作用,其实是有起作用的,不信你可以调试下在后台输出playerLives,只是没有实时更新到界面,
private var playerLives: Int {
didSet {
let lives = childNode(withName: "LivesLabel") as! SKLabelNode
lives.text = "Lives: (playerLives)"
}
}
更改私有属性变成属性观察者,一旦playerlives有变化就执行didSet里面的代码,现在可以了,但是生命值会一直减,没有尽头的,就像是无敌模式,是时候给这个游戏来个收尾了,
创建cocoa touch class命名GameOverScene,并以SKScene为父类,添加以下代码:
import SpriteKit class GameOverScene: SKScene { override init(size: CGSize) { super.init(size: size) backgroundColor = SKColor.purple let text = SKLabelNode(fontNamed: "Courier") text.text = "Game Over" text.fontColor = SKColor.white text.fontSize = 50 text.position = CGPoint(x: frame.size.width/2, y: frame.size.height/2) addChild(text) } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) } }
这就是结束界面,在GameScene里面来实现它:
private func triggerGameOve() { finished = true let path = Bundle.main.path(forResource:"EnemyExplosion", ofType: "sks") let explosion = NSKeyedUnarchiver.unarchiveObject(withFile: path!) as! SKEmitterNode explosion.numParticlesToEmit = 200 //当生命值为0时,爆炸变的更大 explosion.position = playerNode.position scene!.addChild(explosion) playerNode.removeFromParent() let transition = SKTransition.doorsOpenVertical(withDuration: 1) let gameOver = GameOverScene(size: frame.size) view!.presentScene(gameOver, transition: transition) } private func checkForGame() -> Bool { //添加到update(),实时监测 if playerLives == 0 { triggerGameOve() return true } return false } override func update(_ currentTime: TimeInterval) { if finished { return } updatebullets() updateEnemies() if (!checkForGame()) { checkForNextlevel() } }
现在基本可以完了,要是想要美观的话可以搞个开始界面,这边就不搞了。
要下载全部的代码请到我的github库:https://github.com/TypeInfos/SpriteKit-Game