はじめに
iOS アプリをつくっているとだれしも一度はサイコロを振りたい!と思ったことがあるはずです今回は SceneKit
を使ってサイコロを振ってみます。
完成形はこんな感じです。
SwiftUIでもサイコロ振りたかったけどDelegateまわりがややこしくて断念した🙃 pic.twitter.com/qsHSjEeqRl
— am10 (@am103141592) November 30, 2021
ソース
ソース全体はこんな感じです。
ソース全体
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 に追加してます)。色を変えたりご自由にお使いください
サイコロ | サイコロ | サイコロ |
---|---|---|
ソースはこんな感じです。
@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
はサイコロが転がり終わったかどうかの判定用です。
@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
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 表示する必要があるので今まではむずかしくてなんども挫折していたのですがついにサイコロを振ることができました
SceneView
というのがあるようなので SwiftUI
でもサイコロを振れそうです
SceneView:ドキュメント