GodotのC#プロジェクトを複数プロジェクトに分けて管理するには?
この記事は、 Godot Japan User Community Discordサーバーの質問フォーラムへ投稿するために書かれたものです。それ以外のコミュニティの方も知っていることがあればコメントでお知らせくださると幸いです。
更新:
✅ ひとまず解決しました。「進展 2023/10/23」をご覧ください。
概要
- Godotバージョン: Godot_v4.1.1-stable_mono_win64
- OS: Windows 11
- 言語: C#
- dotnetバージョン: net7.0
問題が発生する最小限のプロジェクトをGitHubにリポジトリとして配置しました。 project.godot
ファイルをGodotエディタで開けるはずです。
この質問の🚩ゴールは、 Context.cs
というスクリプトをシーン上のノードにアタッチするか、それと同等の結果を得ることです。
状況説明
このアプリは、依存関係を持つ2つのプロジェクト(.csproj)から成ります。フォルダのルートから見て、
MultipleProjectTest.Godot/MultipleProjectTest.csproj
-
ClassLibrary1/ClassLibrary1.csproj
です。
そして、 MultipleProjectTest.csproj
は ClassLibrary1.csproj
を参照しているため、 ClassLibrary1.Context
クラスに依存する MultipleProjectTest.Root
クラスを実装することができました。
具体的なソースコード
using Godot;
namespace MultipleProjectTest;
public partial class Root : Node
{
[Export] private ContextClone _context;
}
using Godot;
using Godot;
namespace ClassLibrary1;
public partial class Context : Sprite2D
{
[Export] public Sprite2D Sprite { get; private set; }
public override void _Process(double delta)
{
Sprite.Position += new Vector2(0, 10);
}
}
using Godot;
namespace MultipleProjectTest;
public partial class ContextClone : Sprite2D
{
[Export] public Sprite2D Sprite { get; private set; }
public override void _Process(double delta)
{
Sprite.Position += new Vector2(0, 10);
}
}
さて、 Root
クラスをシーン内のオブジェクトにアタッチしました。 Root
クラスはExport属性を使って、 Context
クラスを読み込みますので、 Context
クラスが定義されたスクリプトもシーン内の別のオブジェクトにアタッチしなければいけません。
アタッチすることができたなら💙期待される動作として、画像が画面上側から降りてくるアニメーションを表示できるつもりです。
問題点: メインプロジェクトに無いスクリプトはアタッチできない
ここで問題があります。 Context
クラスは ClassLibrary1
プロジェクトのクラスなので、 MultipleProjectTest.Godot
ディレクトリ以下にはありません。いま、 res://
の指す場所は MultipleProjectTest.Godot
ディレクトリですので、 Context
クラスを何らかのオブジェクトにアタッチしたければ、 Context
スクリプトが MultipleProjectTest.Godot
ディレクトリ以下にある必要があります。
提示したサンプルプロジェクトでは、やむを得ず ContextClone
スクリプトをアタッチすれば💙期待される動作をするようにしておきました。
さて、🚩ゴールを達成するために、何か対策を考えたいところです。
モチベーション
そもそも、なぜ Context
クラスを別のプロジェクトに分けて置いておきたいのかを説明する必要があると思います。
プロジェクトから別のプロジェクトを分離できる場合、各プロジェクトは internal
アクセシビリティを用いて他のプロジェクトから利用できるAPIを提示します。一方で分離ができない場合は、どのクラスも他のどのクラスの持つAPIでも利用でき、ミスを誘います。
ぼくのプロジェクトでは、Godotのメインプロジェクトにエントリポイントがありますが、別のプロジェクトとして「戦闘画面用(.csproj)」「メニュー画面用(.csproj)」などのプロジェクトを分けたいと考えています。
対策
無効な対策: Contextクラスをメインプロジェクトに移動する
まず、 Context
クラスを MultipleProjectTest
プロジェクト内のクラスになるよう移動させるのは無意味でしょう。 MultipleProjectTest
プロジェクトと ClassLibrary1
プロジェクトで折角クラスの居場所を分けたのに、Godotの技術的な制限の都合でプロジェクト構成を変えたいとは思いません。
例えば、 MultipleProjectTest
プロジェクトには単にノードにアタッチするスクリプトを置きたいだけで、実際にノードを操作するコードは ClassLibrary1
プロジェクトに置きたい場合、このような制約は受け入れられません。なぜなら、それは ClassLibrary1
が MultipleProjectTest
への参照を持つ必要があることを意味し、循環参照が生まれるからです。
res://
に含める
無効な対策: 参照先のプロジェクトを また、 ClassLibrary1
フォルダを MultipleProjectTest.Godot
フォルダ以下に移動する手段も考えました。こうすれば、 ClassLibrary1.Context
クラスは res://
の下にあるスクリプトということになるので、オブジェクトにアタッチできるはずです。
しかしこの方法にも問題があります。 res://
以下にあるC#ファイルは全て、メインプロジェクトである MultipleProjectTest
プロジェクトに含まれるという決まりがあり、実際、 res://
以下に ClassLibrary1
プロジェクトを移動させると、その中のC#ファイルが全てメインプロジェクトにも同時に属してしまいます。こうなると同じ名前のクラスが二重定義になるなど、混乱を生みます。
部分的に有効な対策: アタッチ用クラスと、操作用クラスに分ける
この方法では、新しいクラス ContextToAttach
を作成し、これをノードにアタッチするようにします。対策を適用したものをGitHubに配置しています。
ContextToAttach
クラスは以下のようなもので、自身を参照先のプロジェクトにある操作用クラス Context
に変換する ToLogic
メソッドを持っているのが特徴です。
using System.Reactive;
using System.Reactive.Subjects;
using ClassLibrary1;
using Godot;
namespace MultipleProjectTest;
public partial class ContextToAttach : Node
{
[Export] public Sprite2D Sprite { get; private set; }
private readonly Subject<Unit> _onProcess = new();
public override void _Process(double delta)
{
_onProcess.OnNext(Unit.Default);
}
// ClassLibrary1.Context クラスに変換するためのメソッド
public Context ToLogic()
{
return new Context()
{
Sprite = Sprite,
OnProcess = _onProcess
};
}
}
それから、 Context
クラスはもはやノードではなくなります。このため _Process
メソッドをオーバーライドすることはできなくなったので、 IObservable<Unit>
を受け取るようにしてここでProcess時の処理を行います。 _Process
メソッドが実際に置かれているのは ContextToAttach
クラスです。
using System.Reactive;
using Godot;
namespace ClassLibrary1;
public class Context // ノードではなくなった
{
public required Sprite2D Sprite { get; init; }
public required IObservable<Unit> OnProcess { get; init; }
public void Initialize()
{
OnProcess.Subscribe(_ => Sprite.Position += Vector2.Down * 10);
}
}
最後に、これらのクラスを以下の Root
クラスで使用します。
using Godot;
namespace MultipleProjectTest;
public partial class Root : Node
{
[Export] private ContextToAttach _context;
public override void _Ready()
{
_context.ToLogic().Initialize();
}
}
このようにすると実際、💙期待された動作をとります。
どうなった?
これにより、スプライトを下方向へ動かすという責務を、ノードへアタッチするスクリプトとしての責務から分離することができました。確かにこの方法は、🚩ゴールを達成しているようにも見えます。
しかし、 Context
クラスと同じような構造を持つクラス ContextToAttach
ができてしまったことと、 ContextToAttach
から Context
型へ変換するメソッドが必要になってしまった点は望ましくありません。つまり、この方法ではスクリプトとしてアタッチしたいクラスが増える度に同じことをする必要があり、冗長なコードが増え、コードを追うのが難しく、ミスを誘うのです。
また、 ContextToAttach
クラスは純粋なシリアライズ用クラスには見えません。 ToLogic
メソッドのようなものは取り除きたいところです。
アイデアもとむ
改めて今回の質問は、メインプロジェクトとは別のプロジェクトに属するスクリプトを、Godotのシーンにアタッチする方法、あるいはそれと似た効果のある方法は何かを教えてほしい、というものです。
アタッチ用クラスを分ける方法は🚩ゴールを達成しているようにも見えますが、この方法は継続可能には見えません。
Godotのプロジェクトの仕組みに詳しい方がいると嬉しいところです…
進展 2023/10/06
調査の結果、「無効な対策: 参照先のプロジェクトを res://
に含める」で紹介した方法を発展させることができるようですが、この方法では解決しませんでした。ともあれ、なぜ解決できなかったのか見てみましょう。
この方法では、まずメインプロジェクト MultipleProjectTest.csproj
ファイルを編集して、 EnableDefaultCompileItems
要素を追加し、その値を false
にします(デフォルトでは true
)になっています。
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<EnableDynamicLoading>true</EnableDynamicLoading>
+ <EnableDefaultCompileItems>false</EnableDefaultCompileItems>
</PropertyGroup>
このようにすると、 MultipleProjetTest.csproj
の置かれているディレクトリ以下のC#ソースファイルだからといって無差別にプロジェクトに追加されるのを防ぐことができます。
一方で、この設定をすることでメインプロジェクトにはC#ソースファイルが何も含まれていない状態になってしまいますので、このプロジェクトがコンパイルする対象を名指しで指定します。
+ <ItemGroup>
+ <Compile Include="ContextToAttach.cs" />
+ <Compile Include="Root.cs" />
+ </ItemGroup>
ここまで操作したら、ようやく ClassLibrary1
プロジェクトを res://
ディレクトリ以下に置くことができます。なぜなら、今や MultipleProjectTest
は自身のディレクトリ以下のC#ファイルを無差別に取り込んだりしないため、 ClassLibrary1
は独立してコンパイルすることができるためです。
さて、 ClassLibrary1
フォルダを res://
の位置に移動しましたので、 ClassLibrary1
フォルダと MultipleProjectTest.csproj
ファイルは同じ階層にあります。この状態になるとソリューションファイルから ClassLibrary1
プロジェクトを見つけられなくなるので、ソリューションファイルを変更しましょう。
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 2012
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MultipleProjectTest", "MultipleProjectTest.csproj", "{339F7791-A96C-4193-B0A6-4D2AD033E050}"
EndProject
- Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClassLibrary1", "..\ClassLibrary1\ClassLibrary1.csproj", "{2FB4A883-595C-4715-8AEB-AC8D0C75907F}"
+ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClassLibrary1", "ClassLibrary1\ClassLibrary1.csproj", "{2FB4A883-595C-4715-8AEB-AC8D0C75907F}"
EndProject
この状態で、Godot Editorでプロジェクトを開きます。 FileSystemペイン上の res://
ディレクトリ以下を見ると、確かに ClassLibrary1
プロジェクトの中身が表示されています。これなら、 Context.cs
ファイルをノードにアタッチすることができそうです。実際、アタッチすることができます。
🤔ところが、 Context.cs
をいずれかのノードにアタッチしても、 Context.Sprite
プロパティに対応する設定欄がエディターのInspectorに現れません。そして、ゲームを実行すると以下のエラーが出力されます:
E 0:00:00:0756 can_instantiate: Cannot instance script because the associated class could not be found. Script: 'res://ClassLibrary1/Context.cs'. Make sure the script exists and contains a class definition with a name that matches the filename of the script exactly (it's case-sensitive).
<C++ Error> Method/function failed. Returning: false
<C++ Source> modules/mono/csharp_script.cpp:2388 @ can_instantiate()
Context.cs
ファイルが見つからないよ、という内容です。実際にはそういったファイルが存在するのに、どうなっているのでしょう?
調べた結果、godotengineリポジトリのこのissueにたどり着きました。これによると、
I had this error in beta14 and just fixed it. For me, it was caused by my godot project's "dotnet/project/assembly_name" setting no longer matching the assembly name of the Visual Studio solution/project.
And I think the mismatch happened because the dotnet project's assembly name defaults to $(MSBuildProjectName) and I had adjusted the project name.
この報告では、「メインプロジェクトのアセンブリ名が実際のアセンブリ名と一致しなくなったため、このエラーが起きた」とのことです。この報告から察するに、メインプロジェクトに属さないC#ファイルは、たとえ res://
ディレクトリ以下にあったとしてもノードにアタッチすることができないのではないでしょうか。
というわけで、かなり雲行きが怪しい状況になりましたが、引き続き調査を進めていきます。もし🚩ゴールを達成する以外でぼくに合った方法があった場合も報告します。
参考
EnableDefaultCompileItemについて公式ドキュメントはこちらです。
EnableDefaultCompileItemを用いてres://内に別のプロジェクトを入れている例は、こちらのリポジトリを参考にしました。
✅ 進展 2023/10/23
結局、「部分的に有効な対策:アタッチ用クラスと、操作用クラスに分ける」に近い対策をとることにしました。
操作用のクラス Context
はやはりノードでは無いものとして用意します。そして Root
クラスは NodeProvider
クラスを通じて Context
クラスのインスタンスを得ます。
以前の対策と異なる点は、 NodeProvider
をノードにアタッチする必要が無い点です(ContextToAttach
はアタッチする必要がありました)。今回のアプローチでは、プロジェクト全体で見ても Root
クラス以外でノードにアタッチする必要のあるスクリプトはありません。
繰り返しになりますが、 Context
クラスはもはやノードではありません。ただし、プロパティとしてノードへの参照を持っています。
namespace ClassLibrary1;
public class Context
{
public required Sprite2D Sprite { get; init; }
public void Initialize(IObservable<Unit> onProcess)
{
onProcess.Subscribe(_ => Sprite.Position += Vecotr2.Down * 10);
}
}
NodeProvider
は、 Context
プロパティを Context
型の値に変換します。 Node
型では Sprite
への参照があるかどうか分からなかったものを、ここで Context
型にすることで明示するのです。
Context
プロパティが参照しているノードから Sprite
を見つけるためには、Godotのユニーク名機能を使います。
using Godot;
namespace MultipleProjectTest;
public class NodeProvider
{
public required Node Context { get; init; }
private Context? __context;
public Context GetContext()
{
returne __context ??= new Context
{
Sprite = Context.GetNode<Sprite2D>("%Sprite")
};
}
}
Root
クラスはシーン上のノードにアタッチして使います。プロジェクトがどれだけ大きくなろうと、ノードにアタッチされるスクリプトはこれだけです。
Root
は NodeProvider
を生成することで、 _context
変数が Context
型の値に変換できると期待していることを明示します。
using Godot;
namespace MultipleProjectTest;
public partial class Root : Node
{
[Export] private Node _context;
private Subject<Unit> _onProcess = new();
public override void _Ready()
{
var provider = new NodeProvider()
{
Context = _context
};
provider.GetContext().Initialize();
}
public override void _OnProcess()
{
_onProcess.OnNext(Unit.Default);
}
}
一般に Node
が Context
に変換できるかどうかは…… 例えばノードツリー上で %Sprite
というユニーク名を持つノードを見つけられるのかどうかは動的に決まるので、実行するまで誤りに気づくことは無いのですが、これはノードに直接スクリプトをアタッチする場合と同じと言えるでしょう。
すなわち、こういうことです。スクリプトをアタッチする方針ではGUI上でフィールドにノードを設定し、しかもノードの型が合っていることについて責任を持つという約束でした。これを、今回の方針ではノードツリー上に決まったユニーク名を持つノードがあって、しかも型が合っていることについて責任を持つ必要があります。
ここまでくると、もはやメインプロジェクトに属する必要のあるクラスは Root
だけとなります。これで、プロジェクト構成を自由に編集し、適切に分割して管理することができるはずです。
Discussion