UnityのWebGL出力に簡単に無料でグローバルランキングを実装できる仕組みを考えてみた

0.プロローグ

UnityRoomさんの「1週間ゲームジャム(https://unityroom.com/unity1weeks/1 )」が大盛況だったのは記憶に新しいですね。既に次回の開催も決まっているようです。
そんな折、UnityRoomの管理者であるnaichiさんのこんなTweetが

確かに。
WebGL出力はまともな対応しているブラウザさえあればどんな環境でも動くし素晴らしい・・・けど、僕が知る限りの大手mBaaSはWebGL対応しておらず、簡単なランキング処理を実装するのもちょっと技術ハードルや資金ハードルが高かったりが現状です。(MySQLとPHPが使える無料だったり、月300円くらいのレンタルサーバーでもいいんだけど・・・いいんだけど・・・んーーーー。という感じ)

なんで各mBaaSはWebGL対応してないの?というと、socket通信絡みの技術的問題な側面もあれば、「WebGL版のプロダクトがそのまま育っても将来の顧客にならない」というビジネス的な側面もあるのかな。と(想像です)

ないのなら・作ってしまおう・ホトトギス

ということで、まだ試作に近いのですが、作ってみました。
コンセプトは

  • 簡単!
  • 無料!
  • 遅!(くても気にしない)

です。

1.下準備

  • 必要なもの
    • Googleのアカウント

・GoogleSpreadSheetのページへ行く https://www.google.com/intl/ja_jp/sheets/about/

・GoogleSpreadSheetを使う をクリック
image

・右下の+をクリックでシートを追加
image.png

・シートの画面が開くので、「ツール」→「スクリプトエディタ」
image

・function myFunction() {} は消して、以下のコードをペタっとな (最新はこちら→https://github.com/divide-by-zero/GSSA/blob/master/projects/Assets/GSSA/GoogleAppsScript/GSSA.gs)

var Const = {
  Method : "$mt$",
  SheetName : "$sn$",
  Select: "$sl$",
  Distinct: "$di$",
  Where : "$wh$",
  ObjectId : "$oi$",
  Target : "$tg$",
  Value : "$vl$",
  Compare : "$cp$",
  OrderBy : "$ob$",
  Limit : "$li$",
  Skip : "$sk$",
  IsDesc : "$id$",
};

function doPost(e) {
  var res = e.parameter;

  var sheetName = res[Const.SheetName];
  delete res[Const.SheetName];

  var ss = SpreadsheetApp.getActiveSpreadsheet();
  if (ss.getSheetByName(sheetName) == null) {
    ss.insertSheet(sheetName,0);
  } 
  var sheet = ss.getSheetByName(sheetName);  

  var func = res[Const.Method];
  delete res[Const.Method];

  switch(func.toUpperCase()){
    case "SAVE":
      var lock = LockService.getScriptLock();
      var retData = {};
      try {
        lock.waitLock(1000); // 1秒のロック開放待ち
        retData = SaveFunc(sheet,res);
      } catch (e) {
        retData[Const.ObjectId] = -1;
      } finally{
        lock.releaseLock();
        var retJson = JSON.stringify(retData,null,2);
        return ContentService.createTextOutput(retJson).setMimeType(ContentService.MimeType.JSON);
      }
      break;
    case "FIND":
      var data = FindFunc(sheet,res);
      var retJson = JSON.stringify(data,null,2);
      return ContentService.createTextOutput(retJson).setMimeType(ContentService.MimeType.JSON);
      break;
    case "COUNT":
      var data = FindFunc(sheet,res);
      var retJson = JSON.stringify({Count:data.values.length},null,2);
      return ContentService.createTextOutput(retJson).setMimeType(ContentService.MimeType.JSON);
      break;
  }
}

function SaveFunc(sheet,res){
  var postObjectId = res[Const.ObjectId];
  delete res[Const.ObjectId];

  //現在時間を入れておく
  res["createTime"] = new Date().getTime().toString();

  var range = sheet.getDataRange();
  var sheetData = range.getValues();
  var headers = sheetData.splice(0, 1)[0];
  if(range.isBlank()){
    sheetData = [];
    headers = [];
  }
  var insertData = [];
  for(var d in res){
    var index = headers.indexOf(d);
    if(index >= 0){
      insertData[index] = res[d];
    }else{
       index = headers.push(d);
       insertData[index-1] = res[d];
    }
  }

  //データ部分の更新 postObjectIdは行番号と同意。 sheetData.pushした値は0オリジンでsheet上の位置にするため+1する必要あり。つらい
  var oid;
  if(postObjectId > 0){
    oid = postObjectId;
  }else{
    oid = sheetData.push(insertData) + 1;  
  }
  sheet.getRange(oid,1,1,insertData.length).setValues([insertData]);
  //ヘッダー部分の更新
  sheet.getRange(1,1,1,headers.length).setValues([headers]);

  var retCode = {};
  retCode[Const.ObjectId] = oid;
  return retCode;
}

function FindFunc(sheet,res){
  //予約語を先に取っておく
  var orderBy = res[Const.OrderBy];
  var isDesc = res[Const.IsDesc];
  var skip = res[Const.Skip];
  var limit = res[Const.Limit];
  var where = res[Const.Where];
  var select = res[Const.Select];
  var distinct = res[Const.Distinct];

  var range = sheet.getDataRange();
  var sheetData = range.getValues();
  var headers = sheetData.splice(0,1)[0];
  if(range.isBlank()){
    return ContentService.createTextOutput(JSON.stringify({keys:[]})).setMimeType(ContentService.MimeType.JSON);
  }

  var retData = sheetData.map(function(row,rindex){
    var data = {value:row};
    data[Const.ObjectId] = rindex+2;//TODO Headerが無いので2を足してあげる
    return data;
  });

  if(where){
    var wheres = JSON.parse(where);
    retData = retData.filter(function(row,rindex){
      for(var wi in wheres){
        var w = wheres[wi];
        var key = w[Const.Target];
        var value = w[Const.Value];
        var index = headers.indexOf(key);
        if(index < 0)return false;
        //目的に合わないデータは弾く

        var compare = w[Const.Compare];
        switch(compare){
          case "EQ":
            if(!(row.value[index] == value))return false;
            break;
          case "NE":
            if(!(row.value[index] != value))return false;
            break;
          case "LT":
            if(!(row.value[index] < value))return false;
            break;
          case "GT":
            if(!(row.value[index] > value))return false;
            break;
          case "LE":
            if(!(row.value[index] <= value))return false;
            break;
          case "GE":
            if(!(row.value[index] >= value))return false;
            break;
        }
      }
      //↑ではじかれなかったデータが求めている抽出データ
      return true;
    });
  }

  if(orderBy){
    var index = headers.indexOf(orderBy);
    if(index >= 0){
      retData = retData.sort(function(a,b){
        if(a.value[index] > b.value[index]) return 1 * isDesc;
        if(a.value[index] < b.value[index]) return -1 * isDesc;
        return 0;
      });
    }
  }

  //重複を抜く
  if(distinct){
    var index = headers.indexOf(distinct);
    if(index >= 0){
      retData = retData.filter(function (x, i, self) {
        var distinctTarget = x.value[index];       
        for(var findIndex = 0;findIndex < i;findIndex++){
          if(self[findIndex].value[index] == distinctTarget)return false;
        }
        return true;
      });
    }
  }

  if(skip)retData = retData.slice(skip);
  if(limit)retData = retData.slice(0,limit);

  //返却データを全データではなく、selectで指定されている項目だけにしてあげる
  if(select)
  {
    var selects = JSON.parse(select);

    for(var headerIndex = 0;headerIndex < headers.length;){
      var key = headers[headerIndex];
      var findIndex = selects.indexOf(key);
      if(findIndex < 0){
        for(var d in retData)
        {
          retData[d].value.splice(headerIndex,1);
        }
        headers.splice(headerIndex, 1); // indexのところを削除
        continue;
      }
      headerIndex++;
    }   
  }

  return {values:retData,keys:headers}; 
}

・ 「公開」→「Webアプリケーションとして導入...」 → プロジェクト名の編集 (好きな名前を。そのままでも良いです)
image

・プロジェクトバージョンは「新規作成」 アプリケーションにアクセスできるユーザーは「全員(匿名ユーザーを含む)」にして「導入」
image

・「許可が必要です」のダイアログが表示されるので、「許可を確認」→ 自分のGoogleアカウントを選んで「許可」
image


2017/08/16追記

どうも、Googleさんの挙動がちょっと変更になったらしく、今現在この手順ではエラーが出るようになっているようです。

詳しくはこちら
GAS で「一部のスコープへのアクセス権限がありません」と怒られたときの対処法

を参照していただけるとよいかと思います(非常に助かりました。 @zk_phi 氏に足を向けて寝られない・・・)

が、一応こちらにも修正を。

・左下の言語選択から「English(UnitedStates)」を選択
image.png

・自分のGoogleアカウントを選択
image.png

・This app isn't verified と出るので、左下の「Advanced」を選択
image.png

・Go to "自分のプロジェクト名" (unsafe) を選択
image.png

・最終確認ぽい画面になるので「Arrow」を選択
image.png

以上です。やってくれるぜgoogleさん(ただ乗りしている身なので何も言えませんが)
2017/08/16追記終了。 


・「現在のウェブアプリケーションのURL」 をコピーして取っておいてください。
image

GoogleSpreadSheet上の作業は以上です。 簡単に。というわりに手順が多めですが最初の1回だけですし、あまり迷うところがないのでそんな苦じゃないかと。

2.Unity側準備

github(https://github.com/divide-by-zero/GSSA) のReleaseフォルダから GSSA.unitypackage をダウンロード(直リンク:https://github.com/divide-by-zero/GSSA/raw/master/Release/GSSA.unitypackage) し、
使いたいプロジェクトにインポートしてください。
(メニューのAssets→Import Package→Custom PackageからGSSA.unitypackageを選択してImportするか、プロジェクトを起動した状態でGSSA.unitypackageダブルクリックでも大丈夫(なはず))
image

1番最初のシーンにGSSA/Prefabs/SpreadSheetSetting prefabを配置
image
※空のGameObjectを配置して、GSSA/Scripts/SpreadSheetSettingsを自分でアタッチしても良いです

inspectorのurlの枠に↑でコピーしておいた「現在のウェブアプリケーションのURL」をセット
image

Is Debug Log Output はチェックを入れると微妙に通信のログが出力されます。デバッグ時にどうぞ。
Default Sheet Name はとりあえず入れなくても良いです(後述)


2017年11月19日 追記!

どうやら、この「現在のウェブアプリケーションのURL」で取得できるURLが間違っている事があるようです。

script.google.com/macros/u/0/s/AKfy~~/exec

こんな感じのURLになっている場合

script.google.com/macros /u/0 /s/AKfy~~/exec

が邪魔らしく、/u/0 を取り除いた

script.google.com/macros/s/AKfy~~/exec

だと正しくアクセス出来るらしいです。(naichiさん情報ありがとうございます!)

ちなみに
マニフェストから配置
image.png
の中の
image.png
のURLには /u/0 は入っていなかったします。なんなんでしょうね。(こっちをコピーするって手もありますね)

2017年11月19日 追記終了

これで準備完了です! さぁ使いましょう。

3.データの保存

NiftyのmBasSに似た感じになってます(ちょっと参考にしつつ、不要だと思ったところをそぎ落としたので似ているだけです)

例えば、空のGameObjectに適当なScriptをアタッチして、Startにでもこんな感じで書きます

using UnityEngine;

using GSSA;
public class GSSATest : MonoBehaviour {
    void Start ()
    {
        var so = new SpreadSheetObject("Chat");
        so["name"] = "かつーき";
        so["message"] = "たべないでください!";
        so.SaveAsync();
    }
}

これで実行をすると、先ほど作ったスプレッドシートに「Chat」というタブが追加され、1行目にnameとmessageとcreateTime(これだけは勝手に作られます)、2行目にそれぞれプログラムで指定した文字列と作成日時の数字が記入されているはずです。
image.png

もうお分かりですね。 GoogleSpreadSheetを使って簡易なKey-Value-Storeを用意しました。
Google Apps ScriptにはdoPostかdoGetというメソッドを用意することで、Webサービス風にふるまう事が出来るので、それをインタフェースにSpreadSheetを操作しているだけです。

4.データの取得

なお、これではSpreadSheetにデータを追加しているだけなので、取得もやってみます。

using System.Linq;
using UnityEngine;

using GSSA;
public class GSSATest : MonoBehaviour {
    void Start()
    {
        var query = new SpreadSheetQuery("Chat");
        query.Where("name", "=", "かつーき");
        query.FindAsync(list => {
            foreach (var so in list)
            {
                Debug.Log(so["name"] + ">" + so["message"]);
            }
        });
    }
}

image.png

これまたNiftyのmBasSよろしく、Query発行のためにSpreadSheetQueryクラスをnewしています。
なお、コンストラクタに渡しているのはシート名なんですが、「2.Unity側準備」でセットアップしたSpreadSheetSettingsのDefault Sheet Nameに基本的に使用するシート名を記述しておけば省略(nullを指定)した場合そちらが使われます(SpreadSheetObjectも同じです。 無事伏線回収)

また、Whereメソッドで絞り込みができます。 AND条件のみ対応しており、その場合にはAndWhereメソッドを使ってください。それぞれのメソッドが自分自身を返すのでメソッドチェーンで書く事も出来ます。

<例>

query.Where("name","=","かつーき").AndWhere("message","!=","もがもが").FindAsync(list=>{});

(なおOR条件はややこしくなるので省きました。要望があれば追加する・・?かも・・・?)

ソースがくっついているので、詳しくはソース見てね!って感じもしますが、一応補足。
Whereの第一引数はKeyになる項目。 第二引数は比較式です。=,==,<,<=,>,>=,!=,<> あたりだけ対応しています。

他には

  • Limit(int count) … 返却されるリストの先頭から指定した数を上限として取得
  • Skip(int count) … 返却されるリストの先頭から指定した数を飛ばして取得
  • OrderByAscending(string key) … 返却されるリストを指定したkeyで昇順にソート
  • OrderByDescending(string key) … 返却されるリストを指定したkeyで降順にソート

があります。

FindAsyncの引数にはcallbackでAction<List<SpreadSheetObject>>が書けるようになっており、戻ってきたListに対しての処理をラムダ式で記述する感じです。

そして、何気にFindAsync(SaveAsyncも)はTaskのasync-awaitのように、コルーチンの中であればyield return で待機可能になっています。
具体的には↓な感じ

using System.Collections;
using System.Linq;
using UnityEngine;

using GSSA;
public class GSSATest : MonoBehaviour {
    void Start ()
    {
        StartCoroutine(ChatLogGetIterator());
    }

    private IEnumerator ChatLogGetIterator()
    {
        var query = new SpreadSheetQuery("Chat");
        query.Where("name", "=", "かつーき");
        yield return query.FindAsync();

        foreach (var so in query.Result)
        {
            Debug.Log(so["name"] + ">" + so["message"]); 
        }
    }
}

この場合はQueryオブジェクトのResultList<SpreadSheetObject>が格納されます。(こっちの方が使い良いかも?)
なお、Listの件数はQueryオブジェクトのCountに格納されます。
使っても使わなくてもなんですが、SpreadSheetQueryにはCountAsyncメソッドも用意してあり、こっちは件数だけを返却してくれるので、自分のスコアが何位なのか、などをそれなり高速に調べるのに使えます。

5.データの更新

データの更新はどうするのか。 というと、SpreadSheetQueryで取得できたSpreadSheetObjectに対して値をセットして、SaveAsyncを呼ぶだけです。

using System.Collections;
using System.Linq;
using UnityEngine;
using GSSA;

public class GSSATest : MonoBehaviour
{
    void Start()
    {
        StartCoroutine(ChatLogGetIterator());
    }

    private IEnumerator ChatLogGetIterator()
    {
        var query = new SpreadSheetQuery("Chat");
        query.Where("name", "=", "かつーき");
        yield return query.FindAsync();

        var so = query.Result.FirstOrDefault();
        if (so != null)
        {
            so["message"] = "たべないよ!";
            yield return so.SaveAsync();
        }
    }
}

SpreadSheetを見るとちゃんと更新されています。
image.png

内部的にはobjectIdというid(というか、SpreadSheetでの行番号)を持っていてそれの有り無しで新規か更新か分岐している感じです。

最後に

本来ならまだ世に出せるレベルじゃないですが次の1week game jamが近かったので・・・。
見る人が見れば突っ込みどころ満載だと思うので、要望やプルリクをくれると非常にうれしいです。
特にGoogleSpreadSheet側... jsド素人なのにやりたいことだけ調べつつ無理やり実装したので辛みがにじみ出ています。
誰かにきれいに書き直してほしいな・・・。

あとSpreadSheetの排他処理(ロック処理)なんかをもっとちゃんとやらないとロック範囲が広すぎて速度的に実用に耐えない可能性は十分あります。(まぁ、あくまでも仮設のWebGL版だけの簡易ランキングシステム用。 とか割り切ればそこそこ良い選択肢なんじゃないかなーと思って。)

え?ユーザー管理?
ユーザー管理ねぇ…。 やっぱり要ります…?

ユーザー管理ではないですが、近いものでSpreadSheetSettings.Instance.UniqueIDプロパティがあります。(まぁ、guidを保存してるだけですけど。)
PC毎(ブラウザ毎かな?)に限りなく一意に近い値を保持しているので

uniqueidを指定してFindAsync

SpreadSheetObjectの戻りがあるようなら、既にデータが登録されているので、そのSpreadSheetObjectを保持、更新などに使用

戻りが無いようなら、新規ユーザーとみなす。

とかやれば、ユーザー管理っぽい感じになります。
具体的には以下な感じで。

    private IEnumerator ScoreSendIterator()
    {
        scoreSendButotn.interactable = false;

        //すでにスコアが登録されているかチェック
        var hiScoreCheck = new SpreadSheetQuery();
        yield return hiScoreCheck.Where("id", "=", SpreadSheetSetting.Instance.UniqueID).FindAsync();

        //既にハイスコアは登録されている
        if (hiScoreCheck.Count > 0)
        {
            var so = hiScoreCheck.Result.First();
            so["hiscore"] = score;
            yield return so.SaveAsync();//こちらは更新処理
        }
        else //登録されていないので、新規登録
        {
            var so = new SpreadSheetObject();
            so["id"] = SpreadSheetSetting.Instance.UniqueID;
            so["hiscore"] = score;
            yield return so.SaveAsync();//こちらは新規登録処理
        }
    }

と、まぁ、こんな感じですが。
ちゃんとランキングを扱うサンプルプロジェクトを準備中なので、近日中出来次第また解説を書きたいと思います(そんなんばっか)

追記

チャットを実装のサンプル:UnityのWebGL出力に簡単に無料でKVSを使うサンプル1(Chat編)
ハイスコアランキング実装のサンプル:UnityのWebGL出力に簡単に無料でKVSを使うサンプル2(ハイスコアランキング編)

を用意しましたので、興味がある方はこちらもどうぞ。