👾

F#でGodotのチュートリアルを進める

2024/01/21に公開

目標

この記事では,Godot Engine 4 + F# で 2D ゲームのチュートリアルを完成させることを目標とします.C# で実装した場合との違いや,実装の際にハマりやすい点などを紹介します.実装例は GitHub Gist で公開しています.

https://gist.github.com/Double-oxygeN/0529a3b30348aad6f30d4f5912e021ad

環境

  • Godot Engine (.NET): 4.2.1
  • .NET SDK: 6.0.126
    • Godot Engine の対応する SDK バージョンを確認する(GitHub

エディタは Visual Studio Code 推奨です.

C# と F# との連携

Godot プロジェクトを作成し,スクリプトを書きたいシーンを作成したら,まず C# でスクリプトを作成します.次に,F# のプロジェクトを作成します.

dotnet new classlib --language 'F#' --name FS --output lib --framework net6.0

F# のプロジェクトファイル(ここでは FS.fsproj)を編集し,Project の SDK 属性を Godot.NET.Sdk に変更します:

FS.fsproj
<Project Sdk="Godot.NET.Sdk/4.2.1">

C# のプロジェクトファイル(*.csproj)を編集し,F# のプロジェクトを参照するようにします:

Tutorial2D.csproj
  <!-- Add below in <Project></Project> -->
  <ItemGroup>
    <ProjectReference Include="lib/FS.fsproj" />
  </ItemGroup>

ここまで完了したら,Godot Engine に戻っていちどプロジェクトのビルドを行いましょう.

これで F# でスクリプトを書く準備が整いました 🎉

F# の書き方

Godot のスクリプトを F# で書く場合,大きく分けて 2 つの方法があります.それぞれの書き方について比較してみましょう.

抽象クラスとして実装し,C# で継承する

C# でスクリプトを書く場合と同様に,F# で Godot のクラスを継承したクラスを作成します.C# でそのクラスを再度継承することで,F# で書かれた実装をそのまま利用できます.なお,C# では部分クラスとして実装しなければならない関係で,親クラスの実装を明示的に呼び出す必要があることに注意してください.

SomeNode.fs
namespace FS
open Godot

[<AbstractClass>]
type SomeNode () =
    inherit Node ()

    override self._Ready () =
        // Implement something
        ()

    override self._Process (delta: float) =
        // Implement something
        ()
SomeNode.cs
using Godot;

public partial class SomeNode : FS.SomeNode
{
    public override void _Ready()
    {
        base._Ready(); // Must explicitly call the base implementation
    }

    public override void _Process(double delta)
    {
        base._Process(delta); // Must explicitly call the base implementation
    }
}
  • 長所
    • 実装の大部分を F# で書ける
    • C# と同様の書き方で実装できる
  • 短所
    • C# で親クラスの実装を明示的に呼び出す手間がかかる
    • カスタムシグナルの定義などは C# で書く必要がある

関数群として実装し,C# の実装内で呼び出す

F# で状態の型を定義し,関数で状態遷移を実装します.状態自体は C# で管理し,F# の関数を呼び出して状態を変化させることで,F# では関数の純粋性を保つことができます.

SomeNode.fs
namespace FS

module SomeNode =
    type State = {
        foo: int
        bar: string
    }

    let Init () = {
        foo = 0
        bar = ""
    }

    let CountUp (state: State) =
        { state with foo = state.foo + 1 }
SomeNode.cs
using Godot;

public partial class SomeNode : Node
{
    private FS.SomeNode.State state;

    public override void _Ready()
    {
        state = FS.SomeNode.Init();
    }

    public override void _Process(double delta)
    {
        if (Input.IsActionJustPressed("count_up"))
        {
            state = FS.SomeNode.CountUp(state);
        }
    }
}
  • 長所
    • F# の関数の純粋性を保つことができ,状態変化を追跡しやすい
    • クラスを継承する実装と異なり,親クラスの実装を明示的に呼び出すような手間が発生しない
  • 短所
    • F# の純粋性を保とうとすると,入出力など F# で定義しない多くの状態を C# で管理しなければならない

ここでは,C# による実装との差異が少ない前者の方法で進めます.後者の方法は F# の関数型プログラミングの特徴を活かしやすい反面,C# 側の実装が複雑になりやすい気がします.

チュートリアル

2D ゲームのチュートリアルに沿って実装を進めていきましょう.ここからは,チュートリアルを進めるにあたって注意すべき点を挙げていきます.

Export

エディタ上で値を設定できる Export 属性は,C# のクラス内で付与する必要があります.F# 内で利用する場合,まず変数を抽象メンバとして定義します:

Player.fs
[<AbstractClass>]
type Player () =
    inherit Area2D ()

    // [<Export>]
    abstract Speed: int with get, set

次に,継承先の C# のクラスで Export 属性を付与しながら実装します:

Player.cs
public partial class Player : FS.Player
{
    [Export]
    public override int Speed { get; set; } = 400;
}

これにより,F# 内でも同様に export された変数を利用できるようになります.

カスタムシグナル

自作シグナルについても同様に,C# のクラス内で Signal 属性を付与する必要があります.そうした場合,F# では SignalName.Hit のように直接自作シグナルの名前を参照できません.文字列で直接指定してもよいのですが,次のように抽象メンバとして F# からも自作シグナルの名前を参照できるようにするのがよいでしょう:

Player.fs
[<AbstractClass>]
type Player () =
    inherit Area2D ()

    // [<Signal>]
    abstract HitSignal: StringName with get
Player.cs
public partial class Player : FS.Player
{
    [Signal]
    public delegate void HitEventHandler();
    public override StringName HitSignal { get => SignalName.Hit; }
}

シグナルハンドラ

シグナルハンドラも C# のクラス内で再定義する必要があります.
そのため,次のように抽象メソッドのデフォルト実装として実装します:

Player.fs
[<AbstractClass>]
type Player () =
    inherit Area2D ()

    abstract OnBodyEntered: Node2D -> unit
    default self.OnBodyEntered _ =
        self.Hide()
        self.EmitSignal(self.HitSignal) |> ignore
        self.GetNode<CollisionShape2D>("CollisionShape2D").SetDeferred("disabled", true)
Player.cs
public partial class Player : FS.Player
{
    public override void OnBodyEntered(Node2D body)
    {
        base.OnBodyEntered(body);
    }
}

非同期処理

C# では,async/await を利用して非同期処理を実現できます.F# では,代わりに task 式を利用することになります.

ここで,C# と F# とにおける await 可能な式の考え方の違いについて説明しなければなりません.C# の言語仕様で,await 可能な式は次のように定義されています:

  • Await 可能な式は GetAwaiter() メソッドを持ち,その戻り値の型が以下の条件を満たす(awaiter 型である):
    • System.Runtime.CompilerServices.INotifyCompletion インタフェースを実装している
    • bool 型の IsCompleted プロパティを持つ
    • GetResult() メソッドを持つ

一方,F# で await 可能な式にあたる準タスク値は次のように定義されています:

  member Bind...
     when  ^TaskLike: (member GetAwaiter:  unit ->  ^Awaiter)
     and ^Awaiter :> ICriticalNotifyCompletion
     and ^Awaiter: (member get_IsCompleted:  unit -> bool)
     and ^Awaiter: (member GetResult:  unit ->  ^TResult1)

注目すべきは awaiter 型が実装すべきインタフェースの違いで,C# では INotifyCompletion ですが,F# では ICriticalNotifyCompletion になっています.Godot では awaiter 型にあたる IAwaiter インタフェースが定義されていますが,これは INotifyCompletion のみを実装しており,F# では await 可能ではありません

https://github.com/godotengine/godot/blob/4.2/modules/mono/glue/GodotSharp/GodotSharp/Core/Interfaces/IAwaiter.cs

そのため,まず ICriticalNotifyCompletion を awaiter に実装するためのヘルパークラスを定義します:

TaskUtils.fs
namespace FS
open Godot
open System.Runtime.CompilerServices

module TaskUtils =
    type CriticalAwaiter<'a, 'b when 'a :> IAwaitable<'b> and 'a :> IAwaiter<'b>> (awaiter: 'a) =
        interface ICriticalNotifyCompletion with
            member self.OnCompleted continuation = awaiter.OnCompleted continuation
            member self.UnsafeOnCompleted continuation = awaiter.OnCompleted continuation

        member self.IsCompleted
            with get () = awaiter.IsCompleted

        member self.GetResult () =
            awaiter.GetResult ()

        member self.GetAwaiter () =
            self

その上で,非同期処理を次のように実装できます:

HUD.fs
type HUD () =
    inherit CanvasLayer ()

    member self.ShowGameOver () =
        task {
            self.ShowMessage "Game Over"

            let messageTimer = self.GetNode<Timer>("MessageTimer")
            let! _ = self.ToSignal(messageTimer, Timer.SignalName.Timeout) |> TaskUtils.CriticalAwaiter

            let message = self.GetNode<Label>("Message")
            message.Text <- "Dodge the Creeps!"
            message.Show()

            let! _ = self.ToSignal(self.GetTree().CreateTimer(1.0), SceneTreeTimer.SignalName.Timeout) |> TaskUtils.CriticalAwaiter
            self.GetNode<Button>("StartButton").Show()
        }

以上のことに注意すれば,2D ゲームのチュートリアルを完了できます.

まとめ

Godot Engine 4 の 2D ゲームのチュートリアルを F# で進める方法について紹介しました.F# でも C# と同様にスクリプトを書くことができますが,C# と連携するためにいくつかの考慮すべき点があることがわかりました.

Discussion