axiom-spritekit

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

SpriteKit Game Development Guide

SpriteKit游戏开发指南

Purpose: Build reliable SpriteKit games by mastering the scene graph, physics engine, action system, and rendering pipeline iOS Version: iOS 13+ (SwiftUI integration), iOS 11+ (SKRenderer) Xcode: Xcode 15+
用途:通过掌握场景图、物理引擎、动作系统和渲染管线,构建可靠的SpriteKit游戏 iOS版本要求:iOS 13+(支持SwiftUI集成),iOS 11+(支持SKRenderer) Xcode版本要求:Xcode 15+

When to Use This Skill

何时使用本技能

Use this skill when:
  • Building a new SpriteKit game or interactive simulation
  • Implementing physics (collisions, contacts, forces, joints)
  • Setting up game architecture (scenes, layers, cameras)
  • Optimizing frame rate or reducing draw calls
  • Implementing touch/input handling in a game
  • Managing scene transitions and data passing
  • Integrating SpriteKit with SwiftUI or Metal
  • Debugging physics contacts that don't fire
  • Fixing coordinate system confusion
Do NOT use this skill for:
  • SceneKit 3D rendering (
    axiom-scenekit
    )
  • GameplayKit entity-component systems
  • Metal shader programming (
    axiom-metal-migration-ref
    )
  • General SwiftUI layout (
    axiom-swiftui-layout
    )

在以下场景使用本技能:
  • 开发新的SpriteKit游戏或交互式模拟程序
  • 实现物理效果(碰撞、接触、力、关节)
  • 搭建游戏架构(场景、图层、相机)
  • 优化帧率或减少绘制调用
  • 在游戏中实现触摸/输入处理
  • 管理场景过渡与数据传递
  • 集成SpriteKit与SwiftUI或Metal
  • 调试未触发的物理接触事件
  • 解决坐标系混淆问题
请勿在以下场景使用本技能:
  • SceneKit 3D渲染(
    axiom-scenekit
  • GameplayKit实体组件系统
  • Metal着色器编程(
    axiom-metal-migration-ref
  • 通用SwiftUI布局(
    axiom-swiftui-layout

1. Mental Model

1. 核心概念模型

Coordinate System

坐标系

SpriteKit uses a bottom-left origin with Y pointing up. This differs from UIKit (top-left, Y down).
SpriteKit:          UIKit:
┌─────────┐         ┌─────────┐
│    +Y    │         │  (0,0)  │
│    ↑     │         │    ↓    │
│    │     │         │    +Y   │
│(0,0)──→+X│        │    │    │
└─────────┘         └─────────┘
Anchor Points define which point on a sprite maps to its
position
. Default is
(0.5, 0.5)
(center).
swift
// 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)
Scene anchor point maps the view's frame to scene coordinates:
  • (0, 0)
    — scene origin at bottom-left of view (default)
  • (0.5, 0.5)
    — scene origin at center of view
SpriteKit使用左下角为原点的坐标系,Y轴向上。这与UIKit(左上角为原点,Y轴向下)不同。
SpriteKit:          UIKit:
┌─────────┐         ┌─────────┐
│    +Y    │         │  (0,0)  │
│    ↑     │         │    ↓    │
│    │     │         │    +Y   │
│(0,0)──→+X│        │    │    │
└─────────┘         └─────────┘
锚点定义了精灵上的哪个点对应其
position
属性。默认值为
(0.5, 0.5)
(中心位置)。
swift
// 常见锚点陷阱:
// 锚点(0, 0) = 精灵的左下角对应position
// 锚点(0.5, 0.5) = 精灵的中心对应position(默认值)
// 锚点(0.5, 0) = 精灵的底部中心对应position(适用于站在地面上的角色)
sprite.anchorPoint = CGPoint(x: 0.5, y: 0)
场景锚点将视图框架映射到场景坐标系:
  • (0, 0)
    — 场景原点位于视图左下角(默认值)
  • (0.5, 0.5)
    — 场景原点位于视图中心

Node Tree

节点树

Everything in SpriteKit is an
SKNode
in a tree hierarchy. Parent transforms propagate to children.
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"
SpriteKit中的所有元素都是
SKNode
树层级结构的一部分。父节点的变换会传递给子节点。
SKScene
├── SKCameraNode(视口控制)
├── SKNode "world"(游戏内容图层)
│   ├── SKSpriteNode "player"
│   ├── SKSpriteNode "enemy"
│   └── SKNode "platforms"
│       ├── SKSpriteNode "platform1"
│       └── SKSpriteNode "platform2"
└── SKNode "hud"(UI图层,绑定到相机)
    ├── SKLabelNode "score"
    └── SKSpriteNode "healthBar"

Z-Ordering

Z轴排序

zPosition
controls draw order. Higher values render on top. Nodes at the same
zPosition
render in child array order (unless
ignoresSiblingOrder
is
true
).
swift
// 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
}

zPosition
控制绘制顺序,值越大越靠上。
zPosition
相同的节点会按照子数组顺序绘制(除非
ignoresSiblingOrder
设为
true
)。
swift
// 定义清晰的Z轴层级
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. Scene Architecture

2. 场景架构

Scale Mode Decision

缩放模式选择

ModeBehaviorUse When
.aspectFill
Fills view, crops edgesFull-bleed games (most games)
.aspectFit
Fits in view, letterboxesPuzzle games needing exact layout
.resizeFill
Stretches to fillAlmost never — distorts
.fill
Matches view size exactlyScene adapts to any ratio
swift
class GameScene: SKScene {
    override func sceneDidLoad() {
        scaleMode = .aspectFill
        // Design for a reference size, let aspectFill crop edges
    }
}
模式行为适用场景
.aspectFill
填充视图,裁剪边缘全屏沉浸式游戏(大多数游戏)
.aspectFit
适配视图,保留黑边需要精确布局的益智类游戏
.resizeFill
拉伸填充视图几乎不推荐——会导致画面变形
.fill
完全匹配视图尺寸场景需适配任意比例的情况
swift
class GameScene: SKScene {
    override func sceneDidLoad() {
        scaleMode = .aspectFill
        // 基于参考尺寸设计,让aspectFill自动裁剪边缘
    }
}

Camera Node Pattern

相机节点模式

Always use
SKCameraNode
for viewport control. Attach HUD elements to the camera so they don't scroll.
swift
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]
始终使用
SKCameraNode
进行视口控制。将HUD元素绑定到相机,使其不会随场景滚动。
swift
let camera = SKCameraNode()
camera.name = "mainCamera"
addChild(camera)
self.camera = camera

// HUD会自动跟随相机
let scoreLabel = SKLabelNode(text: "Score: 0")
scoreLabel.position = CGPoint(x: 0, y: size.height / 2 - 50)
camera.addChild(scoreLabel)

// 让相机跟随玩家移动
let follow = SKConstraint.distance(SKRange(constantValue: 0), to: playerNode)
camera.constraints = [follow]

Layer Organization

图层组织

swift
// 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)
swift
// 创建用于组织的图层节点
let worldNode = SKNode()
worldNode.name = "world"
addChild(worldNode)

let hudNode = SKNode()
hudNode.name = "hud"
camera?.addChild(hudNode)

// 所有游戏玩法对象都添加到worldNode
worldNode.addChild(playerSprite)
worldNode.addChild(enemySprite)

// 所有UI元素都添加到hudNode(随相机移动)
hudNode.addChild(scoreLabel)

Scene Transitions

场景过渡

swift
// 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)
Data passing between scenes: Use a shared game state object, not node properties.
swift
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))
Note: A singleton works for simple games. For larger projects with testing needs, consider passing a
GameState
instance through scene initializers to avoid hidden global state.
Cleanup in
willMove(from:)
:
swift
override func willMove(from view: SKView) {
    removeAllActions()
    removeAllChildren()
    physicsWorld.contactDelegate = nil
}

swift
// 预加载下一个场景以实现平滑过渡
guard let nextScene = LevelScene(fileNamed: "Level2") else { return }
nextScene.scaleMode = .aspectFill

let transition = SKTransition.fade(withDuration: 0.5)
view?.presentScene(nextScene, transition: transition)
场景间数据传递:使用共享游戏状态对象,而非节点属性。
swift
class GameState {
    static let shared = GameState()
    var score = 0
    var currentLevel = 1
    var playerHealth = 100
}

// 场景过渡时:
let nextScene = LevelScene(size: size)
// GameState.shared可直接访问
view?.presentScene(nextScene, transition: .fade(withDuration: 0.5))
注意:单例模式适用于简单游戏。对于需要测试的大型项目,建议通过场景初始化器传递
GameState
实例,避免隐藏的全局状态。
willMove(from:)
中清理资源
swift
override func willMove(from view: SKView) {
    removeAllActions()
    removeAllChildren()
    physicsWorld.contactDelegate = nil
}

3. Physics Engine

3. 物理引擎

Bitmask Discipline

位掩码规范

This is the #1 source of SpriteKit bugs. Physics bitmasks use a 32-bit system where each bit represents a category.
swift
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
}
Three bitmask properties (all default to
0xFFFFFFFF
— everything):
PropertyPurposeDefault
categoryBitMask
What this body IS
0xFFFFFFFF
collisionBitMask
What it BOUNCES off
0xFFFFFFFF
contactTestBitMask
What TRIGGERS delegate
0x00000000
The default
collisionBitMask
of
0xFFFFFFFF
means everything collides with everything.
This is the most common source of unexpected physics behavior.
swift
// 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
这是SpriteKit最常见的bug来源。物理位掩码使用32位系统,每个位代表一个类别。
swift
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
意味着所有物体都会相互碰撞
,这是导致意外物理行为最常见的原因。
swift
// 正确做法:显式设置位掩码
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

Bitmask Checklist

位掩码检查清单

For every physics body, verify:
  1. categoryBitMask
    set to exactly one category
  2. collisionBitMask
    set to only categories it should bounce off (NOT
    0xFFFFFFFF
    )
  3. contactTestBitMask
    set to categories that should trigger delegate callbacks
  4. Delegate is assigned:
    physicsWorld.contactDelegate = self
对于每个物理体,请验证:
  1. categoryBitMask
    仅设置为一个类别
  2. collisionBitMask
    仅设置为需要反弹的类别(不要设为
    0xFFFFFFFF
  3. contactTestBitMask
    设置为需要触发代理回调的类别
  4. 已分配代理:
    physicsWorld.contactDelegate = self

Contact Detection

接触检测

swift
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)
        }
    }
}
Modification rule: You cannot modify the physics world inside
didBegin
/
didEnd
. Set flags and apply changes in
update(_:)
.
swift
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()
}
swift
class GameScene: SKScene, SKPhysicsContactDelegate {
    override func didMove(to view: SKView) {
        physicsWorld.contactDelegate = self
    }

    func didBegin(_ contact: SKPhysicsContact) {
        // 排序物体,让bodyA的类别值更小
        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)
        }

        // 根据类别分发处理逻辑
        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(_:)
中应用更改。
swift
var enemiesToRemove: [SKNode] = []

func didBegin(_ contact: SKPhysicsContact) {
    // 标记需要移除的对象——不要在此处直接移除
    if let enemy = contact.bodyB.node {
        enemiesToRemove.append(enemy)
    }
}

override func update(_ currentTime: TimeInterval) {
    for enemy in enemiesToRemove {
        enemy.removeFromParent()
    }
    enemiesToRemove.removeAll()
}

Body Types

物理体类型

TypeCreated WithResponds to ForcesUse For
Dynamic volume
init(circleOfRadius:)
,
init(rectangleOf:)
,
init(texture:size:)
YesPlayers, enemies, projectiles
Static volumeDynamic body +
isDynamic = false
No (but collides)Platforms, walls
Edge
init(edgeLoopFrom:)
,
init(edgeFrom:to:)
No (boundary only)Screen boundaries, terrain
swift
// 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)
类型创建方式是否响应力适用场景
动态体积
init(circleOfRadius:)
,
init(rectangleOf:)
,
init(texture:size:)
玩家、敌人、投射物
静态体积动态体 +
isDynamic = false
否(但会发生碰撞)平台、墙壁
边缘
init(edgeLoopFrom:)
,
init(edgeFrom:to:)
否(仅作为边界)屏幕边界、地形
swift
// 使用边缘环创建屏幕边界
physicsBody = SKPhysicsBody(edgeLoopFrom: frame)

// 基于纹理创建不规则形状的物理体
guard let texture = enemy.texture else { return }
enemy.physicsBody = SKPhysicsBody(texture: texture, size: enemy.size)

// 使用圆形以提升性能(碰撞检测成本最低)
bullet.physicsBody = SKPhysicsBody(circleOfRadius: 5)

Tunneling Prevention

穿模问题解决

Fast-moving objects can pass through thin walls. Fix:
swift
// 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
快速移动的物体可能会穿过薄墙。解决方法:
swift
// 为快速移动的物体启用精确碰撞检测
bullet.physicsBody?.usesPreciseCollisionDetection = true

// 让墙壁足够厚(至少等于最快物体每帧移动的距离)
// 在60fps下,速度为600pt/s的物体每帧移动10pt

Forces vs Impulses

力与冲量

swift
// 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)

swift
// 力:持续作用(每帧应用,会累积)
body.applyForce(CGVector(dx: 0, dy: 100))

// 冲量:瞬间改变速度(一次性作用,比如跳跃)
body.applyImpulse(CGVector(dx: 0, dy: 50))

// 扭矩:持续旋转
body.applyTorque(0.5)

// 角冲量:瞬间改变旋转状态
body.applyAngularImpulse(1.0)

4. Actions System

4. 动作系统

Core Patterns

核心模式

swift
// 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)
swift
// 移动
let move = SKAction.move(to: CGPoint(x: 200, y: 300), duration: 1.0)
let moveBy = SKAction.moveBy(x: 100, y: 0, duration: 0.5)

// 旋转
let rotate = SKAction.rotate(byAngle: .pi * 2, duration: 1.0)

// 缩放
let scale = SKAction.scale(to: 2.0, duration: 0.3)

// 淡入淡出
let fadeOut = SKAction.fadeOut(withDuration: 0.5)
let fadeIn = SKAction.fadeIn(withDuration: 0.5)

Sequencing and Grouping

序列与组

swift
// 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)
]))
swift
// 序列:按顺序执行
let moveAndFade = SKAction.sequence([
    SKAction.move(to: target, duration: 1.0),
    SKAction.fadeOut(withDuration: 0.3),
    SKAction.removeFromParent()
])

// 组:同时执行
let spinAndGrow = SKAction.group([
    SKAction.rotate(byAngle: .pi * 2, duration: 1.0),
    SKAction.scale(to: 2.0, duration: 1.0)
])

// 重复
let pulse = SKAction.repeatForever(SKAction.sequence([
    SKAction.scale(to: 1.2, duration: 0.3),
    SKAction.scale(to: 1.0, duration: 0.3)
]))

Named Actions (Critical for Management)

命名动作(管理的关键)

swift
// 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
}
swift
// 使用命名动作以便取消/替换
node.run(pulse, withKey: "pulse")

// 之后停止脉冲动作:
node.removeAction(forKey: "pulse")

// 检查动作是否在运行:
if node.action(forKey: "pulse") != nil {
    // 仍在执行脉冲
}

Custom Actions with Weak Self

使用弱引用的自定义动作

swift
// 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")
swift
// 错误做法:存在循环引用风险
node.run(SKAction.run {
    self.score += 1  // 强引用self
})

// 正确做法:使用弱引用
node.run(SKAction.run { [weak self] in
    self?.score += 1
})

// 对于重复动作,务必使用弱引用
let spawn = SKAction.repeatForever(SKAction.sequence([
    SKAction.run { [weak self] in self?.spawnEnemy() },
    SKAction.wait(forDuration: 2.0)
]))
scene.run(spawn, withKey: "enemySpawner")

Timing Modes

时间模式

swift
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
swift
action.timingMode = .linear     // 匀速(默认值)
action.timingMode = .easeIn     // 从静止开始加速
action.timingMode = .easeOut    // 减速到静止
action.timingMode = .easeInEaseOut  // 平滑的开始和结束

Actions vs Physics

动作与物理的配合

Never use actions to move physics-controlled nodes. Actions override the physics simulation, causing jittering and missed collisions.
swift
// 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))

绝不要使用动作来移动受物理控制的节点。动作会覆盖物理模拟,导致抖动和碰撞丢失。
swift
// 错误做法:动作与物理模拟冲突
playerNode.run(SKAction.moveTo(x: 200, duration: 0.5))

// 正确做法:对受物理控制的节点使用力/冲量
playerNode.physicsBody?.applyImpulse(CGVector(dx: 50, dy: 0))

// 正确做法:对非物理节点(UI、特效、装饰)使用动作
hudLabel.run(SKAction.scale(to: 1.5, duration: 0.2))

5. Input Handling

5. 输入处理

Touch Handling

触摸处理

swift
// 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
    }
}
swift
// 关键:响应节点的isUserInteractionEnabled必须设为true
// SKScene默认值为true;其他节点默认值为false

class Player: SKSpriteNode {
    init() {
        super.init(texture: SKTexture(imageNamed: "player"), color: .clear, size: CGSize(width: 50, height: 50))
        isUserInteractionEnabled = true  // 必须设置!
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        // 处理针对此特定节点的触摸
    }
}

Coordinate Space Conversion

坐标系转换

swift
// 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)
}
Common mistake: Using
touch.location(in: self.view)
returns UIKit coordinates (Y-flipped). Always use
touch.location(in: self)
for scene coordinates.
swift
// 触摸在场景坐标系中的位置(最常用)
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    guard let touch = touches.first else { return }
    let locationInScene = touch.location(in: self)

    // 触摸在特定节点坐标系中的位置
    let locationInWorld = touch.location(in: worldNode)

    // 点击测试:触摸到了哪个节点?
    let touchedNodes = nodes(at: locationInScene)
}
常见错误:使用
touch.location(in: self.view)
返回的是UIKit坐标系(Y轴翻转)。始终使用
touch.location(in: self)
获取场景坐标系。

Game Controller Support

游戏控制器支持

swift
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)
    }
}

swift
import GameController

func setupControllers() {
    NotificationCenter.default.addObserver(
        self, selector: #selector(controllerConnected),
        name: .GCControllerDidConnect, object: nil
    )

    // 检查已连接的控制器
    for controller in GCController.controllers() {
        configureController(controller)
    }
}

6. Performance

6. 性能优化

Performance Priorities

性能优先级

For detailed performance diagnosis, see
axiom-spritekit-diag
Symptom 3. Key priorities:
  1. Node count — Remove offscreen nodes, use object pooling
  2. Draw calls — Use texture atlases, replace SKShapeNode with pre-rendered textures
  3. Physics cost — Prefer simple body shapes, limit
    usesPreciseCollisionDetection
  4. Particles — Limit birth rate, set finite emission counts
如需详细的性能诊断,请参考
axiom-spritekit-diag
中的症状3。关键优先级:
  1. 节点数量 — 移除屏幕外的节点,使用对象池
  2. 绘制调用 — 使用纹理图集,将SKShapeNode替换为预渲染纹理
  3. 物理成本 — 优先选择简单的物理体形状,限制
    usesPreciseCollisionDetection
    的使用
  4. 粒子效果 — 限制生成速率,设置有限的发射数量

Debug Overlays (Always Enable During Development)

调试叠加层(开发期间始终启用)

swift
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
}
swift
if let view = self.view as? SKView {
    view.showsFPS = true
    view.showsNodeCount = true
    view.showsDrawCount = true
    view.showsPhysics = true  // 显示物理体轮廓

    // 性能优化:渲染顺序优化
    view.ignoresSiblingOrder = true
}

Texture Atlas Batching

纹理图集批处理

Sprites using textures from the same atlas render in a single draw call.
swift
// 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
}
使用同一图集纹理的精灵会在一次绘制调用中完成渲染。
swift
// 在Xcode中创建图集:Assets → New Sprite Atlas
// 或在项目中使用.atlas文件夹

let atlas = SKTextureAtlas(named: "Characters")
let texture = atlas.textureNamed("player_idle")
let sprite = SKSpriteNode(texture: texture)

// 预加载图集以避免掉帧
SKTextureAtlas.preloadTextureAtlases([atlas]) {
    // 图集已准备就绪——显示场景
}

SKShapeNode Trap

SKShapeNode陷阱

SKShapeNode generates one draw call per instance. It cannot be batched. Use it for prototyping and debug visualization only.
swift
// 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)
}
每个SKShapeNode实例会生成一次绘制调用,无法进行批处理。仅在原型设计和调试可视化时使用。
swift
// 错误做法:100个SKShapeNode = 100次绘制调用
for _ in 0..<100 {
    let dot = SKShapeNode(circleOfRadius: 5)
    addChild(dot)
}

// 正确做法:预渲染为纹理,使用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)
}

Object Pooling

对象池

For frequently spawned/destroyed objects (bullets, particles, enemies):
swift
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)
    }
}
对于频繁生成/销毁的对象(子弹、粒子、敌人):
swift
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)
    }
}

Offscreen Node Removal

屏幕外节点移除

swift
// 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)
        }
    }
}

swift
// 手动移除比shouldCullNonVisibleNodes更快
override func update(_ currentTime: TimeInterval) {
    enumerateChildNodes(withName: "bullet") { node, _ in
        if !self.frame.intersects(node.frame) {
            self.bulletPool.recycle(node as! SKSpriteNode)
        }
    }
}

7. Game Loop

7. 游戏循环

Frame Cycle (8 Phases)

帧周期(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
1. update(_:)              ← 在此处编写游戏逻辑
2. didEvaluateActions()    ← 动作执行完成
3. [物理模拟]    ← SpriteKit运行物理模拟
4. didSimulatePhysics()    ← 物理模拟完成,可调整结果
5. [约束评估] ← 应用SKConstraints
6. didApplyConstraints()   ← 约束应用完成
7. didFinishUpdate()       ← 渲染前的最后机会
8. [渲染]             ← 绘制帧

Delta Time

增量时间

swift
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)
}
swift
private var lastUpdateTime: TimeInterval = 0

override func update(_ currentTime: TimeInterval) {
    let dt: TimeInterval
    if lastUpdateTime == 0 {
        dt = 0
    } else {
        dt = currentTime - lastUpdateTime
    }
    lastUpdateTime = currentTime

    // 限制增量时间以避免性能崩溃
    // (应用从后台返回时,dt可能会非常大)
    let clampedDt = min(dt, 1.0 / 30.0)

    updatePlayer(deltaTime: clampedDt)
    updateEnemies(deltaTime: clampedDt)
}

Pause Handling

暂停处理

swift
// 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
)

swift
// 暂停场景(停止动作、物理模拟、更新循环)
scene.isPaused = true

// 仅暂停特定子树
worldNode.isPaused = true  // 游戏暂停但HUD仍可动画

// 处理应用进入后台
NotificationCenter.default.addObserver(
    self, selector: #selector(pauseGame),
    name: UIApplication.willResignActiveNotification, object: nil
)

8. Particle Effects

8. 粒子效果

Emitter Best Practices

发射器最佳实践

swift
// 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()
]))
swift
// 从.sks文件加载(在Xcode粒子编辑器中设计)
guard let emitter = SKEmitterNode(fileNamed: "Explosion") else { return }
emitter.position = explosionPoint
addChild(emitter)

// 关键:发射完成后自动移除
let duration = TimeInterval(emitter.numParticlesToEmit) / TimeInterval(emitter.particleBirthRate)
    + TimeInterval(emitter.particleLifetime + emitter.particleLifetimeRange / 2)
emitter.run(SKAction.sequence([
    SKAction.wait(forDuration: duration),
    SKAction.removeFromParent()
]))

Target Node for Trails

轨迹的目标节点

Without
targetNode
, particles move with the emitter. For trails (like rocket exhaust), set
targetNode
to the scene:
swift
let trail = SKEmitterNode(fileNamed: "RocketTrail")!
trail.targetNode = scene  // Particles stay where emitted
rocketNode.addChild(trail)
如果不设置
targetNode
,粒子会随发射器移动。对于轨迹效果(如火箭尾焰),请将
targetNode
设置为场景:
swift
let trail = SKEmitterNode(fileNamed: "RocketTrail")!
trail.targetNode = scene  // 粒子会停留在发射位置
rocketNode.addChild(trail)

Infinite Emitter Cleanup

无限发射器的清理

swift
// 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()
]))

swift
// 错误做法:无限发射器永远不会被清理
let fire = SKEmitterNode(fileNamed: "Fire")!
fire.numParticlesToEmit = 0  // 0表示无限
addChild(fire)
// 内存泄漏——粒子会不断累积

// 正确做法:设置发射限制或在使用完毕后移除
fire.numParticlesToEmit = 200  // 发射200个粒子后停止

// 或手动停止并移除:
fire.particleBirthRate = 0  // 停止生成新粒子
fire.run(SKAction.sequence([
    SKAction.wait(forDuration: TimeInterval(fire.particleLifetime)),
    SKAction.removeFromParent()
]))

9. SwiftUI Integration

9. SwiftUI集成

SpriteView (Recommended, iOS 14+)

SpriteView(推荐,iOS 14+)

The simplest way to embed SpriteKit in SwiftUI. Use this unless you need custom SKView configuration.
swift
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()
    }
}
将SpriteKit嵌入SwiftUI的最简单方式。除非需要自定义SKView配置,否则请使用此方式。
swift
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 (Advanced)

UIViewRepresentable(进阶)

Use when you need full control over SKView configuration (custom frame rate, transparency, or multiple scenes).
swift
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)
        }
    }
}
当需要完全控制SKView配置(自定义帧率、透明度或多场景)时使用。
swift
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)
        }
    }
}

SKRenderer for Metal Hybrid

SKRenderer与Metal混合

Use
SKRenderer
when SpriteKit is one layer in a Metal pipeline:
swift
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
)

当SpriteKit是Metal管线中的一个图层时,使用
SKRenderer
swift
let renderer = SKRenderer(device: metalDevice)
renderer.scene = gameScene

// 在Metal渲染循环中:
renderer.update(atTime: currentTime)
renderer.render(
    withViewport: viewport,
    commandBuffer: commandBuffer,
    renderPassDescriptor: renderPassDescriptor
)

10. Anti-Patterns

10. 反模式

Anti-Pattern 1: Default Bitmasks

反模式1:默认位掩码

Time cost: 30-120 minutes debugging phantom collisions
swift
// 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
时间成本:30-120分钟调试莫名的碰撞问题
swift
// 错误做法:默认collisionBitMask为0xFFFFFFFF
let body = SKPhysicsBody(circleOfRadius: 10)
node.physicsBody = body
// 会与所有物体碰撞——包括不应该碰撞的对象

// 正确做法:始终显式设置三个位掩码
body.categoryBitMask = PhysicsCategory.player
body.collisionBitMask = PhysicsCategory.ground
body.contactTestBitMask = PhysicsCategory.enemy

Anti-Pattern 2: Missing contactTestBitMask

反模式2:缺少contactTestBitMask

Time cost: 30-60 minutes wondering why didBegin never fires
swift
// 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
时间成本:30-60分钟疑惑为什么didBegin从未触发
swift
// 错误做法:contactTestBitMask默认值为0——永远不会触发接触事件
player.physicsBody?.categoryBitMask = PhysicsCategory.player
// 忘记设置contactTestBitMask!

// 正确做法:两个物体都需要设置兼容的掩码
player.physicsBody?.contactTestBitMask = PhysicsCategory.enemy
enemy.physicsBody?.categoryBitMask = PhysicsCategory.enemy

Anti-Pattern 3: Actions on Physics Bodies

反模式3:对物理体使用动作

Time cost: 1-3 hours of jittering and missed collisions
swift
// 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))
时间成本:1-3小时调试抖动和碰撞丢失问题
swift
// 错误做法:SKAction.move会每帧覆盖物理位置
playerNode.run(SKAction.moveTo(x: 200, duration: 1.0))
// 物理体位置由动作设置,忽略力和碰撞

// 正确做法:对受物理控制的节点使用物理方法
playerNode.physicsBody?.applyForce(CGVector(dx: 100, dy: 0))

Anti-Pattern 4: SKShapeNode for Gameplay

反模式4:在游戏玩法中使用SKShapeNode

Time cost: Hours diagnosing frame drops
Each SKShapeNode is a separate draw call that cannot be batched. 50 shape nodes = 50 draw calls. See the pre-render-to-texture pattern in Section 6 (SKShapeNode Trap) for the fix.
时间成本:数小时诊断掉帧问题
每个SKShapeNode都是独立的绘制调用,无法批处理。50个形状节点=50次绘制调用。请参考第6节中的预渲染为纹理模式解决。

Anti-Pattern 5: Strong Self in Action Closures

反模式5:在动作闭包中强引用self

Time cost: Memory leaks, eventual crash
swift
// 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)
])))

时间成本:内存泄漏,最终导致崩溃
swift
// 错误做法:重复动作中强引用self
node.run(SKAction.repeatForever(SKAction.sequence([
    SKAction.run { self.spawnEnemy() },
    SKAction.wait(forDuration: 2.0)
])))

// 正确做法:使用弱引用
node.run(SKAction.repeatForever(SKAction.sequence([
    SKAction.run { [weak self] in self?.spawnEnemy() },
    SKAction.wait(forDuration: 2.0)
])))

11. Code Review Checklist

11. 代码审查清单

Physics

物理部分

  • Every physics body has explicit
    categoryBitMask
    (not default)
  • Every physics body has explicit
    collisionBitMask
    (not
    0xFFFFFFFF
    )
  • Bodies needing contact detection have
    contactTestBitMask
    set
  • physicsWorld.contactDelegate
    is assigned
  • No world modifications inside
    didBegin
    /
    didEnd
    callbacks
  • Fast objects use
    usesPreciseCollisionDetection
  • 每个物理体都有显式的
    categoryBitMask
    (非默认值)
  • 每个物理体都有显式的
    collisionBitMask
    (非
    0xFFFFFFFF
  • 需要接触检测的物体已设置
    contactTestBitMask
  • 已设置
    physicsWorld.contactDelegate
  • 未在
    didBegin
    /
    didEnd
    回调中修改物理世界
  • 快速移动的物体使用了
    usesPreciseCollisionDetection

Actions

动作部分

  • No
    SKAction.move
    /
    rotate
    on physics-controlled nodes
  • Repeating actions use
    withKey:
    for cancellation
  • SKAction.run
    closures use
    [weak self]
  • One-shot emitters are removed after emission
  • 未对受物理控制的节点使用
    SKAction.move
    /
    rotate
  • 重复动作使用
    withKey:
    以便取消
  • SKAction.run
    闭包使用
    [weak self]
  • 一次性发射器在发射完成后会被移除

Performance

性能部分

  • Debug overlays enabled during development
  • ignoresSiblingOrder = true
    on SKView
  • No SKShapeNode in gameplay sprites (use pre-rendered textures)
  • Texture atlases used for related sprites
  • Offscreen nodes removed manually
  • 开发期间已启用调试叠加层
  • SKView已设置
    ignoresSiblingOrder = true
  • 游戏玩法精灵中未使用SKShapeNode(使用预渲染纹理)
  • 相关精灵使用了纹理图集
  • 手动移除了屏幕外的节点

Scene Management

场景管理部分

  • willMove(from:)
    cleans up actions, children, delegates
  • Scene data passed via shared state, not node properties
  • Camera used for viewport control

  • willMove(from:)
    清理了动作、子节点和代理
  • 场景数据通过共享状态传递,而非节点属性
  • 使用相机进行视口控制

12. Pressure Scenarios

12. 压力场景

Scenario 1: "Physics Contacts Don't Work — Ship Tonight"

场景1:"物理接触不工作——今晚必须上线"

Pressure: Deadline pressure to skip systematic debugging
Wrong approach: Randomly changing bitmask values, adding
0xFFFFFFFF
everywhere, or disabling physics
Correct approach (2-5 minutes):
  1. Enable
    showsPhysics
    — verify bodies exist and overlap
  2. Print all three bitmasks for both bodies
  3. Verify
    contactTestBitMask
    on body A includes category of body B (or vice versa)
  4. Verify
    physicsWorld.contactDelegate
    is set
  5. Verify you're not modifying the world inside the callback
Push-back template: "Let me run the 5-step bitmask checklist. It takes 2 minutes and catches 90% of contact issues. Random changes will make it worse."
压力:截止日期压力导致跳过系统性调试
错误做法:随机修改位掩码值,到处设置
0xFFFFFFFF
,或禁用物理
正确做法(2-5分钟):
  1. 启用
    showsPhysics
    ——验证物理体存在且重叠
  2. 打印两个物体的三个位掩码
  3. 验证物体A的
    contactTestBitMask
    包含物体B的类别(反之亦然)
  4. 验证已设置
    physicsWorld.contactDelegate
  5. 验证未在回调中修改物理世界
反驳模板:"让我运行5步位掩码检查清单,只需要2分钟,能解决90%的接触问题。随机修改会让问题更糟。"

Scenario 2: "Frame Rate Is Fine on My Device"

场景2:"我的设备上帧率没问题"

Pressure: Authority says "it runs at 60fps for me, ship it"
Wrong approach: Shipping without profiling on minimum-spec device
Correct approach:
  1. Enable
    showsFPS
    ,
    showsNodeCount
    ,
    showsDrawCount
  2. Test on oldest supported device
  3. If >200 nodes or >30 draw calls, investigate
  4. Check for SKShapeNode in gameplay
  5. Verify offscreen nodes are being removed
Push-back template: "Performance varies by device. Let me check node count and draw calls — takes 30 seconds with debug overlays. If counts are low, we're safe to ship."
压力:权威人士说"我这里能跑60fps,直接上线"
错误做法:未在最低配置设备上进行性能分析就上线
正确做法
  1. 启用
    showsFPS
    showsNodeCount
    showsDrawCount
  2. 在最旧的支持设备上测试
  3. 如果节点数>200或绘制调用>30,进行调查
  4. 检查游戏玩法中是否使用了SKShapeNode
  5. 验证屏幕外节点已被手动移除
反驳模板:"性能因设备而异。让我检查节点数和绘制调用,用调试叠加层只需要30秒。如果数值较低,我们就可以安全上线。"

Scenario 3: "Just Use SKShapeNode, It's Faster to Code"

场景3:"直接用SKShapeNode就行,编码更快"

Pressure: Sunk cost — already built with SKShapeNode, don't want to redo
Wrong approach: Shipping with 100+ SKShapeNodes causing frame drops
Correct approach:
  1. Check
    showsDrawCount
    — each SKShapeNode adds a draw call
  2. If >20 shape nodes in gameplay, pre-render to textures
  3. Use
    view.texture(from:)
    to convert once, reuse as SKSpriteNode
  4. Keep SKShapeNode only for debug visualization
Push-back template: "Each SKShapeNode is a separate draw call. Converting to pre-rendered textures is a 15-minute refactor that can double frame rate. SKSpriteNode from atlas = 1 draw call for all of them."
压力:沉没成本——已经用SKShapeNode构建完成,不想重做
错误做法:上线包含100+SKShapeNode的版本,导致掉帧
正确做法
  1. 查看
    showsDrawCount
    ——每个SKShapeNode都会增加一次绘制调用
  2. 如果游戏玩法中有超过20个形状节点,预渲染为纹理
  3. 使用
    view.texture(from:)
    一次性转换,然后作为SKSpriteNode复用
  4. 仅在调试可视化时保留SKShapeNode
反驳模板:"每个SKShapeNode都是一次独立的绘制调用。转换为预渲染纹理只需要15分钟重构,能让帧率翻倍。来自同一图集的SKSpriteNode只需要一次绘制调用就能完成所有渲染。"

Resources

资源

WWDC: 2014-608, 2016-610, 2017-609, 2013-502
Docs: /spritekit, /spritekit/skscene, /spritekit/skphysicsbody, /spritekit/maximizing-node-drawing-performance
Skills: axiom-spritekit-ref, axiom-spritekit-diag
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