mcpField
A
axiom-spritekit

axiom-spritekit

github.com/charleswiltgen開発1.2k191 回インストール今日更新
リポジトリ

axiom-spritekit について

SpriteKit のゲーム開発者向け実践ガイドです。シーン構成、物理演算、アクション、入力、パフォーマンスを扱い、よくある問題と対処法も説明します。

主な機能

  1. 01シーングラフと座標系
  2. 02シーン遷移と状態の受け渡し
  3. 03物理接触とビットマスク
  4. 04アクションとタッチ入力
  5. 05フレームレートと描画負荷の最適化

活用シーン

  1. 01ゲームシーンとカメラの構成
  2. 02物理接触が検出されない問題の調査
  3. 03タッチ入力とシーン遷移の実装
  4. 04ゲームのフレームレート改善

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-spritekit

インストール後、Claude Code で/axiom-spritekitを入力して読み込まれたか確認します。

詳細

SpriteKit ゲーム開発ガイド

目的:シーングラフ、物理エンジン、アクションシステム、レンダリングパイプラインを習得して、信頼性の高い SpriteKit ゲームを構築します iOS バージョン:iOS 13 以降(SwiftUI との統合)、iOS 11 以降(SKRenderer) Xcode:Xcode 15 以降

このスキルを使う場面

次のような場合に、このスキルを使います:

  • 新しい SpriteKit ゲームまたはインタラクティブなシミュレーションを構築する
  • 物理演算(衝突、接触、力、ジョイント)を実装する
  • ゲームアーキテクチャ(シーン、レイヤー、カメラ)を設定する
  • フレームレートを最適化する、またはドローコールを減らす
  • ゲーム内のタッチ/入力処理を実装する
  • シーン遷移とデータ受け渡しを管理する
  • SpriteKit を SwiftUI または Metal と統合する
  • 発生しない物理接触をデバッグする
  • 座標系の混乱を解消する

次の場合には、このスキルを使わないでください:

  • SceneKit の 3D レンダリング(axiom-scenekit)
  • GameplayKit のエンティティ・コンポーネントシステム
  • Metal シェーダープログラミング(axiom-metal-migration-ref)
  • 一般的な SwiftUI レイアウト(axiom-swiftui-layout)

1. 基本モデル

座標系

SpriteKit は、Y 軸が上向きの左下原点を使います。UIKit(左上原点、Y 軸が下向き)とは異なります。

SpriteKit:          UIKit:
┌─────────┐         ┌─────────┐
│    +Y    │         │  (0,0)  │
│    ↑     │         │    ↓    │
│    │     │         │    +Y   │
│(0,0)──→+X│        │    │    │
└─────────┘         └─────────┘

アンカーポイントは、スプライト上のどの点を position に対応させるかを定義します。デフォルトは (0.5, 0.5)(中央)です。

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

シーンのアンカーポイントは、ビューのフレームをシーン座標に対応させます:

  • (0, 0) — シーンの原点がビューの左下(デフォルト)
  • (0.5, 0.5) — シーンの原点がビューの中央
ノードツリー

SpriteKit では、すべてがツリー階層内の SKNode です。親の変換は子に伝播します。

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"
Z 軸の順序

zPosition は描画順序を制御します。値が大きいほど前面に描画されます。同じ zPosition のノードは、子配列の順序で描画されます(ignoresSiblingOrder が true の場合を除く)。

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

2. シーンアーキテクチャ

スケールモードの選択
モード動作使用する場面
.aspectFillビューを埋め、端を切り取ります全面表示のゲーム(ほとんどのゲーム)
.aspectFitビューに収め、レターボックス表示にします正確なレイアウトが必要なパズルゲーム
.resizeFill引き伸ばして表示領域を埋めますほとんど使いません — 画像が歪みます
.fillビューのサイズに正確に合わせますどのような縦横比にもシーンを適応させます
class GameScene: SKScene {
    override func sceneDidLoad() {
        scaleMode = .aspectFill
        // Design for a reference size, let aspectFill crop edges
    }
}
カメラノードのパターン

ビューポートの制御には、必ず SKCameraNode を使います。HUD 要素をカメラに追加すると、スクロールしなくなります。

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]
レイヤー構成
// 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)
シーン遷移
// 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)

シーン間のデータ受け渡し:ノードのプロパティではなく、共有ゲーム状態オブジェクトを使います。

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

注意:単純なゲームであれば、シングルトンでも問題ありません。テストが必要な大規模プロジェクトでは、隠れたグローバル状態を避けるため、GameState インスタンスをシーンのイニシャライザー経由で渡すことを検討してください。

willMove(from:) でのクリーンアップ:

override func willMove(from view: SKView) {
    removeAllActions()
    removeAllChildren()
    physicsWorld.contactDelegate = nil
}

3. 物理エンジン

ビットマスクの徹底

これは SpriteKit のバグの最大の原因です。 物理ビットマスクは、各ビットがカテゴリを表す 32 ビットシステムを使います。

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
}

3 つのビットマスクプロパティ(すべてのデフォルト値は 0xFFFFFFFF — すべてを対象):

プロパティ目的デフォルト
categoryBitMaskこのボディの種類0xFFFFFFFF
collisionBitMask何と衝突するか0xFFFFFFFF
contactTestBitMaskデリゲートを呼び出す対象0x00000000

デフォルトの collisionBitMask である 0xFFFFFFFF は、すべてがすべてと衝突することを意味します。 予期しない物理挙動の最も一般的な原因です。

// 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
ビットマスクのチェックリスト

すべての物理ボディについて、次の点を確認します:

  1. categoryBitMask が正確に 1 つのカテゴリに設定されている
  2. collisionBitMask が衝突させるカテゴリだけに設定されている(0xFFFFFFFF は使用しない)
  3. contactTestBitMask がデリゲートのコールバックを呼び出すカテゴリに設定されている
  4. デリゲートが設定されている:physicsWorld.contactDelegate = self
接触検出
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)
        }
    }
}

変更に関するルール:didBegin/didEnd の内部では物理ワールドを変更できません。フラグを設定し、変更は update(_:) で適用します。

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()
}
ボディタイプ
タイプ作成方法力に反応するか用途
ダイナミックボリュームinit(circleOfRadius:), init(rectangleOf:), init(texture:size:)はいプレイヤー、敵、弾
スタティックボリュームダイナミックボディ + isDynamic = falseいいえ(ただし衝突します)プラットフォーム、壁
エッジinit(edgeLoopFrom:), init(edgeFrom:to:)いいえ(境界のみ)画面の境界、地形
// 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)
トンネリングの防止

高速で移動するオブジェクトは、薄い壁を通り抜けることがあります。次の方法で修正します。

// 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
力とインパルス
// 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)

4. アクションシステム

基本パターン
// 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)
順次実行とグループ化
// 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)
]))
名前付きアクション(管理に不可欠)
// 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
}
weak self を使ったカスタムアクション
// 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")
タイミングモード
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
アクションと物理演算

物理演算で制御されるノードをアクションで動かさないでください。 アクションが物理シミュレーションを上書きし、動きの乱れや衝突の見逃しが発生します。

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

5. 入力処理

タッチ処理
// 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
    }
}
座標空間の変換
// 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)
}

よくある間違い:touch.location(in: self.view) を使うと、UIKit の座標(Y 軸が反転)で返されます。シーンの座標を取得するには、必ず touch.location(in: self) を使ってください。

ゲームコントローラーのサポート
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)
    }
}

6. パフォーマンス

パフォーマンスの優先事項

詳細なパフォーマンス診断については、axiom-spritekit-diag の症状 3 を参照してください。主な優先事項は次のとおりです。

  1. ノード数 — 画面外のノードを削除し、オブジェクトプーリングを使用します
  2. 描画コール — テクスチャアトラスを使用し、SKShapeNode を事前レンダリング済みテクスチャに置き換えます
  3. 物理演算の負荷 — シンプルなボディ形状を優先し、usesPreciseCollisionDetection の使用を制限します
  4. パーティクル — 発生率を制限し、有限の放出数を設定します
デバッグオーバーレイ(開発中は常に有効にする)
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
}
テクスチャアトラスによるバッチ処理

同じアトラスのテクスチャを使うスプライトは、1 回の描画コールでレンダリングされます。

// 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
}
SKShapeNode の落とし穴

SKShapeNode はインスタンスごとに 1 回の描画コールを生成します。 バッチ処理できません。プロトタイピングとデバッグ表示にのみ使用してください。

// 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)
}
オブジェクトプーリング

頻繁に生成・破棄されるオブジェクト(弾、パーティクル、敵)には、次の方法を使用します。

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)
    }
}
画面外ノードの削除
// 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)
        }
    }
}

7. ゲームループ

フレームサイクル(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
デルタタイム
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)
}
一時停止の処理
// 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
)

8. パーティクルエフェクト

エミッターのベストプラクティス
// 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()
]))
軌跡用のターゲットノード

targetNode を指定しない場合、パーティクルはエミッターと一緒に移動します。軌跡(ロケットの排気など)を作るには、targetNode にシーンを設定します。

let trail = SKEmitterNode(fileNamed: "RocketTrail")!
trail.targetNode = scene  // Particles stay where emitted
rocketNode.addChild(trail)
無限エミッターのクリーンアップ
// 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()
]))

9. SwiftUI との統合

SpriteView(推奨、iOS 14 以降)

SpriteKit を SwiftUI に埋め込む最も簡単な方法です。カスタムの SKView 構成が必要でない限り、これを使用してください。

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(上級)

SKView の構成を完全に制御する必要がある場合(カスタムフレームレート、透明度、複数のシーンなど)に使用します。

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)
        }
    }
}
Metal とのハイブリッド構成向けの SKRenderer

SpriteKit が Metal パイプラインの 1 レイヤーである場合は、SKRenderer を使用します。

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
)

10. アンチパターン

アンチパターン 1:デフォルトのビットマスク

時間的コスト:原因不明の衝突のデバッグに 30〜120 分

// 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
アンチパターン 2:contactTestBitMask の設定漏れ

時間的コスト:didBegin が一度も呼び出されない理由を調べるのに 30〜60 分

// 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
アンチパターン 3:物理ボディにアクションを使う

時間的コスト:動きの乱れや衝突の見逃しの解決に 1〜3 時間

// 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))
アンチパターン 4:ゲームプレイでの SKShapeNode の使用

時間的コスト:フレームレート低下の原因調査に数時間

各 SKShapeNode は個別の描画コールとなり、バッチ処理できません。シェイプノードが 50 個あれば、描画コールも 50 回です。解決方法については、セクション 6(SKShapeNode の落とし穴)のテクスチャへの事前レンダリングパターンを参照してください。

アンチパターン 5:アクションクロージャでの strong self

時間的コスト:メモリリーク、最終的にはクラッシュ

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

11. コードレビューのチェックリスト

物理演算
  • すべての物理ボディで categoryBitMask が明示的に設定されている(デフォルト値ではない)
  • すべての物理ボディで collisionBitMask が明示的に設定されている(0xFFFFFFFF ではない)
  • 接触検出が必要なボディで contactTestBitMask が設定されている
  • physicsWorld.contactDelegate が割り当てられている
  • didBegin/didEnd コールバック内でワールドを変更していない
  • 高速で移動するオブジェクトで usesPreciseCollisionDetection を使用している
アクション
  • 物理演算で制御するノードに SKAction.move/rotate を使用していない
  • 繰り返し実行するアクションにキャンセル用の withKey: を使用している
  • SKAction.run のクロージャで [weak self] を使用している
  • 1回限りのエミッターを放出後に削除している
パフォーマンス
  • 開発中にデバッグオーバーレイを有効にしている
  • SKView で ignoresSiblingOrder = true を設定している
  • ゲームプレイ用スプライトに SKShapeNode を使用していない(事前レンダリングしたテクスチャを使用する)
  • 関連するスプライトにテクスチャアトラスを使用している
  • 画面外のノードを手動で削除している
シーン管理
  • willMove(from:) でアクション、子ノード、デリゲートをクリーンアップしている
  • シーンデータをノードのプロパティではなく、共有状態を介して渡している
  • ビューポートの制御にカメラを使用している

12. プレッシャーのかかる状況

シナリオ 1:「物理演算の接触が機能しない — 今夜リリース」

プレッシャー:体系的なデバッグを省略したくなる締め切りのプレッシャー

誤った対応:ビットマスクの値を手当たり次第に変更したり、あちこちに 0xFFFFFFFF を追加したり、物理演算を無効にしたりする

正しい対応(2~5分):

  1. showsPhysics を有効にし、ボディが存在して重なっていることを確認する
  2. 両方のボディについて、3つすべてのビットマスクを出力する
  3. ボディ A の contactTestBitMask にボディ B のカテゴリが含まれている(またはその逆)ことを確認する
  4. physicsWorld.contactDelegate が設定されていることを確認する
  5. コールバック内でワールドを変更していないことを確認する

異議を唱える際のテンプレート:「5段階のビットマスクチェックリストを実行させてください。2分で終わり、接触の問題の90%を特定できます。手当たり次第に変更すると、状況が悪化します。」

シナリオ 2:「自分のデバイスではフレームレートに問題ない」

プレッシャー:責任者が「自分の環境では60fpsで動いているから、リリースして」と言う

誤った対応:最小スペックのデバイスでプロファイリングせずにリリースする

正しい対応:

  1. showsFPS、showsNodeCount、showsDrawCount を有効にする
  2. 対応対象の中で最も古いデバイスでテストする
  3. ノードが200個超、またはドローコールが30回超の場合は調査する
  4. ゲームプレイで SKShapeNode を使用していないか確認する
  5. 画面外のノードが削除されていることを確認する

異議を唱える際のテンプレート:「パフォーマンスはデバイスによって異なります。ノード数とドローコールを確認させてください。デバッグオーバーレイがあれば30秒で済みます。数値が低ければ、安心してリリースできます。」

シナリオ 3:「SKShapeNode を使えばいい。コードを書くのが速いから」

プレッシャー:すでに SKShapeNode で構築しており、やり直したくないというサンクコスト

誤った対応:フレーム落ちの原因となる SKShapeNode を100個以上使ったままリリースする

正しい対応:

  1. showsDrawCount を確認する — SKShapeNode ごとにドローコールが1回追加される
  2. ゲームプレイでシェイプノードが20個を超える場合は、テクスチャに事前レンダリングする
  3. view.texture(from:) を使って一度変換し、SKSpriteNode として再利用する
  4. SKShapeNode はデバッグ表示にのみ使用する

異議を唱える際のテンプレート:「SKShapeNode ごとにドローコールが1回発生します。事前レンダリングしたテクスチャへの変換は15分程度のリファクタリングで、フレームレートが2倍になる可能性があります。アトラスの SKSpriteNode なら、すべてをまとめてドローコール1回で描画できます。」

リソース

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

コメント