关于 axiom-spritekit
面向 SpriteKit 游戏开发者的实践指南,涵盖场景架构、物理、动作、输入与性能调优。重点说明常见错误及其处理方式。
主要功能
- 01场景图与坐标系
- 02场景转换与状态传递
- 03物理碰撞与位掩码
- 04动作和触控输入
- 05帧率与绘制优化
适用场景
- 01搭建游戏场景与摄像机
- 02排查物理接触未触发
- 03实现触控和场景切换
- 04优化游戏帧率
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-spritekit安装后在 Claude Code 中输入/axiom-spritekit确认已加载。
详细介绍
SpriteKit 游戏开发指南
目的:通过掌握场景图、物理引擎、动作系统和渲染管线,构建可靠的 SpriteKit 游戏 iOS 版本:iOS 13+(SwiftUI 集成),iOS 11+(SKRenderer) Xcode:Xcode 15+
何时使用此技能
在以下情况下使用此技能:
- 构建新的 SpriteKit 游戏或交互式模拟
- 实现物理效果(碰撞、接触、力、关节)
- 设置游戏架构(场景、图层、摄像机)
- 优化帧率或减少绘制调用
- 在游戏中实现触摸/输入处理
- 管理场景切换和数据传递
- 将 SpriteKit 与 SwiftUI 或 Metal 集成
- 调试未触发的物理接触
- 解决坐标系混淆问题
以下情况请勿使用此技能:
- SceneKit 3D 渲染(
axiom-scenekit) - GameplayKit 实体-组件系统
- Metal 着色器编程(
axiom-metal-migration-ref) - 通用 SwiftUI 布局(
axiom-swiftui-layout)
1. 心智模型
坐标系
SpriteKit 使用左下角原点,Y 轴向上。这与 UIKit(左上角原点,Y 轴向下)不同。
SpriteKit: UIKit:
┌─────────┐ ┌─────────┐
│ +Y │ │ (0,0) │
│ ↑ │ │ ↓ │
│ │ │ │ +Y │
│(0,0)──→+X│ │ │ │
└─────────┘ └─────────┘
锚点定义精灵上与其 position 对应的点。默认值为 (0.5, 0.5)(中心)。
// Common anchor point trap:
// Anchor (0, 0) = bottom-left of sprite is at position
// Anchor (0.5, 0.5) = center of sprite is at position (DEFAULT)
// Anchor (0.5, 0) = bottom-center (useful for characters standing on ground)
sprite.anchorPoint = CGPoint(x: 0.5, y: 0)
场景锚点将视图的框架映射到场景坐标:
(0, 0)— 场景原点位于视图左下角(默认)(0.5, 0.5)— 场景原点位于视图中心
节点树
SpriteKit 中的所有内容都是树形层级中的一个 SKNode。父节点的变换会传递给子节点。
SKScene
├── SKCameraNode (viewport control)
├── SKNode "world" (game content layer)
│ ├── SKSpriteNode "player"
│ ├── SKSpriteNode "enemy"
│ └── SKNode "platforms"
│ ├── SKSpriteNode "platform1"
│ └── SKSpriteNode "platform2"
└── SKNode "hud" (UI layer, attached to camera)
├── SKLabelNode "score"
└── SKSpriteNode "healthBar"
Z 轴顺序
zPosition 控制绘制顺序。值越高,渲染位置越靠上。同一 zPosition 上的节点按子节点数组中的顺序渲染(除非 ignoresSiblingOrder 为 true)。
// Establish clear z-order layers
enum ZLayer {
static let background: CGFloat = -100
static let platforms: CGFloat = 0
static let items: CGFloat = 10
static let player: CGFloat = 20
static let effects: CGFloat = 30
static let hud: CGFloat = 100
}
2. 场景架构
缩放模式选择
| 模式 | 行为 | 适用场景 |
|---|---|---|
.aspectFill | 填满视图,裁切边缘 | 全屏游戏(大多数游戏) |
.aspectFit | 适应视图,留黑边 | 需要精确布局的益智游戏 |
.resizeFill | 拉伸以填满视图 | 几乎不会使用——会造成变形 |
.fill | 精确匹配视图大小 | 场景需适应任意宽高比 |
class GameScene: SKScene {
override func sceneDidLoad() {
scaleMode = .aspectFill
// Design for a reference size, let aspectFill crop edges
}
}
摄像机节点模式
始终使用 SKCameraNode 控制视口。将 HUD 元素附加到摄像机上,使它们不会随画面滚动。
let camera = SKCameraNode()
camera.name = "mainCamera"
addChild(camera)
self.camera = camera
// HUD follows camera automatically
let scoreLabel = SKLabelNode(text: "Score: 0")
scoreLabel.position = CGPoint(x: 0, y: size.height / 2 - 50)
camera.addChild(scoreLabel)
// Move camera to follow player
let follow = SKConstraint.distance(SKRange(constantValue: 0), to: playerNode)
camera.constraints = [follow]
图层组织
// Create layer nodes for organization
let worldNode = SKNode()
worldNode.name = "world"
addChild(worldNode)
let hudNode = SKNode()
hudNode.name = "hud"
camera?.addChild(hudNode)
// All gameplay objects go in worldNode
worldNode.addChild(playerSprite)
worldNode.addChild(enemySprite)
// All UI goes in hudNode (moves with camera)
hudNode.addChild(scoreLabel)
场景切换
// Preload next scene for smooth transitions
guard let nextScene = LevelScene(fileNamed: "Level2") else { return }
nextScene.scaleMode = .aspectFill
let transition = SKTransition.fade(withDuration: 0.5)
view?.presentScene(nextScene, transition: transition)
场景间的数据传递:使用共享的游戏状态对象,而不是节点属性。
class GameState {
static let shared = GameState()
var score = 0
var currentLevel = 1
var playerHealth = 100
}
// In scene transition:
let nextScene = LevelScene(size: size)
// GameState.shared is already accessible
view?.presentScene(nextScene, transition: .fade(withDuration: 0.5))
注意:对于简单游戏,使用单例即可。对于需要测试的较大型项目,可以考虑通过场景初始化器传递 GameState 实例,以避免隐藏的全局状态。
在 willMove(from:) 中清理:
override func willMove(from view: SKView) {
removeAllActions()
removeAllChildren()
physicsWorld.contactDelegate = nil
}
3. 物理引擎
位掩码规范
这是 SpriteKit 错误的首要来源。 物理位掩码使用 32 位系统,其中每一位代表一个类别。
struct PhysicsCategory {
static let none: UInt32 = 0
static let player: UInt32 = 0b0001 // 1
static let enemy: UInt32 = 0b0010 // 2
static let ground: UInt32 = 0b0100 // 4
static let projectile: UInt32 = 0b1000 // 8
static let powerUp: UInt32 = 0b10000 // 16
}
三个位掩码属性(默认值均为 0xFFFFFFFF — 全部):
| 属性 | 用途 | 默认值 |
|---|---|---|
categoryBitMask | 此物理体是什么 | 0xFFFFFFFF |
collisionBitMask | 此物理体会与什么发生弹跳碰撞 | 0xFFFFFFFF |
contactTestBitMask | 什么会触发代理回调 | 0x00000000 |
默认的 collisionBitMask 为 0xFFFFFFFF,意味着所有物体都会彼此碰撞。 这是最常见的意外物理行为来源。
// CORRECT: Explicit bitmask setup
player.physicsBody?.categoryBitMask = PhysicsCategory.player
player.physicsBody?.collisionBitMask = PhysicsCategory.ground | PhysicsCategory.enemy
player.physicsBody?.contactTestBitMask = PhysicsCategory.enemy | PhysicsCategory.powerUp
enemy.physicsBody?.categoryBitMask = PhysicsCategory.enemy
enemy.physicsBody?.collisionBitMask = PhysicsCategory.ground | PhysicsCategory.player
enemy.physicsBody?.contactTestBitMask = PhysicsCategory.player | PhysicsCategory.projectile
位掩码检查清单
对于每个物理体,请确认:
categoryBitMask只设置为一个类别collisionBitMask只设置为它应与之发生弹跳碰撞的类别(不要设为0xFFFFFFFF)contactTestBitMask设置为应触发代理回调的类别- 已指定代理:
physicsWorld.contactDelegate = self
接触检测
class GameScene: SKScene, SKPhysicsContactDelegate {
override func didMove(to view: SKView) {
physicsWorld.contactDelegate = self
}
func didBegin(_ contact: SKPhysicsContact) {
// Sort bodies so bodyA has the lower category
let (first, second): (SKPhysicsBody, SKPhysicsBody)
if contact.bodyA.categoryBitMask < contact.bodyB.categoryBitMask {
(first, second) = (contact.bodyA, contact.bodyB)
} else {
(first, second) = (contact.bodyB, contact.bodyA)
}
// Now dispatch based on categories
if first.categoryBitMask == PhysicsCategory.player &&
second.categoryBitMask == PhysicsCategory.enemy {
guard let playerNode = first.node, let enemyNode = second.node else { return }
playerHitEnemy(player: playerNode, enemy: enemyNode)
}
}
}
修改规则:不能在 didBegin/didEnd 中修改物理世界。请设置标记,并在 update(_:) 中应用更改。
var enemiesToRemove: [SKNode] = []
func didBegin(_ contact: SKPhysicsContact) {
// Flag for removal — don't remove here
if let enemy = contact.bodyB.node {
enemiesToRemove.append(enemy)
}
}
override func update(_ currentTime: TimeInterval) {
for enemy in enemiesToRemove {
enemy.removeFromParent()
}
enemiesToRemove.removeAll()
}
刚体类型
| 类型 | 创建方式 | 是否响应力 | 用途 |
|---|---|---|---|
| 动态体积 | init(circleOfRadius:)、init(rectangleOf:)、init(texture:size:) | 是 | 玩家、敌人、投射物 |
| 静态体积 | 动态刚体 + isDynamic = false | 否(但会发生碰撞) | 平台、墙壁 |
| 边缘 | init(edgeLoopFrom:)、init(edgeFrom:to:) | 否(仅边界) | 屏幕边界、地形 |
// Screen boundary using edge loop
physicsBody = SKPhysicsBody(edgeLoopFrom: frame)
// Texture-based body for irregular shapes
guard let texture = enemy.texture else { return }
enemy.physicsBody = SKPhysicsBody(texture: texture, size: enemy.size)
// Circle for performance (cheapest collision detection)
bullet.physicsBody = SKPhysicsBody(circleOfRadius: 5)
防止穿透
高速移动的物体可能会穿过薄墙。解决方法:
// Enable precise collision detection for fast objects
bullet.physicsBody?.usesPreciseCollisionDetection = true
// Make walls thick enough (at least as wide as fastest object moves per frame)
// At 60fps, an object at velocity 600pt/s moves 10pt/frame
力与冲量
// Force: continuous (applied per frame, accumulates)
body.applyForce(CGVector(dx: 0, dy: 100))
// Impulse: instant velocity change (one-time, like a jump)
body.applyImpulse(CGVector(dx: 0, dy: 50))
// Torque: continuous rotation
body.applyTorque(0.5)
// Angular impulse: instant rotation change
body.applyAngularImpulse(1.0)
4. 动作系统
核心模式
// Movement
let move = SKAction.move(to: CGPoint(x: 200, y: 300), duration: 1.0)
let moveBy = SKAction.moveBy(x: 100, y: 0, duration: 0.5)
// Rotation
let rotate = SKAction.rotate(byAngle: .pi * 2, duration: 1.0)
// Scale
let scale = SKAction.scale(to: 2.0, duration: 0.3)
// Fade
let fadeOut = SKAction.fadeOut(withDuration: 0.5)
let fadeIn = SKAction.fadeIn(withDuration: 0.5)
序列与分组
// Sequence: one after another
let moveAndFade = SKAction.sequence([
SKAction.move(to: target, duration: 1.0),
SKAction.fadeOut(withDuration: 0.3),
SKAction.removeFromParent()
])
// Group: all at once
let spinAndGrow = SKAction.group([
SKAction.rotate(byAngle: .pi * 2, duration: 1.0),
SKAction.scale(to: 2.0, duration: 1.0)
])
// Repeat
let pulse = SKAction.repeatForever(SKAction.sequence([
SKAction.scale(to: 1.2, duration: 0.3),
SKAction.scale(to: 1.0, duration: 0.3)
]))
命名动作(管理时至关重要)
// Use named actions so you can cancel/replace them
node.run(pulse, withKey: "pulse")
// Later, stop the pulse:
node.removeAction(forKey: "pulse")
// Check if running:
if node.action(forKey: "pulse") != nil {
// Still pulsing
}
使用弱引用 self 的自定义动作
// WRONG: Retain cycle risk
node.run(SKAction.run {
self.score += 1 // Strong capture of self
})
// CORRECT: Weak capture
node.run(SKAction.run { [weak self] in
self?.score += 1
})
// For repeating actions, always use weak self
let spawn = SKAction.repeatForever(SKAction.sequence([
SKAction.run { [weak self] in self?.spawnEnemy() },
SKAction.wait(forDuration: 2.0)
]))
scene.run(spawn, withKey: "enemySpawner")
计时模式
action.timingMode = .linear // Constant speed (default)
action.timingMode = .easeIn // Accelerate from rest
action.timingMode = .easeOut // Decelerate to rest
action.timingMode = .easeInEaseOut // Smooth start and end
动作与物理模拟
切勿使用动作移动由物理模拟控制的节点。 动作会覆盖物理模拟,导致抖动和漏判碰撞。
// WRONG: Action fights physics
playerNode.run(SKAction.moveTo(x: 200, duration: 0.5))
// CORRECT: Use forces/impulses for physics bodies
playerNode.physicsBody?.applyImpulse(CGVector(dx: 50, dy: 0))
// CORRECT: Use actions for non-physics nodes (UI, effects, decorations)
hudLabel.run(SKAction.scale(to: 1.5, duration: 0.2))
5. 输入处理
触摸处理
// CRITICAL: isUserInteractionEnabled must be true on the responding node
// SKScene has it true by default; other nodes default to false
class Player: SKSpriteNode {
init() {
super.init(texture: SKTexture(imageNamed: "player"), color: .clear, size: CGSize(width: 50, height: 50))
isUserInteractionEnabled = true // Required!
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// Handle touch on this specific node
}
}
坐标空间转换
// Touch location in SCENE coordinates (most common)
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let locationInScene = touch.location(in: self)
// Touch location in a SPECIFIC NODE's coordinates
let locationInWorld = touch.location(in: worldNode)
// Hit test: what node was touched?
let touchedNodes = nodes(at: locationInScene)
}
常见错误:使用 touch.location(in: self.view) 会返回 UIKit 坐标(Y 轴翻转)。场景坐标始终使用 touch.location(in: self)。
游戏控制器支持
import GameController
func setupControllers() {
NotificationCenter.default.addObserver(
self, selector: #selector(controllerConnected),
name: .GCControllerDidConnect, object: nil
)
// Check already-connected controllers
for controller in GCController.controllers() {
configureController(controller)
}
}
6. 性能
性能优化优先事项
如需详细的性能诊断,请参阅 axiom-spritekit-diag 的症状 3。主要优先事项:
- 节点数量 — 移除屏幕外节点,使用对象池
- 绘制调用 — 使用纹理图集,以预渲染纹理替换 SKShapeNode
- 物理开销 — 优先使用简单的刚体形状,限制
usesPreciseCollisionDetection - 粒子 — 限制生成速率,设置有限的发射数量
调试叠加层(开发期间始终启用)
if let view = self.view as? SKView {
view.showsFPS = true
view.showsNodeCount = true
view.showsDrawCount = true
view.showsPhysics = true // Shows physics body outlines
// Performance: render order optimization
view.ignoresSiblingOrder = true
}
纹理图集批处理
使用同一图集纹理的精灵会在一次绘制调用中渲染。
// Create atlas in Xcode: Assets → New Sprite Atlas
// Or use .atlas folder in project
let atlas = SKTextureAtlas(named: "Characters")
let texture = atlas.textureNamed("player_idle")
let sprite = SKSpriteNode(texture: texture)
// Preload atlas to avoid frame drops
SKTextureAtlas.preloadTextureAtlases([atlas]) {
// Atlas ready — present scene
}
SKShapeNode 的陷阱
每个 SKShapeNode 实例都会产生一次绘制调用。 它无法进行批处理。仅将其用于原型开发和调试可视化。
// WRONG: 100 SKShapeNodes = 100 draw calls
for _ in 0..<100 {
let dot = SKShapeNode(circleOfRadius: 5)
addChild(dot)
}
// CORRECT: Pre-render to texture, use SKSpriteNode
let shape = SKShapeNode(circleOfRadius: 5)
shape.fillColor = .red
guard let texture = view?.texture(from: shape) else { return }
for _ in 0..<100 {
let dot = SKSpriteNode(texture: texture)
addChild(dot)
}
对象池
对于频繁生成和销毁的对象(子弹、粒子、敌人):
class BulletPool {
private var available: [SKSpriteNode] = []
private let texture: SKTexture
init(texture: SKTexture, initialSize: Int = 20) {
self.texture = texture
for _ in 0..<initialSize {
available.append(createBullet())
}
}
private func createBullet() -> SKSpriteNode {
let bullet = SKSpriteNode(texture: texture)
bullet.physicsBody = SKPhysicsBody(circleOfRadius: 3)
bullet.physicsBody?.categoryBitMask = PhysicsCategory.projectile
bullet.physicsBody?.collisionBitMask = PhysicsCategory.none
bullet.physicsBody?.contactTestBitMask = PhysicsCategory.enemy
return bullet
}
func spawn() -> SKSpriteNode {
if available.isEmpty {
available.append(createBullet())
}
let bullet = available.removeLast()
bullet.isHidden = false
bullet.physicsBody?.isDynamic = true
return bullet
}
func recycle(_ bullet: SKSpriteNode) {
bullet.removeAllActions()
bullet.removeFromParent()
bullet.physicsBody?.isDynamic = false
bullet.physicsBody?.velocity = .zero
bullet.isHidden = true
available.append(bullet)
}
}
移除屏幕外节点
// Manual removal is faster than shouldCullNonVisibleNodes
override func update(_ currentTime: TimeInterval) {
enumerateChildNodes(withName: "bullet") { node, _ in
if !self.frame.intersects(node.frame) {
self.bulletPool.recycle(node as! SKSpriteNode)
}
}
}
7. 游戏循环
帧周期(8 个阶段)
1. update(_:) ← Your game logic here
2. didEvaluateActions() ← Actions completed
3. [Physics simulation] ← SpriteKit runs physics
4. didSimulatePhysics() ← Physics done, adjust results
5. [Constraint evaluation] ← SKConstraints applied
6. didApplyConstraints() ← Constraints done
7. didFinishUpdate() ← Last chance before render
8. [Rendering] ← Frame drawn
时间增量
private var lastUpdateTime: TimeInterval = 0
override func update(_ currentTime: TimeInterval) {
let dt: TimeInterval
if lastUpdateTime == 0 {
dt = 0
} else {
dt = currentTime - lastUpdateTime
}
lastUpdateTime = currentTime
// Clamp delta time to prevent spiral of death
// (when app returns from background, dt can be huge)
let clampedDt = min(dt, 1.0 / 30.0)
updatePlayer(deltaTime: clampedDt)
updateEnemies(deltaTime: clampedDt)
}
暂停处理
// Pause the scene (stops actions, physics, update loop)
scene.isPaused = true
// Pause specific subtree only
worldNode.isPaused = true // Game paused but HUD still animates
// Handle app backgrounding
NotificationCenter.default.addObserver(
self, selector: #selector(pauseGame),
name: UIApplication.willResignActiveNotification, object: nil
)
8. 粒子效果
发射器最佳实践
// Load from .sks file (designed in Xcode Particle Editor)
guard let emitter = SKEmitterNode(fileNamed: "Explosion") else { return }
emitter.position = explosionPoint
addChild(emitter)
// CRITICAL: Auto-remove after emission completes
let duration = TimeInterval(emitter.numParticlesToEmit) / TimeInterval(emitter.particleBirthRate)
+ TimeInterval(emitter.particleLifetime + emitter.particleLifetimeRange / 2)
emitter.run(SKAction.sequence([
SKAction.wait(forDuration: duration),
SKAction.removeFromParent()
]))
拖尾的目标节点
如果不设置 targetNode,粒子会随发射器移动。对于拖尾效果(如火箭尾焰),将 targetNode 设置为场景:
let trail = SKEmitterNode(fileNamed: "RocketTrail")!
trail.targetNode = scene // Particles stay where emitted
rocketNode.addChild(trail)
无限发射器清理
// WRONG: Infinite emitter never cleaned up
let fire = SKEmitterNode(fileNamed: "Fire")!
fire.numParticlesToEmit = 0 // 0 = infinite
addChild(fire)
// Memory leak — particles accumulate forever
// CORRECT: Set emission limit or remove when done
fire.numParticlesToEmit = 200 // Stops after 200 particles
// Or manually stop and remove:
fire.particleBirthRate = 0 // Stop new particles
fire.run(SKAction.sequence([
SKAction.wait(forDuration: TimeInterval(fire.particleLifetime)),
SKAction.removeFromParent()
]))
9. SwiftUI 集成
SpriteView(推荐,iOS 14+)
在 SwiftUI 中嵌入 SpriteKit 的最简单方式。除非需要自定义 SKView 配置,否则请使用此方式。
import SpriteKit
import SwiftUI
struct GameView: View {
var body: some View {
SpriteView(scene: {
let scene = GameScene(size: CGSize(width: 390, height: 844))
scene.scaleMode = .aspectFill
return scene
}(), debugOptions: [.showsFPS, .showsNodeCount])
.ignoresSafeArea()
}
}
UIViewRepresentable(高级)
需要完全控制 SKView 配置(自定义帧率、透明度或多个场景)时使用。
import SwiftUI
import SpriteKit
struct SpriteKitView: UIViewRepresentable {
let scene: SKScene
func makeUIView(context: Context) -> SKView {
let view = SKView()
view.showsFPS = true
view.showsNodeCount = true
view.ignoresSiblingOrder = true
return view
}
func updateUIView(_ view: SKView, context: Context) {
if view.scene == nil {
view.presentScene(scene)
}
}
}
用于 Metal 混合渲染的 SKRenderer
当 SpriteKit 是 Metal 渲染管线中的一个图层时,使用 SKRenderer:
let renderer = SKRenderer(device: metalDevice)
renderer.scene = gameScene
// In your Metal render loop:
renderer.update(atTime: currentTime)
renderer.render(
withViewport: viewport,
commandBuffer: commandBuffer,
renderPassDescriptor: renderPassDescriptor
)
10. 反模式
反模式 1:默认位掩码
耗时:调试幽灵碰撞需 30-120 分钟
// WRONG: Default collisionBitMask is 0xFFFFFFFF
let body = SKPhysicsBody(circleOfRadius: 10)
node.physicsBody = body
// Collides with EVERYTHING — even things it shouldn't
// CORRECT: Always set all three masks explicitly
body.categoryBitMask = PhysicsCategory.player
body.collisionBitMask = PhysicsCategory.ground
body.contactTestBitMask = PhysicsCategory.enemy
反模式 2:缺少 contactTestBitMask
耗时:花 30-60 分钟疑惑为什么 didBegin 从未触发
// WRONG: contactTestBitMask defaults to 0 — no contacts ever fire
player.physicsBody?.categoryBitMask = PhysicsCategory.player
// Forgot contactTestBitMask!
// CORRECT: Both bodies need compatible masks
player.physicsBody?.contactTestBitMask = PhysicsCategory.enemy
enemy.physicsBody?.categoryBitMask = PhysicsCategory.enemy
反模式 3:对物理刚体使用动作
耗时:花 1-3 小时排查抖动和漏判碰撞
// WRONG: SKAction.move overrides physics position each frame
playerNode.run(SKAction.moveTo(x: 200, duration: 1.0))
// Physics body position is set by action, ignoring forces/collisions
// CORRECT: Use physics for physics-controlled nodes
playerNode.physicsBody?.applyForce(CGVector(dx: 100, dy: 0))
反模式 4:将 SKShapeNode 用于游戏逻辑
耗时:花数小时诊断帧率下降
每个 SKShapeNode 都会产生独立的绘制调用,无法进行批处理。50 个形状节点 = 50 次绘制调用。解决方法请参阅第 6 节中的预渲染到纹理模式(SKShapeNode 的陷阱)。
反模式 5:在动作闭包中强引用 self
耗时:内存泄漏,最终导致崩溃
// WRONG: Strong capture in repeating action
node.run(SKAction.repeatForever(SKAction.sequence([
SKAction.run { self.spawnEnemy() },
SKAction.wait(forDuration: 2.0)
])))
// CORRECT: Weak capture
node.run(SKAction.repeatForever(SKAction.sequence([
SKAction.run { [weak self] in self?.spawnEnemy() },
SKAction.wait(forDuration: 2.0)
])))
11. 代码审查清单
Physics
- 每个物理体都显式设置了
categoryBitMask(非默认值) - 每个物理体都显式设置了
collisionBitMask(非0xFFFFFFFF) - 需要检测接触的物理体已设置
contactTestBitMask - 已指定
physicsWorld.contactDelegate - 不在
didBegin/didEnd回调中修改世界 - 快速移动的对象使用
usesPreciseCollisionDetection
Actions
- 不在受物理系统控制的节点上使用
SKAction.move/rotate - 重复执行的动作使用
withKey:,以便取消 -
SKAction.run闭包使用[weak self] - 发射一次后移除单次发射器
Performance
- 开发期间启用调试叠加层
- 在 SKView 上设置
ignoresSiblingOrder = true - 游戏精灵中不使用 SKShapeNode(改用预渲染纹理)
- 相关精灵使用纹理图集
- 手动移除屏幕外节点
Scene Management
-
willMove(from:)会清理动作、子节点和代理 - 场景数据通过共享状态传递,而非节点属性
- 使用摄像机控制视口
12. 压力场景
场景 1:“物理接触检测不起作用——今晚就要发布”
压力:截止日期带来的压力,导致跳过系统化调试
错误做法:随机更改位掩码值、到处添加 0xFFFFFFFF,或禁用物理系统
正确做法(2-5 分钟):
- 启用
showsPhysics——确认物理体存在且彼此重叠 - 打印两个物理体的全部三个位掩码
- 确认物理体 A 的
contactTestBitMask包含物理体 B 的类别(反之亦然) - 确认已设置
physicsWorld.contactDelegate - 确认没有在回调中修改世界
回应模板:“让我按 5 步位掩码检查清单排查一下。这只需 2 分钟,就能找出 90% 的接触检测问题。随机更改只会让情况更糟。”
场景 2:“我的设备上帧率没问题”
压力:权威人士说:“我这里能跑到 60fps,直接发布吧。”
错误做法:未在最低规格设备上进行性能分析就发布
正确做法:
- 启用
showsFPS、showsNodeCount、showsDrawCount - 在支持的最老设备上测试
- 如果节点数 >200 或绘制调用 >30,则进行调查
- 检查游戏过程中是否使用了 SKShapeNode
- 确认正在移除屏幕外节点
回应模板:“性能会因设备而异。让我检查一下节点数和绘制调用——启用调试叠加层只需 30 秒。如果数量不高,发布就没问题。”
场景 3:“直接用 SKShapeNode 吧,写代码更快”
压力:沉没成本——已经用 SKShapeNode 构建完成,不想返工
错误做法:发布包含 100 多个 SKShapeNode、导致帧率下降的版本
正确做法:
- 检查
showsDrawCount——每个 SKShapeNode 都会增加一次绘制调用 - 如果游戏过程中有 >20 个形状节点,则预渲染为纹理
- 使用
view.texture(from:)转换一次,再作为 SKSpriteNode 重复使用 - 仅将 SKShapeNode 用于调试可视化
回应模板:“每个 SKShapeNode 都会产生单独的绘制调用。转换为预渲染纹理只需 15 分钟重构,就有望让帧率翻倍。使用图集中的 SKSpriteNode,所有节点只需 1 次绘制调用。”
资源
WWDC:2014-608、2016-610、2017-609、2013-502
文档:/spritekit、/spritekit/skscene、/spritekit/skphysicsbody、/spritekit/maximizing-node-drawing-performance
技能:axiom-spritekit-ref、axiom-spritekit-diag