【SwiftUI】奇跡の1ミリに挑戦するアプリを作ってみた
カタールW杯の日本代表の活躍はとても素晴らしかったですね。特にスペインvs日本の試合は本当に大興奮でした。あの時の感動を忘れない為に奇跡の1ミリに挑戦するアプリを作ってみました。
作ったもの
iPhoneの加速度センサーを利用してボールを動かして、画面をタッチすると、現在のボールの位置でストップします。その時のボールの左端の位置とタッチラインの右端の位置の差が0.01
以上かつ2
未満の場合にブラボーと認定されるゲームです。実際の1ミリとは違いますので、雰囲気だけを楽しむものになります。
実際やってみると意外と難しくてなかなかブラボーと認定されませんでした、、、
それでは、作り方を説明していきます。
環境
- Xcode 14.1
- iOS 16.1
Viewの用意
ゲームで使用するViewを用意します。
サッカーボール
SF Symbolsを使用して作成しました。
circle.fill
のシンボル上にoctagon.fill
を配置してサッカーボールを表現しました。
import SwiftUI struct SoccerBall: View { let length: CGFloat private var octagonSize: CGFloat { return length / 4 } var body: some View { Image(systemName: "circle.fill") .font(.system(size: length)) .foregroundColor(.white) .overlay { ZStack { ForEach(BallOctagonAlignment.allCases) { alignment in Image(systemName: "octagon.fill") .font(.system(size: octagonSize)) .fontWeight(.ultraLight) .foregroundColor(.black) .frame(width: length, height: length, alignment: alignment.value) } } .clipShape(Circle()) } .shadow(radius: 1) } }
サッカーボール上のoctagon.fill
はそれぞれのalignment
箇所に配置したい為、独自のenum
を作成しました。
extension SoccerBall { enum BallOctagonAlignment: String, CaseIterable, Identifiable { case top case topTrailing case topLeading case center case centerTrailing case centerLeading case bottom case bottomTrailing case bottomLeading var id: String { return self.rawValue } var value: Alignment { switch self { case .top: return .top case .topTrailing: return .topTrailing case .topLeading: return .topLeading case .center: return .center case .centerTrailing: return .trailing case .centerLeading: return .leading case .bottom: return .bottom case .bottomTrailing: return .bottomTrailing case .bottomLeading: return .bottomLeading } } } }
プレビュー
struct SoccerBall_Previews: PreviewProvider { static var previews: some View { ZStack { Color.green SoccerBall(length: 160) } } }
サッカーフィールド
左側のエリア、タッチライン用のHStack
のspacing
、タッチラインより外のエリアの大きさはなんとなく決めました。
import SwiftUI struct SoccerField: View { @Binding var outsideTouchLineRect: CGRect var body: some View { GeometryReader { proxy in let screenWidth = proxy.size.width HStack(spacing: 32) { Rectangle() .fill(.green) .frame(width: screenWidth * 0.45) Rectangle() .fill(.green) .readFrame($outsideTouchLineRect) } } } }
スクリーン上のタッチラインの右端位置を取得する為に、Viewの独自エクステンションメソッドreadFrame
を使用して、タッチラインより右側のViewのCGRect
をバインディングするようにしています。
readFrame
子ViewのCGRect
を取得するメソッドです。
extension View { func readFrame(_ frame: Binding<CGRect>) -> some View { self.background( GeometryReader { proxy -> AnyView in let rect = proxy.frame(in: .global) if rect.integral != frame.wrappedValue.integral { Task { @MainActor in frame.wrappedValue = rect print(rect) } } return AnyView(EmptyView()) } ) } }
プレビュー
ゲーム用のViewModelを作成
import CoreMotion class BraBallGameViewModel: ObservableObject { init() { startMotion() } @Published var currentBallPosition: CGPoint = .init() @Published var outsideTouchLineField: CGRect = .init() @Published var shouldPresentedResult = false @Published var result: GameResult = .none let ballLength: CGFloat = 120 private let motionManager = CMMotionManager() private var screenRect = CGRect() private var currentBallMinX: CGFloat { currentBallPosition.x - ballLength / 2 } private var touchLineMaxX: CGFloat { outsideTouchLineField.minX } // MARK: - Public Function func setupScreenRect(_ rect: CGRect) { screenRect = rect } func judge() { guard motionManager.isDeviceMotionActive else { return } motionManager.stopDeviceMotionUpdates() let difference = touchLineMaxX - currentBallMinX print(touchLineMaxX) print(currentBallMinX) print("difference", difference) result = GameResult(from: difference.roundedAfterThirdDecimalPlace()) shouldPresentedResult = true } func retryGame() { result = .none startMotion() } // MARK: - Private Function private func startMotion() { guard let queue = OperationQueue.current, motionManager.isDeviceMotionAvailable else { return } motionManager.deviceMotionUpdateInterval = 1 / 100 motionManager.startDeviceMotionUpdates(using: .xMagneticNorthZVertical, to: queue) { [weak self] motion, error in guard let self = self, let motion = motion, error == nil else { return } let xAngle = motion.attitude.roll * 180 / Double.pi let yAngle = motion.attitude.pitch * 180 / Double.pi /// 係数を使って感度を調整する let coefficient: CGFloat = 5 let regulatedX = CGFloat(xAngle) * coefficient let regulatedY = CGFloat(yAngle) * coefficient let currentPositionX = self.calculatedCurrentPositionX(byAdding: regulatedX) let currentPositionY = self.calculatedCurrentPositionY(byAdding: regulatedY) print("x: ", currentPositionX) print("y: ", currentPositionY) Task { @MainActor in self.currentBallPosition = CGPoint(x: currentPositionX, y: currentPositionY) } } } /// 与えられたxと現在のターゲットの位置xを合算し、必要であればスクリーンの内側の値になるように補正された値を算出する /// - Parameter x: 現在のターゲットの位置xを算出する為に追加する値 /// - Returns: 算出された現在のターゲットの位置x private func calculatedCurrentPositionX(byAdding x: CGFloat) -> CGFloat { var position = currentBallPosition position.x += x if screenRect.minX > position.x { position.x = screenRect.minX } else if screenRect.maxX < position.x { position.x = screenRect.maxX } return position.x } /// 与えられたyと現在のターゲットの位置yを合算し、必要であればスクリーンの内側の値になるように補正された値を算出する /// - Parameter x: 現在のターゲットの位置yを算出する為に追加する値 /// - Returns: 算出された現在のターゲットの位置y private func calculatedCurrentPositionY(byAdding y: CGFloat) -> CGFloat { var position = currentBallPosition position.y += y if screenRect.minY > position.y { position.y = screenRect.minY } else if screenRect.maxY < position.y { position.y = screenRect.maxY } return position.y } }
init
init
時にstartMotion()
を実行して、端末のモーションを取得を開始しています。
init() { startMotion() }
モーション取得を開始し、サッカーボールを移動
端末のモーションを取得し、そのモーションに応じて、サッカーボールを移動させます。
下記の記事でUIKitで実装されていた内容を参考にSwiftUI用に書き換えたものになります。
詳しい説明については参考記事内に書いてありますので見ていただければと思います。
private func startMotion() { guard let queue = OperationQueue.current, motionManager.isDeviceMotionAvailable else { return } motionManager.deviceMotionUpdateInterval = 1 / 100 motionManager.startDeviceMotionUpdates(using: .xMagneticNorthZVertical, to: queue) { [weak self] motion, error in guard let self = self, let motion = motion, error == nil else { return } let xAngle = motion.attitude.roll * 180 / Double.pi let yAngle = motion.attitude.pitch * 180 / Double.pi /// 係数を使って感度を調整する let coefficient: CGFloat = 5 let regulatedX = CGFloat(xAngle) * coefficient let regulatedY = CGFloat(yAngle) * coefficient let currentPositionX = self.calculatedCurrentPositionX(byAdding: regulatedX) let currentPositionY = self.calculatedCurrentPositionY(byAdding: regulatedY) print("x: ", currentPositionX) print("y: ", currentPositionY) Task { @MainActor in self.currentBallPosition = CGPoint(x: currentPositionX, y: currentPositionY) } } }
端末の傾きをx,y共にラジアン角に変換しています。係数として、coefficient
を用意していますが、こちらの値は今回ゲームを少し難しくする為に高めに設定してみました。値を低くすれば、傾きに応じてゆっくりサッカーボールが移動してくれるようになります。
最後に、係数によって調節されたラジアン角の値から、現在のサッカーボールのポジションxとyをcalculatedCurrentPositionX(byAdding:)
とcalculatedCurrentPositionY(byAdding:)
を使用して算出しています。
calculatedCurrentPositionX
引数として受け取ったラジアン角の値を現在のサッカーボールのxの位置と合算します。 その値がデバイスのスクリーンの外側になるようだったら、サッカーボールがスクリーンの内側に来るように補正しています。補正する必要がなければ合算した値をそのまま返しています。
private func calculatedCurrentPositionX(byAdding x: CGFloat) -> CGFloat { var position = currentBallPosition position.x += x if screenRect.minX > position.x { position.x = screenRect.minX } else if screenRect.maxX < position.x { position.x = screenRect.maxX } return position.x }
calculatedCurrentPositionY
x同様にyの値も補正する必要があれば補正します。
private func calculatedCurrentPositionY(byAdding y: CGFloat) -> CGFloat { var position = currentBallPosition position.y += y if screenRect.minY > position.y { position.y = screenRect.minY } else if screenRect.maxY < position.y { position.y = screenRect.maxY } return position.y }
プロパティ
少し戻ってプロパティの説明になります。
/// 現在のサッカーボールの位置 @Published var currentBallPosition: CGPoint = .init() /// タッチライン外側エリアのCGRect @Published var outsideTouchLineField: CGRect = .init() /// ゲームの結果を表示すべきかどうか @Published var shouldPresentedResult = false /// ゲームの結果 @Published var result: GameResult = .none /// サッカーボールの一辺の長さ let ballLength: CGFloat = 120 /// サッカーボールを動かす為のMotionManager private let motionManager = CMMotionManager() /// サッカーボールの位置を補正する為のスクリーン自体のCGRect private var screenRect = CGRect() /// サッカーボールの左端位置x private var currentBallMinX: CGFloat { currentBallPosition.x - ballLength / 2 } /// タッチラインの右端位置x private var touchLineMaxX: CGFloat { outsideTouchLineField.minX }
端末のスクリーンの大きさを取得
サッカーボールの位置を補正する為には端末のスクリーンの値を取得する必要がある為、setupScreenRect
関数を用意しました。
func setupScreenRect(_ rect: CGRect) { screenRect = rect }
ゲーム結果を判定
奇跡の1ミリかどうかを判定する為の関数です。
func judge() { guard motionManager.isDeviceMotionActive else { return } motionManager.stopDeviceMotionUpdates() let difference = touchLineMaxX - currentBallMinX print("difference", difference) result = GameResult(from: difference.roundedAfterThirdDecimalPlace()) shouldPresentedResult = true }
まず、デバイスモーションの更新を中止し、これ以上サッカーボールの位置が更新されないようにします。
その後にタッチラインの右端位置x
の値と現在のボールの左端位置x
から差分を算出します。さらにその差の値をroundedAfterThirdDecimalPlace()
を使用して小数第二位までの値にしています。
extension CGFloat { func roundedAfterThirdDecimalPlace() -> CGFloat { floor(self * 100) / 100 } }
その後、その差分の値を用いてゲーム結果を表すGameResult
を生成しています。そして、ゲーム結果を表示する為にshouldPresentResult
フラグをtrue
に切り替えています。
GameResult
ゲーム結果を表現する為のenum
です。
タッチラインとボールとの差が0.01
以上で2
未満の場合は、ブラボーという結果をになり、2
以上で10
より低い場合は、普通という結果になり、それ以外は論外という結果になります。
enum GameResult { case none case normal(CGFloat) case bravo(CGFloat) case bad(CGFloat) init(from difference: CGFloat) { switch difference { case 0.01..<2: self = .bravo(difference) case 2...10: self = .normal(difference) default: self = .bad(difference) } } var title: String { self.displayText.title } var message: String { self.displayText.message } private var displayText: DisplayText { switch self { case .none: return DisplayText(title: "", message: "") case .normal(let difference): return DisplayText(title: "普通?", message: "\nタッチラインとの差は、\(difference)\n普通のプレーです") case .bravo(let difference): return DisplayText(title: "ブラボー?", message: "\nタッチラインとの差は、\(difference)\nあなたは神です。") case .bad(let difference): return DisplayText(title: "論外?", message: "\nタッチラインとの差は、\(difference)\nかける言葉も見当たりません") } } struct DisplayText { let title: String let message: String } }
ゲームを再開
終了したゲームを再度再開できるようにretryGame
関数も用意しました。ゲームの結果をnone
に戻した後に再度startMotion()
を実行しています。
func retryGame() { result = .none startMotion() }
以上でGameViewModel
側の実装は出来たので、View
側で使用します。
BraBallGameView
import SwiftUI struct BraBallGameView: View { @StateObject private var viewModel = BraBallGameViewModel() var body: some View { GeometryReader { proxy in let _ = viewModel.setupScreenRect(proxy.frame(in: .local)) ZStack { SoccerField(outsideTouchLineRect: $viewModel.outsideTouchLineField) SoccerBall(length: viewModel.ballLength) .position(viewModel.currentBallPosition) } .onTapGesture { viewModel.judge() } .alert(Text(viewModel.result.title), isPresented: $viewModel.shouldPresentedResult, actions: { Button { viewModel.retryGame() } label: { Text("リトライ") } }, message: { Text(viewModel.result.message) }) } } }
- 端末のスクリーンのCGRectが必要なので、
viewModel.setupScreenRect(proxy.frame(in: .local))
を使用して、viewModel
側にスクリーンのCGRectをセットしています。 -
SoccerField
のタッチラインの外側エリアのCGRect
をバインディングでviewModel
側に渡しています。 -
SoccerBall
の位置は現在の加速度センサーの値から加工している位置情報を渡しています。
画面をタップするとゲーム結果を判定
画面をタップすると、現在の位置情報からゲーム結果をジャッジします。
.onTapGesture { viewModel.judge() }
ゲーム結果の表示
viewModel
のshouldPresentedResult
に応じて、アラートの表示非表示を切り替えて、viewModel.result
の値によって表示内容を変更しています。
アラートのリトライボタンを押すと再度ゲームが再開されます。
.alert(Text(viewModel.result.title), isPresented: $viewModel.shouldPresentedResult, actions: { Button { viewModel.retryGame() } label: { ext("リトライ") } }, message: { Text(viewModel.result.message) })
以上で、奇跡の1ミリを体験するアプリが完成です。
おわりに
コードはGitHubに上げていますので、挑戦したい方は是非チャレンジしてみてください。
カタールW杯決勝まで後一週間を切りました。さて優勝はどこになるのでしょうか?また日本代表の次のW杯の活躍もとても楽しみですね。