NCMBのSwift SDKを使ってゲームのランキング機能を実装する

ゲームというとUnityなどのゲームエンジンで実装するイメージが強いですが、Swift製のゲームもかなり多いです。SwiftであればGameKitも利用できますが、Androidとそれぞれネイティブで実装しているならば、NCMBでランキング保存をして、共通化するのもお勧めです。

今回はちょっとしたゲームにランキング機能を実装してみます。コードは[NCMBMania/Swift_Ranking_Demo: Swift SDKを使って実装したランキング機能のデモです](https://github.com/NCMBMania/Swift_Ranking_Demo)にアップしてあります。

## 今回のプロジェクト

今回は言語がSwift、インタフェースがSwiftUI、ライフサイクルはSwiftUI Appとしています。

## SDKのインストール

FileメニューからSwift Packages > Add Package Dependencyと選択します。

![ScreenShot_ 2021-08-02 9.35.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/dea9dcdc-3368-7992-67d4-8375355bf2ff.png)

出てきたダイアログでSwift SDKのGitリポジトリURLを入力します。GitHubのリポジトリでHTTPSとして取得できるもの、または下記URLになります。

![ScreenShot_ 2021-08-02 9.35.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/8d5d62cd-cf3f-f535-8f71-f1e61569674a.png)

```
https://github.com/NIFCLOUD-mbaas/ncmb_swift.git
```

バージョンは最新のものでかまいません。

![ScreenShot_ 2021-08-02 9.36.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/9e873825-38be-97e2-edd3-44776cc72d22.png)

後はFinishボタンを押せば完了です。

![ScreenShot_ 2021-08-02 9.36.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/7f74527e-8de8-d5cf-b433-c16b538c24f1.png)

## 初期化

今回はSwiftUIを利用しています。ライフサイクルもSwiftUIです。

まずSDKをインポートします。

```swift
import SwiftUI
import NCMB
```

次に `scenePhase` を追加します。

```swift
@main
struct forumApp: App {
    // 追加
    @Environment(\.scenePhase) private var scenePhase
```

後は body 内で `onChange` を使って初期化します。

```swift
var body: some Scene {
    WindowGroup {
        ContentView()
    }.onChange(of: scenePhase) { scene in
        switch scene {
        case .active:
            // キーの設定
            let applicationKey = "YOUR_APPLICATION_KEY"
            let clientKey = "YOUR_CLIENT_KEY"
            // 初期化
            NCMB.initialize(applicationKey: applicationKey, clientKey: clientKey)
        case .background: break
        case .inactive: break
        default: break
        }
    }
}
```

## 匿名認証

今回は匿名認証（ID、パスワードを使わない、デバイス固有に生成したUUIDを使った認証）を利用します。そこで、初期化した後にSwift SDKの匿名認証を有効にします。

```swift
NCMBUser.enableAutomaticUser()
```

### 認証状態の確認

次に認証状態を確認する `checkAuth` 関数を呼び出します。内容は次の通りです。 `NCMBUser.currentUser` が nil の場合は認証されていないので、匿名認証を実行します。

```swift
func checkAuth() -> Void {
    // 認証データがあれば処理は終了
    if NCMBUser.currentUser != nil {
        return;
    }
    // 匿名認証実行
    _ = NCMBUser.automaticCurrentUser()
}
```

### セッションの有効性チェック

認証されている場合でも、セッションの有効性は確認していません。ローカルにある認証データを復元している状態のためです。そこで、データストアに一度アクセスを行い、API通信の有効性を確認します。

```swift
func checkSession() -> Bool {
    var query : NCMBQuery<NCMBObject> = NCMBQuery.getQuery(className: "Todo")
    query.limit = 1 // レスポンス件数を最小限にする
    // アクセス
    let results = query.find()
    // 結果の判定
    switch results {
    case .success(_): break
    case .failure(_):
        // 強制ログアウト処理
        _ = NCMBUser.logOut()
        return false
    }
    return true
}
```

上記コードでログアウト処理を実行していますが、これは必ず失敗します。セッションが無効になっているため、ログアウトAPIの実行もまた、失敗するためです。そこで `NCMB/NCMBUser.swift` を開いて `logOutInBackground` を次のように修正します。

```swift
public class func logOutInBackground(callback: @escaping NCMBHandler<Void>) -> Void {
    NCMBLogoutService().logOut(callback: {(result: NCMBResult<NCMBResponse>) -> Void in
        // レスポンスに関係なくログアウト処理
        deleteFile()
        _currentUser = nil
        switch result {
            case .success(_):
                callback(NCMBResult<Void>.success(()))
                break
            case let .failure(error):
                callback(NCMBResult<Void>.failure(error))
                break
        }
    })
}
```

bodyのコードは次のようになります。

```swift
var body: some Scene {
    WindowGroup {
        ContentView()
    }.onChange(of: scenePhase) { scene in
        switch scene {
        case .active:
            // キーの設定
            let applicationKey = "YOUR_APPLICATION_KEY"
            let clientKey = "YOUR_CLIENT_KEY"
            // 初期化
            NCMB.initialize(applicationKey: applicationKey, clientKey: clientKey)
            NCMBUser.enableAutomaticUser()
            checkAuth()
            if checkSession() == false {
                checkAuth()
            }
        case .background: break
        case .inactive: break
        default: break
        }
    }
}
```

## ゲームの実装について

![Simulator Screen Shot - iPod touch (7th generation) - 2021-08-07 at 18.56.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/e2fbec99-a717-c53b-a5d4-24dea2521a6d.png)


今回は10秒間タップして、その回数を競うという簡単なアプリになっています。ゲーム部分は主ではないので説明は省きますが、こんな感じです。

```swift
struct ContentView: View {
    // 10秒間のカウントタイマー
    @State private var timer: Timer? = nil
    // タップした回数
    @State private var count = 0
    // ゲームが開始したら true。時間切れになったら false
    @State private var enabled = true
    // 開始したかどうかのフラグ
    @State private var start = false
    // ゲーム秒数
    @State private var limit = 10
    
    // ランキングを表示するかどうかのフラグ
    @State private var showRanking = false
    // ゲームユーザ名
    @State private var userName = ""
    // メッセージ表示用のフラグ
    @State private var showAlert = false
    // メッセージ内容
    @State private var message = ""
    
    var body: some View {
        VStack {
            Text("連打アプリ")
                .padding(10)
                .font(.title)
            Text("\(count)回")
                .padding(10)
            Text(limitText())
                .padding(10)
            HStack {
                Button("連打！", action: add)
                    .disabled(!enabled)
                Button("リセット", action: reset)
                    .disabled(enabled)
            }
            if count > 0 && limit == 0{
                VStack {
                    HStack(alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/, spacing: nil, content: {
                        TextField("名前", text: $userName)
                            .padding(20)
                    })
                    HStack {
                        Button("記録する", action: record)
                        Button("ランキングを見る", action: {
                            showRanking = true
                        })
                    }
                }
            }
        }.sheet(isPresented: $showRanking, content: {
            RankingView()
        })
        .alert(isPresented: $showAlert, content: {
            Alert(title: Text(message))
        })
    }
    
    // リセットボタンをタップした際の処理
    func reset() {
        count = 0
        enabled = true
        start = false
        limit = 10
    }
    
    // 連打ボタンをタップした際の処理
    func add() {
        // 終わっていればすぐ終了
        if !enabled {
            return
        }
        // 開始していなければタイマー開始
        if !start {
            startTimer()
        }
        // タップ回数のカウントアップ
        count = count + 1
    }
    
    // ゲーム開始時に一度だけ実行する
    func startTimer() {
        // フラグを立てる
        start = true
        // ゲームユーザ名を取得しておく
        let user = NCMBUser.currentUser
        if let name:String = user!["displayName"] {
            userName = name
        }
        // 10秒間のカウントダウンタイマー用
        let cal = Calendar(identifier: .japanese)
        let endDate = Calendar.current.date(byAdding:.second,value:limit,to:Date())
        timer = Timer.scheduledTimer(withTimeInterval:0.01, repeats: true){ _ in
            let timeVal = cal.dateComponents([.day,.hour,.minute,.second], from: Date(),to: endDate!)
            limit = timeVal.second!
            // 0になったら終了のフラグを立てて、タイマーを止める
            if limit == 0 {
                enabled = false
                timer?.invalidate()
            }
        }
    }
    
    // 残り何秒表示用の文字列を返す
    func limitText() -> String {
        if limit == 0 {
            return "終了！"
        } else {
            return "残り\(limit)秒"
        }
    }
    
    // ゲームスコアの記録用
    func record() {
    }
    
    // 何位か表示するためにカウントを取る
    func getMyRanking() -> Int {
    }
}
```

## ゲームスコアの記録

ゲームスコアは `record` 関数で保存します。まずゲームユーザ名を確認します。

```swift
if userName == "" {
    message = "ユーザ名を入力してください"
    showAlert = true
    return
}
```

次にランキングデータを作成して、ゲームユーザ名とスコアをセットします。

```swift
// ランキングデータの作成
let ranking = NCMBObject(className: "Ranking")
// ゲームユーザ名とスコアを設定
ranking["displayName"] = userName
ranking["score"] = count
```

不特定多数にデータを改ざんされることがないよう、アクセス権限を設定します。

```swift
// ACL（アクセス権限）を作成・設定
var acl = NCMBACL.empty
let user = NCMBUser.currentUser
// ゲームした本人のみ読み書き可能
acl.put(key: user!.objectId!, readable: true, writable: true)
// 他の人は閲覧のみ可
acl.put(key: "*", readable: true, writable: false)
ranking.acl = acl
```

そして保存します。

```swift
// 保存
let results = ranking.save()
```

## ユーザ名の保存

設定したユーザ名をアプリの再起動後も表示できるように保存しておきます。

```swift
// 名前を保存しておく
user!["displayName"] = userName
_ = user!.save()
```

## ランキングを取得する

保存したら、現在の順位を取得してアラートで表示するようにします。

```swift
switch results {
case .success(_): // 保存が成功した場合
    let num = getMyRanking() // ランキングを取得
    message = "あなたのランキングは\(num)位です！" // アラート用メッセージ
default: // 保存失敗した場合
    message = "ランキングを保存できませんでした" // アラート用メッセージ
}
showAlert = true // アラートを表示するフラグを立てる
```

### getMyRankingについて

getMyRankingはスコアがランキングで何位だったか取得する関数です。まずランキングデータ検索用のクエリオブジェクトを作成します。

```swift
// ランキングデータ検索用のクエリオブジェクトを作成
var query = NCMBQuery.getQuery(className: "Ranking")
```

そして検索条件を指定して実行します。greatherThanで、現在のスコアより大きいデータだけを指定します。countを使えば件数を取得できます。

```swift
// 検索条件を指定
query.where(field: "score", greaterThan: count)
// 検索実行（countで件数が取得できます）
let results = query.count()
```

その結果を取得して、 +1 したものを返します。もし一番良いスコアを取った場合には上位データが存在しないので0件になります。そこで +1 して1位という結果を返します。

```swift
switch results {
case let .success(num): // 検索できれば引数に件数が返ってきます
    return num + 1 // 件数 + 1が順位
case .failure(_):
    return 0
}
```

![Simulator Screen Shot - iPod touch (7th generation) - 2021-08-07 at 18.57.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/d9c4b8bf-288f-a431-941e-32f1c088aa9b.png)


## ランキングの表示

![Simulator Screen Shot - iPod touch (7th generation) - 2021-08-07 at 18.57.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/c639d371-3a8b-89a8-cb6e-0d0ef33eec66.png)


ランキングはRankingViewで表示します。

```swift
// ContentView.swift
}.sheet(isPresented: $showRanking, content: {
    RankingView()
})
```

ランキングの表示自体は、とても簡単なものです。

```swift
struct RankingView: View {
    @State private var rankings: [NCMBObject] = []
    var body: some View {
        VStack {
            Text("ランキング")
                .font(.title)
            if rankings.count > 0 {
                List {
                    ForEach(rankings.indices) { i in
                        Text(listString(index: i))
                    }
                }
            }
        }.onAppear {
            getRanking()
        }
    }
}
```

画面を表示した際に `getRanking` を実行し、ランキングの上位100件のデータを取得しています。先ほどは count を使いましたが、今回はデータ自体が欲しいので find を使っています。並び順は `-score` とすることでスコアの降順（高いスコアが上。徐々に小さくなる）になります。

```swift
// ランキングデータを取得します
func getRanking() {
    // ランキングデータ検索用のクエリオブジェクトを作成
    var query = NCMBQuery.getQuery(className: "Ranking")
    // 並び順はスコアの高い順です
    query.order = ["-score"]
    // 100件のデータを取得します
    query.limit = 100
    // 検索実行
    let results = query.find()
    switch results {
    case let .success(ary):
        // 検索成功したら、それをrankingsに適用します
        rankings = ary
    case .failure(_): break
    }
}
```

そして取得したデータを `listString` で文字列にしています。

```swift
// ランキングの順位、ゲームユーザ名、スコアを返します
func listString(index: Int) -> String {
    let ranking = rankings[index]
    let displayName: String = ranking["displayName"] ?? ""
    let score: Int = ranking["score"] ?? 0
    return "\(index + 1)位 \(displayName)さん (\(score)点)"
}
```

画面表示の際に `rankings.indices` を使っているのは、NCMB自体には順位のデータは存在せず、配列のインデックスからダイナミックに順位を生成しているからです。同じスコアの場合には別途計算がいるので注意してください（@Stateに入れて次の配列要素へ持ち回すのが良さそうです）。

## まとめ

このような形でランキング機能が実装できます。本格的なゲームに限らず、ランキング機能が使える場面は多いと思いますので、ぜひ役立ててください。

コードは[NCMBMania/Swift_Ranking_Demo: Swift SDKを使って実装したランキング機能のデモです](https://github.com/NCMBMania/Swift_Ranking_Demo)にアップしてありますので、参考にしてください。