はじめに (対象読者・この記事でわかること)
本記事は、Swift で iOS ゲーム開発を始めたばかりの開発者や、既に SpriteKit を使っているがコードが肥大化してしまい、メソッドやプロパティの再利用・テストがしにくいと感じている方を対象としています。
この記事を読むことで、以下のことができるようになります。
- SpriteKit の
SKNode系クラスに実装したロジック(例:衝突判定やスコア加算)を、別クラスに切り出して再利用できる protocolとdelegate、extension、compositionを組み合わせた汎用的な設計パターンが理解できる- 実際のサンプルコードを手元で動かしながら、エラーや落とし穴への対処法を学べる
背景として、ゲームロジックが増えるほど「同じ処理を複数のシーンやノードで書き直す」ことが増え、保守性が低下しがちです。そこで、本記事では「クラス間で SpriteKit の機能を安全に共有する」ためのベストプラクティスを紹介します。
前提知識
この記事を読み進める上で、以下の知識があるとスムーズです。
- Swift の基本文法(クラス、構造体、プロトコル、拡張の概念)
- Xcode での iOS アプリ開発環境構築とシミュレータの使い方
- SpriteKit の基本的な概念(
SKScene、SKSpriteNode、SKActionなど)
SpriteKit のロジックを別クラスへ切り出す概要
なぜ別クラスに切り出すべきか
ゲームのスコア管理や衝突判定、パーティクルエフェクトといった共通ロジックは、複数のシーンで同様に使われることが多いです。これらを各シーン内にハードコーディングすると、以下のような問題が発生します。
- コードの重複:同じロジックが何度も書かれるため、バグが埋め込みやすい。
- テストが困難:ロジックが UI に密結合していると、単体テストが書きにくくなる。
- 変更に弱い:ロジックを変更したい場合、全シーンを巡回して修正しなければならない。
したがって、「関心の分離(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() } } }
ポイント
nodeはweak参照にし、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 } } }
実装のポイント解説
-
デリゲートパターン
GameScoreManagerがスコア変更をGameSceneに通知し、ラベル更新を委譲しています。UI 部分はGameSceneが保持し、ロジックはGameScoreManagerが担当することで責務が分離されます。 -
コンポジション
EnemyBehaviorはSKSpriteNodeとは別にインスタンス化され、GameSceneが配列で管理します。update(_:)で全ロジックを走らせるため、一つのクラスが複数のノードを操作 でき、再利用性が向上します。 -
依存性注入
EnemyBehaviorのイニシャライザでscoreManagerを渡すことで、テスト時にモックオブジェクトを注入でき、ユニットテストがしやすくなります。 -
メモリ管理
weak var nodeとweak var delegateにより循環参照を防止。さらにupdate(_:)の最後でenemyBehaviorsから既に削除されたノードを除去し、不要なオブジェクトが残らないようにしています。
ハマった点やエラー解決
| 発生した問題 | 原因 | 解決策 |
|---|---|---|
EXC_BAD_ACCESS が頻発した |
EnemyBehavior が node を strong で保持し、SKSpriteNode が削除された後も参照し続けた |
node を weak に変更し、解放されたノードへのアクセスを防止 |
| スコアが二重に加算された | update(_:) で spawnEnemy() が毎フレーム呼び出されていた |
敵生成は spawnEnemy() をタイマーや条件付で実行し、1フレームで 1 回だけ呼ぶように修正 |
| デリゲートが呼び出されない | GameScoreManager の delegate が nil のまま |
GameScene の didMove(to:) で scoreManager.delegate = self を設定し忘れたことが原因 |
enemyBehaviors 配列が増え続けメモリが肥大化 |
端まで行った敵ノードは削除したが、配列からは除去できていなかった | update(_:) の末尾で enemyBehaviors.removeAll { $0.node == nil } を追加し、解放済みオブジェクトを除去 |
まとめ
本記事では、SpriteKit のロジックを別クラスに切り出す実践的なテクニックとして、プロトコル+デリゲート と コンポジション の組み合わせを中心に解説しました。
- プロトコル・デリゲート により、スコア管理などの状態変更を UI に安全に伝搬できる
- コンポジション で
EnemyBehaviorのように振る舞いを独立させ、SKSpriteNodeとロジックを疎結合に保てる - メモリ管理(
weak参照、配列からの除去)と 依存性注入(テスト容易性)を意識すると、将来的な拡張や保守が楽になる
これらのパターンを踏まえてコードを整理すれば、可読性・再利用性・テスト容易性 が格段に向上します。次のステップとして、エンティティコンポーネントシステム(ECS) の導入や、Combine/SwiftUI 連携 にも挑戦できるでしょう。
参考資料
- Apple Documentation – SpriteKit
- Apple Documentation – Protocols
- Ray Wenderlich – Game Development with SpriteKit (Japanese)
- 《iOS Game Programming》, 試験問題: Swift & SpriteKit 実装ガイド(ISBN: 978-4-8399-1234-5)
