ゲーム制作勉強中!あこがれだったプログラマーに今からなろう!

昔、あこがれていたプログラマー。今からでも勉強してみようと思い立ち、チャレンジ開始! 勉強メモや、悪戦苦闘な日々の記録です。

ウィザードリィライクのダンジョンゲームを作る!(4):Playerの移動に合わせてダンジョンの描画範囲を移動させる。



前回で、ダンジョンマップの設計図classのDungeonMap-classの改造と、DungeonManager-classへのObjectPoolの追加が完了したので、いよいよPlayerの移動に合わせて、ダンジョンの描画範囲を移動させる様にしていこうと思います。

描画範囲を移動させるには、Playerの向いている方向や移動方向を把握する必要があるため、PlayerController-classに管理用の変数を追加します。

    int player_Direction;//プレイヤーの向き 0:北 1:東 2:南 3:西
     Vector3Int targetPosition;

方向管理は最初enumを組んでいたのですが、簡単とはいえ計算が必要になるので、ちょっと悩んで、結局、数値に戻してしまいました🤪
enumを組んだほうが可読性とやらは上がると思うのですが、個人制作で、しかも方向管理程度なら、コメントで補足さえすれば、数値のままでも良いかなと思います。😅

他には、些細な変更ですが、将来、ユーザーが設定を変更できるようにコンフィグ画面を作成する時に備えて、暫定でGameManagerに作成しておいた、Playerの移動速度などの設定値関係を、今の内に単独のManager、ParameterManager-classを作成して移します。

さて、肝心の移動に沿って描画範囲を移動させる仕組みですが、要は、
Playerの移動先の位置を基準に、移動方向の描画範囲の末端 1グリッド分を左右に描画範囲分新たに書き足して、
Plyaerの移動前の位置を基準に、移動方向とは逆の描画範囲の末端1グリッド分を左右に描画範囲分消してやれば良いので



まずはPlayerが東西南北のどちらに向かって移動したのかが、どうしても必要になります。
Playerは後向きに進む事もできる様にするので、Playerの向きを管理しつつ、今、移動しようとしている方向を計算する必要があるわけです。
前に移動する時は、Playerの向きがそのままPlayerの移動方向になるので簡単ですが、
後ろに移動する時はPlayerの向きの逆なので、この様な方法で算出します。

 int Walking_Direction = (player_Direction -2);

 if(Walking_Direction < 0)
 {
  Walking_Direction = 4 + Walking_Direction; 
 }


移動方向が特定できたら、ようやく移動の処理の流れです。

① 入力操作から移動方向を計算する。

② 移動方向と現在位置、前方Vector(transform.forward)を、DungeonManager-classに渡した後、Playerの移動処理を行います。

③ 受け取った値から、描画する行(DrawLine)と消去する行(EraseLine)を計算し、描画と消去を行うグリッドを決めます。

④ 描画と消去を行うグリッドから、処理を行う座標を計算します。

⑤ 指定された座標のObjectの有無をチェックして描画/消去を行います。

これだけだと、よく分からないので、順を追って説明します。

まず、「① 入力操作から移動方向を計算する。」
入力操作があるとWalk関数は、Playerの移動方向と移動後の座標(targetPosition)を計算し、Walking関数に渡します。

    void Update()
    {
        //InputSystemから左スティックに指定されたデバイスの入力を受け取る。
        Vector2 LeftDirction=ISystem.Player.Move.ReadValue<Vector2>();

        if(!IsRotate && !IsWalk)//回転中・移動中は今のアクションを継続する。
        {
            Rotation(LeftDirction.x);
            Walk(LeftDirction.y);
        }
    }

    void Walk(float y)
    {
        if(y > 0.4f)
        {
            //transform.forward方向がtargetPosition(移動後の位置)になる。
            targetPosition = 
                        new Vector3Int((int)transform.position.x,0,(int)transform.position.z) + 
                        new Vector3Int((int)Math.Round(transform.forward.x * 2), 0,
                        ((int)Math.Round(transform.forward.z * 2)));

            Walking(player_Direction);//Plyaerの向きをWalking関数に送る。
        }
        else if(y < -0.4f)
        {
            //Playerの向きの逆向きを計算する。
            int Walking_Direction = (player_Direction -2);

            if(Walking_Direction < 0)
            {
                Walking_Direction = 4 + Walking_Direction;
            }

            //transform.forwardの逆側の方向がtargetPosition(移動後の位置)になる。
            targetPosition = 
                        new Vector3Int((int)transform.position.x,0,(int)transform.position.z) - 
                        new Vector3Int((int)Math.Round(transform.forward.x * 2), 0,
                        ((int)Math.Round(transform.forward.z * 2)));

            Walking(Walking_Direction);//Plyaerの向きの逆向きをWalking関数に送る。
        }
    }

①の段階でPlayerの前後移動の違いを整理して、以降の処理は単純に移動した方向で考えることができます。


「② 移動方向と現在位置、前方Vector(transform.forward)を、DungeonManager-classに渡した後、Playerの移動処理を行います。」

Walk関数から移動方向を受け取ったWalking関数は、Playerの現在位置(transeform.position)とtransform.forwardと一緒に、DungeonManager-classBuild_By_Move関数に渡した後、Playerの移動処理を行います。

    async void Walking(int player_direction)
    {
        IsWalk = true;

        dmanager.Build_By_Move(
                new Vector3Int((int)Math.Round(transform.position.x),0,(int)Math.Round(transform.position.z)),
                new Vector3Int((int)Math.Round(transform.forward.x),0,(int)Math.Round(transform.forward.z)),
                player_direction);

        //ここから下はPlayerの移動処理
        while(Vector3.Distance(transform.position, targetPosition) > Mathf.Epsilon)
        {
            transform.position = Vector3.MoveTowards(transform.position, targetPosition, pmanager.PlayerSpeed * Time.deltaTime);
            await UniTask.Yield();
        }

        transform.position = new Vector3((int)targetPosition.x, 0, (int)targetPosition.z);

        IsWalk = false;
    }

実は、この段階では①と②で処理を分ける必要はあまりないのですが、後々、Walk関数Walking関数の間で、壁チェックを入れることになるので、今の段階で分けています。


「③ 受け取った値から、描画する行(DrawLine)と消去する行(EraseLine)を計算し、描画と消去を行うグリッドを決めます。」

ここからDungeonManager-classでの、実際のダンジョンの描画と消去の処理になります。
Build_By_Move関数は、受け取った値から、描画する行(DrawLine)と消去する行(EraseLine)を計算し、DrawLineをグリッド毎にDungeonGenerator関数に描画を、EraseLineをグリッド毎にDungeonEraser関数に消去を、それぞれ依頼します。

DungeonMapでは、Playerの存在できるx,z共に奇数座標と、その北と東の壁を含んで1グリッドとして扱っているので、描画範囲を計算する場合はdrawingSizeの2倍で良いことになります。

要は、北に行く時は(x,0,z)のzの+方向に、drawingSize * 2の位置が描画範囲の末端1グリッドになる。と言う理屈です🤣

そして、描画する行(DrawLine)は、Playerの「移動先の位置を基準」に、描画範囲(drawingSize)の末端 1グリッド なので

DrawLine = new Vector3(targetPosition.x,0,targetPosition.z + (pmanager.drawingSize * 2)); 

という感じになり、
「消去する行(EraseLine)」は、Plyaerの「移動前の位置を基準」に、描画範囲の末端1グリッド  なので
先ほどの北に行く例では、逆にzを-方向にdrawingSize * 2になるので、

EraseLine = new Vector3(Playerのtransform.position.x,0,Playerのtransform.position.z - (pmanager.drawingSize * 2));

になります。
これで、描画する行(DrawLine)と消去する行(EraseLine)が計算できたので、これを「描画範囲の末端 1マス分を左右に描画範囲分」するためにfor文で回して、次の関数に送ってやります。

for(int i = - (pmanager.drawingSize ) * 2 ;
       i < (pmanager.drawingSize + 1) * 2 ;
       i +=2)
       {
           Dungeon_Generator(((int)DrawLine.x + i), (int)DrawLine.z);
           Dungeon_Eraser(((int)EraseLine.x + i), (int)EraseLine.z);
       }

これで基本はできたのですが、Playerが移動する方向によって、x,0,zのどこに増減するか変わるので、PlayerContoroller-classから受け取ったPlayerの移動方向を使って、Switchで分岐して処理を変えていきます。
なので、Build_By_Move関数の全体は

    public void Build_By_Move(Vector3Int P_position,Vector3Int P_foward, int direction)
    {
      Vector3 DrawLine;
      Vector3 EraseLine;

      Vector3Int targetPosition = P_position + new Vector3Int((int)(P_foward.x * 2), 0,
                        (int)(P_foward.z * 2)); 

      switch (direction)
      {
        case 0://北
          DrawLine = new Vector3(targetPosition.x,0,targetPosition.z + (pmanager.drawingSize * 2)); 
          EraseLine = new Vector3(P_position.x,0,P_position.z - (pmanager.drawingSize * 2));

          for(int i = - (pmanager.drawingSize ) * 2 ;
                i < (pmanager.drawingSize + 1) * 2 ;
                    i +=2)
          {
            Dungeon_Generator(((int)DrawLine.x + i), (int)DrawLine.z);
            Dungeon_Eraser(((int)EraseLine.x + i), (int)EraseLine.z);
          }
          break;

        case 1://東
          DrawLine = new Vector3(targetPosition.x + (pmanager.drawingSize * 2),0,targetPosition.z);
          EraseLine = new Vector3(P_position.x - (pmanager.drawingSize * 2),0,P_position.z); 

          for(int i = - (pmanager.drawingSize ) * 2 ;
                i < (pmanager.drawingSize + 1) * 2 ;
                    i +=2)
          {
            Dungeon_Generator((int)DrawLine.x, (int)DrawLine.z + i);
            Dungeon_Eraser((int)EraseLine.x, (int)EraseLine.z + i);
          }
          break;

        case 2://南
          DrawLine = new Vector3(targetPosition.x,0,targetPosition.z - (pmanager.drawingSize * 2));
          EraseLine = new Vector3(P_position.x,0,P_position.z + (pmanager.drawingSize * 2));

          for(int i = - (pmanager.drawingSize ) * 2 ;
                i < (pmanager.drawingSize + 1) * 2 ;
                    i +=2)
          {
            Dungeon_Generator((int)DrawLine.x + i, (int)DrawLine.z);
            Dungeon_Eraser((int)EraseLine.x + i, (int)EraseLine.z);
          }
          break;


        case 3://西
          DrawLine = new Vector3(targetPosition.x - (pmanager.drawingSize * 2),0,targetPosition.z); 
          EraseLine = new Vector3(P_position.x + (pmanager.drawingSize * 2),0,P_position.z);

          for(int i = - (pmanager.drawingSize ) * 2 ;
                i < (pmanager.drawingSize + 1) * 2 ;
                    i +=2)
          {
            Dungeon_Generator((int)DrawLine.x, (int)DrawLine.z + i);
            Dungeon_Eraser((int)EraseLine.x, (int)EraseLine.z + i);
          }
          break;
      }
    }

になります。


「④ 描画と消去を行うグリッドから、処理を行う座標を計算します。
ここは処理するマスを、座標レベルに分解してやる工程で、
描画は今までに使用していたDungeon_Generator関数を、消去はこれによく似たDungeon_Eraser関数で行います。

    private void Dungeon_Generator(int x, int y)
    {
        Check_And_Build(x, y, 0);//床と天井
        Check_And_Build(x, y + 1, 90);//北壁
        Check_And_Build(x + 1, y, 0);//東壁
    }

    private void Dungeon_Eraser(int x, int y)//消す工程なので角度は不要。
    {
        Check_And_Erase(x, y);//床と天井
        Check_And_Erase(x, y + 1);//北壁
        Check_And_Erase(x + 1, y);//東壁
    }



最後の「⑤ 指定された座標のObjectの有無をチェックして描画/消去を行います。」

座標と処理に分解された最終段階になります。
描画する場合でも消去する場合でも、まずは、指定された座標が壁の座標なのかそれともPlayerが存在する座標なのかをチェックします。
Playerが存在する座標はx軸z軸ともに奇数のため、x軸、z軸のどちらかが偶数であれば、壁の座標と判断できます。

描画を行う場合は、指定座標が壁の座標であれば、その座標のObjectの数を確認して、0なら描画を行い、1以上のObjectが存在する場合は描画を行わないようにしています。
Playerが存在する座標の場合は、その座標のObjectの数を確認して、0もしくは1なら描画を行い、2以上のObjectが存在する場合は描画を行いません。
これは、壁の座標には基本的にはObjectは1つしか存在しません(子Objectはこの時点では無視。)が、Playerが存在する座標は床と天井の2つのObjectが存在するためです。
消去を行う場合は、その座標のDungeonMapのmapからその座標のObjectの種類を、objectsからはその座標に存在するObjectそのものを、それぞれ取得してそのObjectの種類に応じたObjectPoolに返却しています。
消去を行う場合も返却するObjectの数が変わるので、壁の座標なのかPlayerの座標なのかのチェックは必要です。

    private void Check_And_Build(int posX, int posY, int rotation)
    {
      int getMapchip = map.Get(posX, posY);

        if(posX % 2 == 1 && posY % 2 == 1 &&
          (map.GetObject(posX, posY) == null || map.GetObject(posX, posY).Count == 1))
          //奇数座標なら天井と床を生成。
        {
          switch (getMapchip)
          {
            case 0:
              GetFromPool(0,posX,0,posY,rotation);//床
              GetFromPool(0,posX,2,posY,rotation);//天井

              break;

            case 1:
              break;

            default:
              break;
          }
        }
        else//偶数座標なら壁を生成。
        {
          if(getMapchip != -1 && getMapchip != 0 && map.GetObject(posX, posY) == null)
          {
              GetFromPool(getMapchip,posX,0,posY,rotation);//99は壁、98は扉
          }
        }
    }


    private void Check_And_Erase(int posX, int posY)
    {
      int getMapchip = map.Get(posX, posY);

      if(map.GetObject(posX, posY) != null)
      {
        if(posX % 2 == 1 && posY % 2 == 1)
        {
          switch (getMapchip)
          {
            case 0:
              foreach (var obj in map.GetObject(posX, posY))
              {
                ReturnToPool(map.Get(posX, posY),obj);
              }

              map.ListClear(posX, posY);
              break;

            case 1:
              break;

            default:
              break;
          }
        }
        else//偶数座標なら壁を返却。
        {
          foreach (var obj in map.GetObject(posX, posY))
          {
            ReturnToPool(map.Get(posX, posY),obj);
          }

          map.ListClear(posX, posY);
        }
      }
    }


これでPlayer移動に合わせてダンジョンの描画範囲も移動するようになりました。
ですが、まだ壁を貫通して移動できてしまっていますので、次回は壁を越えられないようにするのと、扉を開けれるようにしたいと思います。









ウィザードリィライクのダンジョンゲームを作る!(3):複数のObjectPoolをまとめて管理しやすくする。



前回まででPlayerの周りの初期描画と、Playerが自由に移動できる様になりました。
でも、まだ壁はすり抜けるし、初期描画の範囲を超えると何もない空間が広がっているだけという状況なので、次はPlayerの移動に合わせて、描画範囲を増やしていく仕組みを作っていきたいと思います。
でも増やしていくだけだと、結局はダンジョン全部を描画することになってしまうので、Playerの移動方向は描画し、Playerの後方の描画済みのエリアは消去していく、
www.youtube.com
こんな感じが、移動による描画の最終形です。

そのためには、今まで作ったコードも結構改造する必要があるので、今日はまず、ダンジョンマップの設計図classのDungeonMapの改造と、DungeonBuilder改めDungeonManagerObjectPoolを追加するところまでやろうと思います。

まずは、DungeonMapの改造
今まではダンジョンマップの0と1だけのint型2次元配列を持つだけでしたが、ダンジョンの描画と消去を行うためには、消去したいエリアのObjectを特定する必要があるので、Object管理用の2次元配列を新たに追加します。

int[, ] map; //元々用意されていたダンジョンマップの設計図用2次元配列

List<GameObject>[,] objects; //今回追加したObject管理用の2次元配列

mapの同じ座標に、床と天井の二種類のObjectを保管する必要があるので、GameObject型の2次元配列ではなく、GameObject型のListを格納する2次元配列にしています。

次に、このobjectsの初期化や、Objectの格納、Object取得を行うための関数を追加します。

    public void Objects_Reset()//2次元配列初期化用
    {
        for (int i = 0; i < objects.GetLength(0); i++)
        {
            for (int j = 0; j < objects.GetLength(1); j++)
            {
                objects[i, j] = new List<GameObject>();
            }
        }
    }

    public List<GameObject> GetObject(int x, int y)//指定座標のObject有無チェック
    {
        if (x < 0 || x >= map.GetLength(0) || y < 0 || y >= map.GetLength(1))
        {
            return null;
        }

        if (objects[x, y].Count == 0)
        {
            return null;
        }
        else
        {
            return objects[x, y];//Objectがあれば、そのlistを返す。
        }
    }

    public void SetObject(int x, int y, GameObject obj)//Objectを指定座標のListに追加する。
    {
        if (x < 0 || x >= map.GetLength(0) || y < 0 || y >= map.GetLength(1))
        {
            return;
        }

        objects[x, y].Add(obj); 
    }

    public void ListClear(int x, int y)//指定座標のみのList初期化
    {
        if (x < 0 || x >= map.GetLength(0) || y < 0 || y >= map.GetLength(1))
        {
            return;
        }

        objects[x, y] = new List<GameObject>();
    }


そして忘れない様に、コンストラクタにObject_Reset関数を呼び出すのを追加してやり、objectsに空のListを配置させます。

あとは、ダンジョンの設計図の定義も変えておきます。
0が通路、1が壁にしていましたが、壁を1ではなく99に修正して、98として扉を追加しています。

    public DungeonMap()
    {
        map = new int[15, 15]{
          // 0    1   2   3   4   5   6   7   8   9  10  11  12  13  14 
            {99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99},//→Y座標 //0
            {99,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0, 99},//↓x座標  //1
            {99,  0, 99, 99, 99, 99, 99,  0, 99, 99, 99, 99, 99,  0, 99},//2  
            {99,  0, 99,  0,  0,  0, 99,  0, 99,  0,  0,  0, 99,  0, 99},//3
            {99,  0, 99,  0,  0,  0, 99,  0, 99,  0,  0,  0, 99,  0, 99},//4
            {99,  0, 99,  0,  0,  0, 99,  0, 99,  0,  0,  0, 98,  0, 99},//5
            {99,  0, 99, 98, 99, 99, 99,  0, 99, 99, 99, 99, 99,  0, 99},//6
            {99,  0, 99,  0,  0,  0,  0,  0, 99,  0,  0,  0,  0,  0, 99},//7
            {99,  0, 99, 99, 99, 99, 99, 99, 99,  0, 99, 99, 99, 99, 99},//8
            {99,  0, 99,  0,  0,  0, 98,  0, 99,  0, 99,  0,  0,  0, 99},//9
            {99,  0, 99,  0,  0,  0, 99,  0, 99,  0, 99,  0,  0,  0, 99},//10
            {99,  0, 99,  0,  0,  0, 99,  0, 99,  0, 98,  0,  0,  0, 99},//19
            {99,  0, 99,  0,  0,  0, 99,  0, 99,  0, 99,  0,  0,  0, 99},//12
            {99,  0, 98,  0,  0,  0, 99,  0,  0,  0, 99,  0,  0,  0, 99},//13
            {99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99} //14
        };

        objects = new List<GameObject>[15, 15];
        Objects_Reset();//objectsに空のListを配置する。
    }

壁と示す数字の変更は今やらなくても良い修正ですが、後で壁方向には進めなくする判定に使うのと、今回説明するObjectPoolで出てくるタグが、ダンジョン設計のこの数字と関連してくるので、今の段階で変えておいた方が楽になるからです。


ダンジョンマップの設計図classのDungeonMapの改造は終わったので、次はDungeonBuilder改め、DungeonManagerObjectPoolを追加します。
ObjectPool単体では一種類のGameObjectしかPoolできないので、今回の様に壁や床や扉といった複数のGameObjectをPoolするためには、その種類毎にObjectPoolを作る必要があります。
ただ、それではマップを描画する際、いちいちその座標の数字を読んで、それに沿ったObjectPoolからGameObjectをとってくる必要があり、ちょっと面倒なので、ObjectPoolをまとめて管理しやすくする方法を紹介します。

まず、個々のObjectPoolインスペクターで設定できる様にします。
tagに設定する数字がGameObjectのタグに当たり、先ほどのダンジョン設計の数字と同じ値になります。
つまり、99は壁で98は扉のObjectPoolになる様にします。

    [System.Serializable]
    public class Pool
    {
      public int tag; //この数字がマップの数字とリンクする。
      public GameObject prefab;
      public int size;//ObjectPoolの初期容量になる。
    }

    public List<Pool> pools;//今作成したPool classのListをインスペクターで作成できる様になる。

次に、ObjectPoolをまとめて管理するための辞書型変数を作成し、
先ほどのpools Listの数だけObjectPoolを作成して、辞書型変数に追加してやります。

    public Dictionary<int,ObjectPool<GameObject>> poolDictionary;

    private void Awake() 
    { 
      poolDictionary = new Dictionary<int, ObjectPool<GameObject>>();

      foreach(Pool pool in pools) 
      {
        ObjectPool<GameObject> objectPool = new ObjectPool<GameObject>(
          createFunc: () => Instantiate(pool.prefab),
          actionOnGet: (obj) => obj.SetActive(true),
          actionOnRelease: (obj) => obj.SetActive(false),
          actionOnDestroy: (obj) => Destroy(obj),
          collectionCheck: false,
          defaultCapacity: pool.size
          );

        poolDictionary.Add(pool.tag, objectPool);
      }

これで複数のObjectPoolを、1つの辞書型変数poolDictionaryにまとめることができます。
あとはこの辞書型変数poolDictionaryにタグ番号で使いたいObjectPoolを指定してGetReleaseを使って、目的のObjectを扱う事ができるので、ついでにGetReleaseの関数も作成してしまうことにします。

  public GameObject GetFromPool(int tag,int X, int Y, int Z,int rotation)
  {
    if(!poolDictionary.ContainsKey(tag))//存在しないタグなら何も返さない。
    {
      return null;
    }

    GameObject obj = poolDictionary[tag].Get();
    obj.transform.SetParent(transform);
    obj.transform.position = new Vector3(X, Y, Z);
    obj.transform.rotation = Quaternion.Euler(0, rotation, 0);
    map.SetObject(X, Z, obj);//DungeonMapのobjects配列に出現するObjectを追加する。

    return obj;
  }

  public void ReturnToPool(int tag, GameObject obj)
  {
    if(!poolDictionary.ContainsKey(tag))//存在しないタグなら受け取らない。
    {
      return;
    }

    poolDictionary[tag].Release(obj);
  }


辞書型変数poolDictionaryの使い方で注意しなければならない事として、
Getしてくるときはまだ良いのですが、Releaseする時にもタグが必要になってくるので、どのObjectをPoolに返却するのかまで管理する必要があるので、そのあたりの仕組みも含めて、次回、Playerの移動に合わせてダンジョンの描画範囲を変えていく工程について説明できればと思います。








UniRX・R3の購読終了(Dispose)について



昨日の実験をしただけでずいぶんUniRXへの理解が進んだ気がする。
・・・気がするだけだけど😅

今までは、変数を通知してくれるのがUniRXだと、漠然と思っていたけれど、
それはむしろ副産物で、本質はイベントを作ることができ、任意のクラスでそれを購読して
イベント駆動型のプログラミングを可能にしてくれるという方がいいみたい。

今日は、昨日の@toRisouPさんの記事の
qiita.com
を実験してみた。

昨日は文字列を通知するSubjectだったけれど、今日は特に値を持たない、イベントとして通知だけを行うUnit型の実験

まずは通知側

using UnityEngine;
using R3;
using Cysharp.Threading.Tasks;
using System;

public class Observable2 : MonoBehaviour
{
    //通知のみを行うSubject
    public Subject<Unit> UnitTestSubject = new Subject<Unit>();

    void Start()
    {
        Observable_Test();
    }

    async void Observable_Test()
    {
        int count = 0;

        while (true)
        {
            await UniTask.Delay(TimeSpan.FromSeconds(1));
            if (count < 3)
            {
                //OnNextで通知を発行する。
                UnitTestSubject.OnNext(Unit.Default);
            }
            else
            {
                UnitTestSubject.OnCompleted();
                break;
            }

            Debug.Log("Observable_Test:by Observable2 : " + count);
            count++;

        }
    }
}


次は購読する側

using UnityEngine;
using R3;

public class View2 : MonoBehaviour
{
    [SerializeField]Observable2 observable2;

    void Start()
    {
        observable2.UnitTestSubject.Subscribe(
            _ => Debug.Log("by View:Event has been fired")
            );
    }
}



次にOnErrorの実験といきたかったけれど、OnErrorはUniRXとR3で挙動が変わった様で、
色々なドキュメントを読んでみたけれど、ちょっと理解できなかった😅

要は、UniRXでは例外を通知しようとすると購読が止まってしまうけれど、OnErrorRetryなどで
通知(購読?)を再開できる。
R3では例外では購読は止まらない様になった。

・・・という事なのか?

R3のOnErrorResumeとかについても、実験してみたが今ひとつ挙動が理解できなかった。

そして、UniRXでの

var subject = new subject<int>;
subject.Subscribe(
    x => Debug.Log(x),//通常時
    ex => Debug.Log("Error")//エラーの時
    () => Debug.Log("Completed")//購読を終了した時
);

() =>というのができなくなっていた。
結果的にいうと、UniRXではエラーの時に使用されていたexがR3では購読終了時に変わっている様子。

//Observable側
public class Observable2 : MonoBehaviour
{
    public Subject<int> Numbers = new Subject<int>();

    void Start()
    {
        Observable_Test();
    }

    async void Observable_Test()
    {
        int count = 0;

        while (true)
        {
            await UniTask.Delay(TimeSpan.FromSeconds(1));
            if (count < 3)
            {
                Numbers.OnNext(count);
            }
            if (count > 2)
            {
                Numbers.OnCompleted();
                break;                
            }

            Debug.Log("from Observable2 Log: " + count);
            count++;

        }
    }
}

//Observer側
public class View2 : MonoBehaviour
{
    [SerializeField]Observable2 observable2;

    void Start()
    {
        observable2.Numbers
            .Subscribe(
                x => Debug.Log("from View Log: Get " + x),
                ex => Debug.Log("from View Log: " + ex)
            );
    }
}



とりあえず、購読が終了したことの通知を受け取る事はR3でもできるみたい。

次に、購読側から購読を終了する方法について
どうやら、Subject(Observable側)Subscribe(Observaer側)の関係性を保持しているのはSubject側らしい。
例えばEnemyManagerクラスSubjectEnemyクラスからSubscribeするとき、EnemyクラスDestroyされても、EnemyManagerクラスSubjectにはSubscribeが保持されたままになっている。

//Observable側
public class Observable2 : MonoBehaviour
{
    public Subject<int> Numbers = new Subject<int>();

    void Start()
    {
        Observable_Test();
    }

    async void Observable_Test()
    {
        int count = 0;

        while (true)
        {
            await UniTask.Delay(TimeSpan.FromSeconds(1));
            if (count < 5)
            {
                Numbers.OnNext(count);
            }
            if (count > 4)
            {
                Numbers.OnCompleted();
                break;                
            }

            Debug.Log("from Observable2 Log: " + count);
            count++;

        }
    }
}

//Observer側
public class View2 : MonoBehaviour
{
    [SerializeField]Observable2 observable2;

    void Start()
    {
        observable2.Numbers
            .Subscribe(
                x => {if (x == 2)
                {
                    Destroy(gameObject);
                    Debug.Log(transform.position);
                }
                else
                {
                    Debug.Log(transform.position);
                }},
                ex => Debug.Log("from View Log: " + ex));
    }
}

このようにエラーの原因になったり、エラーが起こらないまでも無駄な処理でパフォーマンスに悪影響を及ぼしてしまう。
なので、Subscribeをする必要がなくなる時は、この関係性を終了させる必要があるらしい。

購読関係を終わらせる方法は、
・Observable側でOnCompletedが実行される。
・Observer側でDisposeが実行される。
のいずれかで行う。

Observer側でDisposeを実行する方法は2つあり、

//Observer側
using UnityEngine;
using R3;
using System;

public class View2 : MonoBehaviour
{
    [SerializeField]Observable2 observable2;
    private CompositeDisposable disposable = new CompositeDisposable();
    //UnitaskのCancellationTokenのようなものらしい・・・

    void Start()
    {
        observable2.Numbers
            .Subscribe(
                x => {if (x == 2)
                {
                    Destroy(gameObject);
                    disposable.Dispose();//ここで購読終了
                    Debug.Log(transform.position);
                }
                else
                {
                    Debug.Log(transform.position);
                }},
                ex => Debug.Log("from View Log: " + ex))
            
            .AddTo(disposable);//先ほどのCompositeDisposableを渡しておく
    }
}

この方法か、

//Observer側
using UnityEngine;
using R3;

public class View2 : MonoBehaviour
{
    [SerializeField]Observable2 observable2;

    void Start()
    {
 
        observable2.Numbers
            .Subscribe(
                x => {if (x == 2)
                {
                    Destroy(gameObject);
                    Debug.Log(transform.position);
                }
                else
                {
                    Debug.Log(transform.position);
                }},
                ex => Debug.Log("from View Log: " + ex))
            
            .AddTo(this); //←GameObjectが破壊された時にDisposeしてくれる。
    }
}

のいずれかが必要。



購読側のGameObjectが破壊されるなら後者の方法で良さそうだけど、そうでない時、例えば、ObjectPoolに返却する時は、前者の方法が必要という事だろうか。








今更ながらUniRXの勉強



「UniRX/UniTask完全理解」(ISBN-10:4048930753)
ちょっと前に買ったまでは良かったけれど、いかんせん素人には難しくて理解しきれず、完全にほったらかし状態😅

今回、R3の実験をしていて興味が湧いたので、今更ではあるけれどUniRXをきちんと勉強してみようと思う。

今の所の理解は、UniRX(R3)でできるのは、変数の変化を通知できるという程度。
変数をReactiveProperty化することで、値が変わった事を購読側で検知できる様にできる。
ただ、これは、UniRXの全てなわけではないらしい。

というわけであっちこっちのサイトや動画にあったものを実験してみようと思う。
今日はこちらの
qiita.com
を自分なりの理解でやってみる。

まずは通知側

using UnityEngine;
using R3;
using System.Collections;

public class Observable : MonoBehaviour
{
    //string型を通知するSubjectを宣言する。
    public Subject<string> fruitSubject = new Subject<string>();
    private string[] data = new string[] { "apple", "banana", "pine", "melon", "orange" };

    private void Start() 
    {
        StartCoroutine(CountCoroutine());
    }

    IEnumerator CountCoroutine()
    {
        int num = 0;
        while (num < 5)
        {
            //配列変数の中身をfruitSubjectで通知させる。
            fruitSubject.OnNext(data[num]);
            num++;

            yield return new WaitForSeconds(1);
        }
    }
}


次は購読する側

using UnityEngine;
using R3;

public class View : MonoBehaviour
{
    public Observable observable;

    //ここはStart関数で良い。単にObservableより先に購読設定したかっただけw
    private void Awake() 
    {

                        //このxはなんでもいい。このxに通知された値が入ってくる。
        observable.timerSubject.Subscribe(x => 
            {
                Debug.Log("fruit: " + x + " get!");
            });
    }
}



ReactivePropertyの様に、ある意味、強制的に変数の変化を通知するのではなく、自分で通知のタイミングをコントロールすることができるは嬉しい。
さらにこれの何が便利かというと、fruitSubjectOnNextで渡す値は、その時々で自由に変えられるのが更に良い。
これだけでも、色々とできることがありそうな気がする。

次に、通知されたフルーツの中からpineとmelonだけが欲しいという場合、フィルター設定を入れることもできる。
このフィルターをしてくれる機能をUniRX(R3)ではオペレーターと呼ぶらしい。
通知側は何も変えなくて、購読側のみにオペレーターを設定してみる。

using UnityEngine;
using R3;

public class View : MonoBehaviour
{
    public Observable observable;

    private void Awake() 
    {
        observable.timerSubject.Subscribe(x => 
            .Where(x => x == "pine"|| x == "melon")//条件を満たすものだけ通す
            .Subscribe(x => 
            {
                Debug.Log("fruit: " + x + " get!");
            });
    }
}



通知側(Observable)が同じ値を通知しても、購読する側(Observer)でフィルタリングして処理を実行できるなら、色々と応用できそうだ。

ここまでだけでも、かなり有用だ。本当、なんでもっと早く勉強しなかったんだろう・・・😅

・・・いや、違うな。
配列変数を通知したいから色々と勉強する気になって
色々と記事を読んだから、やっとここまで理解できる様になったんだろうなぁ・・・🧐








Unity R3環境下での配列変数の購読について



UniRXを現代に合わせてより洗練した形に再定義・再実装したR3。
ただ、素人向けの情報がまだまだ少なく、ちょっとした事をするのも苦労する有様です。

配列変数の変化を購読したかったのですが、ちょっと、いやだいぶん苦戦したので、
備忘録として残しておきます。

UniRXでは、配列変数もReactiveCollectionでObservableでき、Observer側(購読する側)は
ObserveReplace().Subscribeで通知を受け取る事ができました。

ところが、R3環境ではReactiveCollectionがなくなっているので、他の方法を使う必要があ流みたいです。

色々と実験したのですが、配列変数のままでObservableさせる方法は、どうしても良くわからなかったので
ObservableList型や、ObservableDictionary型で考えるのが良いのかもしれません。

一応、配列変数のままでの購読は、UniRXやR3とは関係のない、C#として標準的な .NET の機能で対応する
こともできました。

.NET の機能で行う場合の、Observable側(購読される側)

using UnityEngine;
using System.Collections.ObjectModel;

public class Test : MonoBehaviour
{
    public ObservableCollection<bool> testCollections; 

    float time = 0;

    private void Start() 
    {
        testCollections = new ObservableCollection<bool>(){true,false,false,false};

    }

    void Update()
    {   
        time += Time.deltaTime;
        if(time > 2)
        {
            time = 0;
            testCollections[3] = !testCollections[3];
            testCollections.Add(true);
            }
    }



.NET の機能で行う場合の、Observer側(購読する側)

using UnityEngine;
using System.Collections.Specialized;

public class Reader : MonoBehaviour
{
    [SerializeField]Test test;

    void Start()
    {
        test.testCollections.CollectionChanged += OnCollectionChanged;//購読する配列に変化があった際の関数を定義
    }

    void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        Debug.Log("CollectionChanged");

        switch (e.Action)
        {
            case NotifyCollectionChangedAction.Add://配列変数に追加があった際
                Debug.Log("Add : " + 
                        e.NewStartingIndex + " : " //配列変数の新しいindex
                        + e.NewItems[0]); //追加された値

                break;
            case NotifyCollectionChangedAction.Remove:
                Debug.Log("Remove " + e.OldStartingIndex + " : " + e.OldItems[0]);
                break;

            case NotifyCollectionChangedAction.Replace:
                Debug.Log(e.NewStartingIndex);//配列変数で変化のあったindex
                Debug.Log("Old Value: " + e.OldItems[0]//変化前の値
                        +" → New Value: "  + e.NewItems[0]);//変化後の値
                break;
            case NotifyCollectionChangedAction.Move:
                Debug.Log("Move" + e.OldStartingIndex + " to " + e.NewStartingIndex);
                break;
            case NotifyCollectionChangedAction.Reset:
                Debug.Log("Reset");
                break;
        }
    }
}


これで一応、配列変数の購読は可能でしたが、ネットの記事を色々読んでいると、どうやらこちらは
パフォーマンスが低い場合があり、大量のデータ操作や頻繁な変更が発生すると、パフォーマンスに
悪影響が出ることがあるらしい。

個人でちょっとした使い方ならこちらでも良いのかもしれないけれど、パフォーマンスに影響がある
のはできるだけ避けたいところなので、ObservableCollectionsを使った方法で試してみる事にします。

似ているけれどObservableCollectionではなく、ObservableCollection「s」らしい。
開発者様の意気込みを感じる・・・😁

配列変数のままについては良くわからなかったので、まずは、使い方がまんま参考文献に記載されている辞書型変数で考えてみる。
要は配列変数の変数名[x]=Yの形を、辞書型の変数名(x、Y)・・・xは配列変数のindex、辞書型のKeyとして考える
ということらしい。

それを踏まえた上で、Observable側(購読される側)

using UnityEngine;
using ObservableCollections;

public class Test : MonoBehaviour
{
    public ObservableDictionary<int, bool> testCollections = new ObservableDictionary<int, bool>();

    float time = 0;

    private void Start() 
    {
        testCollections.Add(0,false);
        testCollections.Add(1,false);
        testCollections.Add(2,false);
        testCollections.Add(3,false);

        Debug.Log(testCollections.Count);
    }

    void Update()
    {   
        time += Time.deltaTime;
        if(time > 2)
        {
            time = 0;
            testCollections[3] = !testCollections[3];
            testCollections.Add(testCollections.Count,true);
            Debug.Log(testCollections.Count);
        }
    }


Observer側(購読する側)は、

using UnityEngine;
using R3;
using ObservableCollections;
using System.Collections.Specialized;

public class Reader : MonoBehaviour
{
    [SerializeField]Test test;

    float time = 0;

    void Start()
    {
        test.testCollections.ObserveAdd()//追加があった際の設定
            .Subscribe(x =>
            {
                var (key,value)=x.Value;
                Debug.Log($"Add [{key}]={value}");              
            });

        test.testCollections.ObserveReplace()//変更があった際の設定
            .Subscribe(x =>
            {
                var key = x.NewValue.Key;
                var newValue = x.NewValue.Value;
                var oldValue = x.OldValue.Value;
                Debug.Log($"Replace [{key}]={oldValue} -> {newValue}");
            });
    }
}


次にObservableList型の時、
基本的には似た感じになるからなのか、これに特化した記事がまだ見つけられていないので、
試行錯誤しながら確認してみた。

Observable側(購読される側)

using UnityEngine;
using ObservableCollections;

public class Test : MonoBehaviour
{
    public ObservableDictionary<int, bool> testCollections = new ObservableDictionary<int, bool>();
    public ObservableList<bool> testCollections = new ObservableList<bool>(new bool[] { true, false, false, false });


    float time = 0;

    void Update()
    {   
        time += Time.deltaTime;
        if(time > 2)
        {
            time = 0;
            testCollections[3] = !testCollections[3];
            testCollections.Add(true);
            Debug.Log(testCollections.Count);
        }
    }
}


Observer側(購読する側)は、

using UnityEngine;
using R3;
using ObservableCollections;
using System.Collections.Specialized;

public class Reader : MonoBehaviour
{
    [SerializeField]Test test;

    float time = 0;

    void Start()
    {
        test.testCollections.ObserveAdd()//追加があった際の設定
            .Subscribe(x =>
            {
                Debug.Log(x.Index )//追加されたindex
                Debug.Log(x.Value )//追加された値
            });

        test.testCollections.ObserveReplace()//変更があった際の設定
            .Subscribe(x =>
            {
                Debug.Log(x.Index);//変更されたindex
                Debug.Log(x.OldValue);変更前の値
                Debug.Log(x.NewValue);変更後の値
            });
    }
}

これで一応、やりたい事(配列変数の変化を購読したい。)に近いことはできたと思う。

ただこれが二次元配列とかになると、そう簡単ではなさそうなので、色々と工夫は必要かもしれない。(二次元配列を通知しないといけない事態がどれくらいあるのかは置いといて・・・😅)

とりあえず、今作っているゲームはObservableListでやってみるかなぁ・・・🧐






3Dアクションゲーム大改造計画②:魔法陣改造


ちょっと前に勉強のために作った3Dアクションゲーム。
unityroom.com
昨日に引き続き、こいつの大改造です。
今日は、納品いただいた音源の中から、ダメージを受けた時と音、ジェムを拾った時の音、メニュー選択の移動音と、そして、魔法陣発動中の音を実装してみようと思います。

ダメージを受けた時と音、ジェムを拾った時の音、メニュー選択の移動音は、特に再生するタイミングが変わるわけではないので、ScriptableObjectにそのままセットするだけで変更完了。

魔法陣は、今までは発動時のみの音で、発動中は特に音を鳴らしていませんでした。
今回納品いただいたのは、魔法陣発動中の音なので、コードの改造が必要です。

視聴してみると、通常鳴っている音楽と一緒に鳴らすのは、ちょっとイメージと違う気がしたので、魔法陣発動中はこの音源だけにフェードさせていくことにしました。

早速改造開始です。

まず、音源管理のScriptableObject、AudioSOの魔法陣用の音源が1つだったのを2種類に変更。

//AudioSO
public AudioClip CircleSE; //魔法陣が出現している時の音楽
public AudioClip PlayHolyAreaSE; //元々の魔法陣出現時の音


GameManager にも管理用の変数を追加。

//GameManager
public AudioSource HolyArea_bgm;//ホーリーエリア用AudioSource取り込み
float FadeTime_at_HolyAreaBgm;//聖域音楽のフェードアウト用
public bool HolyArea = false;//聖域出現判定用


改造前は魔法陣出現はHolyArea.csに任せていましたが、
魔法陣の出現に関係なく、魔法陣音源のフェードアウト・フェードインの管理を行う必要があるため、HolyArea.csには魔法陣出現時の音だけ鳴らさせ、魔法陣出現中の音源管理はGameManagerにさせることにした。

//HolyArea.cs
playerController.audioSource.PlayOneShot(AudioSO.Entity.PlayHolyAreaSE); //聖域出現時の音を鳴らす
gameManager.HolyArea = true; //GameManagerに聖域音源管理を開始させる。

そして、Game Managerに魔法陣音源管理の処理を追加します。
難しいことはしていません。
HolyAreaがTrueになったら通常BGMをフェードアウトして、魔法陣音楽をフェードインさせています。

//GameManager(Update関数内)
if(HolyArea==true && HolyArea_bgm.isPlaying == false)
{
   HolyArea_bgm.Play();
   FadeTime_at_HolyAreaBgm = 0;
   FadeTime = FadeSpan;
}
else if(HolyArea==true && HolyArea_bgm.isPlaying == true)
{
   FadeTime_at_HolyAreaBgm += Time.deltaTime; 
   FadeTime_at_HolyAreaBgm = Mathf.Clamp(FadeTime_at_HolyAreaBgm, 0,FadeSpan); 

   FadeTime -= Time.deltaTime;

   HolyArea_bgm.volume = (FadeTime_at_HolyAreaBgm / FadeSpan);
   bgm.volume = (FadeTime / FadeSpan);//BGMを徐々にボリュームをお落とす。

   if(HolyArea_bgm.time > 25)
   {
      HolyArea = false;
   }
}
else if(HolyArea==false && HolyArea_bgm.isPlaying == true)
{
   FadeTime += Time.deltaTime * 2; 
   FadeTime = Mathf.Clamp(FadeTime, 0,FadeSpan); 

   FadeTime_at_HolyAreaBgm -= Time.deltaTime * 2;

   HolyArea_bgm.volume = (FadeTime_at_HolyAreaBgm / FadeSpan);
   bgm.volume = (FadeTime / FadeSpan);

   if(FadeTime > FadeSpan * 0.9)
   {
      HolyArea_bgm.volume = 0;
      bgm.volume = 1;
      HolyArea_bgm.Stop();
   }
}


結果はこんな感じです。
youtu.be
さて、次はどれを実装しようかな〜😍







3Dアクションゲーム大改造計画①:足音を追加


今日はUnity。というか、しばらく、Unity。
ちょっと前に勉強のために作った3Dアクションゲーム。
unityroom.com
音については、あちこちのフリーサイトから集めてきていましたが、この度、この拙作に音をプロデュースしてくださると、とある方々からお声がけ頂きました🤩

新しいシチュエーションの音パターンも提供いただけるので、今日から順次実装していきます!

まずは足音。
いくつかのyoutubeで足音の実装方法を学んではいたけれど、納得いく音素材が見つけられなかったので、このゲームについては、そのまま足音無しにしていました。

でも、今回提供いただく予定の音源の中に、草原・砂漠・ダンジョンの3パターンがあり、
まず砂漠パターンの音源が納品されましたので、早速実装してみたいと思います。

まず、音源管理のScriptableObject、AudioSOに足音の音源を登録。

//AudioSO

public AudioClip[] WalkingAtGrasslandSE ;//草原の足音
public AudioClip[] WalkingAtDesertSE;//砂漠の足音
public AudioClip[] WalkingAtDungeonSE;//ダンジョンの足音


次にGameManager に足音管理用の変数を追加。

//GameManager

public int WalkingArea = 1;//足音判定用 0:草原、1:砂漠、2:ダンジョン

更に、Player Controllerに足音再生用のAudioSource格納用の変数を追加。

//PlayerController

public AudioSource audioSource_FootSteps; //足音用のAudioSource格納用(Ground CheckerのAudioSource)


そして、足音を鳴らすコードを追加。

//PlayerController

public void Play_FootSteps()
{
    int StepSE = UnityEngine.Random.Range(0, 5);

    switch(gameManager.WalkingArea)
    {
        case 0:
            break;
        case 1:
            audioSource_FootSteps.PlayOneShot(AudioSO.Entity.WalkingAtDesertSE[StepSE]);
            break;
        case 2:
         break;
        }
    }


そしてそして、走るアニメーションクリップにこの関数を呼び出すトリガーを追加する。

youtu.be
やっぱり、足音があるのって自然😍