【SwiftUI】奇跡の1ミリに挑戦するアプリを作ってみた

2022.12.12

この記事は公開されてから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)
        }
    }
}

サッカーフィールド

左側のエリア、タッチライン用のHStackspacing、タッチラインより外のエリアの大きさはなんとなく決めました。

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用に書き換えたものになります。

【iOS】CMMotionManagerを使ってViewを動かして遊ぶ

詳しい説明については参考記事内に書いてありますので見ていただければと思います。

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()
}

ゲーム結果の表示

viewModelshouldPresentedResultに応じて、アラートの表示非表示を切り替えて、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杯の活躍もとても楽しみですね。

参考