---
title: ミニゲームを作って学ぶSwiftUIとvisionOS
tags: 
author: [Takuya Nishimoto](https://convert.docswell.com/user/nishimotz)
site: [Docswell](https://www.docswell.com/)
thumbnail: https://bcdn.docswell.com/page/4JQ545657P.jpg?width=480
description: https://pycon-hiroshima.connpass.com/event/324286/  2024-07-31 すごい広島 IT初心者の会 [88] 西本卓也 @24motz / @nishimotz shuaruta.com
published: July 31, 24
canonical: https://convert.docswell.com/s/nishimotz/KG1EPM-2024-07-29-093554
---
# Page. 1

![Page Image](https://bcdn.docswell.com/page/4JQ545657P.jpg)

ミニゲームを作って学ぶSwiftUIとvisionOS
2024-07-31
すごい広島 IT初心者の会 [88]
西本卓也 @24motz / @nishimotz
shuaruta.com
1


# Page. 2

![Page Image](https://bcdn.docswell.com/page/K749Q94VE1.jpg)

概要
• 指（または指＋視線）で操作
• 実装は約200行
• 青い矩形
• 一定速度で落ちていき、ユーザーは左右にだけ操作できる
• 一番下に落ちたら上からまた出てくる
• 緑色のボール
• ランダムな場所に現れる
• 青い矩形をぶつけるとスコアが増える
• 30秒で終了
2


# Page. 3

![Page Image](https://bcdn.docswell.com/page/LJ1KPK447G.jpg)

技術
• SwiftUI
• 宣言型UIフレームワーク
• visionOS / Apple Vision Pro
• 拡張現実（AR）および仮想現実（VR）プラットフォーム
• 視線と手と音声入力で操作
• Mac仮想ディスプレイ / ビューミラーリング
• Cursor / ChatGPT / Claude / Perplexity
• 堂々巡りでビルドが通らない
• 完成してから読み返すと無駄だらけ
3


# Page. 4

![Page Image](https://bcdn.docswell.com/page/GJWD2DXZ72.jpg)

タップとドラッグ
• 矩形のドラッグ＆ドロップを実験
• 2種類の操作ができることに気づいた
• 間接操作（矩形に視線を合わせる）
• 視線で選択する（ホバーエフェクト）→ フォーカス
• 親指と人差し指を閉じる → タップ（アクション実行）
• 閉じたまま動かす → ドラッグ
• 60ポイント以上というガイドラインがある
• 直接操作（手が届くところにシーンを置く）
• 対象そのものに指を当てる（衝突させる）→ タップ
• 当てたまま動かす → ドラッグ
4


# Page. 5

![Page Image](https://bcdn.docswell.com/page/4EZGDG6LJ3.jpg)

// SwiftUIView.swift
import SwiftUI
struct SwiftUIView: View {
var body: some View {
HStack {
ZStack {
Rectangle().gesture( ... )
if isGameActive { Circle() ... }
}
Vstack {
Button(isGameActive ? &quot;ゲーム終了&quot; : &quot;ゲーム開始&quot;)
Text(“スコア: ＼(score)&quot;)
Text(&quot;残り時間: ＼(timeRemaining)秒&quot;)
}
}
}
}
6


# Page. 6

![Page Image](https://bcdn.docswell.com/page/Y76Y1Y2MEV.jpg)

struct SwiftUIView: View {
var body: some View { ...
Button() {
if isGameActive {
endGame()
} else {
startGame()
}
disableButtonTemporarily()
}
}
private func startGame() {
gameTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { ... }
movementTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { }
}
private func endGame() {
gameTimer?.invalidate()
}
}
7


# Page. 7

![Page Image](https://bcdn.docswell.com/page/G75G4G2Q74.jpg)

Rectangle()
.fill(Color.blue)
.frame(width: targetSize, height: targetSize)
.position(x: targetInitialX + targetXPosition, y: targetYPosition)
.animation(.easeInOut(duration: 0.1), value: targetXPosition) // 左右スムーズ移動
.gesture(
// 矩形を直接指で押したまま左右に動かす
// 視線で矩形を選択して、親指と人差し指を閉じて、そのまま左右に動かす
DragGesture()
.onChanged { value in
let newX = value.location.x - targetInitialX
if newX &lt; targetMinX - targetInitialX {
targetXPosition = targetMinX - targetInitialX
} else if newX &gt; targetMaxX - targetInitialX {
targetXPosition = targetMaxX - targetInitialX
} else {
targetXPosition = newX
}
}
)
8


# Page. 8

![Page Image](https://bcdn.docswell.com/page/9J2YVY4WJR.jpg)

movementTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { timer in
if isGameActive {
if targetYPosition &gt; gameAreaSize {
targetYPosition = 0
} else {
// 下に落ちるときだけスムーズに動く
withAnimation(.easeInOut(duration: 0.1)) {
targetYPosition += targetSpeed
}
}
checkCollision()
} else {
timer.invalidate()
}
}
9


# Page. 9

![Page Image](https://bcdn.docswell.com/page/DEYG3GM9EM.jpg)

import AudioToolbox
struct SwiftUIView: View {
private func moveBall() {
// ボールを新しい位置に移動
}
private func checkCollision() {
// 衝突したらスコア更新と衝突中表示、次の位置への移動
}
private func rectIntersectsCircle() {
// ボールの中心から矩形の最も近い点までの距離がボールの半径より小さいかどうかを確認
}
private func playSystemSound(soundID: SystemSoundID) {
AudioServicesPlaySystemSound(soundID)
}
private func disableButtonTemporarily() {
isButtonDisabled = true
// 開始ボタン、終了ボタンは押されたら2秒間無効化
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
isButtonDisabled = false
}
}
}
10


# Page. 10

![Page Image](https://bcdn.docswell.com/page/VJNGVGWD78.jpg)

// @State - ビュー内のローカルな状態に使用
struct SwiftUIView: View {
@State private var targetXPosition: CGFloat = 0.0
@State private var targetYPosition: CGFloat = 0.0
@State private var ballPosition = CGSize.zero
@State private var score = 0
@State private var timeRemaining = 30
@State private var isGameActive = false
@State private var gameTimer: Timer?
@State private var movementTimer: Timer?
@State private var isButtonDisabled = false
@State private var ballColor = Color.green // ボールの色を管理する変数
@State private var ballScale: CGFloat = 1.0 // ボールのスケールを管理する変数
@State private var isCollisionProcessing = false // 衝突処理中かどうか
}
11


# Page. 11

![Page Image](https://bcdn.docswell.com/page/YE9W6WX8E3.jpg)

struct SwiftUIView: View {
private let targetSize: CGFloat = 70
private let ballSize: CGFloat = 50
private let gameAreaSize: CGFloat = 300
private let targetInitialX: CGFloat = 150
private let targetMinX: CGFloat = 35
private let targetMaxX: CGFloat = 265
private let targetSpeed: CGFloat = 5
private let gameDuration: Int = 30
private let collisionSoundID: SystemSoundID = 1004
private let endGameSoundID: SystemSoundID = 1005
private var randomRangeX: ClosedRange&lt;CGFloat&gt; {
let halfGameArea = gameAreaSize / 2
return -halfGameArea...halfGameArea
}
}
12


# Page. 12

![Page Image](https://bcdn.docswell.com/page/GE8NZN2Z7D.jpg)

// TwoApp.swift
import SwiftUI
@main
struct TwoApp: App {
var body: some Scene {
WindowGroup {
SwiftUIView()
}
.windowStyle(.volumetric)
.defaultSize(width: 1.0, height: 1.0, depth: 0.01, in: .meters)
}
}
13


# Page. 13

![Page Image](https://bcdn.docswell.com/page/LELPDP217R.jpg)

リファクタリング
• State から ViewModel を導入
• タイマーサービスを作成
• ViewModel にタイマーサービスを導入
• テスト用のモックタイマーサービスを作成
• ViewModel のユニットテストを作成
14


# Page. 14

![Page Image](https://bcdn.docswell.com/page/4JM4P4857W.jpg)



