SwiftUIでサイコロを振る

![Xcode-14.1](https://img.shields.io/badge/Xcode-14.1-brightgreen) ![iOS-16.0](https://img.shields.io/badge/iOS-16.0-brightgreen) 

## はじめに
去年に[SceneKitでサイコロを振る（Swift）](https://qiita.com/am10/items/8f872daf8d0aa0b72672)でサイコロを振ったのですが SwiftUI でやる方法は断念しました。

今回はそのリベンジです:muscle:

できたやつはこんな感じ。

![dice_roll](https://user-images.githubusercontent.com/34936885/205488954-d560814d-c6e4-42e8-beb1-691cc3938253.gif)

## ソース
ソース全体はこんな感じです（前回は独自 `SCNScene` を作るという発想がなかった）。

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

```swift
import SwiftUI
import SceneKit

struct ContentView: View {
    private let scene = DiceScene()
    @State private var result = "結果："

    var body: some View {
        VStack {
            Text(result)
            SceneView(scene: scene)
            Button("サイコロを振る") {
                result = "結果："
                scene.rollDice { text in
                    self.result = "結果：\(text)"
                }
            }
        }
    }
}

final class DiceScene: SCNScene {

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

    override init() {
        super.init()

        // カメラをシーンに追加する
        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
        }()
        rootNode.addChildNode(cameraNode)

        // 床をシーンに追加する
        let floorNode: SCNNode = {
            let floor = SCNFloor()
            floor.reflectivity = 0.1
            let floorNode = SCNNode(geometry: floor)
            floorNode.name = "floor"
            floorNode.physicsBody = SCNPhysicsBody(type: .static, shape: .init(geometry: floor))
            floorNode.physicsBody?.contactTestBitMask = 0b0011
            return floorNode
        }()
        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 = 0b0011
            return boxNode
        }()
        rootNode.addChildNode(boxNode)
        physicsWorld.contactDelegate = self
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func rollDice(completion: ((String) -> ())?) {
        self.completion = completion

        // サイコロ振り終わったとき用
        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 DiceScene: SCNPhysicsContactDelegate {

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

    private func diceRollDidEnd() {
        timer?.invalidate()
        timer = nil

        // 現在のサイコロの上の面の中心座標を取得する
        var diceTopPosition = boxNode.presentation.position
        diceTopPosition.y += Float(diceLength)/2

        let results = rootNode.hitTestWithSegment(from: diceTopPosition, to: .init(x: 0, y: 0, z: 0))
        // 上の面がどこなのか取得する
        if let result = results.first,
           result.node.name == boxNode.name {
            completion?(diceNumbers[result.geometryIndex])
        }
    }
}
```
</div></details>

## サイコロを表示する
各処理の説明です。

画像はこちら（1 という名前で Xcassets に追加してます）。

| サイコロ | サイコロ | サイコロ |
|:---:|:---:|:---:|
|![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
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 = 0b0011
    return boxNode
}()
rootNode.addChildNode(boxNode)
```

## サイコロを振る
サイコロにランダムで `angularVelocity`　を設定し `velocity` と `position` の設定で画面の右側から落とすようにしています。`timer` はサイコロが転がり終わったかどうかの判定用です。`completion` は結果を画面に表示する用のクロージャです。

```swift
/// Scene側
private var boxNode: SCNNode!
private var timer: Timer?
private var completion: ((String) -> ())?

func rollDice(completion: ((String) -> ())?) {
    self.completion = completion

    // サイコロ振り終わったとき用
    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)
}

/// View側
private let scene = DiceScene()
@State private var result = "結果："

Button("サイコロを振る") {
    result = "結果："
    scene.rollDice { text in
        self.result = "結果：\(text)"
    }
}
```

## サイコロの出た目を取得する
ここが特殊な部分です。

サイコロが転がり終わったのを判定するために床とサイコロが衝突するたびにタイマーを設定しています。衝突した 1.5 秒後に出た目の計算処理をおこないます（1.5 秒以内に再び衝突するとまたタイマーがはじまる）。

出た目の計算処理ではサイコロの上の面の中心座標を取得して `hitTestWithSegment` で `materials` のどのインデックスなのかを取得しています。

`hitTestWithSegment` をあまり理解していないですがおそらく指定した２点間にあるオブジェクトを取れるやつです。今回は「サイコロ上面の中心座標」と「画面の中心座標」を指定しています。

```swift
extension DiceScene: SCNPhysicsContactDelegate {

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

    private func diceRollDidEnd() {
        timer?.invalidate()
        timer = nil

        // 現在のサイコロの上の面の中心座標を取得する
        var diceTopPosition = boxNode.presentation.position
        diceTopPosition.y += Float(diceLength)/2

        let results = rootNode.hitTestWithSegment(from: diceTopPosition, to: .init(x: 0, y: 0, z: 0))
        // 上の面がどこなのか取得する
        if let result = results.first,
           result.node.name == boxNode.name {
            completion?(diceNumbers[result.geometryIndex])
        }
    }
}
```

## 課題
ぱっと見は出目が取得できていそうなのですがたまに取得できないときがあります。たぶん画面の奥にサイコロが転がっていった場合？

`hitTestWithSegment` をあまり理解していないので改善方法が今の所わかりません。。。

## おわりに
前回やったときは SwiftUI だと処理結果の渡し方、デリゲート設定、`SCNView` の取得方法などもろもろわからなかったのですが今回のリベンジでついに SwiftUI でもサイコロが振れました:tada: