はじめに
はじめまして。
株式会社アカツキの最近サーバーも書いたりするUnityクライアントエンジニア、@sevenstartearsです。
今年もまた、アドベントカレンダーのネタ選びに悩まされま...せんでした、去年@sachaosと@hareruyanosukeによる
「まだ都度通信で消耗してるの? 〜ソシャゲの通信頻度削減の実験〜」
前編
中編
このシリーズの後編を書きたいと思います。
都度通信を回避するために、Unity&RailsのプロダクトにLuaによるロジック共通化をやってみたというお話です。
クライアント
中編で提示されたMoonSharpを採用
実際、MoonSharp以外のパーサーも検討してましたか、結構凄まじい機能を備えているパーサーもあるようで
require('Unity.Engine')
でそのままC#のMonoBehaviourとほぼ同じことができるようになるとかならないとか。
今回の目的はゲームロジックの共通化ですので、そこまで求めなくてもいいということで、MoonSharpを採用しました。
※使用Unityバージョンは5.4.2です。
Luaスクリプトの実行
中編とほぼ同じ手法を採用しましたが、複数のLuaスクリプトで構成されたプロジェクトを読み込むことになるので
このような感じでまとめてみました。
using System.Linq;
using MoonSharp.Interpreter;
using UnityEngine;
public class LuaLoader
{
private const string LuaPath = "LuaScript";
private static Script _script;
public static Script Script
{
get
{
if (_script != null)
{
return _script;
}
_script = LoadScript();
return _script;
}
}
private static Script LoadScript()
{
if (_script != null) return _script;
var textAssets = Resources.LoadAll<TextAsset>(LuaPath);
var scripts = textAssets.ToDictionary(ta => ta.name, ta => ta.text);
var loader = new MoonSharp.Interpreter.Loaders.UnityAssetsScriptLoader(scripts) {ModulePaths = new[] {"?"}};
return new Script {Options = {ScriptLoader = loader}};
}
}
指定のディレクトリにLuaスクリプトを格納し、そのパスに対して
Resources.LoadAll<TextAsset>()
で読み込む感じになります。
そして指定のluaファイル(例えばsample.lua)を実行する場合
Script.DoFile("sample");
さらにsample.luaにある全域関数を呼び出す場合
Script.Call(Script.Globals["TestFunction"]);
注意
- .luaはUnity5.4ではimportできないので、全部txtに変えないと何もロードしてこない
- lua側のnil値は
Script.Call(Script.Globals["Test"]) == null
ではなく、
Script.Call(Script.Globals["Test"]).IsNil()
でチェックする
LuaとC#のデータ受け渡し
関数呼び出し時のパラメータなどはCallメソッドの引き数としてそのまま渡しますが
キャラデータなど、開始時に必要なデータはJsonでやり取りしています。(この点について、サーバー部分でも少し触れます)
Luaスクリプトの置き場所
Luaスクリプト自体をアプリに内包する形で問題ないですが、今回、あえてアセットバンドルに置いてみました。
メリット
何と言っても、ロジック側の不具合はアセット更新だけで修正可能ということですね。
そして、細かい調整などもアプリ自体のアップデートを介さずに行えます。
最近随分早くなりましたが、やはり林檎社の審査によるラグを気にしなくていいことはなにかと便利ですので。
UnityEngine側のコードもLuaに移行すれば林檎レビュー完全スルーできるのでは
懸念点
中編でも触れたが、やはりセキュリティ面です。
- クライアント側に必要なスクリプトのみ
- 露出しても問題ないロジック
- 乱数が入るロジックは使わない
という縛りでやってみたが、やはり乱数を絡むのはどうしても避けられないので
乱数の処理について、次のパートですこし触れます。
乱数の扱い
ゲームである以上、完全に乱数から逃げるのに難しいことがあります。
乱数を切り捨てることができない以上、取り入れる方法を考えるしかありません。
まず思い付く方法として
乱数生成に使用されるシードをサーバーで選定し、開始時に送信
というのは、本質としては「乱数生成アルゴリズムまで共通化」という話です。
違うアルゴリズムで乱数生成しているなら、シード共通した所で結果は合わない、当たり前の話です。
今回、採用した方法として
- 乱数自体はクライアントで生成する、生成した結果を記録し、最後にサーバーに送信する
- 乱数生成に一定の規約を設け、サーバー側はその規約に沿ってクライアントが生成した乱数の正当性を検証する
これに加えて
露出したくない乱数などは - サーバー側の開始時に抽選し、結果をクライアント側に送る
という感じな構成にしてみました。
懸念点
開始データから「最適解を計算される」というチートは一定許容しています。
ジャッジラインは
「ユーザーがチートしなくても公開情報から導き出せる解」
例えるなら「将棋対戦ソフトでAIに指し方教えてもらう」の類は許容する形になりました。
パフォーマンス
中編
で触れられたように、ロジック部分に限った計算に関しては、数字上はそれなりにパフォーマンスが低下しています。
しかし、本来の描画などを加えて全体で比べると、誤差レベルに落ち付きます。
クライアント側は、数字を追うよりUXなど体感が大切だと考えます。
ロジックの計算パフォーマンス低下は体感ではまったく感じられず、通信回数が減ることによるテンポ感の向上の方が圧倒的だと感じました。
サーバー
前編では「rufus-lua」を採用していましたが
今回は、だた単に
「railsから外部コマンドを実行し、その結果を受け取る」というシンプルな形になります。
Luaコードの実行
lua_path = 'app/lua_sample'
request = # ゲーム開始時必要なパラメータ
code = <<~_LUA_
package.path = package.path .. ';#{lua_path}/?.lua;;';
local test = require('test')
print(json.encode(test.Main('#{request}')))
_LUA_
response =
Tempfile.create() do |tempfile|
tempfile.puts code
tempfile.close
`luajit #{tempfile.path}`
end
Rails側で呼び出す処理をluaのtempfileにしてcreate、それを外部コマンドに渡す形です
パラメータはJSONで渡すようにしてあります。
サーバーとクライアント間APIはJSONでやっているので、
注意点&感想
- 外部コマンド実行ですので、サーバー環境に依存する部分が多い。DockerImageならBaseIamgeで利用可能なLuaライブラリに要注意です。
- エラーハンドリングの仕方はゲームロジックの仕様で臨機応変に対応した方がよさげ
パフォーマンス
言語 | Response Time(ms) |
---|---|
Ruby | 1263 |
Lua | 900 |
インゲームで使用するデータのActiveRecordオブジェクトをロジックにひきまわさなくなったためか、Luaの方のパフォーマンスが若干上がります。
(次パートで少し触れます)
Lua側の工夫
Scene側で必要な演出データについて
サーバーではロジックのユーザーの操作履歴と乱数抽選と初期データさえあれば、最終状態が計算できるので、それ以外の情報は不要ですが
クライアントではそういう訳にはいかないです。
Scene側で演出には途中過程の情報が必要になります。
例えば
「プレイヤーがコマンドαを実行して、EnemyのHPが20減りました」
これだけでは
- コマンドαの攻撃はクリティカルなのか?
- ランダム発動のスキルがもしあれば、発動したのか?
- もしEnemyのHPが減る可能性は他にも存在した場合、プレイヤーのコマンドが何点HP減らしたのか?
etc..
こういった細かいことが分からないと、演出の出しようがありません。
「プレイヤーがコマンドαを実行して、EnemyのHPが20減りました」という事実だけで、過程を逆算するのはほぼロジックをもう一回実装するのと同義なので論外です。
Lua側で、一連の処理の中に必要なタイミングで、ロジックの経過状態に対してスナップショットを取り、グローバル変数に保存する
Scene側はこれを読み取りその通りに演出を出し分ける
という形にして、この問題を対処したが
ロジックが共通しているということは、これらの処理は全てサーバーにとって無用なオーバーヘッドですので
サーバーで動かす時はこれらの処理をしないような形にしないといけません。
インゲームで使用されるデータについて
ロジックがサーバー上しかなかった場合、インゲームに必要なデータはDBアクセスして読み込めばいいわけですが
LuaからDBアクセスさせるのも変な話であり
ロジックで使用されるDBの数テーブルをDockerImageにビルド時に、DBから
objList = {}
objList[1] = {
name = '敵A',
HP = 30,
weapon_id = 1,
}
objList[2] = {
name = '敵B',
HP = 50,
weapon_id = 4,
}
objList[3] = {
name = '敵C',
HP = 80,
weapon_id = 9,
}
return objList
このような形のluaファイルを吐き出して、Luaロジックの下に置きます。
ロジックでは、requireでこれらにアクセスすればいいので、DBアクセスなどしなくて済む。
デメリットはインゲームのパラメータ調整した場合、サーバーのデプロイし直さなければいけませんが、頻度としてはそんなに高くないので、こういった形を取りました。
まとめ
都度通信を減らす試みとして
サーバーとクライアントで共通のロジックをLuaで持つことをやってみました。
- クライアントはMoonsharpを使い、Luaファイルはアセットバンドルに置き、データのやり取りはJSON
- サーバーは外部コマンド実行でLuaを実行しました。
- クライアントのみ、ロジック実行時、途中経過の演出の都合に応じてスナップショットを取るようにしました。
- DBにアクセスする代わりに、ロジックで使用される数テーブルのデータをデプロイ時にLuaスクリプトとして書き出した。
最後に
セキュリティ面などの懸念点は残りましたが
工数面では一回やっちゃえばむしろ追加開発が楽かも?という感想でした。
サーバーは外部コマンドではなく、マイクロサービス的なサムシングにするとか
クライアントに関してはガッツリパフォーマンス面の工夫をして、Scene側のコードもLuaに切り替えればいろいろ夢が膨らみそうなので
もしかしたら、このシリーズ来年誰かが番外編書いてくれるかもしれません。