godot(C#)でも、プロジェクトを分けて開発しよう

2023/12/10に公開

Godotでプロジェクトを作成し、C#スクリプトを1つ追加すると、プロジェクトフォルダ内はおおむね以下のようになります。

.csproj ファイルが1つだけ含まれています。これで充分な場合もあるのですが、プロジェクトが大きくなる場合、特に元々 C# を使ったコーディングに慣れていると、ゲームを複数のプロジェクトに分けて開発したいと思うことがあります。

プロジェクトを分けるのは何故かと言うと、ひとつは開発途中でGodotを使うのをやめる選択をしやすくするためであり、ひとつはコード内のクラスにアクセスできるスコープを柔軟に管理するためであり、場面に応じて様々な理由があり得ます。

環境

  • Windows 11
  • Godot 4.1.1
  • JetBrains Rider 2023.2.1

プロジェクトがひとつのとき

上に画像で見せたディレクトリ構造を、以下のようにツリー状に書き直してみます。

res://
 └ .godot
 └ AdventCalendar.csproj
 └ MyNode.cs
 └ .gitignore
 └ project.godot
 └ icon.svg.import
 └ icon.svg
 └ .gitattributes
 └ AdventCalendar.sln

AdventCalendar.csproj はC#プロジェクトですが、Godotにおいて特別な意味を持ちます。今回はこれをエントリプロジェクトと呼ぶことにします。

res:// というディレクトリは、ファイルシステム上は実際にはこのような名前ではないのですが、今後の説明で繰り返し出てくるし、Godotエディター上では res:// と表示されるディレクトリなのでこの呼び方で行きます。

エントリプロジェクトは通常のC#プロジェクトとは違い、Godotエディターから影響を受けます。具体的には、 res:// 以下に置かれたC#ファイルが、Godotによって自動的にエントリプロジェクトに追加されます。

プロジェクトの分け方

さて、肝心のプロジェクトの分け方ですが、基本のルールは「プロジェクトを、res:// の外に置く」というだけです。

res:// の中にプロジェクトファイル(A)があると、プロジェクトAにC#ファイルを追加したとき、エントリプロジェクトにも自動的に追加されてしまいます。なぜなら、 res:// 以下に置かれたC#ファイルは自動的にエントリプロジェクトに追加されるからです。

例えば Helpers というプロジェクトを追加したい場合、ディレクトリ構造が以下のようになります。

Root
 └ res://
    └ .godot
    └ AdventCalendar.csproj
    └ MyNode.cs
    └ .gitignore
    └ project.godot
    └ icon.svg.import
    └ icon.svg
    └ .gitattributes
    └ AdventCalendar.sln
 └ Helpers
    └ Helpers.csproj
    └ C#ファイル群(.cs)

また、Godotエディター上では res:// の外に新しいプロジェクトを作成することができませんので、プロジェクトを追加するためにはIDE(今回は、Rider)を通じて行ないます。プロジェクトファイルを置く場所を決める操作は、IDEによって異なります。

Godotを使わない通常のアプリケーションと同様、プロジェクトはソリューションに追加される必要がありますし、 Helpers をゲームで使用するのであればエントリプロジェクトから Helpers プロジェクトへの参照を登録する必要があるでしょう。

エントリプロジェクトが大きくなる問題

この方法でプロジェクトを分割していくと、1つ1つのプロジェクトが小さくなり、管理しやすくなります。しかし、エントリプロジェクトを分割するのは難しいことに気づくでしょう。

それというのも、ゲームの開発中はGodotエディターを操作してノードにスクリプトをアタッチするのですが、アタッチできるC#ファイルは res:// 以下からしか選べないのです。

つまり、アタッチする必要があるスクリプトはエントリプロジェクトに置かざるを得ず、結果として独り立ちできないスクリプトで膨れ上がったプロジェクトが生まれてしまいます。

以前ぼくも、以下の記事でこの問題に向き合っています。

https://zenn.dev/numani/articles/godot-split-project

ノードにスクリプトをアタッチするのを、やめたい

ノードにスクリプトをアタッチするのをやめれば、エントリプロジェクトが肥大化するのを防ぐことができます。なぜアタッチする必要があったのかを振り返る必要があります。

以下の2つのクラスを、ノードにアタッチして使う場面を考えます。

SkillPanel.cs
public partial class SkillPanel : Control
{
   [Export] private SkillMenu Player0 { get; set; }
   [Export] private SkillMenu Player1 { get; set; }
   [Export] private SkillMenu Player2 { get; set; }
}
SkillMenu.cs
public partial class SkillMenu : Control
{
   [Export] private VBoxContainer Skills { get; set; }
   [Export] private Control Header { get; set; }

   public override void _Process(double delta)
   {
      // 毎フレームの処理
   }
}

以下のようにシーンを構成します。

  • SkillPanelをアタッチしたノードA
  • SkillMenuをアタッチしたノードP0
  • SkillMenuをアタッチしたノードP1
  • SkillMenuをアタッチしたノードP2

次に、インスペクターを通じてノードAに P0, P1, P2 を紐づけれるはずなので、そうします。

ここまでが、この2つのクラスが役割を果たすためのセットアップなのですが、ひょっとすると、この2つのクラスは別の書き方でもいいかもしれません。

GetNodeを使って設計する

上記のようなコードではなく、代わりに SkillPanel, SkillMenu を以下のように書きます。

SkillPanel.cs
public partial class SkillPanel : Control
{
   [Export] private Control Player0 { get; set; }
   [Export] private Control Player1 { get; set; }
   [Export] private Control Player2 { get; set; }

   private SkillMenu? __Player0;
   private SkillMenu? __Player1;
   private SkillMenu? __Player2;

   public override void _Process(double delta)
   {
      __Player0 ??= GetSkillMenu(Player0, "0");
      __Player1 ??= GetSkillMenu(Player1, "1");
      __Player2 ??= GetSkillMenu(Player2, "2");

      __Player0.Update(delta);
      __Player1.Update(delta);
      __Player2.Update(delta);
   }

   private SkillMenu GetSkillMenu(Control control, string id)
   {
      return new SkillMenu
      {
         Skills = control.GetNode<VBoxContainer>("%Skills" + id),
         Header = control.GetNode<Control>("%Header" + id),
      };
   }
}
SkillMenu.cs
public partial class SkillMenu
{
   public required VBoxContainer Skills { get; init; }
   public required Control Header { get; init; }

   public void Update(double delta)
   {
      // 毎フレームの処理
   }
}

Godotエディターでは、以下のようにします。

  • SkillPanel スクリプトをノードにアタッチします。
  • 先ほどの例で SkillMenu をアタッチしていたノードには、何もアタッチしません。代わりに、そのノードの子である VBoxContainer, Control にはユニーク名 SkillN, HeaderN を付けておきます。

こうすれば、 SkillMenu はノードにアタッチされなくてよいので、エントリプロジェクトに含める必要がありません。Godotのシーン上にある情報を参照するクラスをエントリプロジェクトから脱出させるために、この方法を使えます。

ソースジェネレータ

前節のソースコードを見ると、定型文のコードがかなり多いと感じるかもしれません。 GetNode を呼び出したり、生成した SkillMenu を保持したりする部分は自動生成してほしいものです。

ぼく自身、そういったものを開発していますが、今人に見せられるほどのものは無いので、残念ながらここには載せられません。そのうち……何か公開するかも?

ぼくからは以上です。よいGodotライフを!

Discussion