[Unity]カスタムエディターを活用してインスペクター上でグリッドマップを直感的に編集する方法

ChatGPT様のアシストもあり、タイトルの通りの非常に便利な方法にたどり着いたので、その備忘録です。

事前説明

グリッドマップとは

グリッドマップは、縦横に並んだ正方形(または長方形)のセルから構成される2次元のマップです。それぞれのセルに、特定の情報(例: 通行可能、壁、リソースなど)やデータを保持させます。タワーディフェンスなどのストラテジーゲームではよく見られます。

ノードとは

ノード(Node)は、データ構造やグラフ理論における基本的な単位を指します。今回の備忘録では出てきませんが後に経路探索アルゴリズムを使用する際に、マップ上の1つ1つのセルをノードとして扱うので、セル=ノードとしてプログラムを書いています。

内容

スクリプト

MapNode.cs

ノードのスクリプト

  • 白、黒、灰の3つのノードタイプがある。
C#
using UnityEngine;

public class MapNode : MonoBehaviour
{
    public Vector2Int Position; // ノードのグリッド座標

    public NodeType Type; // ノードのタイプ(白、黒、灰色)

    public enum NodeType
    {
        White,
        Black,
        Gray
    }

    // ノードの見た目を更新
    public void SetType(NodeType type)
    {
        Type = type;
        var renderer = GetComponent<Renderer>();
        if (renderer != null)
        {
            renderer.material.color = GetColorFromType(type);
        }
    }

    // ノードタイプに応じた色を返す
    private Color GetColorFromType(NodeType type)
    {
        switch (type)
        {
            case NodeType.White: return Color.white;
            case NodeType.Black: return Color.black;
            case NodeType.Gray: return Color.gray;
            default: return Color.gray;
        }
    }
}

MapData.cs

マップのデータ

  • ScriptableObjectとして実現
  • グリッドの縦横のノード数(サイズ)の設定やノードの座標とノードタイプの紐付けを行う
C#
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(fileName = "MapData", menuName = "SampleGame/MapData", order = 1)]
public class MapData : ScriptableObject
{
    public Vector2Int GridSize;

    // ネスト構造を用いてノードタイプの二次元リストをつくる
    public List<RowData> gridRows = new List<RowData>();
    [System.Serializable]
    public class RowData
    {
        public List<MapNode.NodeType> columns = new List<MapNode.NodeType>();
    }

    // 初期化
    public void Initialize(Vector2Int gridSize)
    {
        GridSize = gridSize;
        gridRows.Clear();

        // 各行を初期化
        for (int x = 0; x < gridSize.x; x++)
        {
            RowData row = new RowData();
            for (int y = 0; y < gridSize.y; y++)
            {
                row.columns.Add(MapNode.NodeType.Gray);
            }
            gridRows.Add(row);
        }
    }

    // ノードタイプを取得
    public MapNode.NodeType GetNodeType(Vector2Int position)
    {
        if (IsValidPosition(position))
        {
            return gridRows[position.x].columns[position.y];
        }

        Debug.LogError($"Invalid position: {position}");
        return MapNode.NodeType.Gray;
    }

    // ノードタイプを設定
    public void SetNodeType(Vector2Int position, MapNode.NodeType type)
    {
        if (IsValidPosition(position))
        {
            gridRows[position.x].columns[position.y] = type;
        }
    }

    // 座標が有効かチェック
    private bool IsValidPosition(Vector2Int position)
    {
        return position.x >= 0 && position.x < gridRows.Count &&
               position.y >= 0 && position.y < gridRows[position.x].columns.Count;
    }
}

注意点

二次元のノードデータの定義には注意が必要です。

一般的な多次元配列 [ , ] や多次元リストは List<List<>> はunityのインスペクター上で編集できません。今回これらを用いると、”カスタムエディター上では編集できたように見えても、実際には反映されていない” という事態が起きます。

なのでList型変数をもつクラスのリストのようなインスペクター上で編集できる形で実現する必要があります。

MapDataEditor.cs

マップデータをインスペクター上で編集するためのカスタムエディター

C#
using UnityEngine;
using UnityEditor;

[CustomEditor(typeof(MapData))]
public class MapDataEditor : Editor
{
    private const int CellSize = 25; // グリッドセルのサイズ
    private GUIStyle cellStyle;

    public override void OnInspectorGUI()
    {
        // ターゲットのMapDataを取得
        MapData mapData = (MapData)target;

        // グリッドサイズ設定
        mapData.GridSize = EditorGUILayout.Vector2IntField("Grid Size", mapData.GridSize);

        // 初期化
        if (mapData == null || mapData.GridSize.x <= 0 || mapData.GridSize.y <= 0)
        {
            EditorGUILayout.HelpBox("Grid Size must be greater than 0!", MessageType.Warning);
            return;
        }

        if (mapData.gridRows == null || mapData.gridRows.Count != mapData.GridSize.x ||
            (mapData.gridRows.Count > 0 && mapData.gridRows[0].columns.Count != mapData.GridSize.y))
        {
            mapData.Initialize(mapData.GridSize);
        }

        // グリッドの表示
        EditorGUILayout.LabelField("Node Types:");
        DrawGrid(mapData);

        if (GUI.changed)
        {
            EditorUtility.SetDirty(mapData); // ScriptableObjectの変更を通知
            AssetDatabase.SaveAssets();      // データを保存
        }
    }

    private void DrawGrid(MapData mapData)
    {
        // スタイル設定
        if (cellStyle == null)
        {
            cellStyle = new GUIStyle(GUI.skin.button)
            {
                fontSize = 10,
                alignment = TextAnchor.MiddleCenter
            };
        }

        for (int y = mapData.GridSize.y - 1; y >= 0; y--) // 上から下に描画
        {
            EditorGUILayout.BeginHorizontal();
            for (int x = 0; x < mapData.GridSize.x; x++)
            {
                // 現在のノードタイプ
                MapNode.NodeType currentType = mapData.gridRows[x].columns[y];

                // 色設定
                Color originalColor = GUI.backgroundColor;
                GUI.backgroundColor = GetColorForNodeType(currentType);

                // ノードタイプを変更するボタン
                if (GUILayout.Button(currentType.ToString().Substring(0, 1), cellStyle, GUILayout.Width(CellSize), GUILayout.Height(CellSize)))
                {
                    // 次のタイプに切り替え
                    mapData.gridRows[x].columns[y] = GetNextNodeType(currentType);
                }

                GUI.backgroundColor = originalColor;
            }
            EditorGUILayout.EndHorizontal();
        }
    }

    private Color GetColorForNodeType(MapNode.NodeType type)
    {
        return type switch
        {
            MapNode.NodeType.White => Color.white,
            MapNode.NodeType.Black => Color.black,
            MapNode.NodeType.Gray => Color.gray,
            _ => Color.gray,
        };
    }

    private MapNode.NodeType GetNextNodeType(MapNode.NodeType currentType)
    {
        return currentType switch
        {
            MapNode.NodeType.White => MapNode.NodeType.Black,
            MapNode.NodeType.Black => MapNode.NodeType.Gray,
            MapNode.NodeType.Gray => MapNode.NodeType.White,
            _ => MapNode.NodeType.Gray,
        };
    }
}

注意点

今回に限った話ではないですが、カスタムエディターのスクリプトは、Assets内のEditorという名のフォルダーに保存しないと動きません。

MapManager.cs

マップマネージャー

  • マップデータを基にゲームシーン上に実際にマップを生成する
C#
using UnityEngine;

public class MapManager : MonoBehaviour
{
    public MapData MapData; // マップデータ
    public GameObject NodePrefab; // ノードのプレハブ
    public float NodeSpacing = 1.1f; // ノード間の間隔

    private GameObject[,] nodeObjects; // ノードオブジェクトを管理

    private void Start()
    {
        if (MapData == null)
        {
            Debug.LogError("MapData が設定されていません!");
            return;
        }

        Debug.Log($"MapData が読み込まれました: {MapData.name}");
        Debug.Log($"GridSize: {MapData.GridSize}");

        InitializeMap();
    }

    private void InitializeMap()
    {
        if (MapData == null || NodePrefab == null)
        {
            Debug.LogError("MapDataまたはNodePrefabが設定されていません!");
            return;
        }

        nodeObjects = new GameObject[MapData.GridSize.x, MapData.GridSize.y];

        for (int x = 0; x < MapData.GridSize.x; x++)
        {
            for (int y = 0; y < MapData.GridSize.y; y++)
            {
                // ノードのワールド座標(Zは固定)
                Vector3 position = new Vector3(x * NodeSpacing, y * NodeSpacing, 0);

                // ノードオブジェクトを生成
                GameObject nodeObject = Instantiate(NodePrefab, position, Quaternion.identity, transform);
                nodeObject.name = $"Node_{x}_{y}";

                // Nodeのスクリプトに情報を設定
                MapNode node = nodeObject.GetComponent<MapNode>();
                if (node != null)
                {
                    var nodeType = MapData.GetNodeType(new Vector2Int(x, y));
                    node.Position = new Vector2Int(x, y);
                    node.SetType(nodeType);
                }

                nodeObjects[x, y] = nodeObject;
            }
        }
    }
}

編集の様子

1.MapNodeがアタッチされたPrefabであるSquareを作成

2.MapData型のオブジェクトとしてStage1を作成し、インスペクター上で各ノードの色を設定

3.MapManager(Script)を持つオブジェクトを作成し、先ほど作ったSquareとStage1を付与する

4.実行すると、Stage1の設定通りのグリッドマップが作成される

終わりに

グリッド状のマップを用いたゲームを作ろうとしたとき、ステージ毎にNodeのPrefabをグリッド状に並べてそれぞれのNodeの状態を一つずつ編集する方法があまりにも面倒で、「なんとかInspector上で楽に編集できないか」と考え、今回の方法にたどり着きました。
グリッド状のマップは様々なゲームで使われていますし、汎用性が高いです。さらに自分はストラテジーゲームが好きなので、今後もグリッド状のマップを用いてゲームを作ると思います。そしてその時はこの備忘録が非常に役に立つと確信しています!

コメント

タイトルとURLをコピーしました