UnityのGraphViewでスキル処理を構築するための試み
「Happy Elements Advent Calendar 2023」 12月3日の記事です。
はじめに
はじめまして。私は現在Happy Elements株式会社カカリアスタジオで新規タイトルの開発チームでゲームエンジニアとして働いているnaoya.mです。
現在開発中の企画では、必殺技や攻撃(以下スキル)に関わる処理を非エンジニア職が構築できることを目指しています。
非エンジニア職がスキルの実装を行うことができるようになれば、エンジニアの手を介さずに細かい調整をすることが可能になり、生産性の向上に繋がります。
本記事では、UnityのGraphViewを用いてそれを実現するための試みについて紹介します。
仕様
機能を開発するにあたり、予めプランナー職と大まかな認識を合わせた後、どのような仕様にするかを検討しました。
以下は、本機能に対する要望を列挙したものです。
要望
- 攻撃処理をノードベースで構築したい。
- 攻撃ユニットのパラメータを参照したい。
- スキル自体にもパラメータを持たせ、それを参照したい。
- 複数の処理を同時並行で動かしたい。
- 攻撃ヒット後の処理を同じエディタ内で記述したい。
完成イメージ
ざっくりと完成イメージを描きながらすり合わせを行いました。
下図は実装後にできたものですが、実際にはホワイトボードに絵を描いて議論しました。
緑の線で繋がっているのが処理の流れを表しており、青い線は値の流れを表しています。
黄色の線で繋がれている部分は攻撃ヒット時の分岐を表しています。
方針
上記の要望を踏まえて、ノードエディタとランタイムでの仕様を以下のようにしました。
-
ノードは大きく分けて、処理順序を明示的に指定するActionノードと、すぐに処理が終わるValueノードがある。
- 例えば、エフェクトを表示する、一定時間待つなどはActionノードで表現され、ユニットのパラメータを参照する、値同士を掛け算する、ユニットの現在位置を取得するなどはValueノードで表現されます。
-
ノードグラフには処理の起点を表すEntryノードを1つだけ含むようにする。
-
Entryノードを起点として、Actionポートに繋がれているActionノードを次々と実行していく。
- Actionノードは実行順序を設定するための専用のポート(Actionポート)を、入力側と出力側に一つずつ持ち、それを繋げることで実行順を表現する。Entryノードには出力側にのみActionポートが付いている。
-
Valueノードはそれ単独では処理されず、他のActionノードやValueノードの処理に連動して処理される。
- Actionノードが実行されたタイミングで、インプット側に繋がっているValueノードは逆向きにノードを辿り処理を行う。
- Actionノードのアウトプットは、ノードの処理の実行中に出力データの格納を行う。
-
対象にヒットする効果を持つノードはCollisionポートを持つ。CollisionポートからはActionノードを分岐させることができ、ヒット後に行う処理を構築することができる。
1.~4.は、ざっくり言えば、Actionノードは繋がれた順に評価し、それに繋がれているValueノードは必要になったタイミングで評価するという仕様になっています。これにより、最低限の指定で処理順序の制御を可能にする狙いがあります。
5.は攻撃ヒット後の処理を同じエディタで構築するための工夫です。
なお、Actionノードは上図の緑の線で繋がれているノードで、Valueノードはそれ以外のノードにあたり、Collisionポートは上図の黄色の線で繋がれている箇所にあたります。
実装
実装は大きく分けて以下の3つの部分で構成されます。
- ノードエディタの実装
- コマンドデータへの変換処理
- コマンドデータの実行
ここで、コマンドデータはランタイムで処理を行うためのデータの単位を指します。
コードを生成する方式ではなく、コマンドデータを生成してそれを駆動する方式にした理由としては、クライアントをアップデートすることなく新しいスキルをリリースしたり、不具合があった場合に修正を素早く行いたいという意図があります。[1]
ノードエディタの実装
ノードエディタの実装にはGraphViewを扱いやすくするOSSであるNode Graph Processorを使用しています。
ノードの定義
ノードには入力ポートと出力ポートがあり、それぞれのノードを処理するにあたって必要な入出力を定義しています。Input属性を付けたフィールドは入力ポートとして、Output属性を付けたフィールドは出力ポートとして表示されます。また、ShowAsDrawer属性を付けると、ポートに何も接続されていない場合に、値を直接入力するためのフィールドを表示することができます。
ノードの実装の一例は下記の通りです。
/// <summary>
/// SpineのBoneの位置を取得するためのノード。
/// </summary>
[Serializable]
[NodeMenuItem("Get Bone Position")]
public sealed class GetBonePositionNode : BaseNode
{
[Input(">>")] public ActionPort _previous;
[Input("Bone Path"), ShowAsDrawer] public string _bonePath;
[Output(">>", false)] public ActionPort _next;
[Output("Position")] public float2 _position;
}
このノードはSpineのボーンの位置を取得するためのノードで、入力ポートとしてActionPortとstringを、出力ポートとしてActionPortとfloat2を定義しています。なお、ActionPortはActionノード同士を接続するためのポートです。
ポートの配色
視覚的にわかりやすいように、ポートとそれに繋がれているエッジに色をつけています。
Resourcesフォルダの中にPortViewTypes.ussという名前のファイルを作成し、以下のような記述をすると、ポートとエッジに色をつけることができます。
.Port_Boolean {
--port-color: #ff6161;
}
.Port_Single {
--port-color: #2773ff;
}
.Port_ActionPort {
--port-color: #3af89e;
}
ポートの変換
一部のポートは型が異なっていても接続できるようにします。
ITypeAdapterを実装したクラスに以下のようなstaticメソッドを定義すると、異なる型のポートを接続できるようになっています。
この例では、ActionポートとCollisionポートを変換可能にしています。[2]
public class PortConverter : ITypeAdapter
{
public static ActionPort ConvertCollisionToAction(CollisionPort from) => new();
public static CollisionPort ConvertActionToCollision(ActionPort from) => new();
}
コマンドデータへの変換
ノードでスキルの処理を表現できたら、それをコマンドデータに変換します。
Node Graph Processorは、ノード自体に計算機能を付与する仕組みを持っているのですが、実行順序を制御したいことや非同期処理を行うこと、前処理の必要性などの理由により、別のデータに変換しそれをランタイムで順に読み取って実行する方法で実装を行いました。[3]
コマンドデータを格納するためのアセット定義は以下のようになっています。
public sealed class SkillSequenceData : ScriptableObject
{
[SerializeField] int _id;
[SerializeReference, SubclassSelector] ICommandData[] _commands;
[SerializeField] float[] _parameters;
[SerializeField] string _description;
}
ここで重要なのは、各コマンドデータはICommandDataというインターフェースを実装するようにしている点と、コマンドデータを格納する_commandsにSerializeReference属性を付与している点です。
このようにすることで、様々な種類のコマンドを一つの配列に格納することができます。
SubclassSelector属性は確認用のもので、こちらの記事のものを少し改造して使用しています。
コマンドデータへの変換は以下の順に処理します。
- ノードを適当な順序(例えば深さ優先)で展開する。この際、Entryノードは必ず先頭に配置されるようにする。
- 各ノードを中間データに変換する。
- 各中間データをコマンドデータに変換する。
2.の中間データはコマンドデータと1対1で対応していますが、ノードと中間データは1対1ではない場合があります。中間データを経由せずとも変換処理は実装可能ですが、一度中間データを作った方がスムーズだったため、このような形にしています。
実際の処理は以下のようになっています。
/// <summary>
/// graphをコマンドデータに変換してdataに格納する。
/// </summary>
void Compile(SkillSequenceGraph graph, SkillSequenceData data)
{
var entryNode = graph.nodes.Cast<CommandNode>().FirstOrDefault(it => it is EntryNode);
if (entryNode == null) {
// EntryNodeが無ければエラー
throw new InvalidDataException();
}
// Entryノードから順に深さ優先で展開する。
_nextNodes.Enqueue(entryNode);
while (_nextNodes.Count > 0) {
var node = _nextNodes.Dequeue();
if (_nodeToPreCommand.ContainsKey(node)) {
continue;
}
// 中間データへの変換。
var preCommand = node.ConvertToPreCommand(this);
_preCommands.Add(preCommand);
_nodeToPreCommand[node] = preCommand;
}
// 中間データにインデックスを割り振る。
foreach (var (preCommand, index) in _preCommands.WithIndex()) {
preCommand.CommandIndex = index;
}
// コマンドデータへの変換。
var commands = _preCommands.Select(it => it.ConvertToCommand()).ToArray();
foreach (var (command, index) in commands.WithIndex()) {
command.CommandIndex = index;
}
data.Commands = commands;
}
コマンドデータの実行
コマンドデータの作成が終わったら、ランタイムでの実行処理に移ります。
ランタイムでは以下のルールで処理が実行されます。
- コマンドの実行開始時に、スキルインスタンスとスキルスレッドが1つずつ生成される。スキルインスタンスは1回のスキルの実行につき1つだけ生成され、スキルスレッドは必要に応じて複数生成される。最初に生成されるスレッドを特にプライマリスレッドと呼ぶ。
- プライマリスレッドの処理が終了した時点でその行動は終了となる。プライマリスレッドが終了しても、他のスレッドが処理を継続している場合もある。[4]
- コマンドデータはEntryコマンド[5]から実行される。
- Entryコマンド及びActionコマンド[6]は次に実行するActionコマンドのインデックスを保持している。
- Actionコマンドを実行する際に、コマンドの入力としてValueコマンド[7]が指定されている場合は、必要になったタイミングでValueコマンドの評価を行う。
- Valueコマンドを実行する際に、そのValueコマンドの入力として別のValueコマンドが接続されている場合は、さらに遡って評価する。
- Actionコマンドに出力が存在する場合は、Actionコマンドの中で値を出力する処理を実行する。出力処理が行われると、コマンド毎にユニークなキーが生成され、スキルインスタンス毎に用意された連想配列に格納する。
- Actionコマンドの出力が別のコマンドの入力に接続されている場合、そのコマンドの評価を行うタイミングで連想配列からデータを取得する。
一例として、ヒットした相手にダメージを与えるコマンドは以下のように実装しています。
/// <summary>
/// ターゲットにダメージを与えるコマンドデータ。
/// </summary>
[Serializable]
public sealed class DamageToTargetCommandData : ActionCommandData
{
[field: SerializeField]
public InputKey<InstanceIdWrapper> Target { get; set; }
[field: SerializeField]
public InputKey<float> Damage { get; set; }
public override void Execute(SkillSequenceThread thread)
{
var target = thread.GetValue(Target);
if (target == null) {
// targetが既にいない場合はキャンセル。
thread.SetCanceled();
return;
}
var damage = thread.GetValue(Damage);
// targetにdamage分のダメージを与える。
...
}
}
InputKey<T> は内部的にはint型を保持するのみとなっており、入力を取得するためのコマンドのインデックスを保持しています。
[Serializable]
public struct InputKey<T>
{
[field: SerializeField]
public int CommandIndex { get; set; }
public InputKey(int commandIndex)
{
CommandIndex = commandIndex;
}
}
あえてジェネリックな型にしている理由は、それがノードグラフで指していた型を明示することで、ノードグラフからコマンドデータへの変換時に型チェックを行うためと、thread.GetValueの返り値に型を付与できるようにするためです。
コマンドデータのプロパティ定義は、対応するノードクラスのポート定義からコード生成を行うことで、実装時の手間を削減するという工夫もしています。
非同期処理
Actionコマンドは非同期処理を表現することが可能です。
一例として、指定した秒数だけ待機するコマンドであるWaitCommandDataの実装を示します。
/// <summary>
/// 一定時間待機するためのコマンドデータ。
/// </summary>
[Serializable]
public sealed class WaitCommandData : ActionCommandData
{
[field: SerializeField]
public InputKey<float> Duration { get; set; }
public override void Execute(SkillSequenceThread thread)
{
if (!thread.Busy) {
var duration = thread.GetValue(Duration);
// durationだけ待つタイマーを生成する。
thread.ThreadLocal.WaitTimer = new WaitTimer(TimeSpan.FromSeconds(duration));
thread.Busy = true;
}
else {
if (thread.ThreadLocal.WaitTimer.Elapsed(thread.DeltaTime)) {
// 指定した時間が経過したら処理を終了する。
thread.Busy = false;
}
}
}
}
まず、ActionCommandDataのExecuteは毎フレーム実行されるようになっています。
最初にExecuteが実行されたタイミングでthred.Busy = trueとすることで、次のフレームも同じコマンドが実行され、その後、処理が終わったタイミングでthread.Busy = false とすることで、次のコマンドに処理が移行されるようにしています。
Collisionポートを跨ぐ処理
衝突後に処理を継続させる仕組みはそれなりにややこしくまだ整理できていないのですが、
ざっくりと言えば、RigidBodyを持ったエフェクトのGameObjectに次に実行するコマンドの情報を持たせ、衝突時にその情報を取り出して処理を継続させるようになっています。
今後の展望
非エンジニア職が使用することもあり、ある程度処理を纏めたノードを作る必要があります。
この際、ノードが行う処理の粒度は小さすぎず大きすぎない丁度良いものを考える必要があります。というのも、粒度が小さすぎる場合、ノードの汎用性は高くなりますが、グラフが複雑になってしまうため、作成者の学習コストが高くなる懸念があります。
一方で粒度が大きすぎる場合、ノードの数が少なくなってデータ作成の難易度は低下しますが、個々のノードの汎用性が低くなってしまうため、スキル毎に専用のノードを実装するといったことになりかねません。
この辺りは実装を進める上で適切なバランスを探す必要がありそうです。
今はまだ試作段階なので、ノードやコマンドの処理の細かい仕様は詰めきれていませんが、大まかな構想を具体化するところまではできました。今後はより詳細な実装を行い、開発中のゲームへの適用を進めていきたいと思います。
-
新しいノードを追加する場合は当然クライアントアップデートが必要になりますし、全てにおいてクライアントアップデートが不要ということではありません。 ↩︎
-
実際には、Collisionポート => Actionポートの変換しか使用しませんが、相互の変換を実装しないと警告が表示されます。 ↩︎
-
他の方法として、ノードグラフを元にコード生成を行うという方法も考えられますが、アセットの差し替えのみで新しいスキルをリリースできるようにするという要件があったため、この方法は取りませんでした。 ↩︎
-
例えば、地面にあたってから爆発したり、その場にエフェクトがしばらく残り続ける場合など。 ↩︎
-
Entryノードに対応するコマンドデータ ↩︎
-
Actionノードに対応するコマンドデータ ↩︎
-
Actionノードに対応するコマンドデータ ↩︎
Discussion