post Image
UnityのセーブデータをGoogleAppEngine/Go(with Datastore)に保存したり読み込んだりする話

Unityのセーブデータとタイトルには書いてありますが、実際はいろいろ保存できます。
参考:http://puyooboe.blogspot.jp/2015/12/blog-post.html

Unityでセーブデータっていうと基本的にPlayerPrefがあると思いますが、ローカルに保存するよりはデータがオンラインで見えてたらいろいろ情報がわかって嬉しいことってきっとあります。
(例えばシナリオ番号などをDatastoreで保存すれば、ユーザが全体的にどんなところで詰まっているのか、ログイン時間、セーブ時間を保存するようにすれば見ればログイン率はどうかとか見れそうだなという感じです)

自前でサーバ作っても良いと思いますけど、サーバ作ったりチューニングしたりデータベース作ったり…しかも運用まであるとするとなかなか手が出るものではないかなと思ってます。
GAEはインフラ知識があまりなくてもサーバサイドを構築できてしまうのでそういう意味でもとっつきやすさはあると思いますね。


これから書く内容を要約すると大きく分けて2つ:
サーバサイド(GoogleAppEngine/Go with Datastore)
 ・Datastoreに格納するためのRESTAPIサーバを作る。
 ・GoogleAppEngineにデプロイする。

クライアント側
 データを書き込みたい時:
  ・Unityで保存したいデータ(今回はUserIDと位置データのみ)をKeyValuePairでまとめる
  ・WWWFormを用いて前述のAPIサーバにPOSTする
 データを読み込みたい時:
  ・保存したいデータを読み込みたいときはDatastoreにKeyを渡してデータを引っ張ってくる。

という話をします。

注意:
セキュリティ関係は本質ではないので記事では省きますが、実際こういうことをするとしたら対策はほぼ必須でしょう。

これからやること

こんな感じ

SaveButtonを押すとDatastoreにそのキャラクターの位置情報が書き込まれます。
LoadButtonを押すとキャラクターの位置情報をDatastoreから拾ってキャラクターの位置を修正します。

サーバサイドを構築する

いつものようにGo+ginで書きます。

app.yaml
    application: hogehoge(ここはProjectIDを書いて)
    version: 1
    runtime: go
    api_version: go1
    handlers:
    - url: /.*
      script: _go_app
    builtins:
    - remote_api: on

↑app.yamlは https://console.cloud.google.com/iam-admin/projects で作ったプロジェクトに対してデプロイするときに必要になる。

server.go
package unitygae

import (
    "ds4unity"
    "log"
    "net/http"

    "github.com/gin-gonic/gin"
)

func init() {
    server := gin.Default()
    server.POST("/save", SaveToDS)
    server.GET("/load/:id", LoadFromDS)
    http.Handle("/", server)
}

func SaveToDS(g *gin.Context) {
    httpRequest := g.Request
    httpRequest.ParseForm()
    ids := httpRequest.Form["id"][0] //UnityからPOSTされたname=idのValueを拾う
    pos := httpRequest.Form["pos"][0] //UnityからPOSTされたname=idのValueを拾う

    if err := DataStoreManager.PutSaveData(ids, pos, g); err != nil {
        log.Fatalf("Error Occured to Put SaveData: %v", err)
    }
    //g.Stringで文字列を結果に出す必要はないが、表示させることでUnityでのデバッグがしやすい。
    g.String(200, "Success")
}

func LoadFromDS(g *gin.Context) {
    ids := g.Param("id")
    var dat *DataStoreManager.SaveData
    var err error
    if dat, err = DataStoreManager.GetSaveData(ids, g); err != nil {
        log.Println("Error Occured to Load from ds :  %v", err)
        return
    }
    //データストアに格納していたデータを文字列で返す(PositionのVector3データだけ)
    g.String(200, dat.PositionData)
}

↑server.goとappyamlは$GOPATH以下に置かない。$GOPATH以下に置くとデプロイできない。
↑importの”ds4unity”は自作のライブラリ群です。以下に示します。

$GOPATH/src/ds4unity/DataStoreManager.go
package DataStoreManager

import (
    "fmt"
    "log"
    "time"

    "github.com/gin-gonic/gin"
    "golang.org/x/net/context"
    "google.golang.org/appengine"
    "google.golang.org/appengine/datastore"
)


func PutSaveData(userID string, positionData string, g *gin.Context) error {
    c := appengine.NewContext(g.Request)
    saveTime := time.Now()
    if userID == "" {
        userID, _ = AllocateID(c, "SaveData")
    }

    saveKey := datastore.NewKey(c, "SaveData", userID, 0, nil)
    saveData := SaveData{UserID: userID, PositionData: positionData, SaveTime: saveTime}
    if _, err := datastore.Put(c, saveKey, &saveData); err != nil {
        log.Fatalf("Error Occured when datastore.put: %v", err)
        return err
    }
    log.Println("Data Put Success!")
    return nil
}


func GetSaveData(userID string, g *gin.Context) (*SaveData, error) {
    c := appengine.NewContext(g.Request)

    saveKey := datastore.NewKey(c, "SaveData", userID, 0, nil)
    saveData := SaveData{}
    if err := datastore.Get(c, saveKey, &saveData); err != nil {
        return nil, err
    }
    return &saveData, nil
}

↑セーブデータをDatastoreに書き込むPutSaveDataメソッド(userIDをKeyとしたセーブデータEntityを作ってdatastoreにPutしている)
↑セーブデータをDatastoreから読み込むGetSaveDataメソッド(userIDをKeyとしてPutしたのでセーブデータEntityをそのKeyからGetしてきている)

$GOPATH/src/ds4unity/Kind.go
package DataStoreManager

import (
    "time"
)

type (
    //jsonタグ、bindingタグは別にいらなくて、将来の自分用に残しているだけです。
    SaveData struct {
        UserID string `datastore:"-" json:"id" binding:"required"`
        PositionData string `datastore:"PositionData" json:"pos" binding:"required"` 
        SaveTime time.Time `datastore:"SaveTime"`

    }
)

↑セーブデータの構造体。UserIDをKeyとして位置情報とセーブ時間を格納している。

これらをつくって
server.goとapp.yamlが入っているフォルダに

ターミナル上.bash
$cd [server.goとapp.yamlが入っているフォルダ]
$appcfg.py -A [GAEに作ったプロジェクトのID] -V v1 update --no_cookies ./

でデプロイしましょう。これでRESTAPIサーバがGAE上に構築されました。
http://[プロジェクトID].appspot.com/saveにPOSTするとセーブデータがdatastoreに書き込まれて
http://[プロジェクトID].appspot.com/load/15とGETリクエストを送るとuserIDが15の人のセーブデータが文字列で帰ってきます!

クライアント側(以下全部Unityの話)

Unityは要所だけ見せる感じで

データを書き込みたい時:Save

Saveボタンをおした時の処理を書く

SaveButton.cs
    public void Save(){
        var vec3str = CharacterManager.Instance.transform.position.ToString("G4");
        //Unityで保存したいデータ(今回はUserIDと位置データのみ)をKeyValuePairでまとめる
        //"id"にはID変数を、posには(-4.843, 3.883, -12.97)のような変数をString化したものをひも付けている
        var dic = dic = new Dictionary<string, string> () {
            {"id", NetworkManager.Instance.testID.ToString()},
            {"pos", vec3str}
        };

        //Datastoreに書き込む処理を呼び出す。
        NetworkManager.Instance.SaveToDS(dic);
    }

RESTAPIサーバにPOSTを行う処理を書く

NetworkManager.cs
    public string testID = "25";//testなのでハードコーディングしてしまう。
    public string GETURL{get;set;}
    public string POSTURL{get;set;}
    // Use this for initialization
    void Start () {
        GETURL = "http://hogehoge-1111.appspot.com/load/";
        POSTURL = "http://hogehoge-1111.appspot.com/save";
    }

public void SaveToDS(Dictionary<string,string> post){

        WWWForm form = new WWWForm();
        foreach(KeyValuePair<string,string> postReq in post) {
           form.AddField(postReq.Key, postReq.Value);
        }
        WWW www = new WWW(POSTURL, form);
        //↓でPOSTを送信している
        StartCoroutine(WaitForRequest(www));
    }

    private IEnumerator WaitForRequest(WWW www) {
        yield return www;

        // check for errors
        if (www.error == null) {
            Debug.Log("WWW Ok!: " + www.text);
        } else {
            Debug.Log("WWW Error: "+ www.error);
        }
    }

↑でPOSTができる。
SaveToDSは、POSTのフォームにKeyとValueをくっつけて行く作業をした後それを特定のURLへ投げている。
その結果はコルーチンで吐き出されるLogから分かる。

データを読み込みたい時

Loadボタンを押した時の処理を書く

LoadButton.cs
    //UserIDが25の最後にセーブした場所を取りに行くという処理をしたい。
    public void Load(){
        NetworkManager.Instance.LoadFromDS("25");
    }
Networkmanager.cs
public void LoadFromDS(string userID){
        Debug.Log("Loading");
        StartCoroutine(GetData(userID));
    }

    private IEnumerator GetData(string userID){
        var tmp = GETURL + userID;//http://[プロジェクトID].appspot.com/load/25というURLができる
        WWW www = new WWW(tmp);
        yield return www;
        if (www.error == null) {
            Debug.Log(www.text);
            var str = www.text;//これが引っ張ってきたデータ "(-4.843, 3.883, -12.97)"のような値がかえってくる
            Character.transform.position = makeVec3FromStr(str);//文字列を再びVector3化する
        }

    }
    //文字列化したVector3を再びVector3化して返す。Linqをちゃんと使うともっと綺麗なコードになりそう。
    private Vector3 makeVec3FromStr(string vec3str){
        var removeChars = new char[] { '(', ')' };
        foreach (var c in removeChars)
        {
            vec3str = vec3str.Replace(c.ToString(),"");
        }
        var xyz = vec3str.Split(',');
        var x = float.Parse(xyz[0]);
        var y = float.Parse(xyz[1]);
        var z = float.Parse(xyz[2]);
        return new Vector3(x,y,z);
    }

↑Coroutineは同期処理ではないのでreturn hogehogeというコードでの返り値はとれないのだが、UniRxを使うと返り値が取れるのでもしメソッド化したいときはUniRxを使うと良いですね。


以上によって
セーブボタンを押した時はキャラクターの位置をDatastoreに書き込む
ロードボタンを押した時はキャラクターの位置をDatastoreの値から修正する。
という動作を達成できます。

Datastoreではデータはこんな風に見えます。(実際のスクショ)
スクリーンショット 2016-06-28 12.25.37.png

ほんとはJson(LitJsonやJsonUtilityなど)を扱いたかったのですが、簡易な形でということで最初はGETPOSTでやってみました。
動画を見ればわかると思いますが、SaveもLoadもちょっと遅いのでリアルタイムなものでは使いづらいのですが、ボタン一発ではなくて、ちゃんとセーブUIとか作ってそれなりにセーブに時間を取るようにすれば読み書きに関しては安定して使えると思います。
来年になるとGoogleCloudPlatformのTokyoリージョンができるらしいのでもっと早くなるかもしれない…

自前でサーバを用意することなく、そしてそのメンテナンスも難しいことはGoogleの技術者に任せつつ、規模が大きなっても勝手にスケーリングしてくれるGAEは結構有能ですしdatastoreというデータベースもカジュアルに使えるということで、選択肢の1つとして考えても悪く無いと思います。
(自分はこの方法を使っていろいろ作っていきたいと思ってます。)

終わり!


『 Unity 』Article List