前言
SpriteKit是为iOS和macOS开发的2D游戏引擎,由苹果开发和维护。SpriteKit可以让开发者在应用中方便地添加动画、物理效果、粒子效果等。但是在使用SpriteKit时往往会出现CPU和内存占用高的问题,另外SpriteKit的坐标系和我们开发常用的坐标系也不相同并且SpriteKit中播放GIF、Lottie、PAG等都是很难甚至无法实现的。在次以一个射击小游戏为例介绍一下优化步骤
创建一个游戏场景
- 创建一个controller (SpriteKitMainViewController)并设置为根控制器
swift
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
let vc = SpriteKitMainViewController()
vc.view.frame = CGRect(x: 0, y: 0, width: SCREENW, height: SCREENH)
window.rootViewController = vc
self.window = window
window.makeKeyAndVisible()
}
}
- 在SpriteKitMainViewController中创建SKMainScene(SKScene的子类)和SKView
swift
import UIKit
import SpriteKit
class SpriteKitMainViewController: UIViewController {
private lazy var skView :SKView = {
let view = SKView(frame: CGRect(x: 0, y: 0, width: SCREENW, height: SCREENH))
return view
}()
private lazy var scene :SKMainScene = {
let scene = SKMainScene(size: CGSize(width: SCREENW, height: SCREENH))
scene.scaleMode = .aspectFill
return scene
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
setupUI()
}
private func setupUI(){
self.view.addSubview(self.skView)
//Create and configure the scene
self.skView.presentScene(self.scene)
}
}
- SKMainScene中创建物理场、背景、玩家角色、敌人角色,注意SpriteKit的描点默认是(0.5,0.5)并且坐标原点是在左下角和UIKit的不同
scss
override init(size: CGSize) {
super.init(size: size)
initPhysicsWorld()
initBackground()
initplayerRole()
initFoeRole()
}
ini
private func initplayerRole() {
let image = OCTool.image(toTransparent: "bear", deleteWhite: false)
playerRole = SKSpriteNode(texture: SKTexture(image: image))
playerRole?.position = CGPoint(x: 160, y: 50)
playerRole?.size = CGSize(width: 100, height: 100)
playerRole?.zPosition = 1
playerRole?.physicsBody = SKPhysicsBody(rectangleOf: CGSize(width: (playerRole?.size.width ?? 0)-20, height: playerRole?.size.height ?? 0))
playerRole?.physicsBody?.categoryBitMask = SKRoleCategory.SKRoleCategoryplayerRole.rawValue
playerRole?.physicsBody?.collisionBitMask = SKRoleCategory.SKRoleCategoryFoeBullet.rawValue//0
playerRole?.physicsBody?.contactTestBitMask = SKRoleCategory.SKRoleCategoryFoeBullet.rawValue//UInt32.init(SKRoleCategory.SKRoleCategoryplayerRole.rawValue)
addChild(playerRole!)
firingBullets(bulletType: 0)
}
arduino
private func initFoeRole() {
let image = UIImage(named: "monster")
guard let image = image else {return}
foeRole = SKSpriteNode(texture: SKTexture(image: image))
guard let foeRole = foeRole else {return}
foeRole.name = "foePlane"
foeRole.size = CGSize(width: image.size.width*2, height: image.size.height*2)
foeRole.physicsBody?.isDynamic = true
//categoryBitMask 设置物理体标识符
foeRole.physicsBody?.categoryBitMask = SKRoleCategory.SKRoleCategoryFoePlayer.rawValue
//collisionBitMask 设置碰撞标识符 (非零 决定了物体能否碰撞反应)
foeRole.physicsBody?.collisionBitMask = SKRoleCategory.SKRoleCategoryplayerRole.rawValue
//contactTestBitMask 设置可以那类物理体碰撞 ->触发检测碰撞事件
foeRole.physicsBody?.contactTestBitMask = SKRoleCategory.SKRoleCategoryplayerRole.rawValue
foeRole.position = CGPoint(x: self.size.width*0.5, y: self.size.height - APPTool.topSafeAreaMargin - foeRole.size.height*0.5)
self.addChild(foeRole)
foeRoleAction()
firingBullets(bulletType: 1)
}
这三个属性是 SpriteKit 框架中 SKPhysicsBody 类的成员属性,用于定义物理体之间的碰撞和接触测试。
categoryBitMask
:该属性指定了物理体所属的分类,用一个位掩码(bitmask)来表示。物理体可以属于多个分类,如果两个物理体的分类位掩码相同,则它们属于同一类,将会相互影响。collisionBitMask
:该属性指定了当前物理体可以发生碰撞的分类。如果两个物理体的分类位掩码的交集不为空,则它们将会发生碰撞。contactTestBitMask
:该属性指定了当前物理体需要检测接触事件的分类。如果两个物理体的分类位掩码的交集不为空,则它们将会触发接触事件。例如,假设要让一个小球能够弹跳,并且可以与墙壁发生碰撞。可以把小球的
categoryBitMask
设置为0b001
,把墙壁的categoryBitMask
设置为0b010
,然后将小球的collisionBitMask
设置为0b010
,这样小球就只会和墙壁发生碰撞,而不会和其他障碍物发生碰撞。总之,这三个属性可以帮助开发者控制物理体之间的碰撞和接触测试,实现各种有趣的物理效果,如弹跳、摩擦、碰撞等。
- 玩家移动躲避子弹或走位发射子弹在SKMainScene中实现系统的touchesMoved方法
arduino
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
for touch in touches {
var location = touch.location(in: self)
guard let playerRole = playerRole else {return}
// 超出屏幕
if location.x >= self.size.width - (playerRole.size.width / 2) {
location.x = self.size.width - (playerRole.size.width / 2)
} else if location.x <= (playerRole.size.width / 2) {
location.x = playerRole.size.width / 2
}
if location.y >= self.size.height - (playerRole.size.height / 2) {
location.y = self.size.height - (playerRole.size.height / 2)
} else if location.y <= (playerRole.size.height / 2) {
location.y = (playerRole.size.height / 2)
}
let action = SKAction.move(to: CGPoint(x: location.x, y: location.y), duration: 0.1)
playerRole.run(action)
}
}
- 碰撞检测,主要检测敌方子弹和己方子弹碰撞时互相抵消,敌方子弹击中己方时敌方子弹消失(己方子弹击中敌人时效果未完善)
swift
func didBegin(_ contact: SKPhysicsContact) {
if (contact.bodyA.categoryBitMask & SKRoleCategory.SKRoleCategoryFoeBullet.rawValue != 0 || contact.bodyB.categoryBitMask & SKRoleCategory.SKRoleCategoryFoeBullet.rawValue != 0) && (contact.bodyA.categoryBitMask & SKRoleCategory.SKRoleCategoryPlayerBullet.rawValue != 0 || contact.bodyB.categoryBitMask & SKRoleCategory.SKRoleCategoryPlayerBullet.rawValue != 0){
contact.bodyA.node?.removeFromParent()
contact.bodyB.node?.removeFromParent()
} else if (contact.bodyA.categoryBitMask & SKRoleCategory.SKRoleCategoryFoeBullet.rawValue != 0 || contact.bodyB.categoryBitMask & SKRoleCategory.SKRoleCategoryFoeBullet.rawValue != 0) && (contact.bodyA.categoryBitMask & SKRoleCategory.SKRoleCategoryplayerRole.rawValue != 0 || contact.bodyB.categoryBitMask & SKRoleCategory.SKRoleCategoryplayerRole.rawValue != 0) {
if contact.bodyA.categoryBitMask == SKRoleCategory.SKRoleCategoryFoeBullet.rawValue {
contact.bodyA.node?.removeFromParent()
}else if contact.bodyB.categoryBitMask == SKRoleCategory.SKRoleCategoryFoeBullet.rawValue {
contact.bodyB.node?.removeFromParent()
}
}
}
至此一个简单的射击小游戏完成
改进
问题
问题1:CPU和内存消耗大
看一下CPU的消耗如下图
目前界面主要是敌我双方和射击的子弹以及背景图CPU已经达到37%,为此是否有办法对CPU的使用率进行优化?
问题2:不能播放GIF等效果
另外虽然SKSpriteNode可以通过SKAction.repeatForever方法播放多张图片达到类似播放GIF的效果
less
let animationAction = SKAction.repeatForever(SKAction.animate(with: textureArray!, timePerFrame: 0.1))
需要注意的是:这种方法也具有一些明显的限制,比如无法像原生的GIF动画一样循环播放并且无法控制播放速度,同时也不能以任意的分辨率缩放GIF动画。当遇到播放lottie设置是pag格式的文件时更加显得无能为力。
思路
加载SKView造成了大量的CUP这里用自定义的View代替SKViwe渲染到屏幕上从而达到降低CPU的开销,另外通过重写func update(_ currentTime: TimeInterval)可以获得SKView每一帧的回调,如果采用UIView代替SKView上的元素SKNode在update函数中监听SKNode的位置大小等变化去更新UIView,那么在UIView上通过添加子控件就可以实现播放GIF、pag、lottie等文件
降低CPU和内存使用
代替SKView
- SpriteKitMainViewController中采用自定义的UIView(scene.contentView)代替skView
swift
private func setupUI(){
//self.scene.contentView代替self.skView
//self.view.addSubview(self.skView)
self.view.addSubview(self.scene.contentView)
//Create and configure the scene
self.skView.presentScene(self.scene)
scene.setUpUI()
}
其中scene.contentView 为自定义UIView
SKMainScene中的实现
swift
private(set) lazy var contentView: SceneView = {
let view = SceneView()
view.touchesMovedClosure = {
[weak self](touchs,event) in
guard let self = self else {
return
}
self.touchesMoved(touchs, with: event)
}
return view
}()
class SceneView:UIView{
}
自定义AJSpriteNode代替原来SKSpriteNode并实现界面显示
- 自定义AJSPriteNode并且在AJSpriteNode中定义属性contentView
swift
class AJSpriteNode: SKSpriteNode {
private(set) var contentView :NodeContentView? = NodeContentView()
func removeContentView(){
self.contentView?.removeFromSuperview()
contentView = nil
}
}
其中contentView是实际加到界面代替SKSpriteNode显示的视图,removeContentView方法是当子弹击中目标或者飞出屏幕时候移除的方法
- AJSpriteNode的contentView代替SKSpriteNode在界面显示,以用户玩家为例
swift
private func initplayerRole() {
playerRole = AJSpriteNode()
guard let playerRole = playerRole else {return}
playerRole.position = CGPoint(x: 160, y: 50)
playerRole.size = CGSize(width: 100, height: 100)
//setUpNodeContent方法实现contentView添加到SceneView上并实现坐标转换
setUpNodeContent(node: playerRole, imgStr: "bear")
playerRole.zPosition = 1
playerRole.physicsBody = SKPhysicsBody(rectangleOf: CGSize(width: (playerRole.size.width )-20, height: playerRole.size.height ))
playerRole.physicsBody?.categoryBitMask = SKRoleCategory.SKRoleCategoryplayerRole.rawValue
playerRole.physicsBody?.collisionBitMask = SKRoleCategory.SKRoleCategoryFoeBullet.rawValue
playerRole.physicsBody?.contactTestBitMask = SKRoleCategory.SKRoleCategoryFoeBullet.rawValue
addChild(playerRole)
firingBullets(bulletType: 0)
}
setUpNodeContent的具体实现
arduino
private func setUpNodeContent(node:AJSpriteNode,imgStr:String?){
if let imgStr = imgStr{
//这里是考虑到子弹是根据颜色生成所以不走这个创建UIImage的方法
node.contentView?.imgView.image = OCTool.image(toTransparent: imgStr, deleteWhite: false)
}
//把SKSpriteNode所在的SpriteKit坐标转换成UIKit的坐标体系
let point = view?.convert(node.position, from: self) ?? .zero
node.contentView?.frame = CGRect(x: 0, y: 0, width: node.size.width, height: node.size.height)
//这里设置centent是因为SKSpriteNode的锚点是(0.5,0.5)
node.contentView?.AJCenterY = point.y
node.contentView?.AJCenterX = point.x
if let nodeContentView = node.contentView{
contentView.addSubview(nodeContentView)
}
//加入到数组是为了后续方便取值
nodeArray.append(node)
}
玩家移动的实现修改
因为此时的SKView没有添加到界面(addSubView)所以原SKMainScene中的touchesMoved方法不会被执行,此时在屏幕移动触发的是添加到SpriteKitMainViewController.view的SceneView touchesMoved方法,为此这里把SceneView的touchesMoved回调到 SKMainScene上保存原来的逻辑
- SceneView touchesMoved实现和转发。实际上这里只是单纯的转发
swift
class SceneView:UIView{
var touchesMovedClosure:((_ touches: Set<UITouch>,_ event: UIEvent?)->Void)?
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?){
//通过闭包回调到SKMainScene从而调用SKMainScene的touchesMoved方法
self.touchesMovedClosure?(touches, event)
}
}
//在SKMainScene中
private(set) lazy var contentView: SceneView = {
let view = SceneView()
view.touchesMovedClosure = {
[weak self](touchs,event) in
guard let self = self else {
return
}
self.touchesMoved(touchs, with: event)
}
return view
}()
- 这里相对原来的方法增加了坐标转换
arduino
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
for touch in touches {
var location = touch.location(in: self)
guard let playerRole = playerRole else {return}
//坐标系的转换
location = convert(location, to: self)
// 超出屏幕
if location.x >= self.size.width - (playerRole.size.width / 2) {
location.x = self.size.width - (playerRole.size.width / 2)
} else if location.x <= (playerRole.size.width / 2) {
location.x = playerRole.size.width / 2
}
if location.y >= self.size.height - (playerRole.size.height / 2) {
location.y = self.size.height - (playerRole.size.height / 2)
} else if location.y <= (playerRole.size.height / 2) {
location.y = (playerRole.size.height / 2)
}
//这里的X、Y 不需要考虑锚点问题,因为会在update(_ currentTime: TimeInterval)中校正
let action = SKAction.move(to: CGPoint(x: location.x, y: location.y), duration: 0.1)
playerRole.run(action)
}
}
实时更新个Node元素的Frame
这里按实际出发因为这里的大小不会发生变化所以这里只更新Node的位置坐标,不更新大小
- 重写SKMainScene的func update(_ currentTime: TimeInterval)方法
swift
override func update(_ currentTime: TimeInterval) {
let nodes = self.nodeArray
for node in nodes{
if let node = node as? AJSpriteNode {
if node.contentView?.superview == nil,let contentView = node.contentView{
//防止在调用该方法时node.contentView未添加到SceneView上
self.contentView.addSubview(contentView)
}
let positon = self.convertPoint(toView: node.position)
node.contentView?.AJCenterX = positon.x
node.contentView?.AJCenterY = positon.y
}
}
}
至此,完成了SpriteKit元素到UIKit元素的替换,运行项目看看CPU和内存情况
数据对比
静止时
上图为修改前
优化后
参数 | 修改前 | 优化后 | 优化比 |
---|---|---|---|
CPU | 37% | 9% | 75.7% |
内存 | 65.8 | 28.7 | 56.6% |
移动玩家时的参数
参数 | 修改前 | 优化后 | 优化比 |
---|---|---|---|
CPU | 41% | 17% | 59% |
内存 | 65.5 | 28.8 | 56% |
效果
暂时无法在飞书文档外展示此内容
播放GIF、PAG、lottie等资源
因为我们现在在界面上已经用UIView代替了SKNode所以播放上述资源的方案和我们平时的使用方法是一致的,这里以播放GIF为例
NodeContentView的修改
- 在NodeContentView中添加FLAnimatedImageView
ini
private var gifView :FLAnimatedImageView?
func setupGIF(){
gifView = FLAnimatedImageView()
gifView?.isUserInteractionEnabled = true
gifView?.isHidden = true
gifView?.animationRepeatCount = 0
gifView?.contentMode = .scaleAspectFill
addSubview(gifView!)
self.setNeedsLayout()
self.layoutIfNeeded()
}
- 暴露播放GIF的方法
ini
func playGIf(){
imgView.isHidden = true
gifView?.isHidden = false
let path = Bundle.main.path(forResource: "bear", ofType: "gif")
let data = NSData(contentsOfFile: path ?? "") as? Data
guard let data = data else {return}
gifView?.animatedImage = FLAnimatedImage(animatedGIFData: data)
gifView?.startAnimating()
}
- 点击图像(小熊)时停止播放并回调
swift
@objc private func stopGif(){
gifView?.isHidden = true
gifView?.stopAnimating()
imgView.isHidden = false
self.stopClosure?()
}
AJSpriteNode的修改
- 添加hitCount计算当小熊中弹3次时触发播放GIF
private
var
hitCount = 0
- 传入标识创建GIF,这里因为是demo所以用Mark == 100 代表小熊,才创建GIF。其他如敌机或者子弹等不创建实际开发中按实际情况处理
swift
//当Mark == 100是创建GIF
var mark:Int8 = 0{
didSet{
if mark == 100{
//玩家的mark为100
contentView?.stopClosure = {
//点击小熊的回调清空计数
[weak self] in
guard let self = self else {
return
}
self.hitCount = 0
}
contentView?.setupGIF()
}
}
}
- 触发中弹的处理,中弹3次后触发GIF
csharp
//中弹的处理
func getshot(){
if hitCount < 3{
hitCount += 1
return
}
contentView?.playGIf()
}
效果:
demo地址:gitee.com/liangaijun/...
结语
关于SpriteKit的其他优化方案网上也有介绍这里就不一一列出,如果文章长有出现纰漏希望大家指出