【C#】グリッドマップを管理するクラスを作成する

2D のゲーム実装でマップを扱うときは x と y の2次元のマップデータを使うことがありますがこのデータ構造の実装方法を紹介します。

x と y の 2次元のデータですが、中身は 1次元の配列として扱います。なので 1列の配列データを 2次元のグリッドとして扱う方法となります。

こんな感じのイメージの並び順の2次元配列を想像して書いています。

確認環境

以下の環境で動くことを確認しています。

  • VisualStudio2019
  • .NET Framwwork 4.7.2
  • .NET Core 3.1
  • Unity2019.3

実装コード

では早速実装例を紹介したいと思います。

Map2Dクラス

いちおう2次元配列を直接触ることもできますが、メソッド経由の方が脳にやさしいと思います。

各座標に対するよくある操作を定義しています。

(T)には任意の型を指定できるのでオブジェクトを指定することもできます。

/// <summary>
/// 任意の型(T)の2次元配列を表します。
/// </summary>
public class Map2D<T>
{
    //
    // Descriptions
    // - - - - - - - - - - - - - - - - - - - -
    #region...
    //
    // 以下モデルを想定
    //
    //     xi → → → →
    //  yi 00 01 02 03 04
    //  ↓ 10 11 12 13 14
    //  ↓ 20 21 22 23 24
    //  ↓ 30 31 32 33 34
    //  ↓ 40 41 42 43 44
    //
    // もしくはこう
    //
    // ↑ 40 41 42 43 44
    // ↑ 30 31 32 33 34
    // ↑ 20 21 22 23 24
    // ↑ 10 11 12 13 14
    // yi 00 01 02 03 04
    //    xi → → → →
    //
    #endregion

    /// <summary>
    /// このオブジェクトが内部で管理している配列を取得します
    /// (外から内容を操作してもいいけど自己責任)
    /// </summary>
    public T[] Array { get; private set; }

    /// <summary>
    /// マップの横幅を取得します。
    /// </summary>
    public int Width { get; private set; }

    /// <summary>
    /// マップの縦幅を取得します。
    /// </summary>
    public int Height { get; private set; }

    //
    // Operators
    // - - - - - - - - - - - - - - - - - - - -

    /// <summary>
    /// 多次元配列のインデクサーにより値を設定または取得します。
    /// </summary>
    public T this[int xi, int yi] { get => this.GetItem(xi, yi); set => this.SetItem(xi, yi, value); }

    //
    // Constructors
    // - - - - - - - - - - - - - - - - - - - -

    /// <summary>
    /// 既定の初期値でオブジェクトを新規作成します。
    /// </summary>
    public Map2D() { }

    /// <summary>
    /// マップの縦・横の大きさを指定してオブジェクトを新規作成します。
    /// </summary>
    public Map2D(int width, int height) => this.Init(width, height);

    //
    // Public Methods
    // - - - - - - - - - - - - - - - - - - - -

    /// <summary>
    /// 指定した値でオブジェクトを初期化します。
    /// </summary>
    public void Init(int width, int height)
    {
        this.Width = width;
        this.Height = height;
        this.Array = new T[height * width];
    }

    /// <summary>
    /// 指定した2次元配列でオブジェクトを初期化します。
    /// </summary>
    public void Init(T[] src)
    {
        this.Array = src;
        this.Width = src.GetLength(1);
        this.Height = src.GetLength(0);
    }

    /// <summary>
    /// 指定位置にIDを設定します。
    /// </summary>
    public void SetItem(int xi, int yi, T id) => this.Array[yi * this.Width + xi] = id;

    /// <summary>
    /// 指定位置のIDを取得します。
    /// </summary>
    public T GetItem(int xi, int yi) => this.Array[yi * this.Width + xi];

    /// <summary>
    /// <para>
    /// 指定した位置がマップの範囲内かどうかを判定します。
    /// true : 範囲内 / false : 範囲外
    /// </para>
    /// <para>
    /// 基本的にすべてのメソッドは範囲チェックを行わないので指定するxi, yiが範囲内かどうかは
    /// このメソッドを使用して判定もしくは外部でチェックされていることを想定します。
    /// </para>
    /// </summary>
    public bool IsIn(int xi, int yi) => !(xi < 0 || yi < 0 || xi >= this.Width || yi >= this.Height);

    /// <summary>
    /// 指定したYの行要素をすべて列挙します。
    /// </summary>
    public IEnumerable<(int x, int y, T item)> GetRow(int yi)
    {
        for (int x = 0; x < this.Width; x++)
        {
            yield return (x, yi, this.GetItem(x, yi));
        }
    }

    /// <summary>
    /// 指定したXの列要素をすべて列挙します。
    /// </summary>
    public IEnumerable<(int x, int y, T item)> GetColumn(int xi)
    {
        for (int y = 0; y < this.Height; y++)
        {
            yield return (xi, y, this.GetItem(xi, y));
        }
    }

    /// <summary>
    /// 指定したYの行要素に対し述語で一括で処理を行います。
    /// </summary>
    public void ForRow(int yi, Action<int/*xi*/, int/*yi*/, T/*id*/> func)
    {
        foreach ((int x, int y, T id) in this.GetRow(yi))
        {
            func(x, y, id);
        }
    }

    /// <summary>
    /// 指定したYの列要素に対し述語で一括で処理を行います。
    /// </summary>
    public void ForColumn(int xi, Action<int/*xi*/, int/*yi*/, T/*id*/> func)
    {
        foreach ((int x, int y, T id) in this.GetColumn(xi))
        {
            func(x, y, id);
        }
    }

    /// <summary>
    /// 全ての要素を列挙し述語で処理を行います。
    /// </summary>
    public void ForEach(Action<int/*xi*/, int/*yi*/, T/*id*/> func)
    {
        for (int y = 0; y < this.Height; y++)
        {
            for (int x = 0; x < this.Width; x++)
            {
                func(x, y, this.GetItem(x, y));
            }
        }
    }
}

Map2Diクラス

マップチップなどでCSVから読み取ったデータはint型の配列なことが多いと思うので以下のクラスをあらかじめ定義しておきます。

//
// 一番よく使うと思われるので事前に定義しておく
//

/// <summary>
/// <see cref="int"/> 型の2次元配列を表します。
/// 
/// </summary>
public class Map2Di : Map2D<int>
{
    public Map2Di() { }
    public Map2Di(int width, int hegiht) : base(width, hegiht) { }
}

使い方

上記クラスの使い方は以下の通りです。

値の出し入れと、一括処理の方法です。

public static void Main(string[] args)
{
    // (1) 新規に配列を作成
    var map1 = new Map2Di(10, 10);

    // (2) もともとある配列を指定して初期化
    int[,] _m = new int[,]
    {
        { 0, 0, 0, 0, 0, 1, 1, 1, 1, 1 },
        { 1, 1, 1, 1, 1, 2, 2, 2, 2, 2 },
        { 2, 2, 2, 2, 2, 3, 3, 3, 3, 3 },
        { 3, 3, 3, 3, 3, 4, 4, 4, 4, 4 },
        { 4, 4, 4, 4, 4, 5, 5, 5, 5, 5 },
        { 5, 5, 5, 5, 5, 6, 6, 6, 6, 6 },
        { 6, 6, 6, 6, 6, 7, 7, 7, 7, 7 },
        { 7, 7, 7, 7, 7, 8, 8, 8, 8, 8 },
        { 8, 8, 8, 8, 8, 9, 9, 9, 9, 9 },
        { 0, 0, 0, 0, 0, 1, 1, 1, 1, 1 },
    };
    var map2 = new Map2Di();
    map2.Init(_m);

    // (3) 管理オブジェクトのサイズを取得する
    Console.WriteLine($"({map2.Width}, {map2.Height})");

    // (4) 値の出し入れ
    map2.SetItem(1, 1, 999);
    int item = map2.GetItem(1, 1);

    // (5-1) 1行目のデータを全部取得
    foreach ((int x, int y, int id) p in map2.GetRow(1))
    {
        Console.WriteLine($"({p.x}, {p.y})={p.id}");
    }
    // (5-2) 1行目のデータに対して一括処理
    map2.ForRow(1, (x, y, id) => Console.WriteLine($"({x}, {y})={id}"));

    // (6-1) 1列目のデータを全部取得
    foreach ((int x, int y, int id) p in map2.GetColumn(1))
    {
        Console.WriteLine($"({p.x}, {p.y})={p.id}");
    }
    // (6-2) 1列目のデータに対して一括処理
    map2.ForColumn(1, (x, y, id) => Console.WriteLine($"({x}, {y})={id}"));

    // (7) 全部のデータを列挙
    map2.ForEach((x,y,id)=> Console.WriteLine($"({x}, {y})={id}"));
}

【説明】管理クラスの必要性

2次元配列で実装しているので、この配列にアクセスするときはカッコ内に指定する値はXとYが逆になっています(大抵の人はそうすると思います…

そもそも2次元配列なら管理クラスは必要無いのかもしれませんが、インデックスに指定する順序がY→Xの順のため稀にX→Yの順で指定してアクセス位置を誤る場合があるので誤操作防止の意味があります。

public T GetItem(int xi, int yi)
{
    // YとXの指定は逆、X→Yの順序で指定してアクセス間違いしないようにしている
    return array[y, x];
}

例えば以下のイメージでデータが並んでるときに

上記の色のついているグリッドへのアクセス方法は以下のように直観的に操作できるようになります。

var map2 = new Map2Di();
// ...(中略)...

// 赤い場所のデータを取得
int item1 = map2.GetItem(1, 2);

// 青い場所のデータを取得
int item2 = map2.GetItem(3, 5);

また配列に対する汎用操作をクラスに追加したい場合もクラスに機能追加すれば良いのでクラス化しておけば何かと便利です。

【余談】何故1次元配列で実装するのか?

単純に一番アクセス速度が速いからです。1次元配列を二次元配列として利用するにはインデックスを毎回計算しないといけませんがその計算をしてもほかのデータ形式より実行速度で有利だからです。

速度については以下の記事にまとめました。

takachan.hatenablog.com

上記の気の通りランダムアクセス、シーケンシャルアクセスで最大速度が得られます。C#の2次元配列表現は1次元配列を2次元配列として扱うより速度が低下します。

こういう基本クラスはアクセス頻度が結構高いと思うので処理コストが軽いほうがよいという理由から1次元配列を2次元配列のように扱っています。

以上です。