🕹️

[React Native] Expo iOS アプリから Game Center を呼び出してみる

に公開

Expo 54 であれこれ試行錯誤してみた結果のメモです。

標準機能ではサポートしていないっぽい

https://docs.expo.dev/build-reference/ios-capabilities/

ダメ元で追加してみますが

app.json
  "ios": {,
    "entitlements": {
      "com.apple.developer.game-center": true
    }
  },
$ eas build --profile development --platform ios
🍏 iOS build failed:
The "Run fastlane" step failed because of an error in the Xcode build process. We automatically detected following errors in your Xcode build logs:
- Provisioning profile "*[expo] xxxx.yyyy.zzzzz AdHoc 0000000000" doesn't support the Game Center capability.  (in target 'test' from project 'test')
- Provisioning profile "*[expo] xxxx.yyyy.zzzzz AdHoc 0000000000" doesn't include the com.apple.developer.game-center entitlement. (in target 'test' from project 'test')
Refer to "Xcode Logs" below for additional, more detailed logs.

やっぱりダメでした。

Apple Developer サイトから直接 Game Center を有効にする

Expo 側からは有効にできないようなので Apple Developer の Identifiers から直接有効にします。

Apple Developer にログイン -> Identifiers -> 対象のアプリの Identifier を選択

Game Center にチェックを入れて Save します。

これで準備OKです。

Expo 側で設定できた方が全てコード上で管理できて便利ではありますが、このくらいの手間であれば問題ないですね。

Expo モジュールを作成

Game Center 関連の実装を追加するため土台のモジュールを追加します。

$ npx create-expo-module@latest --local game-kit-ios

作成すると WebView のサンプル実装がありますが削除します。Android 関連のコードもごっそり削除。

最終的にこのような構成にします。

ファイル階層
modules
  └ game-kit-ios
     └ ios
     │  ├ GameKitIos.podspec ・・・ 変更なし
     │  ├ GameKitIosModule.swift
     │  └ GKDashboardView.swift ・・・ 新規作成
     │
     └ src
     │  ├ GameKitIos.types.ts
     │  ├ GameKitIosModule.ts
     │  └ GKDashboardView.tsx ・・・ 新規作成
     │
     ├ expo-module.config.json
     └ index.ts

expo-module.config.json も編集して iOS のみ対応に変更します。

expo-module.config.json
-  "platforms": ["apple", "android", "web"],
+  "platforms": ["apple"],
-  },
+  }
-  "android": {
-    "modules": ["expo.modules.gamekit.GameKitIosModule"]
-  }

モジュールにネイティブコードを追加

追加するネイティブコードは Xcode での追加・編集は必須ではありませんがコードを編集するたびにアプリの再ビルドが必要になるため動作確認に時間がかかります。そのため最初は iOS プロジェクトを別途作成して iOS アプリとして動作確認ができたら追加したいコードだけを Expo プロジェクトにコピペした方が確実かと思います。

以下ダッシュボードを表示する View を追加します。

GKDashboardView.swift
import ExpoModulesCore
import GameKit

class GKDashboardView: ExpoView, GKGameCenterControllerDelegate {
  var viewController: GKGameCenterViewController?
  var reactViewController: UIViewController? {
    let scenes = UIApplication.shared.connectedScenes
    let windowScenes = scenes.first as? UIWindowScene
    return windowScenes?.keyWindow?.rootViewController
  }
  let onDissmiss = EventDispatcher()

  required init(appContext: AppContext? = nil) {
    super.init(appContext: appContext)
  }

  func loadLeaderboard(id: String) {
    if let viewController {
      return
    }
    viewController = GKGameCenterViewController(leaderboardID: id, playerScope: .global, timeScope: .allTime)
    if let reactViewController, let viewController {
        reactViewController.addChild(viewController)
        addSubview(viewController.view)
        viewController.gameCenterDelegate = self
        viewController.view.frame = self.bounds
        viewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        viewController.didMove(toParent: reactViewController)
    }
  }

  func gameCenterViewControllerDidFinish(_ gameCenterViewController: GKGameCenterViewController) {
    gameCenterViewController.dismiss(animated: true, completion: nil)
    self.onDissmiss()
  }
}

続いて GameKitIosModule にログイン・スコアの登録・表示処理の追加、GKDashboardView の登録を行います。

GameKitIosModule.swift
import ExpoModulesCore
import GameKit

public class GameKitIosModule: Module {
  public func definition() -> ModuleDefinition {
    Name("GameKitIosModule")

    // ログイン処理
    AsyncFunction("authenticate") { (promise: Promise) in
      GKLocalPlayer.local.authenticateHandler = { vc, error in
        if let error {
          promise.reject(error)
        } else {
          promise.resolve()
        }
      }
    }

    // スコア送信
    AsyncFunction("submitScore") { (leaderboardId: String, score: Int, promise: Promise) in
      if !GKLocalPlayer.local.isAuthenticated {
        promise.resolve()
        return
      }
      GKLeaderboard.submitScore(score,
                                context: 0,
                                player: GKLocalPlayer.local,
                                leaderboardIDs: [leaderboardId]) { error in
        if let error {
          promise.reject(error)
        } else {
          promise.resolve()
        }
      }
    }

    // スコア取得
    AsyncFunction("loadScore") { (leaderboardId: String, promise: Promise) in
      if !GKLocalPlayer.local.isAuthenticated {
        promise.resolve(nil)
        return
      }
      GKLeaderboard.loadLeaderboards(IDs: [leaderboardId]) { scores, error in
          if let error {
            promise.reject(error)
            return
          }
          if let scores, let score = scores.first {
              score.loadEntries(for: [GKLocalPlayer.local], timeScope: .allTime) { entry, _, error in
                  if let error {
                    promise.reject(error)
                    return
                  }
                  if let entry {
                      let json = """
                          {
                            "rank": \(entry.rank),
                            "score": \(entry.score),
                            "formattedScore": "\(entry.formattedScore)",
                            "date": "\(entry.date)"
                          }
                          """
                      promise.resolve(json)
                  } else {
                    promise.resolve(nil)
                  }
              }
          } else {
            promise.resolve(nil)
          }
      }
    }

    // ダッシュボード表示
    View(GKDashboardView.self) {
      Prop("leaderboardId") { (view: GKDashboardView, leaderboardId: String) in
        view.loadLeaderboard(id: leaderboardId)
      }
      Events("onDissmiss")
    }
  }
}

モジュール編集・アプリビルド

モジュール作成時に自動生成されたコードを以下のように変更します。

GameKitIos.types.ts
export type OnDismissEventPayload = {
};

export type GameKitIosModuleEvents = {
};

export type GKDashboardViewProps = {
  leaderboardId: string;
  onDissmiss: (event: { nativeEvent: OnDismissEventPayload }) => void;
};
GameKitIosModule.ts
import { NativeModule, requireNativeModule } from 'expo';

import { GameKitIosModuleEvents } from './GameKitIos.types';

declare class GameKitIosModule extends NativeModule<GameKitIosModuleEvents> {
  authenticate(): Promise<void>;
  submitScore(leaderboardId: string, score: number): Promise<void>;
  loadScore(leaderboardId: string): Promise<string>;
}

// This call loads the native module object from the JSI.
export default requireNativeModule<GameKitIosModule>('GameKitIosModule');
GKDashboardView.ts
import { requireNativeView } from 'expo';
import * as React from 'react';

import { GKDashboardViewProps } from './GameKitIos.types';

const NativeView: React.ComponentType<GKDashboardViewProps> =
  requireNativeView('GameKitIosModule', 'GKDashboardView');

export default function GKDashboardView(props: GKDashboardViewProps) {
  return <NativeView {...props} />;
}
index.ts
export { default } from './src/GameKitIosModule';
export { default as GKDashboardView } from './src/GKDashboardView';
export * from  './src/GameKitIos.types';

モジュールの準備はこれで完了なので開発用のアプリをビルドします。

$ eas build --profile development --platform ios

ビルドが完了すればモジュールの準備は完了です。

リーダーボードを作成

App Store Connect にアプリを登録して、Game Center の欄からリーダーボードを作成します。

今回は「score」という名称で作成しました。審査準備完了と表示されますが審査を通さなくてもサンプルコードからアクセスすることができました。

React Native 側からの呼び出し

App.js
import { useState } from 'react';
import { Alert, Button, Modal, SafeAreaView, StyleSheet, View } from 'react-native';
import GameKit, { GKDashboardView } from './modules/game-kit-ios';

export default function App() {
  const [modal, setModal] = useState(false);

  return (
    <View style={styles.container}>
      <Button title='login' onPress={async () => {
        GameKit.authenticate()
      }} />

      <Button title='save score' onPress={async () => {
        GameKit.submitScore('score', 567)
      }} />

      <Button title='load score' onPress={async () => {
        const score = await GameKit.loadScore('score')
        Alert.alert('SCORE', score)
      }} />

      <Button title='ranking' onPress={() => setModal(true)} />

      <Modal
        presentationStyle='fullScreen'
        visible={modal}
        onRequestClose={() => setModal(false)}>
        <SafeAreaView style={{ flex: 1 }} >
          <GKDashboardView
            style={{ flex: 1 }}
            leaderboardId={'score'}
            onDissmiss={() => {
              setModal(false)
            }}
          />
        </SafeAreaView>
      </Modal>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
});

簡単にですが Game Center の機能を呼び出すことができました🎉

Discussion