本記事は ZOZO Advent Calendar 2023 カレンダー Vol.8の18日目の記事です。
はじめに
こんにちは、@prpr_manです。ZOZOには11月に入社したばかりで、現在はGo言語でZOZOTOWNのマイクロサービスの開発を行っています。
この記事は前日の@shogoLazyさんのGo言語で作ったゲームをWebで公開してみたに続いて、Go言語でゲーム開発をする事を目指します。こちらの記事ではEbitengineを使用していましたが、本記事ではGodotを利用してGo言語でゲーム開発を行ってみます。
Godotとは
Godotは、オープンソースかつクロスプラットフォームなゲームエンジンの一つです。ゲームエンジンというとUnityやUnreal Engineが有名ですが、Godotも近年注目を集めており、Godot製のゲームも多く登場しています。特にUnityが「ゲームインストール数をベースとした価格体系を導入する」と発表した1ときには、Godotに乗り換えると発表した開発チームもありました。
GodotでGo言語を使うには
Godotでサポートされている言語はC#とGDScriptと呼ばれるPythonライクなスクリプト言語です。Go言語はサポートされていません。
しかし、GodotにはUnityのネイティブプラグインのような、外部のネイティブコード(共有ライブラリ)を実行できるGDExtensionという機能があります。このライブラリからGDExtension APIを実行することでGodotと連携することができます。
今回はGDExtension APIのGoバインディングであるgodot-goを利用します。
実際に作ってみる
今回は題材としてPongを作ってみることにします。よりシンプルにするために、以下の3つのオブジェクトのみ作成し、勝敗判定やスコア計上などは行いません。
また開発環境はmacOSを想定して記載します。
- Paddle
- キー入力で上下に移動するオブジェクト。ボールを跳ね返す。
- Ball
- 画面上を移動するオブジェクト。PaddleやWallにあたると反射する。
- Wall
- 上下に設置される固定のオブジェクト。ボールを跳ね返す。
1. 環境構築
Godot
公式ページからGodotをダウンロードします。godot-goがバージョン4.2以降に対応しているため、4.2以降のバージョンをダウンロードしてください。
go言語
godot-goを利用するにはGo言語のバージョン1.21以降が必要です。
# インストール
$ brew install go
# インストールされたバージョンの確認
$ go version
go version go1.21.5 darwin/amd64
Godotの実行ファイルのシンボリックリンク作成
Godotをコマンドから利用したいため、シンボリックリンクを作成します。
$ ln -s /Applications/Godot.app/Contents/MacOS/Godot /usr/local/bin/godot
2. Godotプロジェクトの作成
適当なディレクトリを作成します。ここではpong-go
とし、その配下にproject
ディレクトリを作成します。
$ mkdir -p ./pong-go/project
Godotを起動して「新規」ボタンから新規プロジェクトを作成します。プロジェクトパスは先ほど作成した./pong-go/project
を指定します。
プロジェクトが作成されたら、今回は2Dゲームを作成するため画面上部のメニューから「2D」を選択します。
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
として保存します。
同様の手順でball.tscn
、wall.tscn
を作成します。
Mainシーンの作成
上記で作成したシーンを配置しゲーム画面を作っていきます。左のメニューから「2Dシーン」を選択した後、ファイルシステムにあるpaddle.tscn
、ball.tscn
、ball.tscn
をドラッグ&ドロップで配置していきます。画面上の青い線がWindowサイズなので、これに合わせて配置していくと良いでしょう。配置し終えたらmain.tcsn
として保存します。
3. コードを用意する
いよいよGo言語でコードを記述していきます。コードで制御したいシーンはPaddleとBallなのでこれらのコードを用意しましょう。
まずは./pong-go
に移動してgo moduleの初期化を行います。続いて./pong-go/pkg/scene
ディレクトリを作成します。
$ cd ./pong-go
$ go mod init pong-go
$ mkdir -p ./pkg/scene
作成した./pong-go/pkg/scene
ディレクトリにPaddleとBallのコードを記述していきます。
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
}
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
で始まるコメントがないと動作しないため注意しましょう。
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情報を更新します。
$ go mod tidy
4. ライブラリのビルド
以下の様なMakefileを作成してライブラリとしてビルドします。./pong-go/project/lib
以下にライブラリとヘッダーファイルができればOKです。
.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
$ 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
をエディタで開いて、該当箇所を以下の様に変更します。
- [node name="Ball" type="Area2D"]
+ [node name="Ball" type="Paddle"]
- [node name="Paddle" type="Area2D"]
+ [node name="Paddle" type="Ball"]
この状態で、makeコマンドでeditorを起動し、上部のメニューの「▶︎」を押してみましょう。もしメインシーンを設定する画面が現れたらmain.tcsn
を選択します。これでPongが動作するはずです。
$ make editor
左右でPaddleの操作キーを変えたい場合はmain.tscn
を開いて、左側に配置したPaddleを選択します。そしてインスペクター上の「Setting」にあるチェックボタンを押すと、左のPaddleはWSキー、右のPaddleは上下キーで操作することができます。
まとめ
今回はgodot-goを用いてGo言語でゲームを作ってみました。godot-goはまだ開発途中であり、うまく動作しない箇所もあるため、Go言語だけで制作するのは難しい印象です。現状では一部の機能だけをGo言語で記載し、その他をGDScriptで作成するのが良さそうです。
参考
-
現在では撤回されています。(https://blog.unity.com/ja/news/open-letter-on-runtime-fee) ↩