この記事はUnity #3 Advent Calendar 2020 18日目の記事です
#動機
ノベルゲーム最近少なくて寂しいなぁ
データが簡単に作れたらもしかして誰か作るかもしれないなぁ
...せや!シンプルなノードベースのシナリオエディタ作ったろ!
#環境
Mac
Unity 2019.4.8.f1
プロジェクト一式
https://github.com/dwl398/GraphViewSample
#どんな人に向けた記事か
・シンプルなノベルシステムを作りたすぎる人
#この記事で紹介する内容
・GraphViewの基本的な使い方
・GraphViewで作ったノードの保存、読込(つまづきポイントのみ)
#GraphViewの基本的な使い方
##1. EditorWindowを作成する
GraphViewを使うためにはまずEditorWindowを用意します
public class ScriptGraphWindow : EditorWindow
{
[MenuItem("Tool/ScriptGraph")]
public static void Open()
{
ScriptGraphWindow window = GetWindow<ScriptGraphWindow>();
window.Show();
}
}
##2. GraphViewを作る
GraphViewを作成します
※UnityEngine.UIElementsに依存した機能が各所に使われているのでusingミスに注意(1敗)
using UnityEngine.UIElements;
using UnityEditor.Experimental.GraphView;
public class ScriptGraphView : GraphView
{
public ScriptGraphView() : base()
{
// 親のサイズに合わせてサイズを設定
this.StretchToParentSize();
// ズームインアウト
SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
// ドラッグで描画範囲を移動
this.AddManipulator(new ContentDragger());
// ドラッグで選択した要素を移動
this.AddManipulator(new SelectionDragger());
// ドラッグで範囲選択
this.AddManipulator(new RectangleSelector());
}
}
private void OnEnable()
{
var scriptGraph = new ScriptGraphView();
this.rootVisualElement.Add(scriptGraph);
}
これでエディタのメニューからTools/ScriptGraph
を選んで開いてみます
###2_a. 背景を付ける
Resourcesに以下のファイルを追加します
GridBackground {
--grid-background-color: #282828;
--line-color: rgba(193,196,192,0.1);
--tick-line-color: rgba(193,196,192,0.1);
--spacing: 20
}![Something went wrong]()
ファイルを読み込んで背景を追加します
public class ScriptGraphView : GraphView
{
public ScriptGraphView() : base()
{
// 省略
// ussファイルを読み込んでスタイルに追加
this.styleSheets.Add(Resources.Load<StyleSheet>("GraphViewBackGround"));
// 背景を一番後ろに追加
this.Insert(0, new GridBackground());
}
}
作成したussファイルをいじれば即反映されるのでカスタマイズも簡単です
##3. Nodeを作る
次は作成したGraphViewに載せるノードを作ります
using UnityEngine;
using UnityEngine.UIElements;
using UnityEditor.Experimental.GraphView;
public class MessageNode : Node
{
private TextField textField;
public MessageNode()
{
// ノードのタイトル設定
this.title = "Message";
// ポート(後述)を作成
var inputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Input, Port.Capacity.Multi, typeof(Port));
inputPort.portName = "In";
inputContainer.Add(inputPort);
var outputOort = Port.Create<Edge>(Orientation.Horizontal, Direction.Output, Port.Capacity.Single, typeof(Port));
outputOort.portName = "Out";
outputContainer.Add(outputOort);
// メイン部分に入力欄追加
textField = new TextField();
// 複数行対応
textField.multiline = true;
// 日本語入力対応
textField.RegisterCallback<FocusInEvent>(evt => { Input.imeCompositionMode = IMECompositionMode.On; });
textField.RegisterCallback<FocusOutEvent>(evt => { Input.imeCompositionMode = IMECompositionMode.Auto; });
this.mainContainer.Add(textField);
}
}
これをGraphViewに追加してみます
public ScriptGraphView() : base()
{
// 省略
this.Add(new MessageNode());
}
これで全てのノードをプログラムで追加しまくりのハードコーディングしまくりで
色々な賞も受賞しまくりです
##4. Nodeをエディタから作れるようにする
もちろん嘘なのでShaderGraphの右クリックで出てくるアレを作ります
using System;
using System.Collections.Generic;
using UnityEditor.Experimental.GraphView;
using UnityEngine;
public class ScriptGraphSearchWindowProvider : ScriptableObject, ISearchWindowProvider
{
private SctiptGraphWindow _window;
private ScriptGraphView _graphView;
public void Init(ScriptGraphView graphView,ScriptGraphWindow window)
{
_window = window;
_graphView = graphView;
}
public List<SearchTreeEntry> CreateSearchTree(SearchWindowContext context)
{
var entries = new List<SearchTreeEntry>();
entries.Add(new SearchTreeGroupEntry(new GUIContent("Create Node")));
entries.Add(new SearchTreeEntry(new GUIContent(nameof(MessageNode))) { level = 1, userData = typeof(MessageNode)});
return entries;
}
public bool OnSelectEntry(SearchTreeEntry SearchTreeEntry, SearchWindowContext context)
{
var type = SearchTreeEntry.userData as Type;
var node = Activator.CreateInstance(type) as Node;
// ノードの生成位置をマウスの座標にする
var worldMousePosition = _window.rootVisualElement.ChangeCoordinatesTo(_window.rootVisualElement.parent, context.screenMousePosition - _window.position.position);
var localMousePosition = _graphView.contentViewContainer.WorldToLocal(worldMousePosition);
node.SetPosition(new Rect(localMousePosition, new Vector2(100, 100)));
_scriptGraphView.AddElement(node);
return true;
}
}
これをScriptGraphView側で生成して設定します
public ScriptGraphView(ScriptGraphWindow window) : base()
{
// 省略
// 右クリックでノード作成するウィンドウ追加
var searchWindowProvider = ScriptableObject.CreateInstance<ScriptGraphSearchWindowProvider>();
searchWindowProvider.Init(this, window);
this.nodeCreationRequest += context =>
{
SearchWindow.Open(new SearchWindowContext(context.screenMousePosition), searchWindowProvider);
};
}
これで右クリックでノード生成できるようになりました
ここまでやって受賞しまくりです
###4_a. Nodeを作るたびにメニューに追加するのは辛い
entries.Add(new SearchTreeEntry(new GUIContent(nameof(MessageNode))) { level = 1, userData = typeof(MessageNode)});
何度もこんなコードを書くのは辛いので少し楽にします
public class ScriptGraphNode : Node
{
}
public class MessageNode : ScriptGraphNode
{
// 省略
}
public List<SearchTreeEntry> CreateSearchTree(SearchWindowContext context)
{
var entries = new List<SearchTreeEntry>();
entries.Add(new SearchTreeGroupEntry(new GUIContent("Create Node")));
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
foreach (var type in assembly.GetTypes())
{
if (type.IsClass == false) continue;
if (type.IsAbstract) continue;
if (type.IsSubclassOf(typeof(ScriptGraphNode)) == false) continue;
entries.Add(new SearchTreeEntry(new GUIContent(type.Name)) { level = 1, userData = type });
}
}
return entries;
}
これでScriptGraphNodeを継承したNodeが自動でノード作成メニューに表示されるようになります
##5. Nodeを繋ぐ
ノードのついているIn Out のポートを接続します
ポートの接続に関する条件付けができる関数がGraphViewに用意されているので
オーバーライドして条件を記載します
public override List<Port> GetCompatiblePorts(Port startPort, NodeAdapter nodeAdapter)
{
var compatiblePorts = new List<Port>();
foreach (var port in ports.ToList())
{
// 同じノードは繋げない
if (startPort.node == port.node) continue;
// Input - Input , Output - Outputは繋げない
if (startPort.direction == port.direction) continue;
// ポートタイプが違うものは繋げない
if (startPort.portType != port.portType) continue;
compatiblePorts.Add(port);
}
return compatiblePorts;
}
ノードが増えてきた頃に重要になりそうです
GraphViewの基本的な機能はここまでになります
なまじノードの拡張性が高いせいで保存、読み込みなどは各自で用意する必要があります
#GraphViewで作ったデータの保存、読込
私のつまづきポイントのみの解説となります
冒頭に記載したgithubにコードがあるので詳細はそちらで確認お願いします
手順としては以下になります
1.保存するデータ(ScriptableObject)を用意
2.編集がScriptableObjectに反映されるように
3.ノードのシリアライザ、デシリアライザを用意
4.ProjectウィンドウのScriptableObject選択でEditorWindowが開くように
5.開いた際にノードが生成されるように
6.ノードをつなぐエッジが保存されるように
7.ノードをつなぐエッジが生成されるように
##2.編集がScriptableObjectに反映されるように
保存するデータはこんな感じです
[CreateAssetMenu(fileName = "scriptgraph.asset", menuName ="ScriptGraph Asset")]
public class ScriptGraphAsset : ScriptableObject
{
public List<ScriptNodeData> list = new List<ScriptNodeData>();
}
[Serializable]
public class ScriptNodeData
{
public int id;
public NodeType type;
public Rect rect;
public int[] outIds;
public byte[] serialData;
}
ノード作成時にスクリプタブルオブジェクトにデータを追加すればとりあえずの保存はできます
罠です
public bool OnSelectEntry(SearchTreeEntry SearchTreeEntry, SearchWindowContext context)
{
// 省略
node.SetPosition(new Rect(localMousePosition, new Vector2(100, 100)));
_scriptGraphView.AddElement(node);
ScriptGraphData data = Serialize(node);
// ここで追加しよう!
_scriptGraphAsset.list.Add(data);
return true;
}
なぜか、これでは正しいpositionが保存されません
_scriptGtaphView.AddElement()
と同じフレームでnodeのpositionを取得しようとすると
Rect(0,0,float.Nan,float.Nan)
が帰ってきます
対策としてパッケージマネージャのEditorCoroutine
などでノード作成の次のフレームで保存するように調整しましょう
#終わりに
かなりざっくりとですが今graphViewを扱う上で最低限の情報を書きました
実際にこのシステムでノベルゲームを作るとなると拡張が必要になりますが
拡張のためのサンプルもあるので(BranchNode)プログラマの人なら割と誰でも拡張ができると思います
ストーリー、グラフィック、サウンド、さまざまな拡張に対応し、いずれノベルゲームが完成する
そんな未来を信じています