SpriteKit（ by SwiftUI）のハンズオン

お仕事で初めてSpriteKitを触った
SpriteKitの日本語記事があまり見つけられず、また古い情報が多かった
情報かき集めながらの対応が初学者にはツラかったので備忘録

普段はWEBアプリケーションが主体、Swiftによる開発も初めてでゲーム開発者でもない
お作法のようなところは存じ上げていないため、不適切な解説・表現があれば是非コメントいただきたい

- 動作確認環境
    - Swift:5.6.1

# はじめに
SpriteKitとは何ぞやは、『[10分で理解するSpriteKit](https://qiita.com/Chanmoro/items/da95cc121bb31263c5ca)』を参照されたい
> Apple が提供している2Dのゲームを作るためのフレームワークです
> アニメーションや物理シミュレーションを使ったゲームが簡単に実装できます
>
> 『10分で理解するSpriteKit』から部分引用

今回、私がやりたかったことはすでにあるSwiftUIベースのアプリケーションに描画アニメーションを多用した機能を追加こと
半日ぐらい色々試してようやくSpriteKitにたどり着いた
SpriteKitはXcodeのテンプレートがStoryBorad前提となっており、SwiftUIに組み込む記事もちらほらあるものの全体感の理解に時間を要した

SwiftUI、SpriteKit、SceneKitと彷徨い歩いた結果、素人理解では下記のようになっている

- SwiftUI
    - 画面描画の根底に使用。StoryBoard使いたくないならこれになる
    - ホームページビルダーが苦手な私は、StoryBoardは向いてないのでSwiftUIのほうが好み
    - 簡単なアニメーション（表示するときに画像がフェードイン、ボタンを押したら色がボタンがフェードアウトするなど）ならSwiftUIだけでいける
        - [SwiftUIで作るお洒落なテキストアニメーション](https://qiita.com/chitomo12/items/13a463ae6dbaf9b192ea)
    - 同様のアニメーションを継続的にループさせることはできるが、様々なイベントに応じてアニメーションをどんどん付加していくようなことは、状態の管理などが煩雑であり大変
- SpriteKit
    - 2Dゲームを作るならこれを使う
    - いわゆるゲームのフレーム毎の処理がライブラリで組まれている
        - [ゲームのフレームとは【Unityゲーム制作基礎】](https://dkrevel.com/unity-explain/unitymove/)
    - SwiftUIか？SpriteKitか？というものではなく、SwiftUIからSpriteKitを呼び出して使う
    - 継続的なアニメーション表現やイベント発火のアニメーションが組める
- SceneKit
    - 3Dモデルを使う、3Dゲームを作る、ARなどの場合はこれを使う
    - ほとんど調べてない

## 目的とゴール
- SpriteKitを用いて簡易なアニメーションとアクションを作る
- Playgrounds／Swift 5.5販でハンズオン
- ゲームには以下の要素を盛り込む
    - シーン切替（SKScene）
    - オブジェクトを配置（SKNode）
    - アニメーション効果（SKAction）
- UIの共通化のため、SwiftUI・

## 動作イメージ
![HandsonSpriteKit.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/231516/c9e35c53-a671-aa33-604b-af863827aab8.gif)

## ソースコード全体
githubに乗せているので、細かいことはいいからサンプルコード、という方はこちらを参照されたい
（Xcodeでアプリケーションを新規作成して、`GameScene.swift`を追加し、`ContentView.swift`に追記をしたのみ）
https://github.com/tukino/Hands-on-SpriteKit

## SpriteKitのライフサイクル
こんな感じに動いている、と思ってる

```plantuml
@startuml
View -> SKScene: 初期表示処理[didMove()]
SKScene --> View: 

loop SKScene表示中、1フレーム毎に呼び出し
SKScene -> SKScene: フレーム処理[update()]
end

-> SKScene: タップイベント[touchesBegan()など]
SKScene -> SKScene: タップ時処理[touchesBegan()]
@enduml
```

# ハンズオン

## Viewからシーンの表示し、シーンの初期化`didMove()`で『Hello, World!』
シーンを作成
```swift:GameScene.swift
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から呼び出し
```swift:ContentView.swift
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とラベルの不透明・透明を切り替え

<details><summary>GameScene.swift</summary><div>

```swift: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
        }
    }
}
```
</div></details>

## SKActionによるアニメーション
即時透明にするのではなく、SKActionによりフェードインやフェードアウトなどのアニメーションが簡単に行える
  - [Action Initializers](https://developer.apple.com/documentation/spritekit/skaction/action_initializers)

<details><summary>GameScene.swift</summary><div>

```swift: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
        }
    }
}
```
</div></details>

## フレーム処理
これにフレーム処理`update()`を実装する。通常はこの部分にゲームのほとんどのロジックが詰め込まれることになると思う
今回はキャッチアップ目的で動作の理解に留める
下記のコードでは、Playingのとき、およそ1/100フレーム毎ぐらいの頻度でランダムな位置に緑色の円形を出現させている

<details><summary>GameScene.swift</summary><div>

```swift: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
        }
    }
}
```
</div></details>

## タッチイベントの制御
タッチイベントはどこかが押されたら発火するため、これまでの実装ではどこをタッチしても次の状態に遷移していた
これではゲームを作り込むことができないので、
- Waiting/Finishのときはどこを押しても次の状態に遷移
- Playingのときは緑色の円形を押したらこの円形を消す、それ以外の場所を押したらFinishに状態を遷移
となるようにする

<details><summary>GameScene.swift</summary><div>

```swift: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
        }
    }
}
```
</div></details>

## SKAction（extension）による独自のアニメーション
SKActionにアニメーションは用意されていないアニメーションはextensionで追加できる
例えば、色をだんだん変えるアニメーションは自分で追加する必要がある
`colorizeLabelFontColor()`として追加し、Playの文字をだんだんと赤くしてみた
（最初は、`colorize()`でできるのか、と思ったらSprite用なのでダメらしい）
試しに円形の色が表示されてから３秒後、５秒をかけて赤色に変化させる

<details><summary>GameScene.swift</summary><div>

```swift: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
        })
    }
}
```
</div></details>

# 最後に
ゲーム開発は興味があったので楽しくキャッチアップできた
ただ調べているなかでアニメーション効果などを付ける方法があまり見つからず、
かつ参考の全体ソースがぱっと出てこなかったり、古くて動かなかったりで苦戦

そういう意味では、今回の記事の目的は、githubを載せた時点で達成している

Unityなど他フレームワークとの優劣などは分からないが、
簡単なiOS系2Dゲームを作るだけであればSpriteKitで十分事足りる、と感じた

今度はFlutterとかで試したいなぁ

# その他、参考にさせていただいたページ
- [SwiftUIでSpriteKitを使用する](https://qiita.com/juno_rmks/items/2d2d5cf9167b086d0dcb)
- [Yuuの悠々自適Blog](https://yuu.1000quu.com/)
- [【Swift】 iOSで使える内蔵フォント一覧と文字の比較](https://j-william.com/swift-fontlist/)
- [SKShapeNode - Animate color change](https://stackoverflow.com/questions/20872556/skshapenode-animate-color-change)