关于 spritekit
这项 Swift 技能介绍如何使用 SpriteKit 开发 2D 游戏和交互动画,面向应用开发者。内容涵盖场景与节点结构、动画、物理、输入、摄像机、粒子效果及 SwiftUI 集成。
主要功能
- 01场景生命周期与节点层级
- 02精灵动作与动画
- 032D 物理与接触检测
- 04触控处理与命中测试
- 05摄像机与粒子效果
- 06集成 SwiftUI SpriteView
适用场景
- 01创建 2D 游戏场景
- 02实现碰撞与接触事件
- 03添加动画效果和摄像机移动
- 04在 SwiftUI 中嵌入 SpriteKit 游戏
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dpearson2699/ swift-ios-skills --skill spritekit安装后在 Claude Code 中输入/spritekit确认已加载。
附带文件evals/evals.jsonreferences/spritekit-patterns.md
详细介绍
SpriteKit
使用 SpriteKit 和 Swift 6.3 为 iOS 26+ 开发 2D 游戏和交互式动画。涵盖场景生命周期、节点层级、动作、物理、粒子、摄像机、触摸处理和 SwiftUI 集成。
内容
场景设置
SpriteKit 通过 SKView 渲染内容。SKView 会呈现一个 SKScene,它是框架逐帧动画和渲染的树形结构中的根节点。
创建场景
继承 SKScene 并重写生命周期方法。默认情况下,坐标系原点位于左下角。
import SpriteKit
final class GameScene: SKScene {
override func didMove(to view: SKView) {
backgroundColor = .darkGray
physicsWorld.contactDelegate = self
physicsBody = SKPhysicsBody(edgeLoopFrom: frame)
setupNodes()
}
override func update(_ currentTime: TimeInterval) {
// Called once per frame before actions are evaluated.
}
}
呈现场景(UIKit)
guard let skView = view as? SKView else { return }
skView.ignoresSiblingOrder = true
let scene = GameScene(size: skView.bounds.size)
scene.scaleMode = .resizeFill
skView.presentScene(scene)
缩放模式
当场景需要适应视图尺寸变化(旋转、多任务处理)时,使用 .resizeFill。固定设计的游戏场景使用 .aspectFill。.aspectFit 会添加黑边;.fill 会拉伸画面,可能导致失真。
帧循环
每一帧按以下顺序执行:
update(_:)— 游戏逻辑- 评估动作
didEvaluateActions()— 动作后的逻辑- 模拟物理
didSimulatePhysics()— 物理模拟后的调整- 应用约束
didApplyConstraints()didFinishUpdate()— 渲染前的最终调整
仅重写需要执行工作的回调。
节点和精灵
使用 SKNode(不带视觉内容)作为不可见容器或布局组。子节点会继承父节点的位置、缩放、旋转、透明度和速度。SKSpriteNode 是主要的视觉节点。
常见节点类型
| 类 | 用途 |
|---|---|
SKSpriteNode | 纹理图像或纯色 |
SKLabelNode | 文本渲染 |
SKShapeNode | 矢量路径(每次绘制调用的开销较大) |
SKEmitterNode | 粒子效果 |
SKCameraNode | 视口控制 |
SKTileMapNode | 基于网格的瓦片 |
SKAudioNode | 空间音频 |
SKCropNode / SKEffectNode | 遮罩 / CIFilter |
SK3DNode | 嵌入的 SceneKit 内容 |
创建精灵
let player = SKSpriteNode(imageNamed: "hero")
player.position = CGPoint(x: frame.midX, y: frame.midY)
player.name = "player"
addChild(player)
绘制顺序
在 SKView 上将 ignoresSiblingOrder = true 设为 true 可提升性能;此时 SpriteKit 会使用 zPosition 确定绘制顺序。否则,节点会按树中的顺序绘制。
background.zPosition = -1
player.zPosition = 0
foregroundUI.zPosition = 10
命名和搜索
为节点设置 name,即可在不使用实例变量的情况下查找节点。使用 childNode(withName:)、enumerateChildNodes(withName:using:) 或 subscript。模式:// 搜索整棵树,* 匹配任意字符,.. 表示父节点。
player.name = "player"
if let found = childNode(withName: "player") as? SKSpriteNode { /* ... */ }
动作和动画
SKAction 对象定义随时间应用于节点的变化。动作不可变且可重复使用。使用 node.run(_:) 运行。
基本动作
let moveUp = SKAction.moveBy(x: 0, y: 100, duration: 0.5)
let grow = SKAction.scale(to: 1.5, duration: 0.3)
let spin = SKAction.rotate(byAngle: .pi * 2, duration: 1.0)
let fadeOut = SKAction.fadeOut(withDuration: 0.3)
let remove = SKAction.removeFromParent()
组合动作
// Sequential: run one after another
let dropAndRemove = SKAction.sequence([
SKAction.moveBy(x: 0, y: -500, duration: 1.0),
SKAction.removeFromParent()
])
// Parallel: run simultaneously
let scaleAndFade = SKAction.group([
SKAction.scale(to: 0.0, duration: 0.3),
SKAction.fadeOut(withDuration: 0.3)
])
// Repeat
let pulse = SKAction.repeatForever(
SKAction.sequence([
SKAction.scale(to: 1.2, duration: 0.5),
SKAction.scale(to: 1.0, duration: 0.5)
])
)
纹理动画
let walkFrames = (1...8).map { SKTexture(imageNamed: "walk_\($0)") }
let walkAction = SKAction.animate(with: walkFrames, timePerFrame: 0.1)
player.run(SKAction.repeatForever(walkAction))
使用 timingMode(.linear、.easeIn、.easeOut、.easeInEaseOut)控制速度曲线。为动作分配键以便稍后访问:
let easeIn = SKAction.moveTo(x: 300, duration: 1.0)
easeIn.timingMode = .easeInEaseOut
player.run(pulse, withKey: "pulse")
player.removeAction(forKey: "pulse") // stop later
物理
SpriteKit 内置 2D 物理引擎。场景的 physicsWorld 管理重力和碰撞检测。
添加物理体
// Circle body
player.physicsBody = SKPhysicsBody(circleOfRadius: player.size.width / 2)
player.physicsBody?.restitution = 0.3
// Static rectangle
ground.physicsBody = SKPhysicsBody(rectangleOf: ground.size)
ground.physicsBody?.isDynamic = false
// Texture-based body for irregular shapes
player.physicsBody = SKPhysicsBody(texture: player.texture!, size: player.size)
类别和接触掩码
使用位掩码控制碰撞和接触回调:
struct PhysicsCategory {
static let player: UInt32 = 0b0001
static let enemy: UInt32 = 0b0010
static let ground: UInt32 = 0b0100
}
player.physicsBody?.categoryBitMask = PhysicsCategory.player
player.physicsBody?.contactTestBitMask = PhysicsCategory.enemy
player.physicsBody?.collisionBitMask = PhysicsCategory.ground
categoryBitMask 用于标识物理体。collisionBitMask 控制物理响应(反弹)。contactTestBitMask 会触发 didBegin/didEnd。
接触检测
实现 SKPhysicsContactDelegate,并在 didMove(to:) 中设置 physicsWorld.contactDelegate = self:
extension GameScene: SKPhysicsContactDelegate {
func didBegin(_ contact: SKPhysicsContact) {
let mask = contact.bodyA.categoryBitMask | contact.bodyB.categoryBitMask
if mask == PhysicsCategory.player | PhysicsCategory.enemy {
queuePlayerHit()
}
}
}
接触回调会在物理模拟期间运行。让 queuePlayerHit() 设置标记或添加事件,然后在 update(_:) 中应用节点、物理体或物理世界的更改。
力与冲量
player.physicsBody?.applyForce(CGVector(dx: 0, dy: 50)) // continuous
player.physicsBody?.applyImpulse(CGVector(dx: 0, dy: 200)) // instant
player.physicsBody?.applyAngularImpulse(0.5) // spin
跳跃和发射投射物时使用 .applyImpulse。使用
physicsWorld.gravity = CGVector(dx: 0, dy: -9.8) 配置重力,并通过
affectedByGravity 配置单个物理体。
触摸处理
SKScene 继承自 UIResponder。在场景中重写 touchesBegan、touchesMoved、
touchesEnded。使用 nodes(at:) 进行命中测试。
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
let tappedNodes = nodes(at: location)
if tappedNodes.contains(where: { $0.name == "playButton" }) {
startGame()
}
}
如需在节点级别处理触摸,请继承该节点并设置
isUserInteractionEnabled = true。这样,该节点会直接接收触摸,而不是由场景接收。
摄像机
SKCameraNode 控制场景的可见区域。将其添加为子节点,并赋值给 scene.camera。
let cameraNode = SKCameraNode()
addChild(cameraNode)
camera = cameraNode
cameraNode.position = CGPoint(x: frame.midX, y: frame.midY)
跟随角色
在 didSimulatePhysics() 中更新摄像机位置,或使用约束:
override func didSimulatePhysics() {
cameraNode.position = player.position
}
// Constrain camera to world bounds
let xRange = SKRange(lowerLimit: frame.midX, upperLimit: worldWidth - frame.midX)
let yRange = SKRange(lowerLimit: frame.midY, upperLimit: worldHeight - frame.midY)
cameraNode.constraints = [SKConstraint.positionX(xRange, y: yRange)]
摄像机缩放和 HUD
对摄像机节点进行反向缩放:setScale(0.5) 表示放大 2 倍,setScale(2.0)
表示缩小 2 倍。添加为摄像机子节点的节点会固定在屏幕上(HUD 元素):
let scoreLabel = SKLabelNode(text: "Score: 0")
scoreLabel.position = CGPoint(x: 0, y: frame.height / 2 - 40)
scoreLabel.fontName = "AvenirNext-Bold"
scoreLabel.fontSize = 24
cameraNode.addChild(scoreLabel)
粒子效果
SKEmitterNode 可生成粒子效果。可在 Xcode 的
SpriteKit 粒子文件编辑器(.sks)中设计发射器,也可以通过代码配置。
// Load from file
guard let emitter = SKEmitterNode(fileNamed: "Fire") else { return }
emitter.position = CGPoint(x: frame.midX, y: 100)
addChild(emitter)
单次发射器
设置 numParticlesToEmit 以生成有限效果,并在效果完成后移除:
func spawnExplosion(at position: CGPoint) {
guard let explosion = SKEmitterNode(fileNamed: "Explosion") else { return }
explosion.position = position
explosion.numParticlesToEmit = 100
addChild(explosion)
let wait = SKAction.wait(forDuration: TimeInterval(explosion.particleLifetime))
explosion.run(SKAction.sequence([wait, .removeFromParent()]))
}
将 targetNode 设置为场景,使发射器移动时粒子仍保持在世界空间中:emitter.targetNode = self。
SwiftUI 集成
SpriteView 可在 SwiftUI 中嵌入 SpriteKit 场景。
import SwiftUI
import SpriteKit
struct GameView: View {
@State private var scene: GameScene = {
let s = GameScene()
s.size = CGSize(width: 390, height: 844)
s.scaleMode = .resizeFill
return s
}()
var body: some View {
SpriteView(scene: scene)
.ignoresSafeArea()
}
}
SpriteView 选项
传入 options: [.allowsTransparency] 以使用透明背景,
传入 .shouldCullNonVisibleNodes 以剔除屏幕外节点,或传入 .ignoresSiblingOrder
以使用基于 zPosition 的绘制顺序。开发期间使用 debugOptions: [.showsFPS, .showsNodeCount]
进行调试。
在 SwiftUI 与场景之间通信
通过共享的 @Observable 对象传递数据。将场景存储在 @State 中,
以避免视图重新渲染时重新创建场景:
@Observable final class GameState {
var score = 0
var isPaused = false
}
struct GameContainerView: View {
@State private var gameState = GameState()
@State private var scene = GameScene()
var body: some View {
SpriteView(scene: scene, isPaused: gameState.isPaused)
.onAppear { scene.gameState = gameState }
}
}
常见错误
每次 SwiftUI 重新渲染时都创建新场景
// DON'T: Scene is recreated on every body evaluation
var body: some View {
SpriteView(scene: GameScene(size: CGSize(width: 390, height: 844)))
}
// DO: Create once and reuse
@State private var scene = GameScene(size: CGSize(width: 390, height: 844))
var body: some View {
SpriteView(scene: scene)
}
添加已有父节点的子节点
一个节点只能有一个父节点。请先从当前父节点中移除,或创建单独的实例。添加已有父节点的节点会导致崩溃。
忘记设置 contactTestBitMask
// DON'T: Bodies collide but didBegin is never called
player.physicsBody?.categoryBitMask = PhysicsCategory.player
enemy.physicsBody?.categoryBitMask = PhysicsCategory.enemy
// DO: Set contactTestBitMask to receive contact callbacks
player.physicsBody?.contactTestBitMask = PhysicsCategory.enemy
将 SKShapeNode 用于性能关键型渲染
SKShapeNode 的每个实例都会产生单独的绘制调用。对于重复元素,优先使用带纹理的
SKSpriteNode,以启用批量渲染。
未移除离开屏幕的节点
// DON'T
enemy.run(SKAction.moveBy(x: -800, y: 0, duration: 3.0))
addChild(enemy)
// DO: Remove after leaving the visible area
enemy.run(SKAction.sequence([
SKAction.moveBy(x: -800, y: 0, duration: 3.0),
SKAction.removeFromParent()
]))
addChild(enemy)
设置 physicsWorld.contactDelegate 太晚
请在 didMove(to:) 中设置 physicsWorld.contactDelegate = self,不要在
update(_:) 中设置,也不要延迟设置。
检查清单
- 场景子类在
didMove(to:)中重写设置逻辑,而不是在init中 - 根据游戏设计选择合适的
scaleMode - 在
SKView上将ignoresSiblingOrder设为true以提升性能 - 启用
ignoresSiblingOrder时,始终使用zPosition - 在
didMove(to:)中设置物理contactDelegate - 正确配置类别、碰撞和接触位掩码
- 为需要
didBegin/didEnd回调的任意对象对设置contactTestBitMask - 接触回调应将更改加入队列,而不是直接修改物理世界
- 静态物理体使用
isDynamic = false - 避免在性能关键路径中使用
SKShapeNode;优先使用SKSpriteNode - 将节点移出屏幕的动作序列中包含
.removeFromParent() - 单次发射器在粒子生命周期结束后移除自身
- 需要粒子保持在世界空间中时,设置发射器的
targetNode - 在 SwiftUI 中使用
SpriteView时,将场景存储在@State中 - 使用纹理图集存放相关精灵,以减少绘制调用
-
update(_:)使用时间增量,实现与帧率无关的移动 - 将节点重新添加到其他位置前,先从父节点中移除
参考资料
- 有关瓦片地图、纹理图集、着色器、场景过渡、游戏循环模式、音频和嵌入 SceneKit,请参阅 references/spritekit-patterns.md。
- SpriteKit 文档
- SKScene
- SKSpriteNode
- SKAction
- SKPhysicsBody
- SKEmitterNode
- SKCameraNode
- SpriteView
- SKTileMapNode