RYO620
DESIGN & DEVELOPMENT
Unityエディタ拡張で NPC の移動ルートを Gizmos を使って可視化し、 シーンビューで編集可能にする
Ryosuke

Unityエディタ拡張で NPC の移動ルートを Gizmos を使って可視化し、 シーンビューで編集可能にする

決められたルートを移動する ノンプレイヤーキャラクター 。 この記事では、

  • 2D見下ろし型に配置される
  • n秒ごとに上下左右のいずれかに1グリッド進む
  • 初期位置まで戻ってきたらループ

という NPC(敵キャラ) を想定して、そのキャラの移動ルートをシーンビュー上で視覚的に確認/編集できるように Unity のエディタを拡張してみました。 ギズモ(UnityEngine.Gizmos) を使うと、シーンビューに任意の図形や情報を表示できるので、特定の位置の目立たせる目印を作ったり、キャラクターの攻撃範囲を可視化したりできます。

↑最終的にはこんな感じになります。


もくじ - Unityエディタ拡張 覚書全3回の3回目

  • 移動ルートをシーンビューに表示しよう
    • コンポーネントを作る
    • ギズモを表示する
    • ギズモで移動ルートを表示する
  • 移動ルートをシーンビューで編集できるUIを作ろう
    • 移動ルートの末尾にボタンを表示する
    • Listの要素を追加/削除するボタンを表示する
  • 完成形

第1回: Unityエディタ拡張でキャラクターのグリッド移動ツールを作る
第2回: Unityエディタ拡張でシーンビュー上にGameObject生成ボタンUIを作る
第3回: Unityエディタ拡張で NPC の移動ルートを Gizmos を使って可視化し、 シーンビューで編集可能にする (この記事)


移動ルートをシーンビューに表示しよう

コンポーネントを作る

まずは敵キャラにアタッチするコンポーネント、 Enemy を作ります。 gridPosList には移動ルートの各ポイントのグリッド座標がリストで入っています。 UniRx で 0.5秒ごとに Move メソッドを呼べば、 ルートを巡回する敵キャラが完成です。

Assets/Scripts/Enemy.cs

using System; using System.Collections.Generic; using UnityEngine; using UniRx; public class Enemy : MonoBehaviour { // 行き先となるグリッド座標のリスト public List<Vector2Int> gridPosList = new List<Vector2Int>(); // リストのカレントインデックス private int _currentIndex = 0; private void Start() { Observable .Interval(TimeSpan.FromSeconds(0.5f)) .Subscribe(_ => { Move(); }); } private void Move() { transform.localPosition = (Vector2) gridPosList[_currentIndex] * GameConfig.GridSize; // インデックスをインクリメント _currentIndex = _currentIndex + 1 == gridPosList.Count ? 0 : _currentIndex + 1; } } public static class GameConfig { public static readonly int GridSize = 16; }

インスペクターで設定した4つのポイントを巡回する敵キャラインスペクターで設定した4つのポイントを巡回する敵キャラ


ギズモを表示する

ギズモ描画用クラス EnemyGizmoEditor を作ります。

ギズモの描画処理は、任意の関数名の staticメソッド内に記述します。 メソッドには DrawGizmo() 属性を付与し、その引数でギズモが描画を実行するタイミングを定義できます。 以下のスクリプトでは GizmoType.Selected: 選択時 もしくは GizmoType.NonSelected: 非選択時 となっています。(つまり常時)

Assets/Scripts/Editor/EnemyGizmoEditor.cs

using UnityEditor; using UnityEngine; public class EnemyGizmoEditor { [DrawGizmo(GizmoType.Selected | GizmoType.NonSelected, typeof(Enemy))] private static void DrawGizmo(Enemy enemy, GizmoType gizmoType) { // ギズモの描画処理 Gizmos.color = Color.red; var pos = enemy.transform.position; Gizmos.DrawLine(pos, pos + Vector3.right * GameConfig.GridSize); } }

static メソッドは第1引数に取得したいインスタンス、第2引数に 属性で指定した Gizmo描画実行するタイミングである GizmoType が入っています。

とりあえず、ただの線を描画する Gizmos.DrawLine(始点, 終点) を使って、赤い線を敵キャラの位置から画面右側に向けて描いてみました。

足元の赤い線が Gizmos.DrawLine() で描画したギズモ足元の赤い線が Gizmos.DrawLine() で描画したギズモ


ギズモで移動ルートを表示する

Enemy クラスが持つ gridPosList のList を回して、 Gizmos.DrawLine() を描画することで赤い移動ルートを表示できます。

Assets/Scripts/Editor/EnemyGizmoEditor.cs

・・・ [DrawGizmo(GizmoType.Selected | GizmoType.NonSelected, typeof(Enemy))] private static void DrawGizmo(Enemy enemy, GizmoType gizmoType) { Gizmos.color = Color.red; var count = enemy.gridPosList.Count; for (var index = 0; index < count; index++) { var next = index + 1 == count ? 0 : index + 1; var start = (Vector2)enemy.gridPosList[index] * GameConfig.GridSize; var end = (Vector2)enemy.gridPosList[next] * GameConfig.GridSize; Gizmos.DrawLine(start, end); } } ・・・

8箇所のポイントを移動するけどその方向がわからない8箇所のポイントを移動するけどその方向がわからない

ただ、このままでは敵キャラがどの方向に移動するのかわかりません。 Gizmos.DrawLine を3本使って強引に矢印っぽく表示してみます。(Gizmosに矢印を描画するメソッドがないのは何故……)

Assets/Scripts/Editor/EnemyGizmoEditor.cs

・・・ Gizmos.DrawLine(start, end); Gizmos.DrawLine(end, (Vector3)end + Quaternion.Euler(0, 0, 45) * (start - end).normalized * 1.5f); Gizmos.DrawLine(end, (Vector3)end + Quaternion.Euler(0, 0, -45) * (start - end).normalized * 1.5f); ・・・

45°傾けた短い線を終点に表示しました。 これで移動ルートが視覚的にわかりやすいと思います。


移動ルートをシーンビューで編集できるUIを作ろう

移動ルートは表示できました。 しかしこのグリッド座標をインスペクターで入力していくのはかなりダルいです。 これもシーンビュー上のボタンUIで入力できるようにします。


ボタンを表示する

新たに EnemyEditor クラスを作り、以下のコードを書きます。 これで「B」というボタンが敵キャラの位置に表示されます。
(この辺りの詳細は エディタ拡張記事 1回目 をぜひ御覧ください!)

Assets/Scripts/Editor/EnemyEditor.cs

using System.Linq; using UnityEditor; using UnityEngine; [CustomEditor(typeof(Enemy))] public class EnemyEditor : Editor { private Enemy _instance; private void OnEnable() { _instance = (Enemy) target; } /// <summary> /// シーンビューのGUI /// </summary> private void OnSceneGUI() { Tools.current = Tool.None; if (_instance.gridPosList.Count <= 0) return; // 移動ルートの初期位置 var firstPos = _instance.gridPosList[0]; var sceneCamera = SceneView.currentDrawingSceneView.camera; // シーンビューにおける初期位置の座標 var pos = sceneCamera.WorldToScreenPoint((Vector2)firstPos * GameConfig.GridSize); var buttonSize = new Vector2(24, 24); var rect = new Rect(pos.x / 2 - buttonSize.x / 2, SceneView.currentDrawingSceneView.position.height - pos.y / 2 - buttonSize.y, buttonSize.x, buttonSize.y); Handles.BeginGUI(); if (GUI.Button(rect, "B")) { Debug.Log("おされた"); } Handles.EndGUI(); } }

足元にボタン足元にボタン

注意!

上のコードは Retina ディスプレイで表示させるときのコードになります。 解像度に影響を受けるようで、通常のドットbyドットディスプレイで表示させる場合は、ボタンに使う Rect の x、y の値に注意してください。

・・・ var rect = new Rect(pos.x - buttonSize.x / 2, SceneView.currentDrawingSceneView.position.height - pos.y - buttonSize.y, buttonSize.x, buttonSize.y); ・・・

↑こちらを使えば、ドットbyドットディスプレイで問題なく表示されるかと思います。Unityエディタを表示しているディスプレイの devicePixelRatio が、スクリプトから取得できればいいんですけどね〜。


Listの要素を追加/削除するボタンを表示する

Bボタンを十字のグリッド座標追加ボタンと削除ボタンに変更します。 押す度に Enemy コンポーネントの gridPosList に追加/削除されます。Undo/Redoを有効にするための Undo.RecordObject() への登録も忘れずに。

Assets/Scripts/Editor/EnemyEditor.cs

/// <summary> /// シーンビューのGUI /// </summary> private void OnSceneGUI() { Tools.current = Tool.None; if (_instance.gridPosList.Count <= 0) return; var firstPos = _instance.gridPosList[0]; var sceneCamera = SceneView.currentDrawingSceneView.camera; var pos = sceneCamera.WorldToScreenPoint((Vector2)firstPos * GameConfig.GridSize); ShowArrowButton(pos, Vector2Int.right, "→"); ShowArrowButton(pos, Vector2Int.left, "←"); ShowArrowButton(pos, Vector2Int.up, "↑"); ShowArrowButton(pos, Vector2Int.down, "↓"); ShowDeleteButton(pos); } /// <summary> /// 矢印ボタンを表示する /// </summary> private void ShowArrowButton(Vector3 pos, Vector2Int direction, string label) { var rect = new Rect( pos.x / 2 - _buttonSize.x / 2 + direction.x * _buttonSize.x, SceneView.currentDrawingSceneView.position.height - pos.y / 2 - _buttonSize.y - direction.y * _buttonSize.y, _buttonSize.x, _buttonSize.y); Handles.BeginGUI(); if (GUI.Button(rect, label)) { var lastPos = _instance.gridPosList.Last(); _instance.gridPosList.Add(lastPos + direction); Undo.RecordObject(_instance, "移動ルートの追加"); } Handles.EndGUI(); } /// <summary> /// 削除ボタンを表示する /// </summary> private void ShowDeleteButton(Vector3 pos) { var rect = new Rect( pos.x / 2 - _buttonSize.x / 2, SceneView.currentDrawingSceneView.position.height - pos.y / 2 - _buttonSize.y, _buttonSize.x, _buttonSize.y); Handles.BeginGUI(); if (GUI.Button(rect, "D")) { _instance.gridPosList.RemoveAt(_instance.gridPosList.Count - 1); Undo.RecordObject(_instance, "移動ルートの削除"); } Handles.EndGUI(); }

編集が楽になった!編集が楽になった!


完成形

移動ルートはループしていないといけないので、グリッド座標リストの先頭と末尾が一致してない場合/している場合でギズモの色を切り替えました。 Gizmos.DrawLine が細くて分かりづらいので画像を使って矢印を表示しています。 ボタンのデザインも修正。

わかりやすい!わかりやすい!

注意点として、Prefabから生成した GameObject(Prefabとは接続されたまま)に対してプロパティの変更を行う場合は、 EditorUtility.SetDirty() を使ってプロパティを変更したことをエディタに知らせる必要があります。 これを記述しないとエディタ再生する度にインスペクターで設定した値が消えてしまいます。

Assets/Scripts/Editor/EnemyGizmoEditor.cs

using System; using System.Collections.Generic; using System.Linq; using UnityEditor; using UnityEngine; public class EnemyGizmoEditor { private static float _barWidth = 1; [DrawGizmo(GizmoType.Selected | GizmoType.NonSelected, typeof(Enemy))] private static void DrawGizmo(Enemy enemy, GizmoType gizmoType) { ArrowTexture arrow; // ループしている場合は青、してない場合は赤 arrow = CanLoop(enemy.gridPosList) ? new ArrowTexture("blue") : new ArrowTexture("red"); var count = enemy.gridPosList.Count; for (var index = 0; index < count - 1; index++) { var next = index + 1 == count ? 0 : index + 1; var start = (Vector2) enemy.gridPosList[index] * GameConfig.GridSize; var end = (Vector2) enemy.gridPosList[next] * GameConfig.GridSize; // 矢印のバーの表示 var barSize = Math.Abs(start.x - end.x) >= GameConfig.GridSize ? new Vector2(GameConfig.GridSize + _barWidth, _barWidth) : new Vector2(_barWidth, GameConfig.GridSize + _barWidth); var barRect = new Rect( Math.Min(start.x, end.x) - _barWidth * 0.5f, Math.Min(start.y, end.y) - _barWidth * 0.5f, barSize.x, barSize.y); Gizmos.DrawGUITexture(barRect, arrow.bar); // 矢印の先端の表示 Texture capTex; var capRect = new Rect(end.x - 2f, end.y - 2f, 4, 4); if (start.x - end.x >= GameConfig.GridSize) capTex = arrow.left; else if (Math.Abs(start.x - end.x) < Mathf.Epsilon) capTex = start.y > end.y ? arrow.up : arrow.down; else capTex = arrow.right; Gizmos.DrawGUITexture(capRect, capTex); } } /// <summary> /// ループできるかどうか /// </summary> private static bool CanLoop(List<Vector2Int> gridPosList) { if (gridPosList.Count == 0) return false; return gridPosList.First() == gridPosList.Last(); } /// <summary> /// 矢印用テクスチャクラス /// </summary> private class ArrowTexture { public Texture bar; public Texture left; public Texture right; public Texture up; public Texture down; public ArrowTexture(string label) { bar = AssetDatabase.LoadAssetAtPath<Texture>("Assets/Textures/" + label + "-bar.png"); left = AssetDatabase.LoadAssetAtPath<Texture>("Assets/Textures/" + label + "-cap-left.png"); right = AssetDatabase.LoadAssetAtPath<Texture>("Assets/Textures/" + label + "-cap-right.png"); up = AssetDatabase.LoadAssetAtPath<Texture>("Assets/Textures/" + label + "-cap-up.png"); down = AssetDatabase.LoadAssetAtPath<Texture>("Assets/Textures/" + label + "-cap-down.png"); } } }

Assets/Scripts/Editor/EnemyEditor.cs

using System.Linq; using UnityEditor; using UnityEngine; [CustomEditor(typeof(Enemy))] public class EnemyEditor : Editor { private Enemy _instance; private readonly Vector2 _buttonSize = new Vector2(24, 24); private void OnEnable() { _instance = (Enemy) target; } /// <summary> /// シーンビューのGUI /// </summary> private void OnSceneGUI() { Tools.current = Tool.None; if (_instance.gridPosList.Count <= 0) return; var firstPos = _instance.gridPosList[0]; var sceneCamera = SceneView.currentDrawingSceneView.camera; var pos = sceneCamera.WorldToScreenPoint((Vector2)firstPos * GameConfig.GridSize); ShowArrowButton(pos, Vector2Int.right, "Assets/Textures/right.png"); ShowArrowButton(pos, Vector2Int.left, "Assets/Textures/left.png"); ShowArrowButton(pos, Vector2Int.up, "Assets/Textures/up.png"); ShowArrowButton(pos, Vector2Int.down, "Assets/Textures/down.png"); ShowDeleteButton(pos); } /// <summary> /// 矢印ボタンを表示する /// </summary> private void ShowArrowButton(Vector3 pos, Vector2Int direction, string path) { var icon = AssetDatabase.LoadAssetAtPath<Texture> (path); var rect = new Rect( pos.x / 2 - _buttonSize.x / 2 + direction.x * _buttonSize.x * 1.1f, SceneView.currentDrawingSceneView.position.height - pos.y / 2 - _buttonSize.y - direction.y * _buttonSize.y * 1.1f, _buttonSize.x, _buttonSize.y); Handles.BeginGUI(); if (GUI.Button(rect, icon, GUIStyle.none)) { var lastPos = _instance.gridPosList.Last(); _instance.gridPosList.Add(lastPos + direction); EditorUtility.SetDirty(_instance); Undo.RecordObject(_instance, "移動ルートの追加"); } Handles.EndGUI(); } /// <summary> /// 削除ボタンを表示する /// </summary> private void ShowDeleteButton(Vector3 pos) { var icon = AssetDatabase.LoadAssetAtPath<Texture> ("Assets/Textures/del.png"); var rect = new Rect( pos.x / 2 - _buttonSize.x / 2, SceneView.currentDrawingSceneView.position.height - pos.y / 2 - _buttonSize.y, _buttonSize.x, _buttonSize.y); Handles.BeginGUI(); if (GUI.Button(rect, icon, GUIStyle.none)) { _instance.gridPosList.RemoveAt(_instance.gridPosList.Count - 1); EditorUtility.SetDirty(_instance); Undo.RecordObject(_instance, "移動ルートの削除"); } Handles.EndGUI(); } }

Gizmos.DrawGUITexture を使うとシーンビューに画像が表示できるのでかなり色んなデバッグ表示ができそうですね。2D表示だけでなく、3Dのオブジェクトやメッシュの表示なんかもできるっぽいです。

エディタ拡張は沼なので、ゲーム開発そっちのけで没頭しないように注意しましょう!