まだ都度通信で消耗してるの? 〜ソシャゲの通信頻度削減の実験〜 (中編)
この記事は Akatsuki Advent Calendar 2016 の22日目の記事です。
2017年12/16追記
1年越しのアドベントカレンダーで後編が作成されました。
はじめに
株式会社アカツキでクライアントエンジニア(Unityでゲーム開発)をやっております。新卒Lv0エンジニアの@hareruyanosukeです。
ちなみに僕もまだ消耗しています。
おさらい(どういう話?)
この記事はまだ都度通信で消耗してるの? 〜ソシャゲの通信頻度削減の実験〜 (前編)の続編になります。
@sachaos による前編の簡単なおさらいをすると、
・ ソーシャルゲームではサーバー・クライアントの通信が多くなりがちなのは悩みの種(UXが落ちたり、サーバー負荷が上がる)
・ 通信を減らすためにクライアントにもサーバーと同様のゲームロジックをクライアントにもたせよう
・ Luaを用いて実験してみる!
という流れでした。
続編の今回ではUnityにおけるLuaスクリプトの処理方法や、速度はどうなるのかといった実験を行っていきます。
クライアントの実装
パーサーの選択
さて、続編なのでサクサク話を進めます。
まず、UnityからLuaスクリプトを呼び出す方法としては、
・ 既存のLuaパーサーを用いる
・ 自分でがりがり実装する
の2つの方法が考えられます。
がりがり実装するのも楽しそうなのですが、今回はあくまで実験が主眼なので既存のものを利用させて頂こうと思います。
既存のライブラリについては、
・ unityでuniluaを使ってADV機能を実装する
・ MoonSharpの紹介 ~ UnityでLuaを使ったイベントスクリプトを書きたい
・ 【Unity】UnityでLuaを使用できるようにする「slua」を触ってみる
などで紹介されているように、かなり種類があるのですが、今回はMoonSharpを使ってみようと思います。
MoonSharpの導入
MoonSharpはAssetStoreで無料で公開されていますので、いつも通り?Unityを立ち上げてインポートを行います。
(https://www.assetstore.unity3d.com/en/#!/content/33776)
実装するサンプルゲームの説明(再掲)
今回、実験のために頑張って考えたサンプルゲームの概略です。
・ 行動 : 3×3マスのマップをプレイヤーが上下左右ずつに1マスづつ移動する。
・ 結果 : 特定のマスにプレイヤーが移動することでプレイヤーのポイントが増える。
1回の行動毎にサーバー通信を行うのではなく、全ての行動が終わった段階で行動をサーバーに送って検証・データの更新を行います。
上記ゲームロジックを実装したLuaスクリプト(前回@sachaos が作成してくれたもの)が以下になります。
command_functions = {
up = function (current_position)
next_position = {}
next_position["x"] = current_position["x"] + 0
next_position["y"] = current_position["y"] + 1
return next_position
end,
down = function (current_position)
next_position = {}
next_position["x"] = current_position["x"] + 0
next_position["y"] = current_position["y"] - 1
return next_position
end,
right = function (current_position)
next_position = {}
next_position["x"] = current_position["x"] + 1
next_position["y"] = current_position["y"] + 0
return next_position
end,
left = function (current_position)
next_position = {}
next_position["x"] = current_position["x"] - 1
next_position["y"] = current_position["y"] + 0
return next_position
end
}
function move_user(map, current_position, command, point)
current_position = command_functions[command](current_position)
if get_map_point(map, current_position["x"], current_position["y"], 0) == 1 then
point = point + 1
end
return current_position, point
end
function get_map_point(map, x, y, default_point)
map_x_line = map[y]
if map_x_line == nil then
return default_point
else
point = map_x_line[y] or default_point
return point
end
end
-- Argument examples
-- map = {
-- {0, 0, 0},
-- {1, 1, 1},
-- {1, 0, 1}
-- }
-- commands = {"up", "right", "left", "down", "right", "left", "up", "right", "left"}
-- current_position = {x = 1, y = 1}
function game (map, current_position, commands)
point = 0
for _, command in ipairs(commands) do
current_position, point = move_user(map, current_position, command, point)
end
return current_position, point
end
Luaスクリプトの実行
今回のサンプルゲームを実装したスクリプトの基本部分が以下になります。
(あくまで実験なので例外処理など行っておりませんが、お許しを・・・)
Luaスクリプトの読み込みは簡略化するため、Resourcesフォルダの配下に”move_user.txt"として保存しています。
(Unityは.luaファイルを認識できない?ため)
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
using System.Collections.Generic;
using System.Linq;
using MoonSharp.Interpreter;
/// <summary>
/// Sample game script.
/// MS means MoonSharp.
/// </summary>
public class MsGameScript : MonoBehaviour
{
//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
// CONSTANT VALUE
//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
/// <summary>
/// Lua script path. ( In "Resource" directory )
/// </summary>
public const string LUA_SCRIPT_PATH = "move_user";
/// <summary>
/// Lua script function name.
/// </summary>
public const string GAME_FUNCTION = "game";
//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
// PRIVATE VARIABLE( INSPECTOR )
//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
// Four Buttons : move to the direction.
[SerializeField] private Button _buttonDown = null;
[SerializeField] private Button _buttonLeft = null;
[SerializeField] private Button _buttonRight = null;
[SerializeField] private Button _buttonUp = null;
/// <summary>
/// Text to show total score
/// </summary>
[SerializeField] private Text _textScore = null;
//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
// PRIVATE VARIABLE
//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
private int _currentPoint = 0;
private Dictionary<string, int> _currentPosition = null;
private string[] _nextDirection = null;
private List<List<int>> _pointsMap = null;
private string _sourceCode = "";
/// <summary>
/// Lua script class.
/// This implements a MoonSharp scripting session.
/// </summary>
private Script _script = null;
/// <summary>
/// Move count.
/// You have to count up this only by count up method.
/// </summary>
private int _totalMoveCount = 0;
//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
// FUNCTION( UNITY EVENT )
//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
/// <summary>
/// Ready to start game.
/// </summary>
void Start()
{
// Load lua script by text file.
TextAsset sourceCodeTextAsset = Resources.Load<TextAsset>( LUA_SCRIPT_PATH );
// Error check.
Debug.Assert( _buttonDown != null, "_buttonDown is null!!");
Debug.Assert( _buttonLeft != null, "_buttonLeft is null!!");
Debug.Assert( _buttonRight != null, "_buttonRight is null!!");
Debug.Assert( _buttonUp != null, "_buttonUp is null!!");
Debug.Assert( _textScore != null, "_textScore is null!!");
Debug.Assert( sourceCodeTextAsset != null, "sourceCodeTextAsset is null!!" );
// Initialize variables.
_currentPoint = 0;
_currentPosition = new Dictionary<string, int>(){ { "x", 1 }, { "y", 1 } };
_nextDirection = new string[1];
_pointsMap = new List<List<int>>() { new List<int>(){ 0,1,0 }, new List<int>(){ 1,1,0 }, new List<int>{ 0,0,1 } };
_script = new Script();
_sourceCode = sourceCodeTextAsset.text;
_totalMoveCount = 0;
_textScore.text = _currentPoint.ToString();
// Load and execute.
_script.DoString( _sourceCode );
// Set callback action
_buttonDown. onClick.AddListener( _GenerateDirButtonCallback( "down" ) );
_buttonLeft. onClick.AddListener( _GenerateDirButtonCallback( "left" ) );
_buttonRight.onClick.AddListener( _GenerateDirButtonCallback( "right" ) );
_buttonUp. onClick.AddListener( _GenerateDirButtonCallback( "up" ) );
}
//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
// PRIVATE FUNCTION
//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
/// <summary>
/// Call game function.
/// </summary>
private DynValue _CallGameFunction( string direction )
{
_nextDirection[0] = direction;
return _script.Call( _script.Globals[ GAME_FUNCTION ], _pointsMap, _currentPosition, _nextDirection );
}
/// <summary>
/// Take point from result value.
/// </summary>
private int _TakePoint( DynValue resultValue )
{
// Tuple[1] : Point.
return (int)resultValue.Tuple[1].Number;
}
/// <summary>
/// Take position from result value.
/// </summary>
private Dictionary<string, int> _TakePosition( DynValue resultValue )
{
Dictionary<string, int> position = new Dictionary<string, int>();
// Tuple[0] : Current position.
foreach (var pair in resultValue.Tuple[0].Table.Pairs)
{
// Key : "x" or "y", Value : x-position or y-position.
position.Add( pair.Key.String, (int)pair.Value.Number );
}
return position;
}
//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
// PRIVATE FUNCTION( CALLBACK )
//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
/// <summary>
/// Generate callback action for direction buttons.
/// </summary>
private UnityAction _GenerateDirButtonCallback( string direction )
{
return () => {
DynValue resultValue = _CallGameFunction( direction );
// Update game state.
_currentPosition = _TakePosition( resultValue );
_currentPoint += _TakePoint ( resultValue );
_totalMoveCount++;
// Log.
Debug.Log( "Current position :: " + "x:" + _currentPosition["x"] + " y:" + _currentPosition["y"] );
Debug.Log( "Current point:: " + _currentPoint );
Debug.Log( "Total move count : " + _totalMoveCount );
// Update GUI.
_textScore.text = _currentPoint.ToString();
// Check is finish.
// Send result to server if finish.
};
}
}
実装では以下のサイトを参考にさせて頂きました。
・ MoonSharp - Getting Started
・ UnityでLuaが動かせるので、使ってみていた。
少し長くなりましたが、Luaスクリプトとの連携で肝となるのは以下の部分です。
private DynValue _CallGameFunction( string direction )
{
_nextDirection[0] = direction;
return _script.Call( _script.Globals[ GAME_FUNCTION ], _pointsMap, _currentPosition, _nextDirection );
}
private int _TakePoint( DynValue resultValue )
{
// Tuple[1] : Point.
return (int)resultValue.Tuple[1].Number;
}
private Dictionary<string, int> _TakePosition( DynValue resultValue )
{
Dictionary<string, int> position = new Dictionary<string, int>();
// Tuple[0] : Current position.
foreach (var pair in resultValue.Tuple[0].Table.Pairs)
{
// Key : "x" or "y", Value : x-position or y-position.
position.Add( pair.Key.String, (int)pair.Value.Number );
}
return position;
}
この部分のみでLuaスクリプトで定義された関数の呼び出しと結果の取得が完了しています。
初めてMoonSharpを使ったのですが、連携処理を非常に簡単に行うことができ、返り値のDynValueからの値の取り出しもそこまで苦労することはありませんでした。
ただ、実際の製品に取り入れるとなると、DynValueからの値の取り出しを上手く扱うクラスなど作ってよしなに処理する必要がありそうです。
感想(考察?)
さて、ここまでの内容から、非常に単純な処理ではありますが、クライアント・サーバーで同じロジックを同じプログラムで扱うことは出来ました。
しかし、実用可能性を確かめなければ、ただLuaのパーサーを使ってみただけの記事になってしまうので、ここからは真面目にサンプルゲームを作ってみた上で感じた、実際の開発にこの実験内容を取り込んだ場合に想定される問題点と対応策について考えたいと思います。
問題点その1 : 開発工数
実際にプロダクトにこの様な手法を導入するとなった場合に、ほぼ確実に懸念点としてあげられるのが開発工数の問題です。
今回の場合は、サーバーにあったロジックをクライアントにも持たせるのですが、共通のプログラムを使うためロジック部分のコスト増加は体感上は無かったです。
ただし、Lua・MoonSharpの学習・LuaとC#の糊付け部分があったため、結果としてはLua版の方が実装時間はかなりかかってしまいました。
しかし、Lua・MoonSharpの学習は初回のみかつ、ロジック部分がLuaになっているおかげで再コンパイルなく何度もロジックのテストを行えることは、長期的に見るとかなりお得だと感じました。
(Luaスクリプトだけ差し替えてシーン再ロードでUnityの再生を止めずにテスト可能)
また、導入コストですがMoonSharpの導入からLuaの学習含めても2人とも1日もかからなかったため(多分)、とても低いです。
後述のテストフローを行い、正しくロジック部分を切り分けれれば、開発工数の点では導入メリットは高そうです。(長期運用前提ですが・・・)
問題点その2 : テスト
実は今回、簡単なスクリプトの実装ではありましたが、恥ずかしながら何度もバグを発生させてしまいました。
(Luaの配列インデックスが1から始まることに気付かずミスるとか、Luaの配列インデックスが1から始まることに気付かずミスるとか・・・)
MoonSharpのエラーからLuaスクリプトをデバッグするという暴挙に出ていたのがそもそも悪かったのですが、やはり作成する中でLuaの中でテストコードによるテストが必要だと感じました。
ですので、実際に開発に取り入れるとしたら、
- Luaで処理と単体テストを記述
- Lua上でテスト
- C#・Ruby上でそのテストを実行する関数を叩いてテスト
と二段階のテストがあった方が良さそうです。
問題点その3 : 安全面
クライアント・サーバーでロジックを導入するにあたって、個人的に一番課題と感じている点が安全面です。
前編の記事が投稿された後に頂いたご指摘としても、チート対策が大丈夫かと言うものが多かったです。
今回の手法の場合、サーバーで守られていたロジック部分がクライアントに渡されるため、クラッカーな方にロジックが漏れる可能性があります。(そして1人に漏れると何やかんやでネット全体に漏れます・・・)
もちろんサーバーで同じロジックの精査を行うので、不正なデータが送信された場合の検査はできるのですが、ゲームロジックによっては、そもそもロジックが漏れることによってチートが可能になる恐れがあります。
特に行動を記録して最後に送信する都合上、乱数が必要な場合はシードを送信することになるので、結果が送信される前にゲームを落として最適解を求めるといったチートも可能になります。
これらのチート行為に対しては正直完全に安全な方法は無いかなと考えています。
暗号化などによって、ロジック自体を出来るだけ見えなくする方法もありますが、解析する人はしてしまうと考えられるので、
・ そもそも露出しても問題ないロジック部分にのみ適用する
・ 乱数が入るロジックは使わない
・ 危険性を理解した上でも、テンポ感を第一にする
といった対策(?)を行うしか無いかと考えています・・・
(ここらへん良い解決策などご存知の方があれば、是非コメントでご意見頂ければと思います。)
問題点その4 : 速度
Luaスクリプトなどを組み込む場合、やはり心配なのは速度面ですね。
今回は、厳密な検証とは言えないのですが、ある程度の指針としてサンプルプログラムのLua版とC#版での比較を行いました。
比較用にロジックをC#で組み直したプログラムが以下になります。
using System;
using System.Collections;
using System.Collections.Generic;
/// <summary>
/// Move user.
/// </summary>
public class MsMoveUser
{
//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
// PROPERTY
//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
public int Point
{
private set;
get;
}
//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
// CONSTRUCTOR
//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
public MsMoveUser()
{
Point = 0;
}
//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
// PUBLIC FUNCTION
//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
/// <summary>
/// Game.
/// </summary>
public Dictionary<string, int> Game( List<List<int>> map, Dictionary<string, int> currentPos, string[] commands )
{
for( int index = 0; index < commands.Length; index++ )
{
currentPos = _MoveUser( map, currentPos, commands[ index ], Point );
}
return currentPos;
}
//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
// PRIVATE FUNCTION
//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
/// <summary>
/// Up.
/// </summary>
private Dictionary<string, int> _Up( Dictionary<string, int> currentPos )
{
Dictionary<string, int> nextPos = new Dictionary<string, int>();
nextPos[ "x" ] = currentPos[ "x" ] + 0;
nextPos[ "y" ] = currentPos[ "y" ] + 1;
return nextPos;
}
/// <summary>
/// Down.
/// </summary>
private Dictionary<string, int> _Down( Dictionary<string, int> currentPos )
{
Dictionary<string, int> nextPos = new Dictionary<string, int>();
nextPos[ "x" ] = currentPos[ "x" ] + 0;
nextPos[ "y" ] = currentPos[ "y" ] - 1;
return nextPos;
}
/// <summary>
/// Right.
/// </summary>
private Dictionary<string, int> _Right( Dictionary<string, int> currentPos )
{
Dictionary<string, int> nextPos = new Dictionary<string, int>();
nextPos[ "x" ] = currentPos[ "x" ] + 1;
nextPos[ "y" ] = currentPos[ "y" ] + 0;
return nextPos;
}
/// <summary>
/// Left.
/// </summary>
private Dictionary<string, int> _Left( Dictionary<string, int> currentPos )
{
Dictionary<string, int> nextPos = new Dictionary<string, int>();
nextPos[ "x" ] = currentPos[ "x" ] - 1;
nextPos[ "y" ] = currentPos[ "y" ] + 0;
return nextPos;
}
/// <summary>
/// Get map point.
/// </summary>
private int _GetMapPoint( List<List<int>> map, int x, int y, int defaultPoint )
{
if( y < 0 || y >= map.Count )
{
return defaultPoint;
}
if( x < 0 || x >= map[y].Count )
{
return defaultPoint;
}
return map[y][x];
}
/// <summary>
/// Move user.
/// </summary>
public Dictionary<string, int> _MoveUser( List<List<int>> map, Dictionary<string, int> currentPos, string command, int point )
{
if( command == "up" )
{
currentPos = _Up( currentPos );
}
else if( command == "down" )
{
currentPos = _Down( currentPos );
}
else if( command == "right" )
{
currentPos = _Right( currentPos );
}
else if( command == "left" )
{
currentPos = _Left( currentPos );
}
Point += _GetMapPoint( map, currentPos["x"], currentPos["y"], 0 );
return currentPos;
}
}
C#では返り値が一つのため、処理が変更されている部分はありますが、概ね同等の処理となっています。
実際の計測は以下の条件で行いました。
・ 予め作成した100個のランダムなコマンドのパターンを10個を用いる
・ 事前にC#ではMsMoveUserをインスタンス化・LuaスクリプトではDoString()を行っておく。
・ C#のstopwatchを用いて、上記コマンドの処理を10回行う。(10,000回のgame関数をコールする)
・ 環境はMacBook Pro (Retina, 13-inch, Early 2015)のUnityEditor上(プロセッサは2.7GhzのCore i5、メモリは1867MHzのDDR3 8GB)
結果
言語 | 時間(秒) |
---|---|
Lua | 1.6181600 |
C# | 0.0208200 |
単純で簡単な処理を繰り返しているためか、二桁オーダーに近い差が出ています。
小並感満載の感想ですが、あまりゴリゴリの計算処理をLuaスクリプトで行うのはやっぱり避けた方が良いかもしれません。
結論
以上、本当今回の通信頻度削減案をプロダクトに導入が出来るかを検討してきました。
(クライアントに関しては、ほとんどスクリプトの組み込みのメリット・デメリットがそのまま見れたという感じですが・・・)
結論としては、部分部分ではUX向上の有効な打ち手になり得る可能性があるといった感じです。
しかし、エンジニアとして安全面にリスクがあるものはプロダクトに組み込めないというのがありますし、その他の部分に関しても実際に大きなプロダクトでやらないことには判定が難しそうです。
なので本当はこの記事は「後編」となる予定だったのですが、個人のプロジェクトで実際にしっかり導入した上で、ちゃんとした「後編」を後日記事に出来ればなと思い「中編」にしておきました。
(いつやるかは未定ですが・・・)
まとめ
・ 都度通信削減の実験としてクライアント・サーバーでLuaスクリプトによるゲームロジックの共通化をやってみた
・ 安全面からプロダクトへの導入は時期尚早
・ だけど、導入は楽だしメリットもあるし、何より楽しいので個人プロジェクトには取り組みたい
・ 後日もしかしたら、「後編」があるかも