8
1

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 2021

Day 1

SceneKitでサイコロを振る(Swift)

Last updated at Posted at 2021-11-30

Xcode-13.0 Swift-5.5 iOS-15.0

はじめに

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

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

ソース

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

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

サイコロを表示する

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

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

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

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

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

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

出た目の計算処理ではサイコロの上の面の中心座標を取得して hitTestmaterials のどのインデックスなのかを取得しています。
参考:Swiftで始まったタッチでヒットしたキューブの面を特定する-SceneKit

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:ドキュメント

参考

8
1
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
8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?