SceneKitでサイコロを振る（Swift）

![Xcode-13.0](https://img.shields.io/badge/Xcode-13.0-brightgreen) ![Swift-5.5](https://img.shields.io/badge/Swift-5.5-brightgreen) ![iOS-15.0](https://img.shields.io/badge/iOS-15.0-brightgreen)

## はじめに
iOS アプリをつくっているとだれしも一度はサイコロを振りたい！と思ったことがあるはずです:sunglasses:今回は `SceneKit` を使ってサイコロを振ってみます。

完成形はこんな感じです。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">SwiftUIでもサイコロ振りたかったけどDelegateまわりがややこしくて断念した🙃 <a href="https://t.co/qsHSjEeqRl">pic.twitter.com/qsHSjEeqRl</a></p>&mdash; am10 (@am103141592) <a href="https://twitter.com/am103141592/status/1465617537962369028?ref_src=twsrc%5Etfw">November 30, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## ソース
ソース全体はこんな感じです。

<details><summary>ソース全体</summary><div>

```swift
import UIKit
import SceneKit

final class ViewController: UIViewController {

    @IBOutlet private weak var scnView: SCNView!
    @IBOutlet private weak var resultLabel: UILabel!

    private var boxNode: SCNNode!
    private var timer: Timer?
    private let diceLength: CGFloat = 1.5
    private let diceNumbers = ["1", "3", "6", "4", "5", "2"]

    override func viewDidLoad() {
        super.viewDidLoad()

        resultLabel.text = nil
        scnView.allowsCameraControl = true
        scnView.scene = SCNScene()

        // カメラをシーンに追加する
        let cameraNode: SCNNode = {
            let cameraNode = SCNNode()
            cameraNode.camera = SCNCamera()
            cameraNode.position = SCNVector3(x: 0, y: 10, z: 10)
            cameraNode.rotation = .init(x: 1, y: 0, z: 0, w: -Float.pi/4)
            return cameraNode
        }()
        scnView.scene?.rootNode.addChildNode(cameraNode)

        // 床をシーンに追加する
        let floorNode: SCNNode = {
            let floor = SCNFloor()
            floor.reflectivity = 0.1
            let floorNode = SCNNode(geometry: floor)
            floorNode.physicsBody = SCNPhysicsBody(type: .static, shape: .init(geometry: floor))
            floorNode.physicsBody?.contactTestBitMask = 1
            return floorNode
        }()
        scnView.scene?.rootNode.addChildNode(floorNode)

        // サイコロをシーンに追加する
        boxNode = {
            let box = SCNBox(width: diceLength, height: diceLength, length: diceLength, chamferRadius: 0)
            box.materials = diceNumbers.map {
                let material = SCNMaterial()
                material.diffuse.contents = UIImage(named: $0)
                return material
            }

            let boxNode = SCNNode(geometry: box)
            boxNode.name = "dice"
            boxNode.position = SCNVector3(x: 0, y: 0, z: 0)
            boxNode.physicsBody = .init(type: .dynamic, shape: .init(geometry: box))
            boxNode.physicsBody?.categoryBitMask = 1
            return boxNode
        }()
        scnView.scene?.rootNode.addChildNode(boxNode)
        scnView.scene?.physicsWorld.contactDelegate = self
    }

    @IBAction private func rollDice() {
        resultLabel.text = nil
        timer?.invalidate()
        timer = nil

        boxNode.physicsBody?.angularVelocity = .init(x: Float.random(in: 0..<10), y: Float.random(in: 0..<10), z: Float.random(in: 0..<10), w: 1)
        boxNode.physicsBody?.velocity = .init(-3, 0, 0)
        boxNode.position = SCNVector3(x: 7, y: 10, z: 0)
    }
}

extension ViewController: SCNPhysicsContactDelegate {

    func physicsWorld(_ world: SCNPhysicsWorld, didEnd contact: SCNPhysicsContact) {
        if contact.nodeA.name == boxNode.name {
            timer?.invalidate()
            timer = nil
            timer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false, block: { [weak self] _ in
                DispatchQueue.main.async {
                    self?.diceRollDidEnd()
                }
            })
        }
    }

    private func diceRollDidEnd() {
        var worldPosition = boxNode.presentation.worldPosition
        worldPosition.y += Float(diceLength)/2
        let projectPoint = scnView.projectPoint(worldPosition)
        let point = CGPoint(x: CGFloat(projectPoint.x), y: CGFloat(projectPoint.y))
        if let result = scnView.hitTest(point).first,
           result.node.name == boxNode.name {
            resultLabel.text = diceNumbers[result.geometryIndex]
        }
    }
}
```
</div></details>

## サイコロを表示する
各処理の説明です。まずサイコロを表示します。`SCNBox` を使ってキューブを表示し、`materials` プロパティに各面の画像を設定します（面の順番はよくわからなかったのでいろいろ試して並べました）。

画像はこちら（1 という名前で Xcassets に追加してます）。色を変えたりご自由にお使いください:bow:

| サイコロ | サイコロ | サイコロ |
|:---:|:---:|:---:|
|![1](https://user-images.githubusercontent.com/34936885/143909566-94fd4887-2440-4e1b-bf74-338c502461e6.png)|![2](https://user-images.githubusercontent.com/34936885/143909585-ee58f3e6-e47b-4f51-b7de-d1be2d93edc7.png)|![3](https://user-images.githubusercontent.com/34936885/143909608-e4970516-668c-49d2-a106-e5923e4923cb.png)|
|![4](https://user-images.githubusercontent.com/34936885/143909631-95b5f088-0e3d-4129-82d4-9a7494e2e75c.png)|![5](https://user-images.githubusercontent.com/34936885/143909675-88a20192-4c6a-4141-9011-aca8d4ecf07a.png)|![6](https://user-images.githubusercontent.com/34936885/143909700-becc90fd-3381-4fd1-8e1e-f24e290170e4.png)|

ソースはこんな感じです。

```swift
@IBOutlet private weak var scnView: SCNView!
private var boxNode: SCNNode!
private let diceLength: CGFloat = 1.5
private let diceNumbers = ["1", "3", "6", "4", "5", "2"]


boxNode = {
    let box = SCNBox(width: diceLength, height: diceLength, length: diceLength, chamferRadius: 0)
    box.materials = diceNumbers.map {
        let material = SCNMaterial()
        material.diffuse.contents = UIImage(named: $0)
        return material
    }

    let boxNode = SCNNode(geometry: box)
    boxNode.name = "dice"
    boxNode.position = SCNVector3(x: 0, y: 0, z: 0)

    // 床とぶつけるために設定
    boxNode.physicsBody = .init(type: .dynamic, shape: .init(geometry: box))
    boxNode.physicsBody?.categoryBitMask = 1
    return boxNode
}()
scnView.scene?.rootNode.addChildNode(boxNode)
```

## サイコロを振る
サイコロを振る処理は単純でサイコロにランダムに `angularVelocity`　を設定し `velocity` と `position` の設定で画面の右側から落とすようにしています。`timer` はサイコロが転がり終わったかどうかの判定用です。

```swift
@IBOutlet private weak var resultLabel: UILabel!
private var boxNode: SCNNode!
private var timer: Timer?

@IBAction private func rollDice() {
    // サイコロ振り終わったとき用
    resultLabel.text = nil
    timer?.invalidate()
    timer = nil

    // サイコロを画面右側から落とす
    boxNode.physicsBody?.angularVelocity = .init(x: Float.random(in: 0..<10), y: Float.random(in: 0..<10), z: Float.random(in: 0..<10), w: 1)
    boxNode.physicsBody?.velocity = .init(-3, 0, 0)
    boxNode.position = SCNVector3(x: 7, y: 10, z: 0)
}
```

## サイコロの出た目を取得する
ここが一番苦労した部分です（たぶんもっといい方法があると思います）。サイコロが転がり終わったのを判定するために床とサイコロが衝突するたびにタイマーを設定しています。衝突した 1.5 秒後に出た目の計算処理をおこないます（1.5 秒以内に再び衝突するとまたタイマーがはじまる）。

出た目の計算処理ではサイコロの上の面の中心座標を取得して `hitTest` で `materials` のどのインデックスなのかを取得しています。  
参考：[Swiftで始まったタッチでヒットしたキューブの面を特定する-SceneKit](https://www.fixes.pub/program/249506.html)

```swift
extension ViewController: SCNPhysicsContactDelegate {

    func physicsWorld(_ world: SCNPhysicsWorld, didEnd contact: SCNPhysicsContact) {
        if contact.nodeA.name == boxNode.name {
            // 床とサイコロがぶつかるたびにタイマーを設定する
            timer?.invalidate()
            timer = nil
            timer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false, block: { [weak self] _ in
                DispatchQueue.main.async {
                    self?.diceRollDidEnd()
                }
            })
        }
    }

    private func diceRollDidEnd() {
        // ワールド座標系での現在のサイコロの上の面の中心座標を取得する
        var worldPosition = boxNode.presentation.worldPosition
        worldPosition.y += Float(diceLength)/2

        // 2Dの座標に変換する
        let projectPoint = scnView.projectPoint(worldPosition)
        let point = CGPoint(x: CGFloat(projectPoint.x), y: CGFloat(projectPoint.y))

        // 上の面がどこなのか取得する
        if let result = scnView.hitTest(point).first,
           result.node.name == boxNode.name {
            resultLabel.text = diceNumbers[result.geometryIndex]
        }
    }
}
```

## おわりに
サイコロを振るのに 3D 表示する必要があるので今まではむずかしくてなんども挫折していたのですがついにサイコロを振ることができました:raised_hands:

`SceneView` というのがあるようなので `SwiftUI` でもサイコロを振れそうです:sun_with_face:   
[SceneView：ドキュメント](https://developer.apple.com/documentation/scenekit/sceneview)

## 参考
* [SceneKit：ドキュメント](https://developer.apple.com/documentation/scenekit/)
* [SCNBox：ドキュメント](https://developer.apple.com/documentation/scenekit/scnbox)
* [SCNGeometrys：ドキュメント](https://developer.apple.com/documentation/scenekit/scngeometrys)
* [Swiftで始まったタッチでヒットしたキューブの面を特定する-SceneKit](https://www.fixes.pub/program/249506.html)
* [SceneKitで重力などの物理シュミレーションを設定する](https://techpartner.jp/blog/physical-simulation)
* [ARKitのための3D数学](https://qiita.com/kboy/items/775633fe3fd6da9c5fb6)