お仕事で初めてSpriteKitを触った
SpriteKitの日本語記事があまり見つけられず、また古い情報が多かった
情報かき集めながらの対応が初学者にはツラかったので備忘録
普段はWEBアプリケーションが主体、Swiftによる開発も初めてでゲーム開発者でもない
お作法のようなところは存じ上げていないため、不適切な解説・表現があれば是非コメントいただきたい
- 動作確認環境
- Swift:5.6.1
はじめに
SpriteKitとは何ぞやは、『10分で理解するSpriteKit』を参照されたい
Apple が提供している2Dのゲームを作るためのフレームワークです
アニメーションや物理シミュレーションを使ったゲームが簡単に実装できます『10分で理解するSpriteKit』から部分引用
今回、私がやりたかったことはすでにあるSwiftUIベースのアプリケーションに描画アニメーションを多用した機能を追加こと
半日ぐらい色々試してようやくSpriteKitにたどり着いた
SpriteKitはXcodeのテンプレートがStoryBorad前提となっており、SwiftUIに組み込む記事もちらほらあるものの全体感の理解に時間を要した
SwiftUI、SpriteKit、SceneKitと彷徨い歩いた結果、素人理解では下記のようになっている
- SwiftUI
- 画面描画の根底に使用。StoryBoard使いたくないならこれになる
- ホームページビルダーが苦手な私は、StoryBoardは向いてないのでSwiftUIのほうが好み
- 簡単なアニメーション(表示するときに画像がフェードイン、ボタンを押したら色がボタンがフェードアウトするなど)ならSwiftUIだけでいける
- 同様のアニメーションを継続的にループさせることはできるが、様々なイベントに応じてアニメーションをどんどん付加していくようなことは、状態の管理などが煩雑であり大変
- SpriteKit
- 2Dゲームを作るならこれを使う
- いわゆるゲームのフレーム毎の処理がライブラリで組まれている
- SwiftUIか?SpriteKitか?というものではなく、SwiftUIからSpriteKitを呼び出して使う
- 継続的なアニメーション表現やイベント発火のアニメーションが組める
- SceneKit
- 3Dモデルを使う、3Dゲームを作る、ARなどの場合はこれを使う
- ほとんど調べてない
目的とゴール
- SpriteKitを用いて簡易なアニメーションとアクションを作る
- Playgrounds/Swift 5.5販でハンズオン
- ゲームには以下の要素を盛り込む
- シーン切替(SKScene)
- オブジェクトを配置(SKNode)
- アニメーション効果(SKAction)
- UIの共通化のため、SwiftUI・
動作イメージ
ソースコード全体
githubに乗せているので、細かいことはいいからサンプルコード、という方はこちらを参照されたい
(Xcodeでアプリケーションを新規作成して、GameScene.swift
を追加し、ContentView.swift
に追記をしたのみ)
https://github.com/tukino/Hands-on-SpriteKit
SpriteKitのライフサイクル
こんな感じに動いている、と思ってる
ハンズオン
Viewからシーンの表示し、シーンの初期化didMove()
で『Hello, World!』
シーンを作成
import SpriteKit
class GameScene: SKScene {
override func didMove(to view: SKView) {
// Hello, World!
let label = SKLabelNode(fontNamed: "HiraginoSans-W3")
label.text = "Hello, World!"
label.fontSize = 40
label.position = CGPoint(x:self.frame.midX, y:self.frame.midY)
self.addChild(label)
}
}
作成したシーンをContentViewから呼び出し
import SwiftUI
import SpriteKit
struct ContentView: View {
var scene: SKScene {
let scene = GameScene()
scene.scaleMode = .resizeFill
return scene
}
var body: some View {
SpriteView(scene: self.scene)
.ignoresSafeArea()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
状態の切り替え
タップイベントtouchesBegan()
でStart→Play→Finishとラベルの不透明・透明を切り替え
GameScene.swift
import SpriteKit
class GameScene: SKScene {
// 状態管理
enum State {
case Wating
case Playing
case Finish
}
var state = State.Wating
let fontName = "HiraginoSans-W3"
let startLabelName = "startLabelName"
let playLabelName = "playLabelName"
let finishLabelName = "finishLabelName"
override func didMove(to view: SKView) {
let startLabel = SKLabelNode(fontNamed: fontName)
startLabel.name = startLabelName
startLabel.text = "Start"
startLabel.fontSize = 40
startLabel.position = CGPoint(x:self.frame.midX, y:self.frame.midY)
startLabel.run(SKAction.fadeIn(withDuration: 1))
self.addChild(startLabel)
let playLabel = SKLabelNode(fontNamed: fontName)
playLabel.name = playLabelName
playLabel.text = "Playing"
playLabel.fontSize = 40
playLabel.position = CGPoint(x:self.frame.midX, y:self.frame.midY)
playLabel.alpha = 0
self.addChild(playLabel)
let finishLabel = SKLabelNode(fontNamed: fontName)
finishLabel.name = finishLabelName
finishLabel.text = "Finish"
finishLabel.fontSize = 40
finishLabel.position = CGPoint(x:self.frame.midX, y:self.frame.midY)
finishLabel.alpha = 0
self.addChild(finishLabel)
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let startLabel = self.childNode(withName: startLabelName) as? SKLabelNode else { return }
guard let playLabel = self.childNode(withName: playLabelName) as? SKLabelNode else { return }
guard let finishLabel = self.childNode(withName: finishLabelName) as? SKLabelNode else { return }
switch state {
case .Wating:
startLabel.alpha = 0
playLabel.alpha = 1
state = .Playing
case .Playing:
playLabel.alpha = 0
finishLabel.alpha = 1
state = .Finish
case .Finish:
finishLabel.alpha = 0
startLabel.alpha = 1
state = .Wating
}
}
}
SKActionによるアニメーション
即時透明にするのではなく、SKActionによりフェードインやフェードアウトなどのアニメーションが簡単に行える
GameScene.swift
import SpriteKit
class GameScene: SKScene {
// 状態管理
enum State {
case Wating
case Playing
case Finish
}
var state = State.Wating
let fontName = "HiraginoSans-W3"
let startLabelName = "startLabelName"
let playLabelName = "playLabelName"
let finishLabelName = "finishLabelName"
var actionFadeIn: SKAction?
override func didMove(to view: SKView) {
actionFadeIn = SKAction.fadeIn(withDuration: 1)
let startLabel = SKLabelNode(fontNamed: fontName)
startLabel.name = startLabelName
startLabel.text = "Start"
startLabel.fontSize = 40
startLabel.position = CGPoint(x:self.frame.midX, y:self.frame.midY)
startLabel.run(actionFadeIn!)
self.addChild(startLabel)
let playLabel = SKLabelNode(fontNamed: fontName)
playLabel.name = playLabelName
playLabel.text = "Playing"
playLabel.fontSize = 40
playLabel.position = CGPoint(x:self.frame.midX, y:self.frame.midY)
playLabel.alpha = 0
self.addChild(playLabel)
let finishLabel = SKLabelNode(fontNamed: fontName)
finishLabel.name = finishLabelName
finishLabel.text = "Finish"
finishLabel.fontSize = 40
finishLabel.position = CGPoint(x:self.frame.midX, y:self.frame.midY)
finishLabel.alpha = 0
self.addChild(finishLabel)
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let startLabel = self.childNode(withName: startLabelName) as? SKLabelNode else { return }
guard let playLabel = self.childNode(withName: playLabelName) as? SKLabelNode else { return }
guard let finishLabel = self.childNode(withName: finishLabelName) as? SKLabelNode else { return }
switch state {
case .Wating:
startLabel.alpha = 0
playLabel.run(actionFadeIn!)
state = .Playing
case .Playing:
playLabel.alpha = 0
finishLabel.run(actionFadeIn!)
state = .Finish
case .Finish:
finishLabel.alpha = 0
startLabel.run(actionFadeIn!)
state = .Wating
}
}
}
フレーム処理
これにフレーム処理update()
を実装する。通常はこの部分にゲームのほとんどのロジックが詰め込まれることになると思う
今回はキャッチアップ目的で動作の理解に留める
下記のコードでは、Playingのとき、およそ1/100フレーム毎ぐらいの頻度でランダムな位置に緑色の円形を出現させている
GameScene.swift
import SpriteKit
![Something went wrong]()
class GameScene: SKScene {
// 状態管理
enum State {
case Wating
case Playing
case Finish
}
var state = State.Wating
let fontName = "HiraginoSans-W3"
let startLabelName = "startLabelName"
let playLabelName = "playLabelName"
let finishLabelName = "finishLabelName"
var targetShapeCount = 0
let targetShapeCountLimit = 10
let targetShapeNamePrefix = "targetShapelName_"
var actionFadeIn: SKAction?
override func didMove(to view: SKView) {
actionFadeIn = SKAction.fadeIn(withDuration: 1)
let startLabel = SKLabelNode(fontNamed: fontName)
startLabel.name = startLabelName
startLabel.text = "Start"
startLabel.fontSize = 40
startLabel.position = CGPoint(x:self.frame.midX, y:self.frame.midY)
startLabel.run(actionFadeIn!)
self.addChild(startLabel)
let playLabel = SKLabelNode(fontNamed: fontName)
playLabel.name = playLabelName
playLabel.text = "Playing"
playLabel.fontSize = 40
playLabel.position = CGPoint(x:self.frame.midX, y:self.frame.midY)
playLabel.alpha = 0
self.addChild(playLabel)
let finishLabel = SKLabelNode(fontNamed: fontName)
finishLabel.name = finishLabelName
finishLabel.text = "Finish"
finishLabel.fontSize = 40
finishLabel.position = CGPoint(x:self.frame.midX, y:self.frame.midY)
finishLabel.alpha = 0
self.addChild(finishLabel)
}
override func update(_ currentTime: TimeInterval) {
if state == .Playing {
if Int.random(in: 0...100) == 0 && targetShapeCount < targetShapeCountLimit {
targetShapeCount += 1
let circleOfRadius: CGFloat = 50.0
let x: CGFloat = CGFloat.random(in: (self.frame.minX + circleOfRadius)...(self.frame.maxX - circleOfRadius))
let y: CGFloat = CGFloat.random(in: (self.frame.minY + circleOfRadius)...(self.frame.maxY - circleOfRadius))
let circle = SKShapeNode(circleOfRadius: circleOfRadius)
circle.name = targetShapeNamePrefix + String(targetShapeCount)
circle.position = CGPoint(x: x, y: y)
circle.fillColor = .green
self.addChild(circle)
}
}
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let startLabel = self.childNode(withName: startLabelName) as? SKLabelNode else { return }
guard let playLabel = self.childNode(withName: playLabelName) as? SKLabelNode else { return }
guard let finishLabel = self.childNode(withName: finishLabelName) as? SKLabelNode else { return }
switch state {
case .Wating:
startLabel.alpha = 0
playLabel.run(actionFadeIn!)
state = .Playing
case .Playing:
playLabel.alpha = 0
finishLabel.run(actionFadeIn!)
state = .Finish
case .Finish:
finishLabel.alpha = 0
startLabel.run(actionFadeIn!)
state = .Wating
}
}
}
タッチイベントの制御
タッチイベントはどこかが押されたら発火するため、これまでの実装ではどこをタッチしても次の状態に遷移していた
これではゲームを作り込むことができないので、
- Waiting/Finishのときはどこを押しても次の状態に遷移
- Playingのときは緑色の円形を押したらこの円形を消す、それ以外の場所を押したらFinishに状態を遷移
となるようにする
GameScene.swift
import SpriteKit
class GameScene: SKScene {
// 状態管理
enum State {
case Wating
case Playing
case Finish
}
var state = State.Wating
let fontName = "HiraginoSans-W3"
let startLabelName = "startLabelName"
let playLabelName = "playLabelName"
let finishLabelName = "finishLabelName"
var targetShapeCount = 0
let targetShapeCountLimit = 10
let targetShapeNamePrefix = "targetShapelName_"
var actionFadeIn: SKAction?
override func didMove(to view: SKView) {
actionFadeIn = SKAction.fadeIn(withDuration: 1)
let startLabel = SKLabelNode(fontNamed: fontName)
startLabel.name = startLabelName
startLabel.text = "Start"
startLabel.fontSize = 40
startLabel.position = CGPoint(x:self.frame.midX, y:self.frame.midY)
startLabel.run(actionFadeIn!)
self.addChild(startLabel)
let playLabel = SKLabelNode(fontNamed: fontName)
playLabel.name = playLabelName
playLabel.text = "Playing"
playLabel.fontSize = 40
playLabel.position = CGPoint(x:self.frame.midX, y:self.frame.midY)
playLabel.alpha = 0
self.addChild(playLabel)
let finishLabel = SKLabelNode(fontNamed: fontName)
finishLabel.name = finishLabelName
finishLabel.text = "Finish"
finishLabel.fontSize = 40
finishLabel.position = CGPoint(x:self.frame.midX, y:self.frame.midY)
finishLabel.alpha = 0
self.addChild(finishLabel)
}
override func update(_ currentTime: TimeInterval) {
if state == .Playing {
if Int.random(in: 0...100) == 0 && targetShapeCount < targetShapeCountLimit {
targetShapeCount += 1
let circleOfRadius: CGFloat = 50.0
let x: CGFloat = CGFloat.random(in: (self.frame.minX + circleOfRadius)...(self.frame.maxX - circleOfRadius))
let y: CGFloat = CGFloat.random(in: (self.frame.minY + circleOfRadius)...(self.frame.maxY - circleOfRadius))
let circle = SKShapeNode(circleOfRadius: circleOfRadius)
circle.name = targetShapeNamePrefix + String(targetShapeCount)
circle.position = CGPoint(x: x, y: y)
circle.fillColor = .green
self.addChild(circle)
}
}
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let startLabel = self.childNode(withName: startLabelName) as? SKLabelNode else { return }
guard let playLabel = self.childNode(withName: playLabelName) as? SKLabelNode else { return }
guard let finishLabel = self.childNode(withName: finishLabelName) as? SKLabelNode else { return }
switch state {
case .Wating:
startLabel.alpha = 0
playLabel.run(actionFadeIn!)
state = .Playing
case .Playing:
if let touch = touches.first {
let locatin = touch.location(in: self)
if let name = self.atPoint(locatin).name {
if let node = self.childNode(withName: name) as? SKShapeNode {
if name.hasPrefix(targetShapeNamePrefix) {
node.alpha = 0
return
}
}
}
}
playLabel.alpha = 0
finishLabel.run(actionFadeIn!)
state = .Finish
case .Finish:
finishLabel.alpha = 0
startLabel.run(actionFadeIn!)
state = .Wating
}
}
}
SKAction(extension)による独自のアニメーション
SKActionにアニメーションは用意されていないアニメーションはextensionで追加できる
例えば、色をだんだん変えるアニメーションは自分で追加する必要がある
colorizeLabelFontColor()
として追加し、Playの文字をだんだんと赤くしてみた
(最初は、colorize()
でできるのか、と思ったらSprite用なのでダメらしい)
試しに円形の色が表示されてから3秒後、5秒をかけて赤色に変化させる
GameScene.swift
import SpriteKit
class GameScene: SKScene {
// 状態管理
enum State {
case Wating
case Playing
case Finish
}
var state = State.Wating
let fontName = "HiraginoSans-W3"
let startLabelName = "startLabelName"
let playLabelName = "playLabelName"
let finishLabelName = "finishLabelName"
var targetShapeCount = 0
let targetShapeCountLimit = 10
let targetShapeNamePrefix = "targetShapelName_"
var actionFadeIn: SKAction?
override func didMove(to view: SKView) {
actionFadeIn = SKAction.fadeIn(withDuration: 1)
let startLabel = SKLabelNode(fontNamed: fontName)
startLabel.name = startLabelName
startLabel.text = "Start"
startLabel.fontSize = 40
startLabel.position = CGPoint(x:self.frame.midX, y:self.frame.midY)
startLabel.run(actionFadeIn!)
self.addChild(startLabel)
let playLabel = SKLabelNode(fontNamed: fontName)
playLabel.name = playLabelName
playLabel.text = "Playing"
playLabel.fontSize = 40
playLabel.position = CGPoint(x:self.frame.midX, y:self.frame.midY)
playLabel.alpha = 0
self.addChild(playLabel)
let finishLabel = SKLabelNode(fontNamed: fontName)
finishLabel.name = finishLabelName
finishLabel.text = "Finish"
finishLabel.fontSize = 40
finishLabel.position = CGPoint(x:self.frame.midX, y:self.frame.midY)
finishLabel.alpha = 0
self.addChild(finishLabel)
}
override func update(_ currentTime: TimeInterval) {
if state == .Playing {
if Int.random(in: 0...100) == 0 && targetShapeCount < targetShapeCountLimit {
targetShapeCount += 1
let circleOfRadius: CGFloat = 50.0
let x: CGFloat = CGFloat.random(in: (self.frame.minX + circleOfRadius)...(self.frame.maxX - circleOfRadius))
let y: CGFloat = CGFloat.random(in: (self.frame.minY + circleOfRadius)...(self.frame.maxY - circleOfRadius))
let circle = SKShapeNode(circleOfRadius: circleOfRadius)
circle.name = targetShapeNamePrefix + String(targetShapeCount)
circle.position = CGPoint(x: x, y: y)
circle.fillColor = .green
self.addChild(circle)
circle.run(SKAction.sequence([
SKAction.wait(forDuration: 3),
SKAction.colorizeShapefillColor(fromColor: .green, toColor: .red, duration: 5)
]))
}
}
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let startLabel = self.childNode(withName: startLabelName) as? SKLabelNode else { return }
guard let playLabel = self.childNode(withName: playLabelName) as? SKLabelNode else { return }
guard let finishLabel = self.childNode(withName: finishLabelName) as? SKLabelNode else { return }
switch state {
case .Wating:
startLabel.alpha = 0
playLabel.run(actionFadeIn!)
state = .Playing
case .Playing:
if let touch = touches.first {
let locatin = touch.location(in: self)
if let name = self.atPoint(locatin).name {
if let node = self.childNode(withName: name) as? SKShapeNode {
if name.hasPrefix(targetShapeNamePrefix) {
node.alpha = 0
return
}
}
}
}
playLabel.alpha = 0
finishLabel.run(actionFadeIn!)
state = .Finish
case .Finish:
finishLabel.alpha = 0
startLabel.run(actionFadeIn!)
state = .Wating
}
}
}
extension SKAction {
static func colorizeShapefillColor(fromColor : UIColor, toColor : UIColor, duration : Double = 0.4) -> SKAction {
func lerp(_ a : CGFloat, b : CGFloat, fraction : CGFloat) -> CGFloat { return (b-a) * fraction + a }
var fred: CGFloat = 0, fgreen: CGFloat = 0, fblue: CGFloat = 0, falpha: CGFloat = 0
var tred: CGFloat = 0, tgreen: CGFloat = 0, tblue: CGFloat = 0, talpha: CGFloat = 0
fromColor.getRed(&fred, green: &fgreen, blue: &fblue, alpha: &falpha)
toColor.getRed(&tred, green: &tgreen, blue: &tblue, alpha: &talpha)
return SKAction.customAction(withDuration: duration, actionBlock: { (node : SKNode!, elapsedTime : CGFloat) -> Void in
let fraction = CGFloat(elapsedTime / CGFloat(duration))
let transColor = UIColor(red: lerp(fred, b: tred, fraction: fraction),
green: lerp(fgreen, b: tgreen, fraction: fraction),
blue: lerp(fblue, b: tblue, fraction: fraction),
alpha: lerp(falpha, b: talpha, fraction: fraction))
(node as! SKShapeNode).fillColor = transColor
})
}
}
最後に
ゲーム開発は興味があったので楽しくキャッチアップできた
ただ調べているなかでアニメーション効果などを付ける方法があまり見つからず、
かつ参考の全体ソースがぱっと出てこなかったり、古くて動かなかったりで苦戦
そういう意味では、今回の記事の目的は、githubを載せた時点で達成している
Unityなど他フレームワークとの優劣などは分からないが、
簡単なiOS系2Dゲームを作るだけであればSpriteKitで十分事足りる、と感じた
今度はFlutterとかで試したいなぁ