godot-goでゲーム開発

:::note info
本記事は ZOZO Advent Calendar 2023 カレンダー Vol.8の18日目の記事です。
:::

## はじめに
こんにちは、@prpr_manです。ZOZOには11月に入社したばかりで、現在はGo言語でZOZOTOWNのマイクロサービスの開発を行っています。

この記事は前日の@shogoLazyさんの[Go言語で作ったゲームをWebで公開してみた](https://qiita.com/shogoLazy/items/a73233e668fc203e68f8)に続いて、Go言語でゲーム開発をする事を目指します。こちらの記事では[Ebitengine](https://ebitengine.org/ja/)を使用していましたが、本記事ではGodotを利用してGo言語でゲーム開発を行ってみます。

## Godotとは
Godotは、オープンソースかつクロスプラットフォームなゲームエンジンの一つです。ゲームエンジンというとUnityやUnreal Engineが有名ですが、Godotも近年注目を集めており、Godot製のゲームも多く登場しています。特にUnityが「ゲームインストール数をベースとした価格体系を導入する」と発表した[^1]ときには、Godotに乗り換えると発表した開発チームもありました。

[^1]: 現在では撤回されています。(https://blog.unity.com/ja/news/open-letter-on-runtime-fee)

https://godotengine.org/

### GodotでGo言語を使うには
Godotでサポートされている言語はC#と[GDScript](https://docs.godotengine.org/ja/4.x/tutorials/scripting/gdscript/gdscript_basics.html)と呼ばれるPythonライクなスクリプト言語です。Go言語はサポートされていません。
しかし、GodotにはUnityのネイティブプラグインのような、外部のネイティブコード(共有ライブラリ)を実行できる[GDExtension](https://docs.godotengine.org/ja/4.x/tutorials/scripting/gdextension/what_is_gdextension.html)という機能があります。このライブラリからGDExtension APIを実行することでGodotと連携することができます。

今回はGDExtension APIのGoバインディングであるgodot-goを利用します。

https://github.com/godot-go/godot-go

## 実際に作ってみる
今回は題材として[Pong](https://ja.wikipedia.org/wiki/%E3%83%9D%E3%83%B3_(%E3%82%B2%E3%83%BC%E3%83%A0))を作ってみることにします。よりシンプルにするために、以下の3つのオブジェクトのみ作成し、勝敗判定やスコア計上などは行いません。
また開発環境はmacOSを想定して記載します。

- Paddle
    - キー入力で上下に移動するオブジェクト。ボールを跳ね返す。
- Ball
    - 画面上を移動するオブジェクト。PaddleやWallにあたると反射する。
- Wall
    - 上下に設置される固定のオブジェクト。ボールを跳ね返す。

![pong.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/23927/5a7298fe-5ed1-dade-c3c5-0ec4cfe7ea05.gif)


## 1. 環境構築
### Godot
[公式ページ](https://godotengine.org/download/archive/)からGodotをダウンロードします。godot-goがバージョン4.2以降に対応しているため、4.2以降のバージョンをダウンロードしてください。

### go言語
godot-goを利用するにはGo言語のバージョン1.21以降が必要です。

```shell-session
# インストール
$ brew install go

# インストールされたバージョンの確認
$ go version
go version go1.21.5 darwin/amd64
```

### Godotの実行ファイルのシンボリックリンク作成
Godotをコマンドから利用したいため、シンボリックリンクを作成します。

```shell-session
$ ln -s /Applications/Godot.app/Contents/MacOS/Godot /usr/local/bin/godot
```

## 2. Godotプロジェクトの作成
適当なディレクトリを作成します。ここでは`pong-go`とし、その配下に`project`ディレクトリを作成します。
```shell-session
$ mkdir -p ./pong-go/project
```
Godotを起動して「新規」ボタンから新規プロジェクトを作成します。プロジェクトパスは先ほど作成した`./pong-go/project`を指定します。
![ss1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/23927/18e0d23d-9248-ab3e-979d-abee486d96a2.png)

プロジェクトが作成されたら、今回は2Dゲームを作成するため画面上部のメニューから「2D」を選択します。
![ss2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/23927/952075bf-e31f-6370-1057-b1e74b018c47.png)

## 3.シーンの作成
Paddle、Ball、Wallのシーンを作成していきます。シーンとは画面を構成する部品（ノード）の集合です。Godotはこのシーンを組み合わせてオブジェクトや画面を作っていきます。Unityを利用したことがある人であれば「GodotのシーンとはUnityにおけるプレハブとシーンの両方の役割を持つもの」と考えると分かり易いかと思います。

### Paddle、Ball、Wallシーンの作成
左のメニューから「その他のノード」を選択し、`Area2D`を選択します。さらに`ColorRect`、`CollisionShape2D`を追加し、以下のような階層構造にします。

```
Area2D
├─ColorRect
└─CollisionShape2D
```

`CollisionShape2D`のインスペクターにはShapeの項目がありますが、これは`RectangleShape2D`となるように設定します。その後、`ColorRect`と`CollisionShape2D`の形状をドラッグで調整します。最後に`Cmd+S`を押して`paddle.tscn`として保存します。
![ss3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/23927/ef032549-88ec-aa86-feda-618ff9722794.png)

同様の手順で`ball.tscn`、`wall.tscn`を作成します。
![ss4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/23927/f001ee2c-2af3-bddd-5054-5434f15dd64f.png)
![ss5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/23927/8a9aa6cb-c1d2-45ac-5a66-287a8066f8ec.png)

### Mainシーンの作成
上記で作成したシーンを配置しゲーム画面を作っていきます。左のメニューから「2Dシーン」を選択した後、ファイルシステムにある`paddle.tscn`、`ball.tscn`、`ball.tscn`をドラッグ&ドロップで配置していきます。画面上の青い線がWindowサイズなので、これに合わせて配置していくと良いでしょう。配置し終えたら`main.tcsn`として保存します。
![ss6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/23927/20340ec0-389d-76d9-a7af-aacf76c46a0c.png)

## 3. コードを用意する
いよいよGo言語でコードを記述していきます。コードで制御したいシーンはPaddleとBallなのでこれらのコードを用意しましょう。

まずは`./pong-go`に移動してgo moduleの初期化を行います。続いて`./pong-go/pkg/scene`ディレクトリを作成します。

```shell-session
$ cd ./pong-go
$ go mod init pong-go
$ mkdir -p ./pkg/scene
```
作成した`./pong-go/pkg/scene`ディレクトリにPaddleとBallのコードを記述していきます。

```go:paddle.go
package scene

import (
	"github.com/godot-go/godot-go/pkg/builtin"
	"github.com/godot-go/godot-go/pkg/constant"
	"github.com/godot-go/godot-go/pkg/core"
	"github.com/godot-go/godot-go/pkg/ffi"
	"github.com/godot-go/godot-go/pkg/gdclassimpl"
)

// 初期化用の関数
func RegisterClassPaddle() {
	core.ClassDBRegisterClass(&Paddle{}, []ffi.GDExtensionPropertyInfo{}, nil, func(t builtin.GDClass) {
		// V_Readyと_ready、V_Processと_processを紐付ける
		core.ClassDBBindMethodVirtual(t, "V_Ready", "_ready", nil, nil)
		core.ClassDBBindMethodVirtual(t, "V_Process", "_process", []string{"delta"}, nil)

        // インスペクター用の設定
		core.ClassDBAddPropertyGroup(t, "Settings", "group_")
		core.ClassDBBindMethod(t, "GetLeft", "get_left", nil, nil)
		core.ClassDBBindMethod(t, "SetLeft", "set_left", []string{"isLeft"}, nil)
		core.ClassDBAddProperty(t, ffi.GDEXTENSION_VARIANT_TYPE_BOOL, "group_main", "set_left", "get_left")
	})
}

const PaddleSpeed = 400

type Paddle struct {
	gdclassimpl.Area2DImpl
	input   builtin.Input
	isLeft  bool
	upKey   int
	downKey int
}

func (*Paddle) GetClassName() string {
	return "Paddle"
}

func (*Paddle) GetParentClassName() string {
	return "Area2D"
}

// _ready 一度だけ呼び出されるメソッド
func (p *Paddle) V_Ready() {
	p.input = gdclassimpl.GetInputSingleton()

    // インスペクターでLeftと設定されていたらWとSキーで上下させる
    // それ以外は上下キー
	if p.isLeft {
		p.upKey = constant.KEY_W
		p.downKey = constant.KEY_S
	} else {
		p.upKey = constant.KEY_UP
		p.downKey = constant.KEY_DOWN
	}
}

// _process　フレーム毎に呼び出される更新用メソッド
func (p *Paddle) V_Process(delta float32) {
	velocity := builtin.NewVector2WithFloat32Float32(0, 0)
	if p.input.IsKeyPressed(constant.Key(p.downKey)) {
		velocity = velocity.Add_Vector2(builtin.NewVector2WithFloat32Float32(0, 1))
	} else if p.input.IsKeyPressed(constant.Key(p.upKey)) {
		velocity = velocity.Add_Vector2(builtin.NewVector2WithFloat32Float32(0, -1))
	}

	velocity = velocity.Normalized()
	velocity = velocity.Multiply_int(PaddleSpeed)

	position := p.GetPosition()
	position = position.Add_Vector2(velocity.Multiply_float(delta))

	p.SetPosition(position)
}

func (p *Paddle) SetLeft(isLeft bool) {
	p.isLeft = isLeft
}

func (p *Paddle) GetLeft() bool {
	return p.isLeft
}
```

```go:ball.go
package scene

import (
	"github.com/godot-go/godot-go/pkg/builtin"
	"github.com/godot-go/godot-go/pkg/constant"
	"github.com/godot-go/godot-go/pkg/core"
	"github.com/godot-go/godot-go/pkg/ffi"
	"github.com/godot-go/godot-go/pkg/gdclassimpl"
)

// 初期化用の関数
func RegisterClassBall() {
	core.ClassDBRegisterClass(&Ball{}, []ffi.GDExtensionPropertyInfo{}, nil, func(t builtin.GDClass) {
		// // V_Readyと_ready、V_Processと_processを紐付ける
		core.ClassDBBindMethodVirtual(t, "V_Ready", "_ready", nil, nil)
		core.ClassDBBindMethodVirtual(t, "V_Process", "_process", []string{"delta"}, nil)

        // V_On_Area_Enteredと_on_Area_enterdを紐づける
		core.ClassDBBindMethodVirtual(t, "V_On_Area_Entered", "_on_Area_entered", []string{"area"}, nil)
	})
}

const (
	BallSpeed = 400
)

type Ball struct {
	gdclassimpl.Area2DImpl
	velocity builtin.Vector2
}

func (*Ball) GetClassName() string {
	return "Ball"
}

func (*Ball) GetParentClassName() string {
	return "Area2D"
}

func (b *Ball) V_Ready() {
	v := builtin.NewVector2WithFloat32Float32(1, 1)
	v = v.Normalized()
	b.velocity = v.Multiply_int(BallSpeed)

    // Area2D同士が接触したら_on_Area_enteredを呼び出す様に設定する
	b.connectSignal("area_entered", "_on_Area_entered")
}

func (b *Ball) V_Process(delta float32) {
	position := b.GetPosition()
	position = position.Add_Vector2(b.velocity.Multiply_float(delta))
	b.SetPosition(position)
}

func (b *Ball) V_On_Area_Entered(area builtin.Variant) {
	paddle := builtin.NewStringWithUtf8Chars("Paddle")
	defer paddle.Destroy()

	if area.ToObject().IsClass(paddle) {
		b.Bounce(builtin.NewVector2WithFloat32Float32(0, 1))
	} else {
		b.Bounce(builtin.NewVector2WithFloat32Float32(1, 0))
	}
}

func (b *Ball) Bounce(vec builtin.Vector2) {
	b.velocity = b.velocity.Reflect(vec)
}

func (b *Ball) connectSignal(signalName string, methodName string) {
	signal := builtin.NewStringNameWithUtf8Chars(signalName)
	defer signal.Destroy()
	method := builtin.NewStringNameWithUtf8Chars(methodName)
	defer method.Destroy()
	callable := builtin.NewCallableWithObjectStringName(b, method)
	defer callable.Destroy()
	b.Connect(signal, callable, constant.OBJECT_CONNECT_FLAGS_CONNECT_PERSIST)
}
```

最後にmain.goを記述します。`export`で始まるコメントがないと動作しないため注意しましょう。

```go:main.go
package main

import "C"
import (
	"pong-go/pkg/scene"
	"unsafe"

	"github.com/godot-go/godot-go/pkg/core"
	"github.com/godot-go/godot-go/pkg/ffi"
	"github.com/godot-go/godot-go/pkg/log"
)

//export PongGoInit
func PongGoInit(p_get_proc_address unsafe.Pointer, p_library unsafe.Pointer, r_initialization unsafe.Pointer) bool {

	initObj := core.NewInitObject(
		(ffi.GDExtensionInterfaceGetProcAddress)(p_get_proc_address),
		(ffi.GDExtensionClassLibraryPtr)(p_library),
		(*ffi.GDExtensionInitialization)(unsafe.Pointer(r_initialization)),
	)

	initObj.RegisterSceneInitializer(func() {
		scene.RegisterClassPaddle()
		scene.RegisterClassBall()
	})

	initObj.RegisterSceneTerminator(func() {
	})

	return initObj.Init()
}

func main() {
}
```

最後にmodule情報を更新します。

```shell-session
$ go mod tidy
```

## 4. ライブラリのビルド
以下の様なMakefileを作成してライブラリとしてビルドします。`./pong-go/project/lib`以下にライブラリとヘッダーファイルができればOKです。

```makefile:Makefile
.DEFAULT_GOAL := build

GOOS?=$(shell go env GOOS)
GOARCH?=$(shell go env GOARCH)
GODOT?=$(shell which godot)

OUTPUT_PATH=project/lib
TEST_MAIN=main.go
TEST_BINARY_PATH=$(OUTPUT_PATH)/libgodotgo-pong-go-macos-$(GOARCH).framework

.PHONY: goenv build

goenv:
	go env

build: goenv
	CGO_ENABLED=1 \
	GOOS=$(GOOS) \
	GOARCH=$(GOARCH) \
	CGO_CFLAGS='-Og -g3 -g -DX86=1 -fPIC' \
	CGO_LDFLAGS='-Og -g3 -g' \
	go build -gcflags=all="-N -l" -tags tools -buildmode=c-shared -x -trimpath -o "$(TEST_BINARY_PATH)" $(TEST_MAIN)

editor:
	LOG_LEVEL=warn \
	GOTRACEBACK=1 \
	GODEBUG=sbrk=1,asyncpreemptoff=1,cgocheck=0,invalidptr=1,clobberfree=1,tracebackancestors=0 \
	$(GODOT) --verbose --debug  --path project/ --editor

```

```shell-session
$ make
```

## 5. Godotからライブラリを利用する
Godotからライブラリを利用するためには以下の様な`.gdextension`ファイルを`./pong-go/project`以下に作成する必要があります。
`entry_symbol`には`main.go`の`PongGoInit`を指定し、`[libraries]`にはビルドしたライブラリを指定します。

```
[configuration]

entry_symbol = "PongGoInit"
compatibility_minimum = 4.1

[libraries]

macos.debug = "res://lib/libgodotgo-pong-go-macos-amd64.framework"
macos.release = "res://lib/libgodotgo-pong-go-macos-amd64.framework"
```

以上で必要なファイルはすべて作成しました。最終的には以下のようなディレクトリ構成になっているはずです。
```
pong-go
├─ pkg
│  └─ scene
│     ├─ ball.go
│     └─ paddle.go
├─ project
│  ├─ .godot
│  ├─ lib
│  │  ├─ libgodotgo-pong-go-macos-amd64.h
│  │  └─ libgodotgo-pong-go-macos-amd64.framework
│  ├─ ball.tscn
│  ├─ godotgo.gdextension
│  ├─ main.tscn
│  ├─ paddle.tscn
│  ├─ project.godot
│  └─ wall.tscn
├─ go.mod
├─ go.sum
├─ main.go
└─ Makefile
```

本来ならば以上の設定をすればGodotはPaddleやBallを認識し、Godot上からノードとしてPaddleやBallを追加できます。しかし今回使用するgodot-goではうまく認識してくれませんでした。
そこで今回は**シーンファイルを直接変更してノードの型を書き換えます。** `paddle.tcsn`と`ball.tcsn`をエディタで開いて、該当箇所を以下の様に変更します。

```diff:paddle.tcsn
- [node name="Ball" type="Area2D"]
+ [node name="Ball" type="Paddle"]
```

```diff:ball.tcsn
- [node name="Paddle" type="Area2D"]
+ [node name="Paddle" type="Ball"]
```

この状態で、makeコマンドでeditorを起動し、上部のメニューの「▶︎」を押してみましょう。もしメインシーンを設定する画面が現れたら`main.tcsn`を選択します。これでPongが動作するはずです。

```shell-session
$ make editor
```

![ss7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/23927/b574a7f1-b1dd-b987-752b-ca1a2ad51e18.png)

左右でPaddleの操作キーを変えたい場合は`main.tscn`を開いて、左側に配置したPaddleを選択します。そしてインスペクター上の「Setting」にあるチェックボタンを押すと、左のPaddleはWSキー、右のPaddleは上下キーで操作することができます。

## まとめ
今回はgodot-goを用いてGo言語でゲームを作ってみました。godot-goはまだ開発途中であり、うまく動作しない箇所もあるため、Go言語だけで制作するのは難しい印象です。現状では一部の機能だけをGo言語で記載し、その他をGDScriptで作成するのが良さそうです。

## 参考

https://github.com/godot-go/godot-go-demo-projects