ぐーるらいふ

底辺。

【Unity】[ECS][レースシステム]第15回「密」あつまれ恐竜レースの開発について【unity1week】

お疲れ様です。ぐーるです。
最近は別アカの方で活動していてこっちはすっかり低浮上です。

今回も始まりました。unity1week。お題は「密」です。
前回三月にやったので、次は六月ぐらいかなと思っていたのですが、
突然の開催ということでビックリ。
しかも長期休暇直前でインフラや基盤の整備しつつ、
リモートワークしてる人たちをサポートしつつ、
客先とweb会議で打ち合わせしたりと
てんやわんやで中々手が回らない中、参加することにしました。
5/1からw


今回作ったゲームはマルチ対戦レースゲーム
「あつまれ恐竜レース」
です。



レースシステムについて

このゲームはとてもシンプルです。
キー入力回数を速度に変換して移動させ、一定の値を超えたらゴールです。

NPCはこのキー入力を再現させて走っています。

実装的にはもちろんオブジェクト指向そのままで
親クラスがあって処理のほとんどを共通化しています。

入力について

Playerはキー入力を受け取って走りますが、NPCは決められた値に従って走ります。
それを実現するシステムを紹介します。

InputControl

ユーザーのキー入力を変換するシステムです。
キーボードの連打をカウントしており、またDBに送信するための履歴を記録しています。
この履歴をサーバに送信して記録することによりNPCとして走らせることが出来ます。

    private List<KeyCode> _targetKeyCodes = null;

    private float _value = 0;
    private float ADD_VALUE = 1f;

    private bool _isActive = false;

    // input data save
    private List<int> _inputList = null;
    private float _counterTime = 0;
    private const float TIME_SPAN = 1f;

    public void Initialize()
    {
        _value = 0;

        if(_targetKeyCodes == null)
        {
            _targetKeyCodes = new List<KeyCode>();
            for (var key = KeyCode.Backspace; key < KeyCode.Joystick8Button19; key++)
            {
                _targetKeyCodes.Add(key);
            }
        }
        _counterTime = 0;
        _inputList = new List<int>();
        _isActive = false;
    }


    // Update is called once per frame
    void Update()
    {
        if (!_isActive) return;

        if(IsAnyKeyDown())
        {
            _value += ADD_VALUE;
        }

        UpdateInputLog();
    }

    // 入力された回数を規定時間ごとに記録する
    private void UpdateInputLog()
    {
        _counterTime += Time.deltaTime;
        if(_counterTime >= TIME_SPAN)
        {
            _inputList.Add((int)_value);
            _counterTime = 0;
        }
    }

    private bool IsAnyKeyDown()
    {
        if(_targetKeyCodes != null)
        {
            foreach (var k in _targetKeyCodes)
            {
                if (Input.GetKeyDown(k))
                {
                    return true;
                }
            }
        }

        return false;
    }

NPCInputControl

NPCは上で記録した一連の流れを元にスピード基準値を生成します。
単純に配列から記録した値と値の間を補完するように計算して値として
入れていくだけというシンプルなシステムです。

    private bool _isActive = false;
    private float _nowSpeed = 0;
    private float _beforeSpeed = 0;
    private float _nextSpeed = 0;
    private float _timer = 0;
    private int _nowIndex = 0;

    private List<float> _speedArray = new List<float>();
    private const float TIMER_SPEED = 1.0f;

    public void Initialize(List<float> speedArray)
    {
        Debug.Assert(speedArray != null);

        _isActive = false;
        _speedArray = speedArray;
        _nowIndex = 0;
        _beforeSpeed = 0;
        _nowSpeed = 0;
        _nextSpeed = _speedArray[_nowIndex];
        _timer = 0;

    }

    public void SetActive(bool active)
    {
        _isActive = active;
    }

    // Start is called before the first frame update
    void Start()
    {
        
    }

    // Update is called once per frame
    void Update()
    {
        if (!_isActive) return;

        _timer = _timer + TIMER_SPEED * Time.deltaTime;

        _nowSpeed = Mathf.Lerp(_beforeSpeed, _nextSpeed, _timer); // 値を補完する

        if(_timer > 1.0f)
        {
            if(_nowIndex < _speedArray.Count - 1)
            {
                _nowIndex++;

            }
            _beforeSpeed = _nextSpeed;
            _nowSpeed = _beforeSpeed;
            _nextSpeed = _speedArray[_nowIndex];
            if(_nowIndex > 5 && _nextSpeed < 100.0f)
            {
                _nextSpeed = 100.0f;
            }

            _timer = 0;
        }
    }

ECS 背景の大量のUnityちゃん

f:id:ghoul_life:20200504225453p:plain

そうです。みんな大好きEntity Component Systemです。
もー詳しくは説明しません。過去にもやってるし、他に詳しい方いっぱいいるし!w

ghoul-life.hatenablog.com

なのでWebGLで使う際に気を付けた点だけ説明していきます。

ある程度は覚悟しよう

DOTSは三つの要素を組み合わせる事によって最大限のパフォーマンスを引き出すことが出来ます。
その三つとは

  • Entity Component System
  • Job System
  • Burst Compiler

です。

現在WebGLビルドでは

  • Job System
  • Burst Compiler

この二つの恩恵を受けることがほとんどできません。

詳しくはこちら
ghoul-life.hatenablog.com

なので、この二つは使わないように注意してシステムを組みます。

本当にシンプルなものに留める

独自にコンポーネントシステムを組んでいくのがECSを組み上げる際の醍醐味ですが、
まぁ今回は単純に見てるだけでいいし、特別なシステムとかいらんし。
ってことでECS関係のコードこれしかありません。

    private void InitializeECS()
    {
        var entityManager = World.Active.EntityManager;

        _waitArcheType = entityManager.CreateArchetype(
                          // renderer
                          typeof(RenderMesh)
                        , typeof(LocalToWorld)

                        // Transform
                        , typeof(Translation)
                        , typeof(Rotation)
                    );

        NativeArray<Entity> entityArray = new NativeArray<Entity>(ECS_CREATE_COUNT * ECS_ROW, Allocator.Temp);

        entityManager.CreateEntity(_waitArcheType, entityArray);

        for (var i = 0; i < ECS_ROW; i++)
        {
            for (var j = 0; j < ECS_CREATE_COUNT; j++)
            {
                Entity entity = entityArray[ECS_CREATE_COUNT * i + j];

                entityManager.SetComponentData(entity, new Translation { Value = new float3(/* 位置 */)) }); // 追加
                entityManager.SetComponentData(entity, new Rotation { Value = quaternion.LookRotationSafe(new float3(/* 位置 */) , Vector3.up) });
                entityManager.SetSharedComponentData(entity, new RenderMesh
                {
                    mesh = _mesh,
                    material = RandomMaterial(),
                });

                x += distance.x;

            }
            x = defaultX;
            y += distance.y;
            z += distance.z;
        }
        

        entityArray.Dispose();
    }
}

話すべき所と言えば、やはりanimationTextureBakerでしょうか。

AnimationTextureBakerとは

https://github.com/sugi-cho/Animation-Texture-Baker

こちらで公開されている、Texture2Dに法線情報や頂点情報を保存し、
Shaderでアニメーションを計算して表現できるというツールです。

今回の背景に並んでいるunityちゃんはこのanimationTextureBakerで生成したデータを使用しています。
必要なものはMeshとTexture , 頂点Textureと法線Texture、Shaderだけです。
後はShaderを使ったMaterialを作って、ECSに渡すだけでOKです。簡単ですね。

DBについて

DBは簡単に使えるNCMBをお借りしてます。
えっ
「大量に通信したら有料では?」
どうせ人気無いんでトータルでアクセス数1000も行きません。

少し楽に使えるように自分なりにこんな感じの簡易Wrapperを用意してます。

        private bool _isConnecting;
        private int _tableCount;
        private NCMBObject _sendObject;
        private List<NCMBObject> _selectObjectList;
        private NCMBException _errorException;
        private System.Action _callBackAction;

        private void InitializeConnectionDB(System.Action callBackAction)
        {
            _isConnecting = true;
            _tableCount = 0;
            _sendObject = null;
            _callBackAction = callBackAction;
            _errorException = null;
            if (_selectObjectList != null)
            {
                _selectObjectList.Clear();
            }
            else
            {
                _selectObjectList = new List<NCMBObject>();
            }
            
        }

        /// <summary>
        /// 指定のテーブルからデータを取得する
        /// (ここではOrderByが1方向しかないので、パクるならいい感じに修正すること)
        /// </summary>
        public void SelectAsync(string tableName , string orderByColumn , int limit, System.Action callBackAction)
        {
            if (_isConnecting) return; // 連打対策

            InitializeConnectionDB(callBackAction);

            NCMBQuery<NCMBObject> query = new NCMBQuery<NCMBObject>(tableName);
            if (!string.IsNullOrEmpty(orderByColumn))
            {
                query.AddAscendingOrder(orderByColumn);
            }
            if(limit > 0)
            {
                query.Limit = limit;
            }
            query.FindAsync((List<NCMBObject> objList, NCMBException e) =>
            {
                _isConnecting = false;
                //検索成功したら
                if (e == null)
                {
                    _selectObjectList.AddRange(objList);
                    _tableCount = objList.Count;
                }
                else
                {
                    Debug.LogError(e);
                    _errorException = e;
                }
                _callBackAction.Invoke();
            }
            );
        }

        /// <summary>
        /// データを挿入する処理
        /// </summary>
        /// <param name="sendObject"></param>
        public void InsertAsync(NCMBObject sendObject, System.Action callBackAction)
        {
            if (_isConnecting) return;  // 連打対策

            InitializeConnectionDB(callBackAction);

            sendObject.SaveAsync((NCMBException e) =>
            {
                _isConnecting = false;
                if (e == null)
                {
                    _sendObject = sendObject;
                }
                else
                {
                    Debug.LogError(e);
                    _errorException = e;
                }
                _callBackAction.Invoke();
            });
        }

        /// <summary>
        /// 指定したテーブルのカウントを取得する
        /// </summary>
        /// <param name="tableName"></param>
        public void CountAsync(string tableName, System.Action callBackAction)
        {
            if (_isConnecting) return; // 連打対策

            InitializeConnectionDB(callBackAction);

            NCMBQuery<NCMBObject> query = new NCMBQuery<NCMBObject>(tableName);
            query.CountAsync((int count, NCMBException e) => {
                _isConnecting = false;
                if (e == null)
                {
                    _tableCount = count;
                }
                else
                {
                    Debug.LogError(e);
                    _errorException = e;
                }
                _callBackAction.Invoke();
            });
        }


        /// <summary>
        /// 現在時刻を指定フォーマットで取得
        /// </summary>
        /// <returns></returns>
        public static string GetNowDate()
        {
            var d = System.DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss");
            return d;
        }

使う側もこんな感じで継承するだけで簡単に使えるようなものを用意。

使いたいテーブル名をGetTableName()で渡して、行いたい処理を各Callbackで行うだけ
でテーブルごとの処理が出来ます。

    /// <summary>
    /// NCMBのテーブルを使う時に継承する
    /// </summary>
    public abstract class MyNcmbTable
    {
        protected NcmbWrapper _ncmbWrapper;

        public abstract string GetTableName();

        public MyNcmbTable()
        {
            _ncmbWrapper = new NcmbWrapper();
        }

        //----------------------------------------------------------------
        // select
        //----------------------------------------------------------------

        public virtual void Select(string orderByColumnName=null, int limit=-1)
        {
            _ncmbWrapper.SelectAsync(GetTableName(), orderByColumnName, limit, SelectCallbackAction);
        }

        public virtual void SelectCallbackAction()
        {

        }

        //----------------------------------------------------------------
        // insert
        //----------------------------------------------------------------
        public virtual void Insert(NCMB.NCMBObject ncmbObject)
        {
            _ncmbWrapper.InsertAsync(ncmbObject, InsertCallbackAction);
        }

        public virtual void InsertCallbackAction()
        {

        }


        //----------------------------------------------------------------
        // count
        //----------------------------------------------------------------
        public virtual void Count()
        {
            _ncmbWrapper.CountAsync(GetTableName(), CountCallbackAction);
        }

        public virtual void CountCallbackAction()
        {

        }

        //----------------------------------------------------------------
        // get value
        //----------------------------------------------------------------
        public bool IsConnecting()
        {
            if(_ncmbWrapper != null)
            {
                return _ncmbWrapper.IsConnecting;
            }
            return false;
        }

    }


こんな感じでテーブルごとにテーブル名と挙動を書いていけばOK

public class Table : MyNcmbTable
{
        public override string GetTableName()
        {
            return "table";
        }
        
        
        public void Insert(TableData tableData)
        {
            var ncmbObject = new NCMBObject(GetTableName());
            ncmbObject.ObjectId = (!string.IsNullOrEmpty(tableData.ObjectId)) ? multiData.ObjectId : null;
            ncmbObject[KEY_PLAYER_NAME] = tableData.PlayerName;
            // 略
            
            ncmbObject[KEY_DATE] = NcmbWrapper.GetNowDate();
            
            base.Insert(ncmbObject);
        }
        
        public override void SelectCallbackAction()
        {
            var selectObjectList = _ncmbWrapper.SelectObjectList;
            if(selectObjectList != null)
            {
                if (_tableDataList != null)
                {
                    _tableDataList.Clear();
                }
                _tableDataList = new List<TableData>();
                
                foreach(var selectObject in selectObjectList)
                {
                    var tableData = new TableData();
                    tableData.ObjectId = selectObject.ObjectId;
                    tableData.PlayerName = selectObject[KEY_PLAYER_NAME];
                    
                    // 略
                    
                    _tableDataList.Add(tableData);
                }
                
            }
            //~~~~~~~~~~~~~
        }
        //~~~~~~~~~~
}

以上

f:id:ghoul_life:20200504230545g:plain

このアニメーション描くのに4時間かかってます…w
元ネタはアレなんですけど、コマ送りして参考にしつつ描きました。
頭とか細かく振れていて一コマ一コマ丁寧に描いてあるんだなーと思いました。
(自分のは雑なんであんまりじーっと見ないでくださいw)

久しぶりの3Dでカメラワークとか楽しかったです。
3Dだとお絵描きほとんど無いんで三日間でちょっと余裕あるぐらいで完成させられますね。
ゲーム的にも凄くシンプルだし。

でもまた次は2Dかなぁ、三か月あればまたちょっと上達してるだろうと。

トラジションの話はいつやるんだー、仕事中にちょっとずつまた書き進めます…。