mcpField
S
spritekit

spritekit

github.com/dpearson2699開発1.2k3.2k 回インストール13 週間前に更新
リポジトリ

spritekit について

SpriteKit を使った 2D ゲームやインタラクティブなアニメーション開発を支援する Swift スキルです。シーンとノード、アニメーション、物理演算、入力、カメラ、パーティクル、SwiftUI 連携を解説します。

主な機能

  1. 01シーンのライフサイクルとノード階層
  2. 02スプライトのアクションとアニメーション
  3. 032D 物理演算と接触検出
  4. 04タッチ処理とヒットテスト
  5. 05カメラとパーティクル効果
  6. 06SwiftUI SpriteView との連携

活用シーン

  1. 012D ゲームシーンの作成
  2. 02衝突と接触イベントの実装
  3. 03アニメーション効果とカメラ移動の追加
  4. 04SwiftUI への 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 はツリーのルートノードであり、フレームワークが各フレームでアニメーションとレンダリングを行います。

シーンの作成

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 を設定します。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 Particle File エディタ(.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 は SpriteKit のシーンを SwiftUI に埋め込みます。

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、zPosition に基づく描画順序には .ignoresSiblingOrder を渡します。開発中は 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)
}
すでに親を持つ子ノードを追加する

ノードが持てる親は 1 つだけです。まず現在の親から削除するか、別のインスタンスを作成します。すでに親を持つノードを追加するとクラッシュします。

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 の設定が遅すぎる

physicsWorld.contactDelegate = self は didMove(to:) で設定します。update(_:) 内や遅延後に設定しないでください。

レビューチェックリスト

  • シーンサブクラスでは、init ではなくセットアップ用に didMove(to:) をオーバーライドしている
  • ゲームのデザインに適した scaleMode を選択している
  • パフォーマンス向上のため、SKView で ignoresSiblingOrder を true に設定している
  • ignoresSiblingOrder が有効な場合、zPosition を一貫して使用している
  • didMove(to:) で物理の contactDelegate を設定している
  • カテゴリ、衝突、接触のビットマスクを正しく設定している
  • didBegin/didEnd コールバックが必要なペアに contactTestBitMask を設定している
  • 接触コールバックでは、物理ワールドを直接変更せず、変更をキューに追加している
  • 静的ボディに isDynamic = false を設定している
  • パフォーマンスが重要な箇所では SKShapeNode を避け、SKSpriteNode を優先している
  • ノードを画面外に移動するアクションのシーケンスに .removeFromParent() を含めている
  • 単発エミッターはパーティクルの寿命が切れた後に自身を削除している
  • パーティクルをワールド座標系にとどめる場合、エミッターの targetNode を設定している
  • SwiftUI で SpriteView と併用する際、シーンを @State に保存している
  • 関連するスプライトにテクスチャアトラスを使用して、描画呼び出しを減らしている
  • フレームレートに依存しない移動のため、update(_:) でデルタタイムを使用している
  • 別の場所に再追加する前に、ノードを親から削除している

参考資料

コメント