ぐーるらいふ

底辺。

【Unity】【LWRP】 Point Light 2Dでゆらゆらさせたかっただけなのに

久しぶりのUnityです

お久しぶりです。ぐーるです。
のーらいふです。

最近Unity 2019.3.0f3まで上げて使ってて見た目が随分変わっててびっくりしました。
アイコンとか慣れないです。

LWRPで2D Lightを使ってみたいと思い、触ってみました。
そしてTwitterに投稿しました。


そこで一点気になった事があったので記事にしてみます。

LWRPを設定する

LWRPを設定するとSpriteがLightの影響を受けるようになるらしい。
おおこれはやってみたい!ということで先人の知恵をお借りします。

kan-kikuchi.hatenablog.com

(いつもお世話になっています。)

自分でも軽く設定の仕方だけ書いておきます。

1. 新規プロジェクトを作成する

Unityは2019.3.0f3を使用。(2019.2.18f1でも確認はしました)

f:id:ghoul_life:20200117174751p:plain

2. PackageManagerでLWRPをインストールする

上部メニュー Window > Package Managerで PackageManagerを開く
Lightweight RPをインストールする
(ここではv7.1.7)

f:id:ghoul_life:20200117174951p:plain
f:id:ghoul_life:20200117175011p:plain

3. 必要なAssetを作成する

ProjectのCreateからPipeline Asset , 2D Rendererを作成する

ProjectのCreate > Rendering > Universal Render Pipeline > Pipeline Asset(Forward Renderer)
ProjectのCreate > Rendering > Universal Render Pipeline > 2D Renderer(Experimental)

f:id:ghoul_life:20200117175122p:plain

を作成します。
この時
「UniversalRenderPipelineAsset」
「UniversalRenderPipelineAsset_Renderer」
「New 2D Renderer Data」
の三つのファイルが作られます。

4. LWRP Setting

UniversalRenderPipelineのGeneral > RenderList

に2D Renderer(Experimental)で作成したNew RenderDataをセット。
(特にパラメータをいじる必要はありません。)

そして
上部メニューEdit > Project Settings で Project Settingsを開き

f:id:ghoul_life:20200117175547p:plain

GraphicsのScriptable Render Pipeline Settingsに
「UniversalRenderPipelineAsset」ファイルをセットすればOKです。

f:id:ghoul_life:20200117175710p:plain

5. 2D Lightに対応したSpriteを確認する

その後
適当にSpriteRendererを作成すると、
Materialが「Sprite-Lit-Default」になっており、真っ暗になります。

GameObject > Light > 2D > Point 2D Light(Experimental)
などで2D Lightを作成すると、Lightの影響を受ける2Dスプライトが確認出来ます。

f:id:ghoul_life:20200117175834p:plain
f:id:ghoul_life:20200117175847p:plain
(上がSceneです)

2D Lightをゆらゆらさせたい!

ここからが本題。
こういうの作ったら、揺らめく炎を作りたくなるもの。

Point Light 2D
Inspectorでパラメータいじってみると以下のような関係がわかる
・Outer Radius : 光の大きさ
・intensity : 光の強さ
・falloff Intensity : 境界線の強さ

f:id:ghoul_life:20200117180107p:plain

なるほど、falloff Intensityをいじるといい感じに揺らめくな、
じゃあこれをスクリプトで触ればいいや

f:id:ghoul_life:20200117180126p:plain
(getterしかない。なんで?)

で、触ってみるとこれ、アクセサーがgetしかない。
なんでええええ?何か別の値から計算して求めているのかな?

詳しいことまではわかりませんでしたが、とにかく揺らめかしたいので、リフレクションで触ることにしました。

美味しいとこだけ抜き出し

    // Light2Dに値をセットするが、リフレクションでprivateフィールドにアクセスする…(なんでset無いんだ?)
    private void SetFalloffIntensity(Light2D light2D , float value)
    {
        System.Type light2DType = light2D.GetType();

        // "m_FalloffIntensity"は sourceコードから拾ってきた
        // https://github.com/Unity-Technologies/ScriptableRenderPipeline/blob/master/com.unity.render-pipelines.universal/Runtime/2D/Light2D.cs#L146

        // privateだからBindingFlags
        System.Reflection.FieldInfo falloutIntensity = light2DType.GetField("m_FalloffIntensity", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);

        falloutIntensity.SetValue(light2D, value);
    }


ソースコード全体としてはこんな感じ。
UnityEngine.Experimental.Rendering.LWRPではなく、UnityEngine.Experimental.Rendering.Universalになります。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Experimental.Rendering.Universal;

public class LightModule : MonoBehaviour
{
    // Light2D Object
    [SerializeField] private Light2D _light2D;

    [Header("暗くなる度合い")]
    [SerializeField] private float _maxIntensity = 0.8f;

    [Header("明るくなる度合い")]
    [SerializeField] private float _minIntensity = 0.3f;

    [Header("明暗変化速度")]
    [SerializeField] private float _addIntensity = 0.001f;

    private float _intensity = 0;
    private bool _isForward = true;


    // Start is called before the first frame update
    void Start()
    {
        _intensity = _minIntensity;
        _isForward = true;
    }

    // Update is called once per frame
    void Update()
    {
        if (_isForward)
        {
            _intensity += _addIntensity;
            if(_intensity > _maxIntensity)
            {
                _intensity = _maxIntensity;
                _isForward = false;
            }
        }
        else
        {
            _intensity -= _addIntensity;
            if (_intensity < _minIntensity)
            {
                _intensity = _minIntensity;
                _isForward = true;
            }
        }

        SetFalloffIntensity(_light2D , _intensity);
    }

    // Light2Dに値をセットするが、リフレクションでprivateフィールドにアクセスする…(なんでset無いんだ?)
    private void SetFalloffIntensity(Light2D light2D , float value)
    {
        System.Type light2DType = light2D.GetType();

        // "m_FalloffIntensity"は sourceコードから拾ってきた
        // https://github.com/Unity-Technologies/ScriptableRenderPipeline/blob/master/com.unity.render-pipelines.universal/Runtime/2D/Light2D.cs#L146

        // privateだからBindingFlags
        System.Reflection.FieldInfo falloutIntensity = light2DType.GetField("m_FalloffIntensity", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);

        falloutIntensity.SetValue(light2D, value);
    }
}


これで揺らめく炎を2D Lightでも実現できました。

久しぶりにUnityやったなぁ~、やっぱ2D RPG風なのいいですね。

【unity】unity1week「さがす」蜻蛉切りの実装について【unity1week】

お疲れ様です。

今回も参加しました。第13回unity1week「さがす」。
今回のゲームは反応速度を測るシンプルなゲーム
蜻蛉切り」(とんぼきり)です。


unityroom.com


今回も今までと同様に色々と工夫した点があるので、
そこを紹介したいと思います。

【プログラム的なアプローチ】

毎回自分なりに学習的に何か新しいことに挑戦していこうという
スタンスで望んでいるのですが、
今回は
「リアルタイムネットワーク」
「サブシーン」
の2つに挑戦しました。

【ゲーム的なアプローチ】

自分としては前回ストーリー性ゼロでやったので、
話を作るのが好きなので今回こそは
ストーリーものやりたいなぁと漠然と考えていました。

【先にさっくりと開発の感想について】

めちゃくちゃ満足しています。
(Gold Rushより満足しています。)
ストーリー作れたし、敵キャラ可愛いし、最高です。
こういうのが作りたかったんだよなぁーと。

欲を言うともっとやりたかった。

  • リズムを合わせて三回タップするリズムゲー
  • 武器を切り替えて相性ゲー
  • 進捗マップ(○がこうつながってるやつ)があって、進んでいく感じとか
  • キャラ変えられる

などなど

妄想が広がってますが、とりあえず今回は一週間しかありません。
やれるところまでやって、上手くまとめようと思っていました。

ゲームについて

プログラム側の作業がメインになってしまうだろうと当初から想像していました。
何せ初めてのリアルタイム通信対応、何が起きるわからないし、
デバッグ想像しただけで冷えるレベルです。
なので、ゲーム側は極力シンプルにまとめて、わかりやすいステートメントで対応出来るような
ゲームがいいなぁと考えていました。

漠然と

「前回中世風だったから今回和風とか」
「最近鬼滅の刃にハマってるからそっち系で」
「居合斬りみたいな一発系ならオンラインもやりやすいかな」

とか簡単に考えてました。

ゲームとしてパッと思いついたのは、これが頭に浮かびました。

jin115.com

正直、もっと似てるゲームあるでしょ!と。
カービィの刹那の見斬りでしょうと。
ごめんなさい。マジで似てしまいました。

最初はストーリーも無くて、本当に反射神経テストなだけだったのですが、
ストーリー書きたい!という欲望からNPCの実装をどうにか詰め込みました。

(ここだけでいいので本当に見てほしい!)

ネットワーク

ネットワーク対応を行おうと思っていたので、
以前から調査していたPhotonを使うことにしました。

最終的にはちょっと挙動に自信が持てなくなってしまったため、
別の疑似的な通信システムに乗り換えてしまったのですが、
リアルタイム通信Photonの実装についてお話したいと思います。


ghoul-life.hatenablog.com
(長すぎるため別記事に分けました。)


とにかくテストするためにはWebGLへのビルドが必要です。
実装し、吐き出し、ブラウザとUnityEditorでマルチプレイを行う…
というテストを繰り返すことになります。ビルド一発15分ぐらいかかります。

これがとにかく時間がかかりました。
ビルドしている間は

・Utility系のコードを書く
・自分コードレビューして設計見直す
・お絵描きする
・寝る
・お茶を飲む(ほぼこれ)

と上手く時間を使って、作業効率をあげてました…。

そして事件が起きる

土曜日、同じようにテストをしていると、ルーム一覧が取れなくなりました。
調べてみると20CCUしか無料では受け入れることが出来ず、
テストで使い切ってしまい、制限されてしまったようです。
しばらく時間を置くと直りましたが、不安がよぎりました。

「このままリリースしたら繋がらないという阿鼻叫喚が待っているかも…」

100CCUに上げるには約2万円ほどかかってしまいます。
それはやりすぎだなと思ったため、思い切って別のシステムに舵を切ることにしました。

擬似的な対戦

このゲームでリアルタイムマッチングを行った際にやりとりするメッセージは以下の3つを考えていました。

  • 準備完了(READY)
  • タップタイム(FIRE)
  • リザルト(RESULT)

もっとシンプルにまとめるとデータとしては最低限以下だけで良いです。

1, 2, 3 本目のタップタイム , 衣装index , 名前

上記のデータだけ保持出来ればそのまま対戦を行うことが出来ます。
これをNCMBに喰わせて、後は出し入れするだけでまかなうことが出来るなと考えました。
土曜日一日を使ってこのシステムを作成し、ネットワーク対戦基盤をPhotonからNCMB疑似システムに差し替えました。
一度設計を綺麗にしていたのがここで効きました。

代わりに捨てたのが

  • ゲームプレイ中の乱入処理
  • 切断されてしまった時の中断処理

この辺りがまるっとペンディングになっています。
(結構頑張った所ではあるのですが…。)

感想というか

よくある対戦格闘ゲームのように、NPCプレイして待っていて、
乱入される、乱入する、といった事を実現出来そうではありました。
まだまだ研究の余地がありそうです…。
(他のシステムとかどうかな、モノビットとか)

サブシーンについて

もう一つ今回実装した技術的なお話をしたいと思います。

普段unity1weekをやるときは1sceneファイルでやっていました。
シーンまたぎでデータを保持したり、キャッシュを温め直したりといった事が嫌で、
内部的なステートだけでInGame , OutGameを切り替えていました。
Canvasを分けておいて、なんちゃって画面切り替えを実現するなんていう工夫をしていました。

が、これには大きな問題があります。それは

多人数開発に向かない

です。

永遠のソロな自分には関係ないと思っていたのですが、
もう13回も参加しているので、いい加減にちょっとは
やってみたほうがいいだろうと重い腰を上げて「シーン分割」やってみることにしました。

f:id:ghoul_life:20191027173241g:plain

全部このサブシーンシステムの上に乗っかってます。

設計について

ベースとなるMainSceneがあり、
その上にSubSceneとして各画面が乗っかっています。
そしてそれを管理するのはSceneManagerです。

f:id:ghoul_life:20191027171538p:plain

  • 画面はスタックで管理
  • 画面遷移アニメーションも管理
  • 一気にタイトルまで戻れるように

そんなシステムにしたいと考えました。

SceneManagerのコードを紹介

namespace lightning
{
    public class SceneManager : SingletonMonoBehavior<SceneManager>
    {
        private Stack<string> _sceneNameQueue = null;
        private string _nowSceneName = null;
        private BaseScene _nowScene = null;
        private bool _isMoveScene = false;

        protected override void Awake()
        {
            base.Awake();
            _isMoveScene = false;
            _nowSceneName = null;
            _sceneNameQueue = new Stack<string>();
        }

        public void Clean()
        {
            _sceneNameQueue.Clear();
        }

        public void LoadScene(string sceneName)
        {
            StartCoroutine(MoveSceneCoroutine(sceneName, SceneMoveType.None, /* IsPop */  false));
        }

        public void MoveScene(string sceneName)
        {
            StartCoroutine(MoveSceneCoroutine(sceneName, SceneMoveType.RightToLeft, /* IsPop */  false));
        }

        public void PopScene()
        {
            if (_sceneNameQueue.Count > 0)
            {
                var sceneName = _sceneNameQueue.Pop();
                Debug.Log("[NoccaSceneManager Pop] == " + sceneName);

                StartCoroutine(MoveSceneCoroutine(sceneName, SceneMoveType.LeftToRight , /* IsPop */  true));
            }
            else
            {
                StartCoroutine(UnloadScene(_nowSceneName));
            }
        }

        private IEnumerator MoveSceneCoroutine(string sceneName , SceneMoveType sceneMoveType, bool IsPop)
        {
            _isMoveScene = true;

            if(_nowScene != null)
            {
                switch (sceneMoveType)
                {
                    case SceneMoveType.None:
                        _nowScene.OnExitNone();
                        break;
                    case SceneMoveType.RightToLeft:
                        _nowScene.OnExitLeft();
                        break;
                    case SceneMoveType.LeftToRight:
                        _nowScene.OnExitRight();
                        break;
                }
                yield return new WaitUntil(() => _nowScene.IsSceneMoveDone);

                yield return UnloadScene(_nowSceneName);

                if (!IsPop)
                {
                    Debug.Log("[SceneManager stack] == " + _nowSceneName);
                    _sceneNameQueue.Push(_nowSceneName);
                }
            }

            _nowSceneName = sceneName;

            yield return loadScene(_nowSceneName);

            var scene = UnityEngine.SceneManagement.SceneManager.GetSceneByName(sceneName);
            var sceneObject = scene.GetRootGameObjects().FirstOrDefault(s => s.name.Equals(sceneName));
            _nowScene = sceneObject.GetComponent<BaseScene>();

            if (_nowScene != null)
            {
                switch (sceneMoveType)
                {
                    case SceneMoveType.None:
                        _nowScene.OnEnterNone();
                        break;
                    case SceneMoveType.RightToLeft:
                        _nowScene.OnEnterLeft();
                        break;
                    case SceneMoveType.LeftToRight:
                        _nowScene.OnEnterRight();
                        break;
                }
                yield return new WaitUntil(() => _nowScene.IsSceneMoveDone);
            }

            _isMoveScene = false;
        }

        private IEnumerator UnloadScene(string sceneName)
        {
            if (!string.IsNullOrEmpty(sceneName))
            {
                Debug.Log("[SceneManager UnloadScene] == " + sceneName);

                var unloadOperation = UnityEngine.SceneManagement.SceneManager.UnloadSceneAsync(sceneName);
                yield return new WaitUntil(() => unloadOperation.isDone);
            }
        }

        private IEnumerator loadScene(string sceneName)
        {
            if (!string.IsNullOrEmpty(sceneName))
            {
                Debug.Log("[SceneManager loadScene] == " + sceneName);
                var loadOperation = UnityEngine.SceneManagement.SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive);
                yield return new WaitUntil(() => loadOperation.isDone);
            }
        }

        //--------------------------------------------------------
        // getter
        //--------------------------------------------------------
        public bool IsMoveScene
        {
            get { return _isMoveScene; }
        }
    }
}

各サブシーンの基盤となるBaseScene

namespace lightning
{
    public class BaseScene : MonoBehaviour
    {
        [SerializeField] protected Canvas _canvas;
        [SerializeField] protected RectTransform _content;

        private const float MOVE_X = 2000;
        private const float MOVE_TIME = 0.3f;
        protected bool _isSceneMoveDone = true;

        protected virtual void OnEnterEvent()
        {
            Debug.LogFormat("[{0}] OnEnterEvent()", this.GetType().Name);
        }

        protected virtual void OnExitEvent()
        {
            Debug.LogFormat("[{0}] OnExitEvent()", this.GetType().Name);
        }

        void Start()
        {
            if(_canvas != null)
            {
                // RenderMode: Cameraにしてるので。Overlayで良ければ必要ないです。
                _canvas.renderMode = RenderMode.ScreenSpaceCamera;
                _canvas.worldCamera = Camera.main;
                _canvas.sortingLayerName = "Canvas";
                _canvas.sortingOrder = 100;
            }
        }

        public void BackScene()
        {
            SceneManager.Instance.PopScene();
        }

        //-----------------------------------------------
        // scene move
        //-----------------------------------------------

        public virtual void OnEnterNone()
        {
            OnEnterEvent();
            _isSceneMoveDone = true; // 移動なし
        }

        public virtual void OnEnterRight()
        {
            OnEnterEvent();
            MoveRight(-MOVE_X);
        }

        public virtual void OnEnterLeft()
        {
            OnEnterEvent();
            MoveLeft(MOVE_X);
        }

        public virtual void OnExitNone()
        {
            _isSceneMoveDone = true; // 移動なし
            OnExitEvent();
        }

        public virtual void OnExitRight()
        {
            MoveRight(0);
            OnExitEvent();
        }

        // 左にはける
        public virtual void OnExitLeft()
        {
            MoveLeft(0);
            OnExitEvent();
        }

        void OnDestroy()
        {
            if(_content != null)
            {
                DOTween.Kill(_content); // これ忘れるとリークの元ですよ!と教えてもらいました…。
                _content = null;
            }
            
        }

        protected void MoveLeft(float firstPositionX)
        {
            MoveX(firstPositionX, -MOVE_X);
        }

        protected void MoveRight(float firstPositionX)
        {
            MoveX(firstPositionX, MOVE_X);
        }

        protected void MoveX(float firstPositionX, float moveValue)
        {
            if (_content == null)
            {
                Debug.LogError("content is null. please check scene inspector.");
                return;
            }

            if (_isSceneMoveDone)
            {
                _isSceneMoveDone = false;

                var p = _content.anchoredPosition;
                p.x = firstPositionX;
                _content.anchoredPosition = p;
                // DoTweenに依存しているのはちょっとダメかもですね。単純な移動なので、自作したほうがいいかもしれません。
                _content.DOAnchorPosX(moveValue, MOVE_TIME).SetRelative(true).SetEase(Ease.InOutCirc).OnComplete(() => {
                    _isSceneMoveDone = true;
                });
            }
        }

        public bool IsSceneMoveDone
        {
            get { return _isSceneMoveDone; }
        }
    }
}

各画面コード例

namespace lightning
{
    public class SampleScene: BaseScene
    {
        // シーンに入った時に呼ばれるイベント。初期化などを行う想定。
        protected override void OnEnterEvent()
        {
            base.OnEnterEvent();
        }

        public void OnNextButton()
        {
            SceneManager.Instance.LoadScene("BattleScene"); // バトル画面へ(など)
        }

        // 戻るボタン押下イベント
        public void OnBackButton()
        {
            SceneManager.Instance.PopScene(); // ひとつ前の画面に戻る
        }
    }
}

Baseでは共通で持っている命令と、uGUIのRectTransformを持っていて、
画面遷移に使用しています。
また、ベースとなるMainSceneにのみCameraとEventSystemを入れて、
その他サブシーンでは外しています。
一気にタイトルに戻りたい時は、StackをClearしてLoadSceneすればOKです。

何が便利だった?

カスタマイズ性が高いです。
とにかく遷移変えたいなという時にささっと変えられる。

A -> B -> C

という画面遷移から

A -> B -> B' -> C

としたければ、LoadSceneの文字列変えるだけで間に挟むことが出来ます。
戻る場合も意識することはありません。
スタックされているものを取り出していくだけなので、
どんなフローからでも一つずつ戻っていくことが可能です。

今回の蜻蛉きりではMainSceneとTitleSceneを分けて、
MainSceneをロードしてからTitleSceneをロードするようにしています。
例えば将来的にタイトル表示する前に利用規約画面とか挟みたいな!
となったらLoadScene変えるだけでいいのです。

拡張性も悪くない

BaseSceneを継承したクラス作ってサブシーンに当てはめればそれだけでOKです。
PopやLoadといった基本的な呼び出しは全てBaseにあるのですぐ使えますし、
画面遷移アニメーションとか意識せずに勝手にやってくれるようになります。
Contentの設定が手動なのはちょっと今後の課題ですね。

逆にダメな所は?

一人でやっているので、どこに何があるのか?はもちろん全て把握しているのです。
バトル画面いじってて「あ、あの画面のテキストも直さなきゃ」と思ったら
別のSceneを開き直さないとなりません。ここが若干面倒でした。

シーンを切り替えると、前のシーンが閉じられるので、地味にEditorロードが入って
若干なイラつきがあります。

また、サブシーンだけでちょっと実行してみよう、なんて思った時は
EventSystemやCameraを付けてあげないと動かないことに注意する必要があります。
付けて、動かしてテストして、また消して保存する、なんていう手順が必要かも。

サブシーンやってみて

単一シーンを多人数で開発するとコンフリクトを引き起こしがちで、
Slackで「今から○○触りますー」なんてやりとりが必要になったりするのかもしれません。

この辺りはサブシーン化すると分けて編集することが出来るので、並行開発が可能になります。
また共通処理はBaseで賄えたり、MainSceneがキャッシュを持ってくれたりといった
1Sceneのいい所も併せ持ってます。
ここは大きなアドバンテージなんじゃないかなと思いました。

孤独な開発者にはちょっとオーバースペックな基盤かもしれませんが、
慣れるといい感じですね。Hierarchyがスッキリするのもいいです。

f:id:ghoul_life:20191027173732p:plain

こんな感じで別れて管理できます。

開発を終えて

とにかくとにかっくネットワークは大変でした。
おかげ様でビビらなくはなったのですが、ネットワーク対応はくっそメンドイな…と思いました。

ですが、リアルタイムで二人で対戦出来るようになると本当に感動的ではあります。
自分みたいな大バカはチャレンジしてみてもいいのではないでしょうか?

2D -> 2Dと来てるので次は3Dを…と思っていますが、
次はDotsだろうなーと思ってます。
それまでにエンジンをブラッシュアップしておこう!

以上、ここまで読んでくれてありがとうございました。
また次回も頑張ります。

【Unity】Unity1weekでPhotonを使ってリアルタイム対戦やろうとして躓いたことを告白【unity1week】

unity1weekでリアルタイムオンライン対戦を実現しようとしたのですが、挫折しました。
そこまでに勉強したことをまとめてみようと思います。

まずはドンと公式を貼っちゃいます。

doc.photonengine.com

問題なく開始するために、「PUNベーシックチュートリアル」を
十分に確認したうえでコーディングしてください。

と書いてありますが…まあちょっとわかりにくいというか悪い面を伏せているというか。


概念とか仕組みいろんな機能とか色々とあるようですが、
一旦全て置いちゃいます。

「細かいことは置いておいて、とりあえずホストとクライアントで
メッセージのやりとりするにはどうすればいいんだよ?」

という観点だけ解説させてください。

それでもめちゃくちゃ長いです。
文章はななめ読みして、コードと画像だけ見る、でも充分です。

使用するAssetはこちらになります。
Unityは2019.2.8f1です。

assetstore.unity.com

そしてテストするためにWebGLで吐き出したものと二つでテストしています。
所々にある検証画像も参考にしていただけると。

まず覚えてほしいこと

いきなり説明してんじゃん!ですが、これだけは覚える必要がありますのでご容赦を。

Photonは3つの概念があります。それは

  • Photonサーバ
  • ロビー
  • ルーム

この3つです。
これさえ忘れなければもう怖いものはありません。
相対関係は以下の図をイメージしていただければOKです。

f:id:ghoul_life:20191026110145p:plain

アカウントを作成&事前準備

この辺はささっと。

Photon側の作業

Photonを利用するにはアカウントが必要です。

dashboard.photonengine.com

新しくアプリを作成する

f:id:ghoul_life:20191026112445p:plain

作りたい名前を付けて、作成する
ですぐに作成できます。

f:id:ghoul_life:20191026121512p:plain

そしてアプリケーションIDをコピーして覚えておきます。

Unity側の作業

AssetStoreからPUN2 Freeをインストールし、パッケージをインポートします。
Photonの設定が開くので、設定にアプリケーションIDなど必要な情報を入力し、
SetupProjectボタンを押下すると、自動で設定ファイルが作られます。

f:id:ghoul_life:20191026115812p:plain

これで準備完了です。

photonを使うには

最低限であれば適当なクラスに

Photon.PUN.MonoBehaviourPunCallbacks

を継承してあげればOKです。
これでクラスがPhotonサーバのListenerとなってくれます。

public partial class PhotonController : MonoBehaviourPunCallbacks
{
    private static PhotonController _instance;
    public static PhotonController Instance
    {
        get
        {
            if (_instance == null)
            {
                var gameObject = new GameObject("PhotonController");
                _instance = gameObject.AddComponent<PhotonController>();
                DontDestroyOnLoad(gameObject);
            }
            return _instance;
        }
    }
    // 略
}

Photonサーバに接続(ホスト、クライアント共通)

3つの概念を覚えているでしょうか?
その通りに実装すれば問題ありません。
まずPhotonサーバに接続します。

以下のコードでPhotonサーバに接続することが出来ます。

PhotonNetwork.ConnectUsingSettings();
public partial class PhotonController
{
    //--------------------------------------------------------------------
    // 1. Photonサーバに接続する
    public void ConnectToPhotonServer()
    {
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
        PhotonNetwork.ConnectUsingSettings();
    }

    //---------------------------------------------------------------------
    // MonoBehaviourPunCallbacks
    //---------------------------------------------------------------------

    // Photonサーバに接続2
    public override void OnConnectedToMaster()
    {
        base.OnConnectedToMaster();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    // Photonサーバに接続1
    public override void OnConnected()
    {
        base.OnConnected();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    // 切断時
    public override void OnDisconnected(DisconnectCause cause)
    {
        base.OnDisconnected(cause);
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name + ":" + cause);
    }
}

コールバックは以下の2つになります。1,2の順番で呼ばれます。
(なんで二つとも呼ばれるんだろう…?)

OnConnected()
OnConnectedToMaster()

接続失敗した時のリスナー…は見当たりませんでした。
Disconnectが直接来るのかな?

f:id:ghoul_life:20191026120743p:plain
(AddMessage()で経由したメソッド名を画面に出力しています。)

注意点としては

通信出来ない所で接続しようとした場合は
コールバックが来ない可能性があるので、
ポーリングしてタイムアウト処理などを自作しておいたほうが安心です。

ロビーに入る(ホスト、クライアント共通でも良い)

Photonサーバに繋いだら、Lobbyに接続します。

なお、共通でも良いと言ったのは例外がありまして、
実はホストのCreateRoomはロビーに入らなくても行うことが出来ます。
その際は自動でロビーにも接続されます。
(Lobby.Defaultに接続するならば、特にLobbyに入らなくてもOKという意味のようです。)

ロビーをカスタマイズするならば、ログインしてから作るか、
指定してルームを作成することになります。

ここでは正しい手順でロビー入ってからルームを作っていきます。

public partial class PhotonController
{

    //--------------------------------------------------------------------
    // 2.ロビーに入る
    public void JoinLobby()
    {
        PhotonNetwork.JoinLobby();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    // ロビーから抜ける
    public void LeaveLobby()
    {
        PhotonNetwork.LeaveLobby();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    //---------------------------------------------------------------------
    // MonoBehaviourPunCallbacks
    //---------------------------------------------------------------------
    // ロビーにログインした
    public override void OnJoinedLobby()
    {
        base.OnJoinedLobby();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }
    // ロビーから離脱した
    public override void OnLeftLobby()
    {
        base.OnLeftLobby();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    // ***** 他はわかるけど、これは何? *****
    public override void OnRoomListUpdate(List<RoomInfo> roomList)
    {
        base.OnRoomListUpdate(roomList);
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name + " roomList.Count:" + roomList.Count);
    }
}


お、なんか変なのいるぞ?(OnRoomListUpdate)
これは次に解説します。

ルーム一覧を取得する(クライアント)

例えば、凄く単純なオンラインゲームを作りたいと考えて
ホストが部屋を立てて、ゲストがその部屋を探して接続する
としたいと思った場合は、ホストが立てた部屋リストを取得したいですよね。

実は簡単なコマンドは用意されていません。

じゃあどうやってリストを取るのか?
ロビーに参加した瞬間に、以下のコールバックが叩かれます。

void OnRoomListUpdate(List roomList)

ここで現在ロビーにあるルームリストを取ることが出来ます。

    public override void OnRoomListUpdate(List<RoomInfo> roomList)
    {
        base.OnRoomListUpdate(roomList);

        _roomList = roomList;

        // ルームの情報を取得してみる
        if(_roomList != null)
        {
            for (var i = 0; i < _roomList.Count; i++)
            {
                var room = _roomList[i];
                AddMessage("----------[" + i + "]-----------");
                AddMessage("RoomName: " + room.Name);
                AddMessage("CustomProperties : " + room.CustomProperties.ToStringFull()); // なんで空っぽなの…?
                AddMessage("Slots: " + room.PlayerCount + " / " + room.MaxPlayers);
                AddMessage("---------------------");
            }
        }
        
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name + " roomList.Count:" + roomList.Count);
    }

Q. え、任意でルーム取りたいに決まってんじゃん。ふざけてるの?

普通そーする。誰だってそうする。
が、しかしありません。このOnRoomListUpdateが呼び出されるのは
ロビーにログインした後は自動です。

誰かがルームを作成したタイミングで呼び出されます。

// 公式よりのメッセージ

PUN 2では、ルームリストはもう内部的にキャッシュされないため、別の方法でルームリストを処理する必要があります。
まず、PUN 2パッケージに含まれているAsteroidsデモをご覧になることをお勧めします。
これをインポートしたら、LobbyMainPanelクラスを確認できます。
これは、という名前の必要なコールバックを実装しますvoid OnRoomListUpdate(List roomList)。
クライアントがサーバーから更新されたルームリストを受信するたびに呼び出されます。
これは自動的に行われます。

このコールバック内で、void UpdateCachedRoomList(List roomList)呼び出され、
それに応じてキャッシュルームリストを更新します。
その後、キャッシュされた部屋リストにアクセスして、利用可能な部屋を確認できます。
デモでは、void UpdateRoomListView()関数でこれを行っています。

Q. どうにかやる方法無いん?

実は以下の方法で再度roomlistを取ることが出来ます。

  • ロビーから離脱する
  • 再度ロビーに参加する

あ、ええやん。Leaveしてjoinしなおせばええんやろ。
これでOK~なんて思いきや、なぜかLeaveするとPhotonServerにも再接続します。
(何故かコールバックされる)

なんでやねーんとツッコミますが、そういう仕様のようです。
(一敗)

ルームを作成する(ホスト)

ここは簡単です。
CreateRoomするだけです。
ルームを作る時に各種パラメータを付与してゲームに合わせた情報を
付与することが出来ます。
(公式はこういう所の説明が足りないのですよね。自分がちゃんと読んでないだけでしょうけど…。)

public partial class PhotonController
{
    public readonly string TAG_PLAYER_CUSTOM = "PLAYER_CUSTOM_HOGE";
    public readonly string TAG_ROOM_CUSTOM = "ROOM_CUSTOM_FUGA";
    //-----------------------------------------------------------
    // ルームを作成する
    public void CreateRoom()
    {
        // プレイヤーデータをセット
        var playerProperties = new ExitGames.Client.Photon.Hashtable() {
              { TAG_PLAYER_CUSTOM , "FROM CreateRoom" },
        };
        PhotonNetwork.NickName = GetName();
        PhotonNetwork.SetPlayerCustomProperties(playerProperties);
        
        // ルームデータをセット
        var roomProperties = new ExitGames.Client.Photon.Hashtable() {
              { TAG_ROOM_CUSTOM , "FROM CreateRoom" },
        };

        var roomOptions = new RoomOptions();
        roomOptions.MaxPlayers = 2;
        roomOptions.CustomRoomProperties = roomProperties;

        // ルームを作成する
        PhotonNetwork.CreateRoom("AAAAAAA", roomOptions, TypedLobby.Default);

        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    //---------------------------------------------------------------------
    // MonoBehaviourPunCallbacks
    //---------------------------------------------------------------------

    // ルームを作成した.
    public override void OnCreatedRoom()
    {
        base.OnCreatedRoom();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    // ルーム作成に失敗した
    public override void OnCreateRoomFailed(short returnCode, string message)
    {
        base.OnCreateRoomFailed(returnCode, message);
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }
}

ルーム作成に成功すると

OnCreatedRoom()
OnJoinedRoom() // <= 後述します

の両方がコールバックされます。
(なんで…?)


つまり、Created来たら「作成しました!」って
出してJoinedRoomしたら「参加しました!」って
出すなんて実装すると状況とずれたメッセージが出ます。
参加ステート、部屋立てたステートとか分けてたりするとこれも異なってしまいます。
(二敗)

f:id:ghoul_life:20191026124249p:plain

また、この時、すでにロビーにログインしているユーザーがいた場合、
そのユーザーのOnRoomListUpdate()が呼ばれます。

ルームに入る

長い…長いぞPhoton。
説明すると本当に長い。

ルームリストを取るとroomInfo.nameを取ることが出来ます。
これがroomのIDになっているので、joinRoomにこの値を引数で渡せばOKです。

public partial class PhotonController
{
    //----------------------------------------

    // ルームに参加する(強制的にクライアントになる)
    public void JoinRoom(RoomInfo roomInfo)
    {
        // CreateRoom側と同様にプレイヤーデータを付けてあげるとホスト側にも情報を渡せます
        var playerProperties = new ExitGames.Client.Photon.Hashtable() {
              { TAG_PLAYER_CUSTOM , "FROM JoinRoom" },
        };
        PhotonNetwork.NickName = GetName();
        PhotonNetwork.SetPlayerCustomProperties(playerProperties);
        
        // ルームに参加する(roomInfoのNameを引数に渡せばOKです)
        PhotonNetwork.JoinRoom(roomInfo.Name);

        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name + " roominfo:" + roomInfo.ToStringFull());
    }

    //---------------------------------------------------------------------
    // MonoBehaviourPunCallbacks
    //---------------------------------------------------------------------

    // ルーム参加、作成に失敗したときのコールバック
    public override void OnJoinRoomFailed(short returnCode, string message)
    {
        base.OnJoinRoomFailed(returnCode, message);
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    // ルームの参加が成功した時に呼ばれるコールバック
    // 普通引数にRoomInfo付けるでしょ…どんなルームに入ったか教えてくれても良くない?
    public override void OnJoinedRoom()
    {
        base.OnJoinedRoom();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);

        if (PhotonNetwork.InRoom)
        {
            // PhotonNetwork.CurrentRoomが入ってるルームって事らしい
            AddMessage("RoomName: " + PhotonNetwork.CurrentRoom.Name);
            AddMessage("HostName: " + PhotonNetwork.MasterClient.NickName);
            AddMessage(TAG_ROOM_CUSTOM + " : " + PhotonNetwork.CurrentRoom.CustomProperties[TAG_ROOM_CUSTOM] as string);
            AddMessage("Slots: " + PhotonNetwork.CurrentRoom.PlayerCount + " / " + PhotonNetwork.CurrentRoom.MaxPlayers);
        }
    }
}

OnJoinedRoom()がコールバックで呼ばれます。
逆にホスト側では
OnEnterRoom()が呼ばれます。

    // 誰か部屋に入ってきた
    public override void OnPlayerEnteredRoom(Player newPlayer)
    {
        base.OnPlayerEnteredRoom(newPlayer);

        // 誰が入ってきたんだよ?はこんな感じで
        AddMessage("newPlayer.NickName : " + newPlayer.NickName);
        AddMessage("newPlayer." + TAG_PLAYER_CUSTOM + " : " + newPlayer.CustomProperties[TAG_PLAYER_CUSTOM] as string);

        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name + " newPlayer:" + newPlayer.NickName);
    }

ここで誰が入ってきたかわかります。詳しくは後述

f:id:ghoul_life:20191026125556p:plain

プロパティについて

ここまで来る途中のコードにもちょいちょい書いてあるのですが、
ルームにメッセージを付けたり、
ユーザー名やゲームパラメータを出してあげたりしたいですよね。

こういったプロパティをつけるにはどうするのか?
をここにまとめておきます。

ユーザーにプロパティを付与する

こう書きます。

        var playerProperties = new ExitGames.Client.Photon.Hashtable() {
              { TAG_PLAYER_CUSTOM , "FROM CreateRoom" },
        };
        PhotonNetwork.NickName = GetName();
        PhotonNetwork.SetPlayerCustomProperties(playerProperties);

ルームにプロパティを付与する

こう書きます。

        var roomProperties = new ExitGames.Client.Photon.Hashtable() {
              { TAG_ROOM_CUSTOM , "FROM CreateRoom" },
        };

        var roomOptions = new RoomOptions();
        roomOptions.MaxPlayers = 2;
        roomOptions.CustomRoomProperties = roomProperties;
        PhotonNetwork.CreateRoom("AAAAAAA", roomOptions, TypedLobby.Default);

ユーザープロパティを受け取る

こう書きます。

// void OnPlayerEnteredRoom(Player newPlayer) など

newPlayer.NickName
newPlayer.CustomProperties[TAG_PLAYER_CUSTOM] as string

ルームプロパティを受け取る

こう書きます。

PhotonNetwork.CurrentRoom.Name
PhotonNetwork.CurrentRoom.CustomProperties[TAG_ROOM_CUSTOM] as string
PhotonNetwork.CurrentRoom.PlayerCount + " / " + PhotonNetwork.CurrentRoom.MaxPlayers

注意

OnRoomListUpdate(List roomList)を取得した時のRoomInfoからルームのプロパティが見えません。
なんで…?いや、これどうやって付与するの?
教えてPhotonの人…。
(三敗)

ホストがルームから抜けたらどうなるの?

OnMasterUpdate()が呼ばれ、
ゲスト側はルームホストに昇格します。

    // ゲストで入ってホストが抜けた
    public override void OnMasterClientSwitched(Player newMasterClient)
    {
        base.OnMasterClientSwitched(newMasterClient);

        // ゲストからホストに昇格するって事ですね

        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

ルームから抜けると?

public partial class PhotonController
{
    //--------------------------------------------------------------------
    // ルームから退室
    public void LeaveRoom()
    {
        PhotonNetwork.LeaveRoom();
    }

    //---------------------------------------------------------------------
    // MonoBehaviourPunCallbacks
    //---------------------------------------------------------------------

    public override void OnLeftRoom()
    {
        base.OnLeftRoom();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }
}

LeaveRoomを呼ぶと
何故か以下のコールバックまで呼ばれます。

OnConnected()
OnConnectedToMaster()

これなんでなんでしょうね…?

f:id:ghoul_life:20191026132214p:plain

変な処理にしてたりすると困ったことになるので注意です。
(四敗)

また、残されたユーザーは
ここが呼ばれます。

    // 誰かが抜けた
    public override void OnPlayerLeftRoom(Player otherPlayer)
    {
        // 抜けた時はここ
        base.OnPlayerLeftRoom(otherPlayer);
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name + " leftPlayer:" + otherPlayer.NickName);
    }

メッセージの送信

やっと来ました。
お互いルームに入っている状態でメッセージを送りあう方法はこちらです。

こんな感じでメッセージを送るイベントがあった時にInstantiateしています。
受け取る側もこれで大丈夫か?と思うのですが、これで問題なく、メッセージを受け取ることが出来ました。

    // メッセージを送信するボタンを押下。オブジェクトが無ければ作る
    public void OnSendMessage()
    {
        if(_myPhotonView == null)
        {
            _myPhotonView = PhotonController.Instance.CreateMyPhotonView();
        }
        _myPhotonView.SendMessageRPC(PhotonController.Instance.GetName(), "hello friends.");
    }
    //-----------------------------------------------------------------------------
    // マルチプレイ用オブジェクトを生成する
    //-----------------------------------------------------------------------------
    public MyPhotonView CreateMyPhotonView()
    {
        // 「Resources/MyPhotonView.prefab」 をInstantiateしようとするので作っておくこと
        var g = PhotonNetwork.Instantiate("MyPhotonView", Vector3.zero, Quaternion.identity);
        return g.GetComponent<MyPhotonView>();
    }
[RequireComponent(typeof(PhotonView))] // PhotonViewも一緒に持っておくこと
public class MyPhotonView : MonoBehaviour
{
    // RPC = Remote Procedure Call (ネットワーク越しに別のコンピュータ上のプログラムを呼び出す手法)
    public void SendMessageRPC(string nickName , string message)
    {
        PhotonView photonView = PhotonView.Get(this);

        PhotonController.Instance.AddMessage("[MyPhotonView] send message == " + message);

        // RpcTarget.Allにすると自分にも飛びます
        photonView.RPC("ReceiveMessageRPC", RpcTarget.Others, "send by " + nickName , message);
    }

    [PunRPC]
    void ReceiveMessageRPC(string title, string message)
    {
        string receiveMessage = string.Format("[MyPhotonView] Receive:({0}) {1}", title, message);

        Debug.Log(receiveMessage);

        PhotonController.Instance.AddMessage(receiveMessage);
    }
}

呼び出し側は
リモートクライアントに対してメソッドの呼び出しを行います。
(SendMessageRPCでReceiveMessageRPCを呼び出す)

この呼び出しを有効にするためには [PUNRPC] 属性を適用してあげる必要があります。
適用さえしてあげれば他には特に何もしなくても呼び出されます。

f:id:ghoul_life:20191026132936p:plain

まとめと感想

まとめると大したことに無いような気がしますが、
これが本当にわからず、手探りで進んでいくのは中々骨が折れました。

今更だけどPhotonを使ってみたい、なんて考えてる方の
アドバイスになればいいなと思います。
(RoomInfoのカスタムプロパティの設定方法分かったら教えて下さい…)

【Unity】uGUI Scroll List で Recycle したい (初心者向け)【再利用】

お疲れ様です。ぐーるです。
また一ヶ月ぐらい空いてしまいました。

「あ、そういえば一個思いついた」
と下書きにメモだけしてて、保存して
放置してた記事があったのを二重に思い出しました。

今回の単語は

unity UGUI Scroll List Recycle

です。

marginとか追記するの忘れた…時間見つけて追記しとこう。自分用のメモ。
あと最上段で引っ張ると判定に入ってIndexOutOfException吐いちゃうかもいかんいかん

  • > 追記しました。(2019/07/30)


サンプルプロジェクトを用意しましたので、
手っ取り早く見たい方はこちらを!

github.com

unityでスクロールリストをまず作ってみよう

よくある縦リストをunityで作る場合はこんな手順になります。

  1. ScrollRectを作る
  2. ViewPort(Mask)をよしなに作る
  3. ScrollBarを作る
  4. VerticalLayoutGroupとContentSizeFitterを作る
  5. LayoutElementを持ったprefabなどを複数追加するとリスト表示出来る

(ScrollViewをベースに作るのがおすすめです)

f:id:ghoul_life:20190729234203p:plain

要素を使いまわそう

まず、要素を使い回すには、以下のものを消します。

  • VeritcalLayoutGroup
  • ContentSizeFitter

えっ?て思うかも知れませんが、ここを自作する形になります。
大丈夫、難しくないです。

f:id:ghoul_life:20190730003734g:plain

ソースコードをまるっと

以下のソースを作成します。

/// <summary>
/// 要素を再利用するレイアウトグループ(Vertical)
/// </summary>
public class RecycleLayoutGroup : MonoBehaviour
{
    
    [SerializeField] RectTransform _itemElementPrefab; // リスト内の1要素
    [SerializeField] int _instantateItemCount = 9; // 何個作っておくか
    [SerializeField] float _margin = 10; // 隙間

    private int SAMPLE_ITEM_COUNT = 100; // サンプルで作成する数

    // 生成したアイテムはLinkedListで管理する
    public LinkedList<RectTransform> itemList = new LinkedList<RectTransform>();

    // cacheして処理の高速化を図る
    private RectTransform _rectTransform;
    private float _itemSizeheight = -1;

    // field
    protected float _diffPreFramePositionY = 0; // 一つ前の位置
    protected int currentItemNo = 0; // 現在の一番上の位置

    void Start()
    {
        _itemElementPrefab.gameObject.SetActive(false);

        for (int i = 0; i < _instantateItemCount; i++)
        {
            var item = GameObject.Instantiate(_itemElementPrefab) as RectTransform;
            item.SetParent(transform, false);
            item.name = "Recycle Item " + i.ToString();
            item.anchoredPosition = new Vector2(0, -ItemHeight * i);
            itemList.AddLast(item);

            item.gameObject.SetActive(true);

            OnUpdateItem(i, item.gameObject);
        }


        // 先に全アイテム分だけscrollRectを広げておくのだ
        var delta = rectTransform.sizeDelta;
        delta.y = ItemHeight * SAMPLE_ITEM_COUNT;
        rectTransform.sizeDelta = delta;
    }

    void Update()
    {
        if (itemList.First == null)
        {
            return;
        }

        // 下
        while (anchoredPositionY - _diffPreFramePositionY < -ItemHeight * 2)
        {
            _diffPreFramePositionY -= ItemHeight;

            var item = itemList.First.Value;
            itemList.RemoveFirst();
            itemList.AddLast(item);

            var pos = ItemHeight * _instantateItemCount + ItemHeight * currentItemNo;
            item.anchoredPosition = new Vector2(0, -pos);

            OnUpdateItem(currentItemNo + _instantateItemCount, item.gameObject);

            currentItemNo++;
        }

        // 上
        while (anchoredPositionY - _diffPreFramePositionY > 0)
        {
            _diffPreFramePositionY += ItemHeight;

            if(currentItemNo > 0)
            {
                var item = itemList.Last.Value;
                itemList.RemoveLast();
                itemList.AddFirst(item);

                currentItemNo--;

                var pos = ItemHeight * currentItemNo;
                item.anchoredPosition = new Vector2(0, -pos);
                OnUpdateItem(currentItemNo, item.gameObject);
            }   
        }
    }

    private void OnUpdateItem(int itemIndex, GameObject gameObject)
    {
        if (itemIndex < 0 || itemIndex >= SAMPLE_ITEM_COUNT)
        {
            gameObject.SetActive(false);
        }
        else
        {
            gameObject.SetActive(true);

            var listElement = gameObject.GetComponentInChildren<ItemElement>();
            listElement.SetMessage("message == " + itemIndex);
        }
    }


    //-----------------------------------------------------------------------
    // getter

    private float anchoredPositionY
    {
        get
        {
            return -rectTransform.anchoredPosition.y;
        }
    }


    public float ItemHeight
    {
        get
        {
            if (_itemElementPrefab != null && _itemSizeheight == -1)
            {
                _itemSizeheight = _itemElementPrefab.sizeDelta.y + _margin;
            }
            return _itemSizeheight;
        }
    }

    protected RectTransform rectTransform
    {
        get
        {
            if (_rectTransform == null) _rectTransform = GetComponent<RectTransform>();
            return _rectTransform;
        }
    }
}

これをVerticalLayoutGroupの代わりに使えばOKです。

f:id:ghoul_life:20190730004034p:plain

終わり!なんて言うとブーイングの嵐なので、ちょっと解説をば。

Start

    void Start()
    {
        _itemElementPrefab.gameObject.SetActive(false);

        for (int i = 0; i < _instantateItemCount; i++)
        {
            var item = GameObject.Instantiate(_itemElementPrefab) as RectTransform;
            item.SetParent(transform, false);
            item.name = "Recycle Item " + i.ToString();
            item.anchoredPosition = new Vector2(0, -ItemHeight * i);
            itemList.AddLast(item);

            item.gameObject.SetActive(true);

            OnUpdateItem(i, item.gameObject);
        }


        // 先に全アイテム分だけscrollRectを広げておくのだ
        var delta = rectTransform.sizeDelta;
        delta.y = ItemHeight * SAMPLE_ITEM_COUNT;
        rectTransform.sizeDelta = delta;
    }

ここでは、使い回す前提で指定個数しかprefabをinstantiateしないでおきます。
で、その際のanchorPositionを各アイテムの高さをずらして配置します。
今までであれば、VerticalLayoutGroupがやってくれていたことを自作するイメージです。

指定個数のprefabを作り終わったら、
このリスト全体の大きさを表示したい全ての項目分広げておきます。
(ここがポイントです。)

Update

スクロール処理はScrollRectがまかないます。
ScrollRectがcontentに指定しているRectTransformを動かします。
このupdateで動いた値をチェックして、逐一情報を更新します。
下に動かすか、上に動かすかで、リストの付与先(First or Last)が違うのに注意してください。

   void Update()
    {
        if (itemList.First == null)
        {
            return;
        }

        // 下
        while (anchoredPositionY - _diffPreFramePositionY < -ItemHeight * 2)
        {
            _diffPreFramePositionY -= ItemHeight;

            var item = itemList.First.Value;
            itemList.RemoveFirst();
            itemList.AddLast(item);

            var pos = ItemHeight * _instantateItemCount + ItemHeight * currentItemNo;
            item.anchoredPosition = new Vector2(0, -pos);

            OnUpdateItem(currentItemNo + _instantateItemCount, item.gameObject);

            currentItemNo++;
        }

        // 上
        while (anchoredPositionY - _diffPreFramePositionY > 0)
        {
            _diffPreFramePositionY += ItemHeight;

            if(currentItemNo > 0)
            {
                var item = itemList.Last.Value;
                itemList.RemoveLast();
                itemList.AddFirst(item);

                currentItemNo--;

                var pos = ItemHeight * currentItemNo;
                item.anchoredPosition = new Vector2(0, -pos);
                OnUpdateItem(currentItemNo, item.gameObject);
            }   
        }
    }

OnUpdateItem

ここでカラムの更新を行います。
最初に作った指定個数より少ない場合に注意。

    private void OnUpdateItem(int itemIndex, GameObject gameObject)
    {
        if (itemIndex < 0 || itemIndex >= SAMPLE_ITEM_COUNT)
        {
            gameObject.SetActive(false);
        }
        else
        {
            gameObject.SetActive(true);

            var listElement = gameObject.GetComponentInChildren<ItemElement>();
            listElement.SetMessage("message == " + itemIndex);
        }
    }

結果

簡単に要素を使い回すスクロール処理を作ることが出来ました。
今回の例では縦でしたが、xにすればもちろん横でも出来ます。

簡単なのに効果大です。
是非使ってみて下さい。

注意

「リストに入れるprefabの種類がいくつかあるんだけど…?」
こうなってくるとリサイクルがちょっと複雑になりそうです。
あくまでここでは単一のリストの例ってことで。

お疲れ様でした。
次はまた当分先になりそう!
サークル活動、ゲーム作りの方も頑張ります。

【Unity】unity1week「あつめる」 GOLD RUSHの実装について 【unity1week】

unity1week「あつめる」が終わりました

お疲れ様です。ぐーるです。

終わりましたunity1week。
今回も難産でした。
お題は「あつめる」。
ちょろっと作ったカードダンジョンRPGを利用して
ゴールドを集めるゲームにしようと思い立ちました。

f:id:ghoul_life:20190708211936g:plain

Twitter上でも非常に多くの方が注目してくれて
気恥ずかしい限りでしたが、非常に励みになりました。

本当にありがとうございます。
今回も毎回お馴染みの実装について紹介したいと思います。
なるべくコンパクトにまとめたいのですが、毎回長文です…。

設計について

正直あんまり話すこと無い…w
前回のやつと比べるとすごい簡単。全部uGUIだし。

前回のなんてObject Pool , Preload System , Recycler
とメモリ管理の嵐でInstantiateを鬼の形相で排除してました。

ghoul-life.hatenablog.com

これに比べたら全然言うことがない。

設計といえるほど工夫した点は特に無いんですが、
カードはもちろん継承して汎用的に処理をまとめて
どっからでも処理できるようにしてました。

UtilやExtension,NCMB,Effect,Soundなどゲームを補助するシステム
は省いてるけど基盤はこんな感じです。

f:id:ghoul_life:20190708212110p:plain

(基本的にメモリに乗っかっててほしいので、1sceneです。
3Dじゃないし、リソースそんなに多くないのでめちゃくちゃ軽量だしね)

EnemyEngine?

この関連図を見ると異様なものが一個ある。そうEnemyEngineです。
これは敵キャラのカードを作った後、そのカードのステータスを決定するシステムです。
フロアによって出現する敵を制御したり、パラメータをセットしたり、画像リソースを持ってたりする。

これ、本当に製品にするなら、全てのカードに作るべきだと思った。

「GoldCard」を作る時は必ず「GoldEngine」を経由する とか

カードを生成する時、パラメータが状況によって変わってくる。
武器やゴールドは値しかないので、その値入れるだけなのだが、
その処理を外に出しておくと見通しが良くなって拡張性が高くなっていく
今回は面倒でEnemy以外はそのまま書いてしまった。反省。

ちゃんとやるなら全てのカードはEngine化する

画面について

左半分カードフィールド側

f:id:ghoul_life:20190708212551p:plain

正直ちょっと失敗だったかも。
カード含めて全てuGUIでやってました。
が、カードの上に攻撃パーティクルを表示したくて
それを出すのにSortingLayerとか使って涙ぐましい努力してたりします。

SpriteRenderer + TextMeshProにしちゃって、3Dオブジェクトとして扱えばもっとラクに色々出来たかもしれない。
また、フロアが進むと3x3 , 5x5 , 7x7 ...とどんどんフィールドが広くなっていくので、
メモリ管理にどうなのかとちょっと不安ではありました。

今はいいですが20x20とかやろうとすると
同一カードのグルーピングをしてバッチング処理を考えた実装にしないと影響が出るかも知れません。

右半分ステータス側

また、値周りはUniRXのReactiveProperty使って値変えるだけで即座に反映されるようにしてました。
例えばゲーム一周してタイトルに戻った時に値を初期化するのですが、
その時にnewし直すと当たり前にsubscribeが外れるので、上手く回そう。
(newと初期値投入は別にするとかね。やっぱりInitializerクラスを作って別でやるべきだったかなぁと。
初期化処理は分散してますが、大量に処理があります。)

プログラムについて

特筆すべき所は…ありません!w
とか言うと本当に何にもなくなっちゃうので、むりやり捻出。
(コード的に難しい事は何にもしてないのでちょっと恥ずかしい)

f:id:ghoul_life:20190708212715p:plain

ゲームログを表示している箇所はキューイングして複数のログを受け取ることが出来て、
一個ずつ遅延評価してます。

using UnityEngine;
using System.Collections;
using UnityEngine.UI;
using System.Text;
using System.Linq;
using System.Text.RegularExpressions;
using System.Collections.Generic;

namespace CardDungeon
{
    public class TextWriter : MonoBehaviour
    {

        [SerializeField] private Text _text;

        // 全部フィールド化して少しでもメモリ管理をラクにしてあげたい
        // 涙ぐましい努力
        private int _length;
        private string _textString;
        private StringBuilder _stringBuilder;
        private bool _isAnimation = false;
        private const float ONE_TIME = 0.01f;

        private List<string> _messageQue = new List<string>();

        public void Initialize()
        {
            _isAnimation = false;
            _text.text = "";
            _length = 0;
            _textString = "";
            _stringBuilder = null;
            _messageQue = new List<string>();
        }

        public void Show(string txt)
        {
            _messageQue.Add(txt);
        }

        private void Update()
        {
            if (!_isAnimation && _messageQue != null && _messageQue.Count > 0)
            {
                StartCoroutine(textWriter(_messageQue[0]));
            }
        }

        IEnumerator textWriter(string text)
        {
            _isAnimation = true;
            _length = 0;
            _stringBuilder = new StringBuilder();
            _text.text = _stringBuilder.ToString();

            yield return null;

            while (_length < text.Length)
            {
                _textString = text.Substring(_length, 1);
                _stringBuilder.Append(_textString);
                _text.text = _stringBuilder.ToString();
                _length++;


                yield return new WaitForSeconds(ONE_TIME);
            }

            _text.text = text;

            yield return new WaitForSeconds(0.25f);

            _isAnimation = false;
            _messageQue.RemoveAt(0);

            yield return true;
        }
    }
}

またPopupする処理は得意のプーリング。

f:id:ghoul_life:20190708212852p:plain

namespace CardDungeon
{
    public class PopupManager : SingletonMonoBehavior<PopupManager>
    {
        [SerializeField] Dialog _dialog;
        [SerializeField] Dialog _yesnoDialog;
        [SerializeField] RectTransform _popupCanvas;
        [SerializeField] PopText _popTextPrefab;

        private List<PopText> _popList;

        private readonly int POP_TEXT_CNT = 10;

        private bool _isEnable;

        public bool IsEnable { get => _isEnable; set => _isEnable = value; }

        public void Initialize()
        {
            if (_popList != null)
            {
                foreach (var p in _popList)
                {
                    Destroy(p);
                }
                _popList.Clear();
            }

            // 予め生成しておいて非表示する
            _popList = new List<PopText>();
            for (var i = 0; i < POP_TEXT_CNT; i++)
            {
                var p = Instantiate(_popTextPrefab, _popupCanvas);
                p.Initialize();

                _popList.Add(p);
            }

            _dialog.Initialized();
            _yesnoDialog.Initialized();
        }

        // 以下略
        public void ShowPopup(string valueString, RectTransform rect, Vector3 adjustPos)
        {

            if (!_isEnable) return;

            if (_popList != null)
            {
                // 使用中でないポップアップを使用する
                var p = _popList.FirstOrDefault(pop => !pop.IsUse);
                if (p != null)
                {
                    p.Show(valueString, rect, adjustPos);
                }
            }

        }

    }
}

簡単なコードだけどゲームには効果抜群です。
unity1weekとか出してるやつはほとんどやってそうですけど。

金土日の追い込みでやったこと

ここが一番つらく、楽しい時間です。
4時間睡眠 x 2です。(気絶するように寝る)

基本的なシステムは出来上がっていたが、
御粗末すぎるので、それを以下に製品に近づけるかの作業でした。

  • オンラインランキング
  • 敵エンジン
  • グラフィックを仮素材から正式版に(UIまで出来ず)
  • バグ取り(結局取り切れず。まれに同じ場所にカードが作られてしまう)
  • タイトル、チュートリアル、ゲームオーバー画面などのUI全部
  • その他細かい調整など

これは寝れない…。
寝たら死ぬぞ!と思いながらストロングゼロ呑んでました。
拾った画像なんですが、凄くお気に入りです。

f:id:ghoul_life:20190708213329j:plain

グラフィック周りは本当にうつらうつらと…。

終わって

今回もすごいゲームが多いです。
ほんとみんな発想の鬼だなと。

とある漫画家の方がインタビューで言ってましたが、
「目についたものでネームを切ってみる練習をしている」
と。
普段から「これをゲームにしたらどうだろう?」と考えながら
生活しているような人は発想力が鍛えられているのではないでしょうか。
自分もそうありたいものです。

ゲーム紹介ページにも書きましたが、沢山の課題が残ってます。
それでも遊んでくださった方がいて凄く嬉しいです。
本当にありがとうございます。

次回はもう少しぶっ飛んだ感じのやつ作りたいなと思ってます。

unityroom.com

【Unity】 文字列に含まれる絵文字を判別する

お世話になっております。ぐーるです。
また久しぶりになってしまいました。

ずっと開発はしてて、RPGとカードゲームを2本同時に作成しているのですが、
このRPG作るのが凄く楽しいのです。

シナリオ、システム、グラフィック、キャラクター
全部自分で用意するのですが、
キャラクターの絵を描く、描いたキャラクターをいきいきと会話させる、
といった事だけでとても楽しい。

「こんな会話させよう」「こうしたらこう展開出来るな」
といったシーンを考えて実装してるだけで楽しいです。
(現在進捗率70%)

妄想癖が功を奏するなんて事もあるのだな、と思います。

閑話休題、今日はunityで絵文字の判別をする方法を共有しようかなと。

unityで絵文字って使えるの?

基本的に使えません。
使おうと思うと特別なライブラリや対応を入れる必要があります。

baba-s.hatenablog.com

じゃあ対応する必要なくない?

使いたい、という要望があったり
外部サービスと連携していたりすると
対応する必要が出てきます。

判別方法

  1. 文字列からUnicode配列に変換
  2. Unicode配列からUTF32に変換
  3. UTF32で絵文字判定を行う

という手順で判別が可能です。
(コードは後ほど。先に絵文字とはというお話を)

Unicodeの絵文字って

www.unicode.org

Unicodeでは絵文字は上記ルールに沿って実現されています。
Unicode一つで表示出来るものもあれば、複数にまたがって表示しているものもあります。
これを一つ一つ判別する必要があります。

判別する方法として

の2つがあります。

正規表現パターンの方がオススメですが、
絵文字以外の文字を含めてしまう恐れがあります。
完全一致であれば、含めてしまう恐れが少なくなりますが、
追加があった場合、逐一入力する必要があります。

正規表現
iOSで扱われるUnicode 6.0絵文字の判定をする正規表現 · GitHub


完全一致例
Unity-UI-emoji/info.txt at master · mcraiha/Unity-UI-emoji · GitHub

実装

完全一致で実装する場合のコード例を共有します。
文字コードをまるっとソースに入れちゃってますが、
これはブログ用の実装で、プロジェクトにするならTextAssetsなど
の方が管理はしやすいでしょう。
また、絵文字コード表は全ては網羅出来ていないと思われますので、ご了承ください。

コード例)

    // inputからEmojiを取り除いた文を返却する
    public static string RemoveEmojiString(string inputString)
    {
        int i = 0;
        string firstString = null;
        string secondString = null;
        string threeString = null;
        string fourString = null;
        StringBuilder sb = new StringBuilder();
        var uint32Size = sizeof(UInt32);

        // unicode byte配列に文字列を変換
        byte[] unicodeBytes = Encoding.Unicode.GetBytes(inputString);

        // unicode配列からUTF32配列に変換
        var utf32Bytes = Encoding.Convert(Encoding.Unicode, Encoding.UTF32, unicodeBytes);
        // 配列の長さ
        int length = utf32Bytes.Length / uint32Size;

        while (i < length)
        {
            // 1文字目をチェック
            firstString = BitConverter.ToUInt32(utf32Bytes, i * uint32Size).ToString("X4");

            // 2文字目までつなげてチェック
            if (i < (length - 1))
            {
                secondString = firstString + "-" + BitConverter.ToUInt32(utf32Bytes, (i + 1) * uint32Size).ToString("X4");
            }

            // 4文字目までつなげてチェック
            if (i < (length - 3))
            {
                threeString = BitConverter.ToUInt32(utf32Bytes, (i + 2) * uint32Size).ToString("X4");
                fourString = secondString + "-" + threeString + "-" + BitConverter.ToUInt32(utf32Bytes, (i + 3) * uint32Size).ToString("X4");
            }

            // 後ろから文字コード表と合わせてチェックして一致してたら絵文字と判断して読み飛ばす
            if (EMOJI_CODES.Any(e => e.Equals(fourString)))
            {
                i += 4;
            }
            else if (EMOJI_CODES.Any(e => e.Equals(secondString)))
            {
                i += 2;
            }
            else if (EMOJI_CODES.Any(e => e.Equals(firstString)))
            {
                i += 1;
            }
            else
            {
                // 正しい文字だけ投入
                sb.Append(Char.ConvertFromUtf32(BitConverter.ToInt32(utf32Bytes, i * uint32Size)));
                i += 1;
            }
        }

        return sb.ToString();
    }

    // 絵文字コード一覧表
    public static readonly string[] EMOJI_CODES =
    {
        "1F004",
        // 略 ~~~~~~~~
     };

文字コード一覧表をそのまま載せるとあまりにソースが長くなるので別ファイルにしました。

Unicode Emoji Code List · GitHub

あとがき

以上で絵文字の判別が出来ます。
コード見ると「はいはい、まあそうだよね」って感じですよね。

軽く調べるとサロゲートペアだけ判別すればいいよ、なんて
乱暴なコードもあったりして、ちょっと気になるなと思った次第です。

次はUnity1Weekの記事になりそうです。
うーん、もうちょっと更新したい。

【Unity】Unity2019.1で初級者でも本当に簡単にECSを実現する

ECSって難しそう

  • ECSってのがあるらしい。
  • Unityで大量のオブジェクトを表示しても軽いらしい。
  • だがまだpreview版なので、仕様変更が激しいらしい。

という噂だけ聞いていて、

「ほーんでもまだゲームに使うには早そうだぬ」

と煎餅をバリバリ食べて放置していたんですが、
重い腰を上げてやってみたら凄い簡単に出来ちゃいました。

なので、記事にして記憶を残しておこうと思います。

ECSの解説…はちょっと置いといて

ECSってなに?ComponentSystemって?といった解説記事は
沢山の先駆者が残してくれていて、自分も存分に参考にさせて頂いてます。
ちゃんと知りたい!といった方は是非読んでみてください。

tsubakit1.hateblo.jp

qiita.com

www.f-sp.com


などなど、本当にいつもお世話になっております。

まず動かしてみよう

自分は、

まず動かしてみてから、動作を見つつ用語を紐づけて理解していく

スタイルでやってみようと思いました。

まず、単純に一つのcubeをECSを利用して表示するコードを紹介します。

導入

Unity 2019.1.1f1 を使用。
このサンプルでは新規プロジェクトから行ってますが、
途中のプロジェクトでも必要なpackageをインストールすれば問題なくECS使えます。

unityが開いたらPackageManagerを開きます。

f:id:ghoul_life:20190511140847p:plain

Advancedからpreview packageを表示させます。

f:id:ghoul_life:20190511141550p:plain

以下のpackageをinstallします。

  • Burst
  • Entities
  • Hybrid Renderer
  • Mathematics
  • Jobs (後々必要になると思いますが、本当に最小なら無くても大丈夫です)

f:id:ghoul_life:20190511141453p:plain

これで事前準備は完了です。

実装

早速コードを書いていきます。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Entities;
using Unity.Transforms;
using Unity.Rendering;
using Unity.Collections;

public class ECSBootstrap : MonoBehaviour
{
    [SerializeField] private Mesh _mesh;
    [SerializeField] private Material _material;

    private const int ECS_CREATE_COUNT = 1;

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

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

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

            // Transform
            ,typeof(Translation)
        );

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

        entityManager.CreateEntity(entityArchetype, entityArray);
        for (var i = 0; i < entityArray.Length; i++)
        {
            Entity entity = entityArray[i];

            entityManager.SetSharedComponentData(entity, new RenderMesh
            {
                mesh = _mesh,
                material = _material,
            });
        }
        entityArray.Dispose();
    }
}

コードこれだけでOKです。

meshは標準で入っているcubeを、materialは適当にcreateして使用します。shaderはstandardで大丈夫です。

f:id:ghoul_life:20190511144744p:plain

実行してみましょう。

f:id:ghoul_life:20190511144932p:plain

はい、出ました。ECS簡単ですね。
画像では、Entity Debuggerというものも一緒に載せています。
これはECSで作られたEntityを確認することが出来るwindowsです。
Window > Analysisの所にあります。

f:id:ghoul_life:20190511145233p:plain

ECSを利用してオブジェクトを生成するとInspectorに表示されないため、
どんな状態になっているか把握できないことがあります。
そういった時にこれを見ると、Entityが実際に作られているか確認することが出来ます。
この例で行くと、「Entity 0」が作られているのが確認できます。

Entityって?

EntityとはGameObjectに相当する構造体の事です。
ECSでは1Entityを1つの実体として処理します。

簡単に解説

Managerを取得

EntityManagerはECSのEntityを管理するクラスです。
ECSを利用するためにまずそのManagerを取得します。

        // 現在有効なEntityManagerを取得
        var entityManager = World.Active.EntityManager;

どのようなEntityを生成するか指定

続いてどのようなEntityを生成するか?を指定します。
cubeを表示するだけならLocalToWorld , Translationは必要ないのでは?
と思うかもしれませんが、描画する位置情報を処理するarcheTypeも追加しないと
カメラ上には出てきませんので、追加しています。
(Entity的には無くてもちゃんと作られます)

        // どのようなEntityを生成するか指定
        var entityArchetype = entityManager.CreateArchetype(
            // renderer
             typeof(RenderMesh) // Meshを描画する
            ,typeof(LocalToWorld) // 座標をWorld座標に変換させ3D空間に描画する

            // Transform
            ,typeof(Translation) // 座標を指定してEntityに反映させる
        );

Entityをいくつ作るかを指定

次にそのEntityはいくつ作るか?を指定します。

        // いくつEntityを生成するかを指定。ここでは1つだが、この値を変えればその個数分作られる
        private const int ECS_CREATE_COUNT = 1;
        NativeArray<Entity> entityArray = new NativeArray<Entity>(ECS_CREATE_COUNT, Allocator.Temp);
        entityManager.CreateEntity(entityArchetype, entityArray);

Entityの初期設定

そして作ったEntityの初期設定を行います。

        // 各Entityに対する初期設定を行います。
        for (var i = 0; i < entityArray.Length; i++)
        {
            Entity entity = entityArray[i];
            
            // 各EntityのRenderMeshに使用したいmeshとmaterialを渡します。
            entityManager.SetSharedComponentData(entity, new RenderMesh
            {
                mesh = _mesh,
                material = _material,
            });
        }
        entityArray.Dispose(); // ちゃんと破棄する

こんな流れになります。

初期位置を変更してみよう

つまり、初期設定で位置を指定すれば位置を変えることが出来そうだ。
やってみましょう。

まず個数を10にします。

private const int ECS_CREATE_COUNT = 10;  // 10に変更

続いて、以下のように初期化処理にTranslationを操作する処理を追加します。

       using Unity.Mathematics; // float3はUnity.Mathematics.float3なので

        entityManager.CreateEntity(entityArchetype, entityArray);
        for (var i = 0; i < entityArray.Length; i++)
        {
            Entity entity = entityArray[i];
            entityManager.SetComponentData(entity, new Translation { Value = new float3(UnityEngine.Random.Range(-5f, 5f), UnityEngine.Random.Range(-5f, 5f), UnityEngine.Random.Range(-5f, 5f)) }); // 追加

            entityManager.SetSharedComponentData(entity, new RenderMesh
            {
                mesh = _mesh,
                material = _material,
            });
        }
        entityArray.Dispose();

実行してみましょう。

f:id:ghoul_life:20190512155018p:plain

Entityの個数が10に増え、位置がランダムになっています。
このようにentityManager.SetComponentDataで初期値を操作する事が可能です。

独自の処理を追加する

こっからです。
もちろん予め用意されたものだけではゲームなんて作れません。
独自の振る舞いを追加したいに決まっています。

初期値で定めた速度で上下に移動する処理

を追加したいと思います。

オリジナルのComponentDataを作成する

移動速度を持つComponentDataを作成します。

using Unity.Entities;

// classではなく、structなのに注意して下さい。
public struct MoveSpeedComponent : IComponentData
{
    public float _moveSpeed;
}

Entityを移動させる処理を作成する

Entityをリアルタイムで操作する処理はComponentSystemが行っています。
そのComponentSystemを独自に実装する事で振る舞いを追加することが出来ます。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Entities;
using Unity.Transforms;
using Unity.Mathematics;

[AlwaysUpdateSystem]
public class MoveSystem : ComponentSystem
{
    protected override void OnUpdate()
    {

        Entities.ForEach((ref Translation translation, ref MoveSpeedComponent moveSpeedComponent) =>
        {
            translation.Value.y += moveSpeedComponent._moveSpeed;

            // 上下反転
            if (translation.Value.y > 5f)
            {
                moveSpeedComponent._moveSpeed = -math.abs(moveSpeedComponent._moveSpeed);
            }
            if (translation.Value.y < -5f)
            {
                moveSpeedComponent._moveSpeed = +math.abs(moveSpeedComponent._moveSpeed);
            }
        });
    }
}

生成した各EntityをForEachで回して、そのEntityが持っているComponentDataの参照を取得し処理を行うように記述します。

[AlwaysUpdateSystem]

これが無いと
f:id:ghoul_life:20190512161424p:plain
このようにComponentSystemが動いていない状態になります。
独自にWorldを作成している場合はこの限りでは無いと思うのですが、この例ではWorldは一旦置いています。

archeTypeにMoveSpeedComponentを追加

作成したMoveSpeedComponentをEntityに追加します。

        var entityArchetype = entityManager.CreateArchetype(
            // renderer
             typeof(RenderMesh) // Meshを描画する
            ,typeof(LocalToWorld) // 座標をWorld座標に変換させ3D空間に描画する

            // Transform
            ,typeof(Translation) // 座標を指定してEntityに反映させる

            // Custom
            ,typeof(MoveSpeedComponent) // Entityの移動速度を指定する
        );

Entityの初期化処理に移動速度を設定

移動速度をランダム値で初期化して、Entityごとに移動速度をバラつかせます。

        entityManager.CreateEntity(entityArchetype, entityArray);
        for (var i = 0; i < entityArray.Length; i++)
        {
            Entity entity = entityArray[i];
            entityManager.SetComponentData(entity, new Translation { Value = new float3(UnityEngine.Random.Range(-5f, 5f), UnityEngine.Random.Range(-5f, 5f), UnityEngine.Random.Range(-5f, 5f)) });
            entityManager.SetComponentData(entity, new MoveSpeedComponent { _moveSpeed = UnityEngine.Random.Range(-0.5f, 0.5f) }); // 追加

            entityManager.SetSharedComponentData(entity, new RenderMesh
            {
                mesh = _mesh,
                material = _material,
            });
        }
        entityArray.Dispose();

これで独自処理追加完了です。実行してみましょう。

f:id:ghoul_life:20190512162209g:plain

Entityがランダムな速度で上下移動しているのが確認できました。

プラスワン

個数を10000にしてみましょう。

f:id:ghoul_life:20190512164208p:plain

余りに大量なため、画面が埋まっていますが、
FPSに影響が無く動作する事が確認できます。

GameObjectがTransformで行っている処理であれば
Unity.Transforms.Scale , Unity.Transforms.Rotation
なども使えますし、
色を変化させたければ、RenderMeshのmaterialからcolorを変化させたりもできます。
(materialはSharedなので、全部一緒に変わりますが、shaderを使えばもっと複雑に出来そうではあります。)

        Entities.ForEach((Entity e) => {

            var renderMesh = EntityManager.GetSharedComponentData<RenderMesh>(e);
            var material = renderMesh.material;
            // ~~~~
            renderMesh.material = material;
       });

こんな感じでRenderMeshにアクセスすることはできます。

とっかかりとして

ECSをまず動かす。という観点でやってみました。

ここから

  • どんな仕組みで動いているんだろう
  • Worldってなんだろう
  • NativeArrayって何なんだろう
  • なんでComponentDataは構造体しか使えないんだろう
  • もっと複雑な処理をECSで実現したい

といったさらに深く学習していくとっかかりとなってくれたら幸いです。

反省

4月記事書いてない!最低でも一月一記事ぐらい投稿したかった。
次はもう少しゲームらしい記事を上げたい所です。

別件でストーリーのあるゲームを作成していまして

  • ストーリーのプロット完成
  • カットシーンのラフは完成(画像などは仮だが動作や文章は出来ている)
  • 立ち絵、顔画像 (いまここ作成中)

となっています。
エターなりそうですが、出来るだけ頑張りたいと思ってます…。