ルーグの備忘録

主にC#についてまとめてます。

Minecraft ForgeでMod作ってみた

Minecraft Forgeとは

Minecraft Forgeは、マイクラ(マインクラフト)において、他のMODの導入の前提となるシステムMODです。 他のMOD同士が衝突しないようにロードする機能や、そのMODを簡単に制作できる機能が含まれています。

artisan-select.com

成果物

ゲーム『VALORANT』に登場するソーヴァの各スキルをできるだけ再現した。
Javaの勉強から始めて、完成までにだいたい1ヶ月くらいかかった。

youtu.be

感想

はじめてのJavaだったけど、C#にすごく似てる言語だからそこまで苦じゃなかった。
イクラってこういう楽しみ方もできるんだなー。新発見でした。

Enum Flagsで入力を検知する

Let's bit演算

qiita.com

bit演算の基本(演算子の意味とか)についてはこちらを参照。

Unityでbit演算

note.dokeep.jp

UnityだとEnum Flagsで使う。

使用例

Enum Flagsを使って入力を検知する。

定義

[Flags]
public enum KeyCodeFlag
{
    None = 0,

    // ----- 移動 ----- //
    W = 1 << 0,
    A = 1 << 1,
    S = 1 << 2,
    D = 1 << 3,
    Right_Shift = 1 << 4,

    // 攻撃
    E = 1 << 5,

    // 戻る
    Q = 1 << 6,

    // メニュー
    M = 1 << 7,

    // Ui決定
    Return = 1 << 8,

    // スキル
    One = 1 << 9,
    Two = 1 << 10,
    Three = 1 << 11,
}

検知

    /// <summary>
    /// 入力中ずっと
    /// </summary>
    /// <returns></returns>
 private KeyCodeFlag CreateGetKeyFlag()
    {
        var flag = KeyCodeFlag.None;

        if (Input.GetKey(KeyCode.W))
            flag |= KeyCodeFlag.W;

        if (Input.GetKey(KeyCode.A))
            flag |= KeyCodeFlag.A;

        if (Input.GetKey(KeyCode.S))
            flag |= KeyCodeFlag.S;

        if (Input.GetKey(KeyCode.D))
            flag |= KeyCodeFlag.D;

        if (Input.GetKey(KeyCode.RightShift))
            flag |= KeyCodeFlag.Right_Shift;

        if (Input.GetKey(KeyCode.E))
            flag |= KeyCodeFlag.E;

        if (Input.GetKey(KeyCode.Q))
            flag |= KeyCodeFlag.Q;

        if (Input.GetKey(KeyCode.M))
            flag |= KeyCodeFlag.M;

        if (Input.GetKey(KeyCode.Return))
            flag |= KeyCodeFlag.Return;

        if (Input.GetKey(KeyCode.Alpha1))
            flag |= KeyCodeFlag.One;

        if (Input.GetKey(KeyCode.Alpha2))
            flag |= KeyCodeFlag.Two;

        if (Input.GetKey(KeyCode.Alpha3))
            flag |= KeyCodeFlag.Three;

        return flag;
    }

判定

// 拡張メソッド
static class EnumExtensions
{
    public static bool HasBitFlag(this KeyCodeFlag value, KeyCodeFlag flag) => (value & flag) == flag;
}

ガベージコレクションとは

ガベージコレクションとは

e-words.jp

ガベージコレクションとは、コンピュータプログラムの実行環境などが備える機能の一つで、実行中のプログラムが占有していたメモリ領域のうち不要になったものを自動的に解放し、空き領域として再利用できるようにするもの。そのような処理を実行するプログラムを「ガベージコレクタ」(garbage collector)という。

よくGCと略されて呼ばれる。
ちなみにGCで断片化したメモリを整理するコンパクションという処理もあるが、これは厳密にはGCとは区別される。

GCの種類

GCやコンパクションといったメモリに関わる処理はとても重いので、できる限り避けた方がいい。
UnityのGCはBoehm GCという名前で、これはGCが走るとそれ以外の処理を全て止めてしまうStop the World方式となっている。
またBoehm GCではコンパクションは行わない。

具体的にどうすればよいのか

以下の記事が分かりやすい。
light11.hatenadiary.com

また、以前にクロージャは良くないということは書いたが、これはGCを防ぐためである。
lougestudy.hatenablog.jp

仮想メソッドと脱仮想化

仮想メソッドとは

“virtual”が頭に付いた関数やインターフェイス内の関数のこと。継承先のクラスでoverrideすることで、名前は同じなのにクラスによって違う動きをするメソッドを作ることができる。このような性質を多態性という。

仮想メソッドの内部実装

仮想メソッドは仮想関数テーブル(virtual function table)という手法を使って実装されている。C++の知識が必要らしいが、一応知っておきたい気はする。でも今回はスルーで。

仮想メソッドの実行コスト

仮想メソッドの実行コストとなる要因は2つ。

  1. 仮想関数テーブルを引くための間接参照の増加
  2. インライン展開ができない

特に後者の方がパフォーマンスに大きく影響する。

インライン展開とは

インライン展開とは、関数として呼び出すよりも中身を展開してしまった方が確実に良いと判定できる関数に対して、呼び出し箇所に関数の中身を展開するコンパイラーの最適化処理のこと。
関数の呼び出しや戻り時のジャンプによって発生するコストを無くすことができる。

public class InlineSample : MonoBehaviour
{
    private void Start()
    {
        string name = Name(); // Name()はインライン展開で"Louge"に置き換えられる
    }

    public string Name() => "Louge";
}

脱仮想化(devirtualization)とは

脱仮想化とは、メソッド内でさかのぼれば具体的な型がわかる場合に、仮装メソッドを通常のメソッド呼び出しに置き換えるコンパイラーの最適化処理のこと。
仮想関数の場合は多態的な動きをするのでインライン展開ができないが、脱仮想化を挟むことでインライン展開ができる場合がある。

public interface IPerson
{
    string Name();
}

public class Louge : IPerson
{
    string IPerson.Name() => "Louge";
}

public class DevirtualizationSample : MonoBehaviour
{
   private void Start()
    {
        IPerson person = new Louge();
        person.Name(); // IPerson.Name()の仮想呼び出しではなく、Louge.Name()を直接呼び出す。
    }
}

しかし実際問題、脱仮想化が入るような状況はかなりレアなので、基本的に仮想メソッドは普通のメソッドよりもコストが大きくなってしまうっぽい。

Unityの場合

Unityで用いられているコンパイラーであるIL2CPPでは脱仮想化が行われてない。つまり、仮装メソッドは仮装メソッドとしてでしか呼ばれないので、普通のメソッドよりもコストが必ず大きくなる。
但し.NET6では脱仮想化は行われるので、将来Unityが.NET6をサポートすれば高速化されるかもしれない。

クロージャに甘えない

クロージャとは

ラムダ式とかで関数の外にある変数や関数を使うと、実はそれらをキャプチャするために暗黙的にクラスがnewされる。この仕組みを使っている関数をクロージャと呼ぶ。引数を渡さなくても自由にラムダ式を書けるのはこの便利な仕組みのおかげ。
日本語だと関数閉包っていうらしい。

クロージャは甘え?

クロージャで暗黙的にnewされるクラスはしっかりGCゴミになるので、なるたけクロージャは避けた方がいい。

対策例

UniRxではステート付き関数が充実しているのでそれを使えば良い。

SubscribeWithState

    protected override void Initialize()
    {
        base.Initialize();

        // 入力購読
        m_InputManager.InputEvent.SubscribeWithState(this, (input, self) => self.DetectInput(input.KeyCodeFlag)).AddTo(this);
    }

SubscribeWithState2

    /// <summary>
    /// 敵ステータスセット
    /// </summary>
    /// <param name="enemyStatus"></param>
    private void SetEnemyStatus(EnemyStatus enemyStatus)
    {
        ..... 前略

        // 死亡時に経験値を与える
        if (Owner.RequireEvent<ICharaBattleEvent>(out var battle) == true)
        {
            battle.OnDead.SubscribeWithState2(this, enemyStatus, (result, self, enemyStatus) =>
            {
                foreach (var unit in self.m_UnitHolder.FriendList)
                {
                    // 味方キャラによるキルなら経験値加算
                    if (result.Attacker == unit)
                    {
                        self.m_TeamLevelHandler.AddExperience(enemyStatus.Param.Ex);
                        break;
                    }
                }
            }).AddTo(CompositeDisposable);
        }
    }

Disposable.CreateWithState

2つ目の例ではタプルを使って複数の値を引き渡している。

    /// <summary>
    /// カメラを追従させる
    /// </summary>
    /// <param name="parent"></param>
    /// <returns></returns>
    IDisposable ICameraHandler.SetParent(GameObject parent)
    {
        m_MainCamera.transform.SetParent(parent.transform);
        return Disposable.CreateWithState(this, self => self.m_MainCamera.transform.parent = null);
    }
    /// <summary>
    /// バフを付与する
    /// </summary>
    /// <param name="buff"></param>
    /// <returns></returns>
    public IDisposable AddBuff(BuffTicket buff)
    {
        m_BuffList.Add(buff);
        return Disposable.CreateWithState((m_BuffList, buff), tuple => tuple.m_BuffList.Remove(tuple.buff));
    }

参考

UniRx作者さんの記事。
neue.cc
neue.cc

Zenjectを使ってSingletonを撲滅する

前置き

Singletonなんてそんな野蛮な…ここは穏便にDIで…。

Zenjectとは

Unityで使えるDIフレームワーク
assetstore.unity.com

DIとは

Dependency Injectionの略。日本語訳すると「依存性の注入」だけど…直訳すぎてよく分からない。
だからか「機能の注入」とか「オブジェクト(インスタンス)の注入」ってよく言い換えられてる。こっちの方がなんとなく分かりやすいけど、言葉の意味を追うよりも実際に使ってみた方が理解が早かったので先に進む。

DIでSingletonを撲滅する

例えばキー入力を購読できるイベントを公開してるこんな感じのシングルトンがあったとする。

public class InputManager : Singleton<InputManager>
{
    /// <summary>
    /// 入力イベント
    /// </summary>
    private Subject<InputInfo> m_InputEvent = new Subject<InputInfo>();
    public IObservable<InputInfo> InputEvent => m_InputEvent;

..... 以下省略

入力を購読したいと思っている他のクラスはInputManagerに依存することになる。

public class PlayerInput : ActorComponentBase, IPlayerInput
{
    protected override void Initialize()
    {
        // 入力イベント購読
        InputManager.Instance.InputEvent.Subscribe(input =>
        {
            DetectInput(input.KeyCodeFlag);
        }).AddTo(CompositeDisposable);

..... 以下省略

これを解決する。

まず、イベント公開機能をインターフェイスとして切り出す。

public interface IInputManager
{
    IObservable<InputInfo> InputEvent { get; }
}
public class InputManager : MonoBehaviour, IInputManager
{
    /// <summary>
    /// 入力イベント
    /// </summary>
    private Subject<InputInfo> m_InputEvent = new Subject<InputInfo>();
    IObservable<InputInfo> IInputManager.InputEvent => m_InputEvent;

..... 以下省略

次に依存関係をInstallerクラスに定義する

public class CommonSystemInstaller : MonoInstaller
{
    [SerializeField]
    private GameObject m_CommonSystem;

    public override void InstallBindings()
    {
        Container.Bind<IInputManager>()
            .To<InputManager>()
            .FromComponentOn(m_CommonSystem)
            .AsSingle()
            .NonLazy();

..... 以下省略

Inject属性を使うと、Zenjectが勝手にIInputManagerを提供してくれるようになる。便利。

public class PlayerInput : ActorComponentBase, IPlayerInput
{
    [Inject]
    private IInputManager m_InputManager;

    protected override void Initialize()
    {
        // 入力イベント購読
        m_InputManager.InputEvent.Subscribe(input =>
        {
            DetectInput(input.KeyCodeFlag);
        }).AddTo(CompositeDisposable);

..... 以下省略

結局、DIとはなんだったのか

シングルトンパターンだと、入力イベントを購読したいクラス(PlayerInput)が入力イベントを持っているクラス(InputManager)を絶対に知っておく必要がある。
しかし、イベント公開機能をインターフェイスとして切り出して、さらにInstallerクラスが第三者の立場となって依存関係を定義することで、イベントを購読する側はインターフェイスさえ外から提供されれば、イベントの購読ができるようになる。

これの何が嬉しいの?

InputManagerで実装の中身の変更をしても、PlayerInputは(インターフェイスの変更がない限り)その影響を受けない。
インターフェイスの実装を別のクラスで置き換えたいときは、Installerクラスだけを変更すれば良い。
→依存する側は依存先のことを考える必要がなく、依存先の変更もしやすい。つまり疎結合ってこと。

まとめ

Zenjectを使ってSingletonとさようならしたお話でした。

参考

依存関係の設計理念をゴリゴリに解説してる。
www.nuits.jp

C#で書くA*アルゴリズム

前置き

 ローグライクでキャラクターの移動AIを作るのに必要だった。
 アセットとかに頼らずに自分の力で書いてみたかった。

A*アルゴリズムとは

【アルゴリズム】A*アルゴリズム
 ↑これを読んだ。ここでは概要だけかいつまんでおく。

コード

ノード

    /// <summary>
    /// ノード
    /// </summary>
    public class Node : IEquatable<Node>, IEquatable<Vector2Int>
    {
        /// <summary>
        /// 親ノード
        /// </summary>
        public Node Parent { get; }

        /// <summary>
        /// X座標
        /// </summary>
        public int X { get; }

        /// <summary>
        /// Y座標
        /// </summary>
        public int Y { get; }

        /// <summary>
        /// 今までの移動コスト
        /// </summary>
        public float TotalMoveCost { get; }

        /// <summary>
        /// 推定コスト(ゴールまでの距離)
        /// </summary>
        public float HeuristicCost { get; }

        /// <summary>
        /// スコア(スコア = 今までの移動コスト + 推定コスト)
        /// </summary>
        public float Score => TotalMoveCost + HeuristicCost;

        /// <summary>
        /// コンストラクタで座標情報と移動コストとヒューリスティックコストをセット
        /// </summary>
        /// <param name="x"></param>
        /// <param name="y"></param>
        public Node(Node parent, int x, int y, float mCost, Vector2Int goal)
        {
            this.Parent = parent;
            this.X = x;
            this.Y = y;
            this.TotalMoveCost = mCost;
            this.HeuristicCost = new Vector2Int(x, y).GetDistance(goal);
        }

        /// <summary>
        /// 座標同じならtrue
        /// </summary>
        /// <param name="other"></param>
        /// <returns></returns>
        public bool Equals(Node other) => X == other.X && Y == other.Y;
        public bool Equals(Vector2Int other) => X == other.x && Y == other.y;
    }

ヒューリスティックコストの計算

 チェビシェフ距離の改良版っぽいのを採用。

    /// <summary>
    /// 二点間の改良版チェビシェフ距離を計算する(斜め移動時に距離増)
    /// ヒューリスティックコストに使う
    /// </summary>
    /// <param name="distanceX"></param>
    /// <param name="distanceY"></param>
    /// <returns></returns>
    private static float GetDistance(int distanceX, int distanceY) => distanceX > distanceY ? distanceY * SQUARE2 + (distanceX - distanceY) : distanceX * SQUARE2 + (distanceY - distanceX);
    private static float GetDistance(this Node nodeA, Node nodeB) => GetDistance(Math.Abs(nodeA.X - nodeB.X), Math.Abs(nodeA.Y - nodeB.Y));
    private static float GetDistance(this Vector2Int a, Vector2Int b) => GetDistance(Math.Abs(a.x - b.x), Math.Abs(a.y - b.y));

ノードリスト用拡張メソッド

    /// <summary>
    /// リストから同じ座標のノードを取得する
    /// </summary>
    /// <param name="nodes"></param>
    /// <param name="other"></param>
    /// <param name="old"></param>
    /// <returns></returns>
    public static bool TryGetSamePositionNode(this List<Node> nodes, Vector2Int pos, out Node old)
    {
        old = null;

        foreach (var node in nodes)
            if (node.Equals(pos) == true)
            {
                old = node;
                return true;
            }
        return false;
    }||<

** 経路探索メソッド
>|cs|
    /// <summary>
    /// パス検索
    /// </summary>
    /// <param name="startPos"></param>
    /// <param name="goalPos"></param>
    /// <param name="grid"></param>
    /// <returns></returns>
    public static List<Node> FindPath(Vector2Int startPos, Vector2Int goalPos, int[,] grid)
    {
        // スタートとゴールのノード作成
        var startNode = new Node(null, startPos.x, startPos.y, 0, goalPos);

        List<Node> openList = new List<Node>(); // 調査対象ノード
        List<Node> closeList = new List<Node>(); // 調査済みノード
        openList.Add(startNode);

        int calculationCount = 0;

        // OpenListが空になるまで続ける
        while (openList.Count > 0)
        {
            Node currentNode = openList[0];

            // openListの中で、スコアが最小のノードを探す
            for (int i = 0; i < openList.Count; i++)
            {
                if (openList[i].Score < currentNode.Score ||
                    openList[i].Score == currentNode.Score && openList[i].TotalMoveCost < currentNode.TotalMoveCost)
                {
                    currentNode = openList[i];
                }
            }

            // オープンリストからクローズリストに移動(調査済みとしてマーク)
            openList.Remove(currentNode);
            closeList.Add(currentNode);
            // goalと同じノードなら終了
            if (currentNode.Equals(goalPos) == true)
            {
#if DEBUG
                Debug.Log("計算量:" + calculationCount);
#endif
                return RetracePath(startNode, currentNode);
            }

            calculationCount++;
            // 隣接するノードを調査する
            OpenNeighborNode(currentNode, grid, goalPos, openList, closeList);
        }

        return null;
    }

    /// <summary>
    /// パス復元
    /// </summary>
    /// <param name="startNode"></param>
    /// <param name="endNode"></param>
    /// <returns></returns>
    private static List<Node> RetracePath(Node startNode, Node endNode)
    {
        List<Node> path = new List<Node>();
        Node currentNode = endNode;

        while (currentNode != null)
        {
            path.Add(currentNode);
            currentNode = currentNode.Parent;
        }

        path.Reverse();
        return path;
    }

    /// <summary>
    /// 近傍のノードを調査する
    /// </summary>
    /// <param name="node"></param>
    /// <param name="grid"></param>
    /// <returns></returns>
    private static void OpenNeighborNode(Node node, int[,] grid, Vector2Int goalPos, List<Node> openList, List<Node> closeList)
    {
        for (int x = -1; x <= 1; x++)
        {
            for (int y = -1; y <= 1; y++)
            {
                // 中心のノードは除外
                if (x == 0 && y == 0)
                    continue;

                int checkX = node.X + x;
                int checkY = node.Y + y;

                // マップの範囲外は除外
                if (checkX >= 0 && checkX < grid.GetLength(0) && checkY >= 0 && checkY < grid.GetLength(1))
                {
                    int moveCost = grid[checkX, checkY]; // 移動コスト
                    // 壁は除外
                    if (moveCost == 0)
                        continue;

                    // 斜め移動の場合
                    if (x != 0 && y != 0)
                        // 斜め移動の条件を満たすマップでないなら除外
                        if (grid[checkX, node.Y] == 0 || grid[node.X, checkY] == 0)
                            continue;

                    Vector2Int pos = new Vector2Int(checkX, checkY); // Node位置
                    float totalMoveCost = node.TotalMoveCost + moveCost; // トータル移動コスト計算

                    // 既に調査済みである
                    if (closeList.TryGetSamePositionNode(pos, out var close) == true)
                    {
                        // トータル移動コストが既存Node以上なら差し替え不要
                        if (totalMoveCost >= close.TotalMoveCost)
                            continue;
                        closeList.Remove(close);
                    }

                    // 現在調査中である
                    if (openList.TryGetSamePositionNode(pos, out var open) == true)
                    {
                        // トータル移動コストが既存Node以上なら差し替え不要
                        if (totalMoveCost >= open.TotalMoveCost)
                            continue;
                        openList.Remove(open);
                    }

                    Node neighbor = new Node(node, checkX, checkY, totalMoveCost, goalPos);
                    openList.Add(neighbor);
                }
            }
        }
    }