GodotのC#プロジェクトを複数プロジェクトに分けて管理するには?

2023/10/05に公開

この記事は、 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.csprojClassLibrary1.csproj を参照しているため、 ClassLibrary1.Context クラスに依存する MultipleProjectTest.Root クラスを実装することができました。

具体的なソースコード
Root.cs
using Godot;

namespace MultipleProjectTest;

public partial class Root : Node
{
    [Export] private ContextClone _context;
}

Context.cs
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);
    }
}
ContextClone.cs
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 プロジェクトに置きたい場合、このような制約は受け入れられません。なぜなら、それは ClassLibrary1MultipleProjectTest への参照を持つ必要があることを意味し、循環参照が生まれるからです。

無効な対策: 参照先のプロジェクトを res:// に含める

また、 ClassLibrary1 フォルダを MultipleProjectTest.Godot フォルダ以下に移動する手段も考えました。こうすれば、 ClassLibrary1.Context クラスは res:// の下にあるスクリプトということになるので、オブジェクトにアタッチできるはずです。

しかしこの方法にも問題があります。 res:// 以下にあるC#ファイルは全て、メインプロジェクトである MultipleProjectTest プロジェクトに含まれるという決まりがあり、実際、 res:// 以下に ClassLibrary1 プロジェクトを移動させると、その中のC#ファイルが全てメインプロジェクトにも同時に属してしまいます。こうなると同じ名前のクラスが二重定義になるなど、混乱を生みます。

部分的に有効な対策: アタッチ用クラスと、操作用クラスに分ける

この方法では、新しいクラス ContextToAttach を作成し、これをノードにアタッチするようにします。対策を適用したものをGitHubに配置しています

ContextToAttach クラスは以下のようなもので、自身を参照先のプロジェクトにある操作用クラス Context に変換する ToLogic メソッドを持っているのが特徴です。

ContextToAttach.cs
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 クラスです。

Context.cs
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 クラスで使用します。

Root.cs
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:// に含める」で紹介した方法を発展させることができるようですが、この方法では解決しませんでした。ともあれ、なぜ解決できなかったのか見てみましょう。

今回の対策を入れた状態をGitHubに配置しています

この方法では、まずメインプロジェクト MultipleProjectTest.csproj ファイルを編集して、 EnableDefaultCompileItems 要素を追加し、その値を false にします(デフォルトでは true )になっています。

MultipleProjetTest.csproj の一部
  <PropertyGroup>
      <TargetFramework>net7.0</TargetFramework>
      <EnableDynamicLoading>true</EnableDynamicLoading>
+     <EnableDefaultCompileItems>false</EnableDefaultCompileItems>
  </PropertyGroup>

このようにすると、 MultipleProjetTest.csproj の置かれているディレクトリ以下のC#ソースファイルだからといって無差別にプロジェクトに追加されるのを防ぐことができます。

一方で、この設定をすることでメインプロジェクトにはC#ソースファイルが何も含まれていない状態になってしまいますので、このプロジェクトがコンパイルする対象を名指しで指定します。

MultipleProjetTest.csproj の一部
+  <ItemGroup>
+    <Compile Include="ContextToAttach.cs" />
+    <Compile Include="Root.cs" />
+  </ItemGroup>

ここまで操作したら、ようやく ClassLibrary1 プロジェクトを res:// ディレクトリ以下に置くことができます。なぜなら、今や MultipleProjectTest は自身のディレクトリ以下のC#ファイルを無差別に取り込んだりしないため、 ClassLibrary1 は独立してコンパイルすることができるためです。

さて、 ClassLibrary1 フォルダを res:// の位置に移動しましたので、 ClassLibrary1 フォルダと MultipleProjectTest.csproj ファイルは同じ階層にあります。この状態になるとソリューションファイルから ClassLibrary1 プロジェクトを見つけられなくなるので、ソリューションファイルを変更しましょう。

MultipleProjectTest.slnの一部
  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 クラスはもはやノードではありません。ただし、プロパティとしてノードへの参照を持っています。

Context.cs
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のユニーク名機能を使います。

NodeProvider.cs
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 クラスはシーン上のノードにアタッチして使います。プロジェクトがどれだけ大きくなろうと、ノードにアタッチされるスクリプトはこれだけです。

RootNodeProvider を生成することで、 _context 変数が Context 型の値に変換できると期待していることを明示します。

Root.cs
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);
    }
}

一般に NodeContext に変換できるかどうかは…… 例えばノードツリー上で %Sprite というユニーク名を持つノードを見つけられるのかどうかは動的に決まるので、実行するまで誤りに気づくことは無いのですが、これはノードに直接スクリプトをアタッチする場合と同じと言えるでしょう。

すなわち、こういうことです。スクリプトをアタッチする方針ではGUI上でフィールドにノードを設定し、しかもノードの型が合っていることについて責任を持つという約束でした。これを、今回の方針ではノードツリー上に決まったユニーク名を持つノードがあって、しかも型が合っていることについて責任を持つ必要があります。

ここまでくると、もはやメインプロジェクトに属する必要のあるクラスは Root だけとなります。これで、プロジェクト構成を自由に編集し、適切に分割して管理することができるはずです。

Discussion