グーグルプレイSavedGames（＝クラウドセーブ）の実装と落とし穴

#はじめに
GooglePlayには無料で利用できるSaved Gamesという名前のクラウドセーブ機能が準備されています。容量の上限は3MBです。(2018.4.16現在)
日本語情報が少なく、Unityでの実装に苦労したので共有したいと思い記事を書きました。

[developers.google.comのSaved Gamesのページ](https://developers.google.com/games/services/common/concepts/savedgames)
#Unityアプリ側
##動作
- バックアップボタンを押すだけの操作
- =>セーブデータがなければバックアップ
- =>ローカルのプレイよりプレイ時間の短いデータしかクラウドにないならクラウドにセーブ
- =>ローカルのプレイよりプレイ時間の長いデータがクラウドにあるならクラウドからロード

##実装コード
- GooglePlayServicesの導入に関してはここでは触れません
- このスクリプトはゲーム開始時に存在し破壊されないオブジェクトのコンポーネントであることを前提にしています。
- stringをクラウドとやりとりするコードを以下に記します。セーブデータを1つのstringにする処理はここでは触れません（自分はJsonUtility等でやっています）

```CloudManager.cs
using UnityEngine;
using System;
using GooglePlayGames;
using GooglePlayGames.BasicApi;
using GooglePlayGames.BasicApi.SavedGame;
using System.Text;
using UnityEngine.SocialPlatforms;

public class CloudManager : MonoBehaviour {
    public static CloudManager Instance;

    public DateTime openedSavedGame_commitDate;

    bool isSaving;
    static string saveFileName = "SaveFileName_AsYouLike";
    static string saveString = "";

    bool hasInternetConnection { get { return  Application.internetReachability != NetworkReachability.NotReachable; } }

    bool isAuthed { get { return Social.Active.localUser.authenticated; } }


#region lifeCycle

    void Awake() {
        Instance = this;
    }

void Start(){
//ログイン処理例
            PlayGamesClientConfiguration config = new PlayGamesClientConfiguration.Builder()
                .EnableSavedGames()
                .Build();            
            PlayGamesPlatform.InitializeInstance(config);
            PlayGamesPlatform.Activate();
}
#endregion

#region input
//バックアップボタンから呼ぶメソッド
    public void PressCloudSyncButton() {
        OpenSavedGame();
    }

#endregion

    //まずクラウド上のファイルをOPEN
    public void OpenSavedGame() {
        if (!hasInternetConnection) {
            Debug.Log("No Internet Connection");
            return;
        }

        if (!isAuthed) {
            Debug.Log("Not Authed");
            return;
        }
        isSaving = false;
        Debug.Log("Opening savedGame in the cloud");

        ((PlayGamesPlatform)Social.Active).SavedGame.OpenWithAutomaticConflictResolution(
            saveFileName,
            DataSource.ReadCacheOrNetwork,
            ConflictResolutionStrategy.UseLongestPlaytime,
            OnSavedGameOpened
        );
    }

    public void SaveToCloud() {
        if (!hasInternetConnection) {
            Debug.Log("No Internet Connection");
            return;
        }

        if (isAuthed) {
            isSaving = true;
            //first, open the file
            ((PlayGamesPlatform)Social.Active).SavedGame.OpenWithAutomaticConflictResolution(
                saveFileName,
                DataSource.ReadCacheOrNetwork,
                ConflictResolutionStrategy.UseLongestPlaytime,
                OnSavedGameOpened
            );
        } else {
            Debug.Log("Not Authed");
            return;
        }
    }

#region savedGames Callbacks

    ISavedGameMetadata currentOpenedSavedGameMetadata;

    // OPENできたらwrite/readする
    void OnSavedGameOpened(SavedGameRequestStatus status, ISavedGameMetadata gameMetadata) {

        if (status == SavedGameRequestStatus.Success) {

            openedSavedGame_commitDate = gameMetadata.LastModifiedTimestamp;
            currentOpenedSavedGameMetadata = gameMetadata;

            if (isSaving) {
                BuildBackupDate();

                byte[] data = ToBytes(saveString);
                var builder = new SavedGameMetadataUpdate.Builder();
                var updateMetadata = builder
                    .WithUpdatedPlayedTime(GameController.statTotalBattleTime)
                    .WithUpdatedDescription("SAVED AT:" + DateTime.UtcNow.ToLocalTime().ToShortDateString())
                    .Build();


                ((PlayGamesPlatform)Social.Active).SavedGame.CommitUpdate(gameMetadata, updateMetadata, data, OnSavedGameWritten);
            } else {

                SAVEDGAME_STATUS stat = SAVEDGAME_STATUS.Default;

                if (gameMetadata.Description == null || gameMetadata.Description == "") {
                    stat = SAVEDGAME_STATUS.NoData;
                }
                if (gameMetadata.TotalTimePlayed > GameController.statTotalBattleTime) {
                    stat = SAVEDGAME_STATUS.LongerPlaytime;
                } else {
                    if (gameMetadata.TotalTimePlayed == GameController.statTotalBattleTime) {
                        stat = SAVEDGAME_STATUS.Default;      
                    } else {
                        stat = SAVEDGAME_STATUS.ShorterPlaytime;
                    }
                }

                switch (stat) {
                case SAVEDGAME_STATUS.Default:
                    Debug.Log("バックアップデータの更新は不要です");
                    break;
                case SAVEDGAME_STATUS.NoData:
                case SAVEDGAME_STATUS.LongerPlaytime:
                case SAVEDGAME_STATUS.ShorterPlaytime:
                    OpenDialog(stat);
                    break;
                }
            }
        }
    }

    public void LoadSavedGameData() {
        ((PlayGamesPlatform)Social.Active).SavedGame.ReadBinaryData(currentOpenedSavedGameMetadata, OnSavedGameDataRead);
    }

    void OnSavedGameDataRead(SavedGameRequestStatus status, byte[]data) {
        if (status == SavedGameRequestStatus.Success) {
            ProcessCloudData(data);
        } else {
            Debug.LogWarning("Error: Reading game:" + status);
        }
    }

    void OnSavedGameWritten(SavedGameRequestStatus status, ISavedGameMetadata gameMetadata) {
        if (status == SavedGameRequestStatus.Success) {
            GameController.Instance.gameProgress.cloudSaveNum++;
            GameController.Instance.SaveGameProgress();
            Debug.Log("Game " + gameMetadata.Description + " written");
        } else {
            Debug.LogWarning("Error: saving on the cloud " + status);
        }
    }

#endregion

    void ProcessCloudData(byte[]cloudData) {
        DistributeLoadedData(cloudData);
    }

    string FromBytes(byte[] bytes) {
        return Encoding.UTF8.GetString(bytes);
    }

    byte[] ToBytes(string data) {
        return Encoding.UTF8.GetBytes(data);
    }

    enum SAVEDGAME_STATUS {
        Default,
        NoData,
        ShorterPlaytime,
        LongerPlaytime
    }
//ダイアログ表示,YES NOを選択すると対応するメソッドを呼ぶ。具体的な内容にはこの記事では触れない
    void OpenDialog(SAVEDGAME_STATUS stts) {
        Debug.Log("OPEN DIALOG:" + stts);
        switch (stts) {
        default:
            break;
        case SAVEDGAME_STATUS.NoData:
            Debug.Log("バックアップデータがありません、バックアップしますか？");
            break;
        case SAVEDGAME_STATUS.ShorterPlaytime:
            Debug.Log("バックアップしますか？");
            break;
        case SAVEDGAME_STATUS.LongerPlaytime:
            Debug.Log("バックアップしたデータをロードしますか？");
            break;
        }
    }

    void QuitApp() {
        Debug.Log("QuitApp");
        Application.Quit();
    }

    void BuildBackupData() {        
        //バックアップしたいデータをここで代入する
        saveString = "(バックアップしたいデータ)";
    }

//ロードしたデータをゲームに反映する処理
    void DistributeLoadedData(byte[] dataFromCloud) {
        if (dataFromCloud == null) {
            Debug.Log("No data saved on the cloud yet");
            return;
        }
        Debug.Log("Decoding cloud data from bytes.");
        string stringFromCloud = FromBytes(dataFromCloud);
        if (stringFromCloud == "") {
            Debug.Log("No data saved on the cloud yet");
            return;
        }

        //データを反映する
        HogeHoge(SaveString);
//念の為アプリを落とす
        QuitApp();
    }
}
```
-プレイ時間とクラウドにセーブ成功した回数を記録しておくクラスの例

```GameController.cs
public class GameController : MonoBehaviour {
    public static GameController Instance;
    public static double statTotalBattleTime;
    public class GameProgress{
        public int cloudSaveNum;
    }
    public GameProgress gameProgress;
}
```

- 参照したサイト
[GPG Tutorial – Saved Games (Code)]http://resocoder.com/2017/03/10/gpg-tutorial-saved-games-code/

#FireBase側設定
- FireBaseの導入に関してはここでは触れません

##1. GooglePlayConsole=>ゲームサービス=>（アプリ名）=>ゲームの詳細⇒保存済みゲーム（オンを選択）
![2f0155ca-bb71-11e5-9d05-d9257613882f.png](https://qiita-image-store.s3.amazonaws.com/0/91234/1e7a8c9f-f77f-2de1-16d2-fbad27fa1793.png)
##2. APIコンソール プロジェクトでGooglePlayGameServicesとDriveAPIにチェックマークがあることを確認（なければリンクから設定）
![キャプチャ-1.jpg](https://qiita-image-store.s3.amazonaws.com/0/91234/cd5d12d5-3b59-abd9-a76c-39305bbeaf8a.jpeg)
##3. ゲームサービス=>（アプリ名）=>公開=>ゲームに対する変更の公開=>反映を待つ（24時間程度？）
- 自分はここで落とし穴に落ちました。この項目の存在に気づかず、公開しなくてもベータテスターはSavedGamesの機能が利用できるため、うまく出来ていると思いこんでそのままアプリをリリースしてしまいました…。状況を認識するのに1週間以上かかりました。

- 公開前状態ではリリースのAPKからこんなLOGがLOGCATに出てきます

>Exception in com/google/android/gms/games/snapshot/Snapshots.load: java.lang.IllegalStateException: Cannot use snapshots without enabling the 'Saved Game' feature in the Play console.

- NullReferenceなんですよねこの後。口に酸っぱいもの上がってきますよねこんなの。
- 公開から一晩寝かせておくと、上記Errorは綺麗に消えていました。
 
##**「公開」が必須手順なら未公開の変更があるって警告表示出してほしい**
##このUIは罠すぎるので腹立ち紛れに記事を投稿することにしました