使用 SpriteKit 開發 2D 遊戲與動畫。適用於使用 SKScene 與 SKView 建立遊戲場景、透過 SKSpriteNode 新增精靈(sprite)、利用 SKAction 序列製作動畫、使用 SKPhysicsBody 與碰撞檢測模擬物理效果、透過 SKEmitterNode 建立粒子特效、構建瓦片地圖(tile map)、使用 SKCameraNode,以及在 SwiftUI 中透過 SpriteView 整合 SpriteKit 場景。
SpriteKit
使用 SpriteKit 與 Swift 6.3 為 iOS 26+ 開發 2D 遊戲和互動式動畫。涵蓋場景生命週期、節點階層、動作、物理引擎、粒子特效、攝影機、觸控處理以及 SwiftUI 整合。
目錄
場景設定
SpriteKit 透過 SKView 渲染內容,而 SKView 負責呈現 SKScene —— SKScene 是樹狀結構的根節點,框架在每一幀(frame)都會對此樹狀結構進行動畫處理與繪製。
建立場景
繼承 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) {
// 在評估動作前,每一幀都會呼叫一次此方法。
}
}
呈現場景 (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)
縮放模式 (Scale Modes)
當場景需要適應視圖尺寸變化(如旋轉、多工處理)時,請使用 .resizeFill。對於固定設計尺寸的遊戲場景,請使用 .aspectFill。.aspectFit 會加上黑邊(letterboxing);.fill 則會拉伸圖像且可能造成變形。
畫格週期 (Frame Cycle)
每一幀都遵循以下執行順序:
update(_:)—— 遊戲邏輯- 評估動作 (Evaluate actions)
didEvaluateActions()—— 動作評估完畢後的邏輯- 模擬物理效果
didSimulatePhysics()—— 物理模擬完成後的調整- 套用約束 (Apply constraints)
didApplyConstraints()didFinishUpdate()—— 繪製前的最終調整
僅覆寫有實作需求的回呼函式(callbacks)。
節點與精靈
使用 SKNode(無視覺呈現)作為隱形的容器或佈局分組。子節點會繼承父節點的位置、縮放、旋轉、透明度(alpha)與速度。SKSpriteNode 則是主要的視覺節點。
常見節點類型
| 類別 | 用途 |
|---|---|
SKSpriteNode |
紋理圖像(Textured image)或純色區塊 |
SKLabelNode |
文字繪製 |
SKShapeNode |
向量路徑(每次 Draw Call 開銷較大) |
SKEmitterNode |
粒子特效 |
SKCameraNode |
視埠(Viewport)控制 |
SKTileMapNode |
基於網格的瓦片(Tile)地圖 |
SKAudioNode |
位置音訊(Positional audio) |
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 以獲得更佳的效能;屆時 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 物件定義了隨時間套用到節點上的變更。動作具有不可變性(immutable)且可重複使用。透過 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()
組合動作
// 順序執行:一個接一個執行
let dropAndRemove = SKAction.sequence([
SKAction.moveBy(x: 0, y: -500, duration: 1.0),
SKAction.removeFromParent()
])
// 並行執行:同時執行
let scaleAndFade = SKAction.group([
SKAction.scale(to: 0.0, duration: 0.3),
SKAction.fadeOut(withDuration: 0.3)
])
// 循環重複
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)來控制速度曲線。為動作指定 Key 以便日後存取或管理:
let easeIn = SKAction.moveTo(x: 300, duration: 1.0)
easeIn.timingMode = .easeInEaseOut
player.run(pulse, withKey: "pulse")
player.removeAction(forKey: "pulse") // 稍後停止此動作
物理引擎
SpriteKit 內建 2D 物理引擎。場景的 physicsWorld 負責管理重力與碰撞檢測。
新增物理剛體 (Physics Bodies)
// 圓形剛體
player.physicsBody = SKPhysicsBody(circleOfRadius: player.size.width / 2)
player.physicsBody?.restitution = 0.3
// 靜態矩形剛體
ground.physicsBody = SKPhysicsBody(rectangleOf: ground.size)
ground.physicsBody?.isDynamic = false
// 用於不規則形狀的紋理剛體
player.physicsBody = SKPhysicsBody(texture: player.texture!, size: player.size)
分類與接觸遮罩 (Category and Contact Masks)
使用位元遮罩(bit masks)來控制碰撞與接觸回呼:
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() 中設定標記(flag)或新增事件,隨後在 update(_:) 中再對節點、剛體或世界狀態進行變更。
施力與衝量 (Forces and Impulses)
player.physicsBody?.applyForce(CGVector(dx: 0, dy: 50)) // 持續施力
player.physicsBody?.applyImpulse(CGVector(dx: 0, dy: 200)) // 瞬時衝量
player.physicsBody?.applyAngularImpulse(0.5) // 角衝量(自轉)
跳躍與發射子彈請使用 .applyImpulse。透過 physicsWorld.gravity = CGVector(dx: 0, dy: -9.8) 設定全域重力,個別剛體則可透過 affectedByGravity 控制是否受重力影響。
觸控處理
SKScene 繼承自 UIResponder。可在場景中覆寫 touchesBegan、touchesMoved、touchesEnded。使用 nodes(at:) 進行點擊測試(hit-test)。
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() 中更新攝影機位置,或使用約束(constraints):
override func didSimulatePhysics() {
cameraNode.position = player.position
}
// 將攝影機限制在世界邊界內
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 Particle File 編輯器(.sks)中設計發射器,或直接以程式碼配置。
// 從檔案載入
guard let emitter = SKEmitterNode(fileNamed: "Fire") else { return }
emitter.position = CGPoint(x: frame.midX, y: 100)
addChild(emitter)
單次觸發的粒子發射器 (One-Shot Emitters)
針對有限次數的特效設定 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 整合
在 SwiftUI 中透過 SpriteView 嵌入 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 可進行畫面上未顯示節點的剔除(offscreen culling),.ignoresSiblingOrder 則可依據 zPosition 決定繪製順序。開發期間可使用 debugOptions: [.showsFPS, .showsNodeCount]。
SwiftUI 與場景之間的溝通
透過共享的 @Observable 物件傳遞資料。將場景存放在 @State 中,可避免 View 重新繪製(re-render)時重複建立場景:
@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 重新繪製時都建立新場景
// 錯誤做法:每次 body 評估時都會重新建立場景
var body: some View {
SpriteView(scene: GameScene(size: CGSize(width: 390, height: 844)))
}
// 正確做法:建立一次並重複使用
@State private var scene = GameScene(siz
<!-- truncated for translation batch; full body continues in source -->




