![見出し画像](https://assets.st-note.com/production/uploads/images/155978466/rectangle_large_type_2_093a4b84ed23ee934f33439855e7b7ca.png?width=1200)
Apple Vision Pro向けのハンドトラッキングを活用した空間ゲームをリリースしました
Apple Vision Pro向けのハンドトラッキングを活用した、スペースシップを操縦しながらチェックポイントをできるだけ早く通過する空間ゲームをリリースしました!
きっかけ
Apple Vision ProやvisionOSが発表されて以来、ずっとvisionOSネイティブで何かを作ってみたいと思っていたところ、WWDC 2024のDiscover RealityKit APIs for iOS, macOS, and visionOSというセッションとそのサンプルコードを動かしてみたら、ハンドトラッキングだけでスペースシップを操縦するのがとても新感覚で面白く、このハンドトラッキングの操作をもとに、レーシング要素を組み合わせたら面白いのではないか?と思ったのが、今回のゲームを作るきっかけでした。
どんなゲーム?
Space Time Attackはハンドトラッキングを活用して、スペースシップを操縦しながら空間に配置されたチェックポイントをできるだけ早く通過するタイムアタックの空間ゲームです。最初に表示されたウィンドウで好きなスペースシップを選んでから、PLAYを押すとImmersive Spaceに切り替わり、ステージ1からゲームを始めることができます。
ステージは全部で1~6があり、それぞれのステージでランダムにチェックポイントが配置されていて、ステージが進むにつれて数が多くかつ小さくなります。スペースシップをうまく操縦して、宇宙空間を駆け回りながら最短タイムを目指しましょう!
ゲームの全体的な遷移
visionOSではWindow, Volume, Immersive Spaceの3種類のコンテンツの表示方法がありますが、今回のゲームは2つのWindowとImmersive Spaceで構成され、PLAYボタンやEXITボタンを押すと、WindowやImmersive Spaceを開閉することでシーンの切り替えを行なっています。
![](https://assets.st-note.com/img/1727509412-9EoqQkantSTyrpUcbC40Rl35.png?width=1200)
なお、openWindowやopenImmersiveSpaceなどは@Environmentを使って対象アクションのインスタンスを取得できて、idを指定することで対象のWindowやImmersive Spaceを表示することができます。
@Environment(\\.openWindow) var openWindow
@Environment(\\.dismissWindow) var dismissWindow
@Environment(\\.dismissImmersiveSpace) private var dismissImmersiveSpace
@Environment(\\.openImmersiveSpace) private var openImmersiveSpace
Window内にスペースシップの3Dモデルを表示する
RealityKitではModel3DというViewプロトコルに準拠したものがあり、非同期で3DモデルをロードしてSwiftUI上で表示できます。今回はそれを活用して予め用意した複数のスペースシップの3Dモデルを横スクロールで表示させて選択できるようにしました。
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 40) {
ForEach(ShipModel.allCases, id: \\.self) { shipModel in
VStack(spacing: 16) {
Model3D(named: shipModel.name, bundle: realityKitContentBundle) { model in
model
.resizable()
.scaledToFit()
.rotation3DEffect(.init(degrees: -90), axis: (x: 1, y: 0, z: 0)) // 画面上で上向きにするためにx軸回りに回転させる
} placeholder: {
ProgressView()
}
.onTapGesture {
// タップ時の処理
}
Image(systemName: "checkmark.circle.fill")
.resizable()
.scaledToFit()
.frame(width: 40, height: 40)
.opacity(selectedShipModel == shipModel ? 1 : 0) // 選択されている場合に表示する
}
.padding(.vertical, 16)
}
}
}
![](https://assets.st-note.com/img/1727450143-3hT5EX47wNkJPercuVAy8bYB.png?width=1200)
Immersive Spaceでスペースシップやチェックポイントなどを表示
先程も書いたように、TitleWindowでPLAYボタンを押すとopenImmersiveSpaceでImmersive Spaceを開いて、宇宙空間にスペースシップやチェックポイントが表示されます。この空間では、RealityKitのRealityViewを使ってリッチな3Dコンテンツを表示することができ、基本的な使い方は以下のように、表示したいコンテンツのEntityを生成、必要に応じてComponentを追加して、makeクロージャーで渡されるcontentにaddしていきます。
var body: some View {
RealityView { content in
let material = SimpleMaterial(color: .init(red: 1, green: 0, blue: 0, alpha: 1), roughness: 1, isMetallic: false)
let entity = ModelEntity(mesh: .generateSphere(raduis: 1.0), materials: [material])
entity.components.set(SomeComponent())
content.add(entity)
}
}
ただ、Entityの生成処理が複雑になったり、動的に追加したかったりした時に、RealityViewのクロージャーに書いていくのは効率が悪いので、公式のサンプルコードにもあるやり方で、contentにrootEntityのみを追加してそれをViewModelに保持させて、ViewModelで必要に応じてEntityを生成したりComponentを追加して、保持しているrootEntityに追加する方法を採っています。
struct ImmersiveView: View {
@State private var viewModel = ImmersiveViewModel()
var body: some View {
RealityView { content in
let entity = Entity()
viewModel.rootEntity = entity
content.add(entity)
}
}
}
@Observable
@MainActor
final class ImmersiveViewModel {
var rootEntity: Entity?
private(set) var ship: ModelEntity?
private(set) var checkPoints: [Entity] = []
func setup() {
let ship = try await createShip()
self.ship = ship
rootEntity?.addChild(ship)
let checkPoints = createCheckPoints(...)
self.checkPoints = checkPoints
for checkPoint in self.checkPoints {
rootEntity?.addChild(checkPoint)
}
}
}
extension ImmersiveViewModel {
func createShip() async throws -> ModelEntity {
let ship = ModelEntity()
let shipModel = try await Entity(named: "Ship", in: realityKitContentBundle)
ship.addChild(shipModel)
var physicsBody = PhysicsBodyComponent(mode: .dynamic)
physicsBody.isAffectedByGravity = false
physicsBody.linearDamping = 0.1
physicsBody.massProperties.mass = 0.8
ship.components.set(physicsBody)
let bodyCollisionShape = ShapeResource.generateSphere(radius: 0.08)
ship.components.set(CollisionComponent(shapes: [bodyCollisionShape]))
ship.components.set(...)
...
return ship
}
func createCheckPoints(...) -> [Entity] { ... }
}
![](https://assets.st-note.com/img/1727450201-1wdhngXoQspVHqDcPCJOrAKS.png?width=1200)
ハンドトラッキングでスペースシップを操縦する
次にゲームの根幹であるスペースシップの姿勢や飛行のコントロール、加速した時のパーティクルやオーディオ、ハンドトラッキングの仕組みについて説明します。
ECS構成
RealityKitではECS (Entity Component System)というデータ指向の概念があり、先程のコードにもあるように、生成したshipのEntityにPhysicsBodyComponentやCollisionComponentなどの動作を付与して、この後説明する様々なSystemでshipの振る舞いを定義していきます。ちなみに、Unityにも最近導入されているらしいです。
Entity: RealityKitシーンにおける要素の単位。コンテナのようなもの。
Component: Entityに追加することでいろんな外観や動作を適用できる。ロジックは持たない。
System: フレームごとに対象のEntityに振る舞いを定義して、動作やロジックを実装できる。クエリで対象のEntityをで効率的に検索できる。
今回のゲームでは、shipのEntityには実に様々なComponentが追加されており、細かい部分は省略しますが、全体像は次の図のようになっています。
![](https://assets.st-note.com/img/1727450248-oOzxdDHnmBvl5r4p3hZ7efcW.png?width=1200)
飛行時の姿勢や推進力のコントロール
上の図にある通り、飛行時の姿勢や推進力のコントールはShipFlightSystemで行われます。ShipFlightSystemではShipFlightComponent, ThrottleComponent, PitchRollComponentを持つEntity(ship)をクエリで抽出し、throttle, pitch, rollの値を取得して、ShipFlightStateComponentのyaw, pitchRollのクォータニオンを更新します。そして、flightState.yaw, flightState.pitchRollを掛け合わせて正規化すると、entity.transform.rotationとなり飛行時の姿勢が更新されます。
また、throttleから推進力を計算して、addForceを呼ぶことでスペースシップに推進力を加えることができますが、注意として対象のEntityがHasPhysicsプロトコルに準拠していて、CollisionComponent, PhysicsMotionComponentを持つ必要があります。力を加えるので、物理的な振る舞いが必要であることを考えると理解できます。
struct ShipFlightComponent: Component {
init() {
ShipFlightSystem.registerSystem()
}
}
final class ShipFlightSystem: System {
// Entityを抽出するためのクエリ
static let query = EntityQuery(
where: (
.has(ShipFlightComponent.self) &&
.has(ThrottleComponent.self) &&
.has(PitchRollComponent.self)
)
)
init(scene: Scene) {}
func update(context: SceneUpdateContext) {
for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) {
let throttle = entity.components[ThrottleComponent.self]!.throttle
let pitchRoll = entity.components[PitchRollComponent.self]!
var flightState = entity.components[ShipFlightStateComponent.self]!
flightState.yaw = ...
flightState.pitchRoll = ...
entity.transform.rotation = (flightState.yaw * flightState.pitchRoll).normalized // 姿勢を更新する
entity.components[ShipFlightStateComponent.self] = flightState
guard let physicsEntity = entity as? HasPhysics else { return }
...
physicsEntity.addForce(force, relativeTo: nil) // 推進力を与える
}
}
}
struct ShipFlightStateComponent: Component {
var yaw = simd_quatf(ix: 0, iy: 0, iz: 0, r: 1)
var pitchRoll = simd_quatf(ix: 0, iy: 0, iz: 0, r: 1)
init() {
ShipFlightStateComponent.registerComponent()
}
}
Throttleでエンジン出力のパーティクルをコントロール
ShipFlightSystemではthrottleから推進力を計算してスペースシップに加えていましたが、ShipVisualsSystemではthrottleを利用して、エンジン出力に応じたパーティクルをコントロールしています。
Reality Composer Proでスペースシップのエンジン付近に、それぞれLeftEngine, RightEngineのオブジェクトを配置し、その配下にParticleEmitterを追加します。インスペクタでParticle EmitterのEmitter, Particlesのカラーやサイズ、生成率、テクスチャーなどを調整します。
そして、ShipVisualsSystemでThrottleComponentを持つEntityを抽出し、throttleの値からparticleEmitter.mainEmitter.lifeSpanを計算して、LeftEngine, RightEngineのParticleEmitter Componentを更新すれば、throttleに応じてパーティクルの演出が変化して、エンジン出力の強弱を表現できます。
![](https://assets.st-note.com/img/1727450376-qlAZ2075XtQEcgIveM4jTGJH.png?width=1200)
final class ShipVisualsSystem: System {
// Entityを抽出するためのクエリ
static let query = EntityQuery(where: .has(ThrottleComponent.self))
init(scene: Scene) {}
func update(context: SceneUpdateContext) {
for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) {
guard let throttle = entity.components[ThrottleComponent.self]?.throttle else { return }
for engineName in ["LeftEngine", "RightEngine"] {
guard let engine = entity.findEntity(named: engineName),
let particles = engine.findEntity(named: "ParticleEmitter") else { return }
// LeftEngine, RightEngineそれぞれのParticleEmitterを取得して、throttleに応じた更新処理を行う
updateVaporTrail(particles, throttle: throttle)
}
}
}
func updateVaporTrail(_ vaporTrail: Entity, throttle: Float) {
var particleEmitter = vaporTrail.components[ParticleEmitterComponent.self]!
particleEmitter.isEmitting = throttle > 0.1
particleEmitter.mainEmitter.lifeSpan = Double(throttle) * 0.25 // throttleに係数を掛けてlifeSpanを更新する
vaporTrail.components.set(particleEmitter)
}
}
struct ShipVisualsComponent: Component {
init() {
ShipVisualsSystem.registerSystem()
}
}
Throttleでエンジン音のオーディオをコントロール
ShipVisualsSystemと同じように、ShipAudioSystemではthrottleを利用して、エンジン出力に応じたエンジン音のオーディオをコントロールしています。
準備としてshipのModelEntityの生成時に、パーティクルと同じようにLeftEngine, RightEngineにオーディオ再生用のAudioSource-EngineExhaustというEntityを追加しておきます。
extension ImmersiveViewModel {
func configureEngineAudioSource(on spaceship: Entity) async throws {
let audioSource = Entity()
audioSource.name = "AudioSource-EngineExhaust"
audioSource.orientation = .init(angle: .pi, axis: [0, 1, 0])
audioSource.components.set(SpatialAudioComponent(directivity: .beam(focus: 0.25))) // 空間オーディオ用のComponentも追加する
spaceship.addChild(audioSource)
spaceship.addChild(audioSource)
}
func createShip() async throws -> ModelEntity {
let ship = ModelEntity()
...
for engineName in ["LeftEngine", "RightEngine"] {
if let engine = shipModel.findEntity(named: engineName) {
try await configureEngineAudioSource(on: engine)
}
}
...
return ship
}
}
次に、エンジン音のオーディオリソースを生成して、AudioSource-EngineExhaustでオーディオリソースを再生できるようにprepareAudioを呼んでAudioPlaybackControllerを取得して保持しておきます。今回はゲーム開始時にAudioPlaybackControllerでplayを呼ぶことでエンジン音が再生されるようになります。
@MainActor
final class SpaceshipAudioStorage {
// オーディオ再生用のコントローラを保持する配列
var exhaustControllers: [EnginePlacement: AudioPlaybackController] = [:]
func prepareAudio(for spaceship: Entity) async throws {
for enginePlacement in EnginePlacement.allCases {
let engineName = "\\(enginePlacement.rawValue.capitalized)Engine" // LeftEngine, RightEngine
if let engine = spaceship.findEntity(named: engineName) {
try await prepareExhaustAudio(on: engine, placement: enginePlacement)
}
}
}
func play() {
exhaustControllers.values.forEach { $0.play() }
}
func prepareExhaustAudio(on engine: Entity, placement: EnginePlacement) async throws {
let audioSource = engine.findEntity(named: "AudioSource-EngineExhaust")!
let audio = try await AudioFileResource(
named: "Exhaust",
configuration: .init(
shouldLoop: true,
shouldRandomizeStartTime: true,
mixGroupName: MixGroup.spaceship.rawValue
)
)
exhaustControllers[placement] = audioSource.prepareAudio(audio) // オーディオ再生用のAudioPlaybackControllerが返却される
}
}
enum EnginePlacement: String, CaseIterable {
case left, right
}
エンジン音が再生できるようになりましたが、まだthrottleに応じて変化できていないので、ShipAudioSystemでLeftEngine, RightEngineのAudioSource-EngineExhaustを取得して、throttleからgainを計算して、SpatialAudioComponentのgainを更新することで、エンジン音が強弱がthrottleに応じて変化するようになります。
final class ShipAudioSystem: System {
static let query = EntityQuery(
where: (
.has(ShipAudioComponent.self) &&
.has(ThrottleComponent.self)
)
)
init(scene: Scene) {}
func update(context: SceneUpdateContext) {
var throttle: Float = 0
for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) {
throttle = entity.components[ThrottleComponent.self]!.throttle
for engineName in ["LeftEngine", "RightEngine"] {
guard let engine = entity.findEntity(named: engineName),
let exhaust = engine.findEntity(named: "AudioSource-EngineExhaust") else { return }
// LeftEngine, RightEngineそれぞれのAudioSource-EngineExhaustを取得して、throttleに応じた更新処理を行う
updateExhaust(exhaust, throttle: throttle)
}
}
}
@MainActor func updateExhaust(_ exhaust: Entity, throttle: Float) {
let gain = decibels(amplitude: Double(throttle)) // throttleをデシベルに変換する
exhaust.components[SpatialAudioComponent.self]!.gain = gain
}
}
struct ShipAudioComponent: Component {
init() {
ShipAudioSystem.registerSystem()
}
}
ハンドトラッキング処理
先程はShipFlightSystemでthrottle, pitch, rollをもとに飛行時の姿勢や推進力をコントロールしていると書きましたが、それらのパラメータはHandsShipControlProviderSystemというハンドトラッキングを司るSystemで計算して更新されます。
HandsShipControlProviderSystemでは、まずハンドトラッキングを含む空間トラッキングを可能にするSpatialTrackingSessionを生成して開始させています。更新処理ではHandTrackingComponentを持つEntity(leftIndexFingerTip, leftThumbTip, leftPalm, rightPalm)のtransformを保持し、さらに左手の親指と人差し指の位置からthrottleを、左右の手のひらpitch, rollを計算してShipControlParametersのパラメータを更新します。
![](https://assets.st-note.com/img/1727450431-GZN4mYbCvIoXxKhMFcap6920.png?width=1200)
struct HandTrackingComponent: Component {
enum Location: String {
case leftIndexFingerTip, leftThumbTip, leftPalm, rightPalm
}
let location: HandTrackingComponent.Location
}
final class HandsShipControlProviderSystem: System {
static var dependencies: [SystemDependency] = [.before(ShipControlSystem.self)]
let session = SpatialTrackingSession()
init(scene: Scene) {
HandTrackingComponent.registerComponent()
Task { @MainActor in
let config = SpatialTrackingSession.Configuration(tracking: [.hand])
_ = await session.run(config)
}
}
func update(context: SceneUpdateContext) {
// Anchor transformを保存する
var transforms: [HandTrackingComponent.Location: simd_float4x4] = [:]
for entity in context.entities(matching: EntityQuery(where: .has(HandTrackingComponent.self)), updatingSystemWhen: .rendering) {
...
guard let handTrackingComponent = entity.components[HandTrackingComponent.self] else { continue }
transforms[handTrackingComponent.location] = entity.transformMatrix(relativeTo: nil)
}
// ハンドトラッキングでShipControlParametersを更新する
for entity in context.entities(matching: EntityQuery(where: .has(ShipControlComponent.self)), updatingSystemWhen: .rendering) {
updateShipControlParameters(for: entity, context: context, transforms: transforms)
}
}
@MainActor
func updateShipControlParameters(for entity: Entity, context: SceneUpdateContext, transforms: [HandTrackingComponent.Location: simd_float4x4]) {
let shipControlParameters = entity.components[ShipControlComponent.self]!.parameters
guard let indexTipTransform = transforms[.leftIndexFingerTip],
let thumbTipTransform = transforms[.leftThumbTip] else {
// 左手の親指,人差し指の位置からthrottleを計算して更新する
shipControlParameters.throttle = interpolateThrottle(current: shipControlParameters.throttle, target: .zero, deltaTime: context.deltaTime)
return
}
...
// 左右の手のひらpitch, rollを計算して更新する(左手の手のひらは中間点の計算に使われる)
let (pitch, roll) = computePitchAndRoll(leftPalmTransform: leftPalmTransform,
rightPalmTransform: rightPalmTransform)
shipControlParameters.pitch = pitch
shipControlParameters.roll = roll
}
}
このようにハンドトラッキングでShipControlParametersのパラメータを更新できたので、最後にShipControlSystemでShipControlParametersのパラメータを取得して、ThrottleComponent, PitchRollComponentの値を更新することで、ShipFlightSystemで参照するthrottle, pitch, rollの値が更新されるようになります。
@Observable
class ShipControlParameters {
var throttle: Float = 0
var pitch: Float = 0 // radians
var roll: Float = 0 // radians
}
struct ShipControlComponent: Component {
let parameters: ShipControlParameters
}
final class ShipControlSystem: System {
static var dependencies: [SystemDependency] = [.before(ShipFlightSystem.self)]
static let query = EntityQuery(where: .has(ShipControlComponent.self))
init(scene: Scene) {}
func update(context: SceneUpdateContext) {
for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) {
guard let shipControlComponent = entity.components[ShipControlComponent.self] else { return }
let parameters = shipControlComponent.parameters
entity.components.set([
ThrottleComponent(throttle: parameters.throttle),
PitchRollComponent(pitch: parameters.pitch, roll: parameters.roll)
])
}
}
}
以上で、ハンドトラッキングからスペースシップの姿勢や推進力、エンジン出力のパーティクル、エンジン音のオーディオの更新まで、一連の処理が繋がり、ハンドトラッキングで自在にスペースシップを操縦できるようになりました。
なお、ハンドトラッキングでスペースシップを操縦する処理は、公式のサンプルコードをベースにしているので、詳しく知りたい場合はダウンロードして実際に実行してみると、より理解が深まると思います。
3Dモデルやオーディオリソース
今回のゲームを開発する上で、スペースシップの3DモデルやBGM、SEのオーディオも大事なアセットです。自前でBlenderなどでモデリングから作る方法もありますが、まだそこまでのスキルがないので、まずはLuma AI Genieで3Dモデルを生成させてみましたが、概ね良さそうな感じですが細部が歪んでいたりつぶれていたので断念しました。生成AIの進化に期待したいです。
![](https://assets.st-note.com/img/1727450476-sYKwo12uOg0ietaAX4d7lMLF.png?width=1200)
USDZの3Dモデルを購入・ダウンロードできるサイトも、Free3DやSketchfabなどがありますが、今回はUnity Assets StoreにあるLow-Poly Spaceships Setというアセットを利用させていただきました。ローポリなスペースシップの3Dモデルが13種類×5色もあり、軽量で種類豊富なのでおすすめのアセットです。
宇宙空間のBGMやカウントダウン、チェックポイントの通過時、クリア時の効果音はSpringinというフリー音源素材サイトを利用させていただきました。いろんな種類の効果音が豊富に揃っているのでおすすめです。
他に工夫したポイント
カウントダウン表示
各ステージの開始時に5秒のカウントダウンをアニメーション付きで表示していますが、このカウントダウンは以下のようにCircle()にtrimを適用して、0から残り秒数までのprogressをトリミングしつつ、親Viewから渡されるcountの変更を検知してprogressを更新しています。
struct CountDownRing: View {
let seconds: Int
@Binding var count: Int
@State private var progress: CGFloat = 1
var body: some View {
ZStack {
Circle()
.stroke(lineWidth: 15)
.frame(width: 160, height: 160)
.foregroundStyle(.gray.opacity(0.3))
Circle()
.trim(from: 0, to: progress) // 0から残り秒数までをトリミングする
.stroke(style: StrokeStyle(lineWidth: 18, lineCap: .round, lineJoin: .round))
.frame(width: 160, height: 160)
.foregroundStyle(.white)
.rotationEffect(.degrees(-90)) // 縦方向からアニメーションさせるために、反時計回りに90°回転させる
Text("\\(count)")
.font(.system(size: 48, weight: .bold))
.monospacedDigit()
}
.onChange(of: count) { _, newValue in
withAnimation(.easeOut(duration: 0.5)) {
progress = CGFloat(newValue) / CGFloat(seconds)
}
}
}
}
#Preview(windowStyle: .automatic) {
@Previewable @State var count: Int = 10
CountDownRing(seconds: 10, count: $count)
.onAppear {
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
count = count > 0 ? count - 1 : 10
}
}
}
また、カウントダウン時に効果音も再生したいので、ViewModelにAVAudioPlayerのプロパティを宣言し、オーディオリソースファイルのURLを取得して初期化すれば、タイマーでカウントが減った時にplay()で再生できます。
private var audioPlayer: AVAudioPlayer?
init() {
if let sound = Bundle.main.url(forResource: "CountDown", withExtension: "mp3") {
audioPlayer = try? AVAudioPlayer(contentsOf: sound)
}
}
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true, block: { [weak self] _ in
...
self?.audioPlayer?.play()
...
}
Immersive Spaceが消えた時にタイトル画面に戻す
今回のゲームでは、プレイ開始時にImmersive Spaceを開いて、TitleWindowを閉じてGameWindowを開いてますが、プレイ中にホームボタンを押した時にImmersive Spaceは閉じられるが、GameWindowは開いたままでプレイを再開できない問題がありました。
これを解決するために、AppModelにImmersiveSpaceState, WindowStateを定義して、それぞれのViewのonAppear, onDisappearでstateを更新し、必要に応じてopenWindow, dismissWindowを行うことで解決しました。
@MainActor
@Observable
class AppModel {
enum WindowState {
case closed
case open
}
enum ImmersiveSpaceState {
case closed
case inTransition
case open
}
static let shared = AppModel()
var titleWindowState = WindowState.closed
var gameWindowState = WindowState.closed
var immersiveSpaceState = ImmersiveSpaceState.closed
...
}
struct TitleView: View {
@Environment(AppModel.self) private var appModel
...
var body: some View {
VStack(spacing: 40) {
...
}
...
.onAppear {
AppModel.shared.titleWindowState = .open
if AppModel.shared.gameWindowState == .open {
// タイトルウィンドウの表示時に、ゲームウィンドウを閉じる
dismissWindow(id: Constants.windowIdGameWindow)
}
}
.onDisappear {
AppModel.shared.titleWindowState = .closed
}
}
}
struct ImmersiveView: View {
@Environment(AppModel.self) private var appModel
...
var body: some View {
RealityView { content in
...
}
.onAppear {
AppModel.shared.immersiveSpaceState = .open
}
.onDisappear {
AppModel.shared.immersiveSpaceState = .closed
...
if AppModel.shared.titleWindowState == .closed {
// Immersive Spaceが閉じられた時に、タイトルウィンドウを開く
openWindow(id: Constants.windowIdTitleWindow)
}
}
}
}
struct GameView: View {
@Environment(AppModel.self) private var appModel
...
var body: some View {
VStack(spacing: 0) {
...
}
.onAppear {
AppModel.shared.gameWindowState = .open
...
}
.onDisappear {
AppModel.shared.gameWindowState = .closed
}
}
}
おわりに
長文になりましたが、今回Apple Vision Pro向けに作ったハンドトラッキングを活用した空間ゲームとその設計や実装について紹介しました。今までiOSアプリを中心に開発してきて、空間ゲームの開発が初めてで非常に新鮮でわくわくしながら開発してきました。
Apple Vision Proによって空間コンピューティングの時代がやってきて、これからたくさんの空間アプリ・空間ゲームが出てきて、空間コンピューティングがどんどん日常になっていくのが楽しみです!
今回作ったゲームのアップデートももちろんですが、次にどんな空間アプリを作ろうかと早くもアイデアを練り始めています!