>100 Views
July 31, 24
スライド概要
https://pycon-hiroshima.connpass.com/event/324286/
2024-07-31
すごい広島 IT初心者の会 [88]
西本卓也 @24motz / @nishimotz
shuaruta.com
Shuaruta Inc. ウェブアクセシビリティ基盤委員会 (WAIC) NVDA日本語版 すごい広島 with Python
ミニゲームを作って学ぶSwiftUIとvisionOS 2024-07-31 すごい広島 IT初心者の会 [88] 西本卓也 @24motz / @nishimotz shuaruta.com 1
概要 • 指(または指+視線)で操作 • 実装は約200行 • 青い矩形 • 一定速度で落ちていき、ユーザーは左右にだけ操作できる • 一番下に落ちたら上からまた出てくる • 緑色のボール • ランダムな場所に現れる • 青い矩形をぶつけるとスコアが増える • 30秒で終了 2
技術 • SwiftUI • 宣言型UIフレームワーク • visionOS / Apple Vision Pro • 拡張現実(AR)および仮想現実(VR)プラットフォーム • 視線と手と音声入力で操作 • Mac仮想ディスプレイ / ビューミラーリング • Cursor / ChatGPT / Claude / Perplexity • 堂々巡りでビルドが通らない • 完成してから読み返すと無駄だらけ 3
タップとドラッグ • 矩形のドラッグ&ドロップを実験 • 2種類の操作ができることに気づいた • 間接操作(矩形に視線を合わせる) • 視線で選択する(ホバーエフェクト)→ フォーカス • 親指と人差し指を閉じる → タップ(アクション実行) • 閉じたまま動かす → ドラッグ • 60ポイント以上というガイドラインがある • 直接操作(手が届くところにシーンを置く) • 対象そのものに指を当てる(衝突させる)→ タップ • 当てたまま動かす → ドラッグ 4
// SwiftUIView.swift import SwiftUI struct SwiftUIView: View { var body: some View { HStack { ZStack { Rectangle().gesture( ... ) if isGameActive { Circle() ... } } Vstack { Button(isGameActive ? "ゲーム終了" : "ゲーム開始") Text(“スコア: \(score)") Text("残り時間: \(timeRemaining)秒") } } } } 6
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
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 < targetMinX - targetInitialX {
targetXPosition = targetMinX - targetInitialX
} else if newX > targetMaxX - targetInitialX {
targetXPosition = targetMaxX - targetInitialX
} else {
targetXPosition = newX
}
}
)
8
movementTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { timer in if isGameActive { if targetYPosition > gameAreaSize { targetYPosition = 0 } else { // 下に落ちるときだけスムーズに動く withAnimation(.easeInOut(duration: 0.1)) { targetYPosition += targetSpeed } } checkCollision() } else { timer.invalidate() } } 9
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
// @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
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<CGFloat> {
let halfGameArea = gameAreaSize / 2
return -halfGameArea...halfGameArea
}
}
12
// 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
リファクタリング • State から ViewModel を導入 • タイマーサービスを作成 • ViewModel にタイマーサービスを導入 • テスト用のモックタイマーサービスを作成 • ViewModel のユニットテストを作成 14