mcpField
S
spritekit

spritekit

github.com/dpearson2699开发1.2k3.2k 次安装13 周前更新
代码仓库

关于 spritekit

这项 Swift 技能介绍如何使用 SpriteKit 开发 2D 游戏和交互动画,面向应用开发者。内容涵盖场景与节点结构、动画、物理、输入、摄像机、粒子效果及 SwiftUI 集成。

主要功能

  1. 01场景生命周期与节点层级
  2. 02精灵动作与动画
  3. 032D 物理与接触检测
  4. 04触控处理与命中测试
  5. 05摄像机与粒子效果
  6. 06集成 SwiftUI SpriteView

适用场景

  1. 01创建 2D 游戏场景
  2. 02实现碰撞与接触事件
  3. 03添加动画效果和摄像机移动
  4. 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 会拉伸画面,可能导致失真。

帧循环

每一帧按以下顺序执行:

  1. update(_:) — 游戏逻辑
  2. 评估动作
  3. didEvaluateActions() — 动作后的逻辑
  4. 模拟物理
  5. didSimulatePhysics() — 物理模拟后的调整
  6. 应用约束
  7. didApplyConstraints()
  8. 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(_:) 使用时间增量,实现与帧率无关的移动
  • 将节点重新添加到其他位置前,先从父节点中移除

参考资料

评论

支持 Markdown