はじめに
去年にSceneKitでサイコロを振る(Swift)でサイコロを振ったのですが SwiftUI でやる方法は断念しました。
今回はそのリベンジです
できたやつはこんな感じ。
ソース
ソース全体はこんな感じです(前回は独自 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 に追加してます)。
サイコロ | サイコロ | サイコロ |
---|---|---|
ソースはこんな感じです(前回とほぼ同じ)。
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
は結果を画面に表示する用のクロージャです。
/// 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
をあまり理解していないですがおそらく指定した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 でもサイコロが振れました