はじめに (対象読者・この記事でわかること)

本記事は、Swift で iOS ゲーム開発を始めたばかりの開発者や、既に SpriteKit を使っているがコードが肥大化してしまい、メソッドやプロパティの再利用・テストがしにくいと感じている方を対象としています。
この記事を読むことで、以下のことができるようになります。

  • SpriteKit の SKNode 系クラスに実装したロジック(例:衝突判定やスコア加算)を、別クラスに切り出して再利用できる
  • protocoldelegateextensioncomposition を組み合わせた汎用的な設計パターンが理解できる
  • 実際のサンプルコードを手元で動かしながら、エラーや落とし穴への対処法を学べる

背景として、ゲームロジックが増えるほど「同じ処理を複数のシーンやノードで書き直す」ことが増え、保守性が低下しがちです。そこで、本記事では「クラス間で SpriteKit の機能を安全に共有する」ためのベストプラクティスを紹介します。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。

  • Swift の基本文法(クラス、構造体、プロトコル、拡張の概念)
  • Xcode での iOS アプリ開発環境構築とシミュレータの使い方
  • SpriteKit の基本的な概念(SKSceneSKSpriteNodeSKAction など)

SpriteKit のロジックを別クラスへ切り出す概要

なぜ別クラスに切り出すべきか

ゲームのスコア管理や衝突判定、パーティクルエフェクトといった共通ロジックは、複数のシーンで同様に使われることが多いです。これらを各シーン内にハードコーディングすると、以下のような問題が発生します。

  1. コードの重複:同じロジックが何度も書かれるため、バグが埋め込みやすい。
  2. テストが困難:ロジックが UI に密結合していると、単体テストが書きにくくなる。
  3. 変更に弱い:ロジックを変更したい場合、全シーンを巡回して修正しなければならない。

したがって、「関心の分離(Separation of Concerns)」 を意識し、ロジックを専用クラスに分離することが重要です。

代表的なアプローチ

アプローチ 特徴 使いどころ
プロトコル + デリゲート インターフェースを明示し、弱結合で通信できる シーン間の双方向通信やイベント通知
拡張(Extension) 既存クラスにメソッドを追加できるが、状態は持てない ユーティリティ系メソッドの提供
コンポジション(Composition) ロジックを持つクラスをプロパティとして保持 複数のノードが同じロジックを共有したい場合
サブクラス化 親クラスの機能を継承し拡張 カスタムノードを作りたいとき
userData SKNode の辞書に任意データを格納 小規模な状態保存やタグ付け

本稿では、プロトコル+デリゲートコンポジション の 2 パターンを中心に、実装例と落とし穴の対処法を紹介します。

実装例とステップバイステップ解説

以下では、簡単な「敵キャラが画面端に到達したらスコアを加算し、エフェクトを表示する」ロジックを別クラスに切り出す手順を解説します。実装は 3 ファイルに分割します。

  • GameScoreManager.swift – スコア管理とスコア加算ロジック
  • EnemyBehavior.swift – 敵の移動・衝突判定ロジック(コンポジション)
  • GameScene.swift – 既存のシーンファイルで、外部ロジックを呼び出す

ステップ 1: プロトコルとデリゲートの定義

まずは、スコア加算の結果をシーン側へ通知するためのプロトコルを定義します。

Swift
// GameScoreManager.swift import Foundation /// スコア変更を通知するデリゲートプロトコル protocol GameScoreDelegate: AnyObject { /// スコアが加算されたときに呼び出される func didUpdateScore(to newScore: Int) }

このプロトコルは weak で保持できるように AnyObject 継承させ、循環参照を防ぎます。

ステップ 2: スコア管理クラスの実装

次に、スコアを保持し、加算時にデリゲートへ通知するクラスです。

Swift
// GameScoreManager.swift (続き) final class GameScoreManager { private(set) var score: Int = 0 weak var delegate: GameScoreDelegate? /// スコアを加算し、デリゲートへ通知 func add(points: Int) { guard points > 0 else { return } score += points delegate?.didUpdateScore(to: score) } /// スコアをリセット func reset() { score = 0 delegate?.didUpdateScore(to: score) } }

ポイント

  • private(set) で外部からの直接書き換えを防止し、add/reset メソッドだけがスコアを操作する。
  • デリゲートは weak にしてメモリリークを回避。

ステップ 3: 敵の振る舞いロジックをコンポジションで作る

次に、敵キャラの移動と端到達判定を行うクラスです。SKSpriteNode に対して「行動」を付与する形で実装します。

Swift
// EnemyBehavior.swift import SpriteKit /// 敵の移動・端判定ロジックを提供するクラス final class EnemyBehavior { private weak var node: SKSpriteNode? private let moveSpeed: CGFloat private let scoreManager: GameScoreManager private let pointsOnEscape: Int init(node: SKSpriteNode, speed: CGFloat = 150.0, scoreManager: GameScoreManager, points: Int = 10) { self.node = node self.moveSpeed = speed self.scoreManager = scoreManager self.pointsOnEscape = points } /// 毎フレーム呼び出すことで位置更新を行う func update(deltaTime: TimeInterval) { guard let node = node else { return } let deltaX = moveSpeed * CGFloat(deltaTime) node.position.x += deltaX // 画面右端を超えたらスコア加算と削除 if let scene = node.scene, node.position.x > scene.size.width + node.size.width / 2 { scoreManager.add(points: pointsOnEscape) node.removeFromParent() } } }

ポイント

  • nodeweak 参照にし、EnemyBehavior が解放されたときに循環参照が残らないようにする。
  • update(deltaTime:) はシーンの update(_:) から呼び出す想定。
  • scoreManager を注入することで、依存性注入 の形を取り、テストしやすくなる。

ステップ 4: GameScene でロジックを組み合わせる

最後に、GameScene 側で上記クラスを組み合わせます。

Swift
// GameScene.swift import SpriteKit class GameScene: SKScene, GameScoreDelegate { private var scoreLabel: SKLabelNode! private let scoreManager = GameScoreManager() private var enemyBehaviors: [EnemyBehavior] = [] override func didMove(to view: SKView) { // デリゲート設定 scoreManager.delegate = self // スコア表示ラベル設定 scoreLabel = SKLabelNode(fontNamed: "Helvetica") scoreLabel.fontSize = 24 scoreLabel.fontColor = .white scoreLabel.position = CGPoint(x: size.width / 2, y: size.height - 40) addChild(scoreLabel) updateScoreLabel() // 敵生成サンプル spawnEnemy() } // MARK: - GameScoreDelegate func didUpdateScore(to newScore: Int) { updateScoreLabel() } private func updateScoreLabel() { scoreLabel.text = "Score: \(scoreManager.score)" } // MARK: - 敵生成 private func spawnEnemy() { let enemy = SKSpriteNode(color: .red, size: CGSize(width: 40, height: 40)) enemy.position = CGPoint(x: -enemy.size.width / 2, y: size.height * 0.5) addChild(enemy) // 行動ロジックを生成し保持 let behavior = EnemyBehavior(node: enemy, speed: 200, scoreManager: scoreManager, points: 20) enemyBehaviors.append(behavior) } // MARK: - 毎フレーム更新 override func update(_ currentTime: TimeInterval) { // 前回フレームとの時間差を算出 static var lastTime: TimeInterval = 0 let delta = currentTime - lastTime lastTime = currentTime // すべての敵ロジックを更新 for behavior in enemyBehaviors { behavior.update(deltaTime: delta) } // 端まで行った敵は配列から除去(メモリリーク防止) enemyBehaviors.removeAll { $0.node == nil } } }

実装のポイント解説

  1. デリゲートパターン
    GameScoreManager がスコア変更を GameScene に通知し、ラベル更新を委譲しています。UI 部分は GameScene が保持し、ロジックは GameScoreManager が担当することで責務が分離されます。

  2. コンポジション
    EnemyBehaviorSKSpriteNode とは別にインスタンス化され、GameScene が配列で管理します。update(_:) で全ロジックを走らせるため、一つのクラスが複数のノードを操作 でき、再利用性が向上します。

  3. 依存性注入
    EnemyBehavior のイニシャライザで scoreManager を渡すことで、テスト時にモックオブジェクトを注入でき、ユニットテストがしやすくなります。

  4. メモリ管理
    weak var nodeweak var delegate により循環参照を防止。さらに update(_:) の最後で enemyBehaviors から既に削除されたノードを除去し、不要なオブジェクトが残らないようにしています。

ハマった点やエラー解決

発生した問題 原因 解決策
EXC_BAD_ACCESS が頻発した EnemyBehaviornodestrong で保持し、SKSpriteNode が削除された後も参照し続けた nodeweak に変更し、解放されたノードへのアクセスを防止
スコアが二重に加算された update(_:)spawnEnemy() が毎フレーム呼び出されていた 敵生成は spawnEnemy() をタイマーや条件付で実行し、1フレームで 1 回だけ呼ぶように修正
デリゲートが呼び出されない GameScoreManagerdelegatenil のまま GameScenedidMove(to:)scoreManager.delegate = self を設定し忘れたことが原因
enemyBehaviors 配列が増え続けメモリが肥大化 端まで行った敵ノードは削除したが、配列からは除去できていなかった update(_:) の末尾で enemyBehaviors.removeAll { $0.node == nil } を追加し、解放済みオブジェクトを除去

まとめ

本記事では、SpriteKit のロジックを別クラスに切り出す実践的なテクニックとして、プロトコル+デリゲートコンポジション の組み合わせを中心に解説しました。

  • プロトコル・デリゲート により、スコア管理などの状態変更を UI に安全に伝搬できる
  • コンポジションEnemyBehavior のように振る舞いを独立させ、SKSpriteNode とロジックを疎結合に保てる
  • メモリ管理weak 参照、配列からの除去)と 依存性注入(テスト容易性)を意識すると、将来的な拡張や保守が楽になる

これらのパターンを踏まえてコードを整理すれば、可読性・再利用性・テスト容易性 が格段に向上します。次のステップとして、エンティティコンポーネントシステム(ECS) の導入や、Combine/SwiftUI 連携 にも挑戦できるでしょう。

参考資料