9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

iOSAdvent Calendar 2022

Day 11

SwiftUIでサイコロを振る

Last updated at Posted at 2022-12-10

Xcode-14.1 iOS-16.0

はじめに

去年にSceneKitでサイコロを振る(Swift)でサイコロを振ったのですが SwiftUI でやる方法は断念しました。

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

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

dice_roll

ソース

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

ソース全体
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])
        }
    }
}

サイコロを表示する

各処理の説明です。

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

サイコロ サイコロ サイコロ
1 2 3
4 5 6

ソースはこんな感じです(前回とほぼ同じ)。

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 を設定し velocityposition の設定で画面の右側から落とすようにしています。timer はサイコロが転がり終わったかどうかの判定用です。completion は結果を画面に表示する用のクロージャです。

/// 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 秒以内に再び衝突するとまたタイマーがはじまる)。

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

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

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:

9
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?