Xcode Cloudでビルドの前後に処理を行う

今更だが、Xcode Cloudが便利だ。Apple純正のCI/CD環境ということもあって、設定で複雑なことをする必要がなく、やれることも限られている分わかりやすい。

大抵のアプリのビルドでは、Xcode Cloudの標準の機能だけで問題なくApp Storeへの申請/TestFlightでの配布ができているのだが、稀にビルドの前後に何らかの処理を行いたいケースがある。

https://developer.apple.com/documentation/xcode/writing-custom-build-scripts

Xcode Cloudには、Custom Build Scriptという仕組みが用意されており、このルールに従ってスクリプトを用意すると、下記のタイミングで任意の処理を実行できる。

  • レポジトリからのCloneが終わった直後(ci_post_clone.sh)
  • xcodebuildが実行される直前(ci_pre_xcodebuild.sh)
  • xcodebuildが終わった直後(ci_post_xcodebuild.sh) ※失敗時も呼ばれる

ビルド番号(CI_BUILD_NUMBER)や何をトリガーにワークフローが開始されたのか(CI_START_CONDITION)などの情報を環境変数として参照できるようになっている。

https://developer.apple.com/documentation/xcode/environment-variable-reference

デバッグ用の情報(COMMIT HASHなど)をアプリ内で参照できるようにする、など色々使える場面があって、覚えておくと何かと便利。

Godot 3系のstore_varで保存したファイルをGodot 4で読み込めない問題

GodotのFileクラスのstore_varというメソッドを使うと、容易にオブジェクトの状態を保存できる。

https://docs.godotengine.org/ja/stable/classes/class_file.html#class-file-method-store-var

store_varで保存したファイルをget_varで簡単に復元することができるので、Godotで色々なオブジェクトの状態を保存/復元する方法として紹介されることがある。便利なので自分のアプリでも使っている箇所があったのだが、今回そのアプリのGodot 4対応を進めようとして思わぬ問題にぶつかってしまった。

https://godotforums.org/d/31966-filestore-var-in-35-to-godot-4/16

フォーラムでも報告している人がいるように、Godot 3系のstore_varメソッドで作成したファイルをGodot 4のget_varで復元しようとすると正しく復元できないのだ。

https://github.com/godotengine/godot/issues/73530#issuecomment-1435792453

残念ながら、これは不具合ではなくGodot 4で想定済みの仕様のようで、今後バージョンアップなどで対応される予定もないそうだ。この問題に対する対策としては、下記の二つの方法が提案されている。

  1. Godot 4系にアップデートする前に、ファイルをJSONなど別のフォーマットに移行する機能を作る
  2. 自前でバイナリーローダーを書いて、3系で保存されたバイナリファイルを解読して4系に読み直す

後者は現実的に面倒で難易度も高そうなので、3系のstore_varで作成したデータを引き継ぐには前者の方法をとるしかなさそうだ。ただし、その場合は当面の間3系を使い続けることになってしまうので頭がいたい。

store_varは仕様上今後のアップデートでも同様の問題が起きる可能性があるので、オブジェクトの状態を保存したいときは面倒だがstore_stringとget_as_textを使って自前でシリアライズ/デシリアライズを実装した方が良いようだ。(現在公式ドキュメントのサンプルもそうなっている)

Godot 4でtresファイルのダイナミックロードがやりにくくなった問題

Godot 4で、フォルダ内にあるtresファイルを動的に読み込むプログラムを書いたところうまく動作しなかった。

for file in dir_contents(folder_path):
	if file.get_extension() == "tres":
		tres_files.append(file)

上のコードは、特定のフォルダにあるtresファイルを全てリストアップするためのコードだ。Godot Editor上でこのコードを実行すると、フォルダ内のtresファイルがリストアップされるが、iOS上ではファイルが一つもリストアップされない。調べてみると、.tresファイルは見つからないが代わりに.tres.remapというファイルが存在していることがわかった。

https://github.com/godotengine/godot/issues/66014

Godot 3では動作していたのだが、どうやら仕様が変わりtresファイルもRemapされるようになったらしい。Remapされたファイルはload()やpreload()でres://プロトコルでファイルを指定して呼び出す際には内部的に自動でRemap後のデータが参照されるため、プログラム内で参照先をハードコーディングしているような場合は特にファイルの実態を意識する必要がないが、今回のように「フォルダからファイルを探して動的に読み込む」というような実装では「フォルダ上に見つかるファイル(remapファイル)」と「res://プロトコルで参照する際のURL」に差異が生まれてしまうのでそのままでは動かなくなってしまう。

for file in dir_contents(folder_path):
	if file.get_extension() == "tres":
		tres_files.append(file)
	if '.tres.remap' in file: # 追加
		image_files.append(file.trim_suffix(".remap"))

Godot 4でそのようなことをやりたい場合は、フォルダ内から.tres.remap ファイルを探し、読み込む際には.remapを取り除いたURLでload() するように実装する。ただし、Godot Editor上で作業している際にはRemapが行われていない元ファイルを参照しに行くことになるので、上のコードのように.tresファイルと.tres.remapファイルの両方に対応できるようなコードを書く必要がある。

https://github.com/godotengine/godot/issues/25672

最初にはったIssueと、上に貼ったもう一つのIssueでも指摘されているが、なかなかわかりにくい仕様だと思う。

M1 iMacへの買い換え

2020年末に購入したM1チップ搭載のMacBook Proが快適だ。Xcodeを使ったアプリ開発やPremiereでの動画編集はもちろん、最近少しずつ増えてきたニューラルネットワークが内部的に使われているようなAIアプリのパフォーマンスにおいて、Intel搭載のiMacとは顕著な差があることを実感している。

早くiMacもApple Silicon搭載の27インチモデルが発売されないかとずっと待ってきたが、待ち続けているうちに2年が経過してしまった。2023年中には発売されるのではないかという噂もあったが、昨今のApple製品の値段設定を見ていると発売されてもかなり高価になりそうなこと、それを待つ間の作業今のiMacの作業効率で失う生産性のバランスを考えて、24インチモデルの現行iMacに買い換えることにした。

Apple Siliconが出た当初はDockerや音源プラグインなどで色々問題があったが、最近ではほとんど解消され、さらにはプログラムの最適化も進んでおり環境構築もかなりスムーズに終わった。環境が綺麗な状態で、パフォーマンスをテストしておこうと思う。

“M1 iMacへの買い換え” の続きを読む

Golang用のGoogle Driveを扱うライブラリ

Google Driveにはプログラムから色々な操作を行うためのAPIが用意され、またそのAPIを簡単に呼び出せる各言語用の公式SDKが用意されている。公式SDKが使いにくいということはないが、実際に使いたい場面ではAPIの呼び出しのチェインやエラーハンドリング、パラメータの指定方法の都合などで少しばかり冗長なコードを書かざるを得ない部分がある。過去数年いくつかのシステムでGoogle Driveに関する機能の実装をしてきたが、振り返ってみると同じような冗長なコードをそれぞれのプロジェクトで書いてきていたので、このタイミングでコードを共通化して使いまわせるようにライブラリを作った。

https://github.com/yokoe/hakucho

v0.0.4現在、下記のような処理が実装されている。

  • フォルダの作成
  • ファイルのアップロード
  • アクセス権限の付与
  • ファイルの削除
  • 検索

例えば、ファイルの検索であればページングの処理なども含めて下記のように書くことができる。

files, err := hakucho.NewClient("/path/to/secret.json", "/path/to/token.json")
  .ListFiles(
    []string{"id", "createdTime", "name"}, // 作成日時、ファイル名などを結果に含める
    20, // 最大20件
    option.FullTextContains("Guitar"), // ファイル名や内容にGuitarが含まれるもの
    option.OrderBy("createdTime"), // 作成日時順に並び替える
)

GodotプロジェクトのiOSアプリ出力で、リソースをpckファイルにまとめるかフォルダ参照するかで挙動が変わる話

GodotではiOS用のエクスポートテンプレートを追加すると、ExportメニューからXcodeプロジェクトを書き出してiOSでアプリを動かすことができる。書き出し作業自体は手軽なのだが、毎回数十秒程度処理に時間がかかることやXcode側での設定(アプリ名のローカライズなど)がリセットされてしまうので、コードやリソースを変更するたびにExportを実行するのはなかなか面倒だ。

そこで、公式ドキュメントの「Active Development Considerations」の項では、XcodeプロジェクトからGodotのプロジェクトをフォルダ参照することで、変更のたびに毎回Exportし直さなくても常に最新の内容でiOSアプリをビルド・実行可能にする方法が説明されている。(※プラグインの追加時など、Xcodeプロジェクトの構造変更が必要な時はプロジェクトの出力し直しが必要になる)これは実に便利で、特にアプリ内課金の機能などiOSのネイティブ機能を使う部分の開発・デバッグには不可欠な手順になっている。

ただし、上の公式ドキュメントには特に記載がないがプロジェクトを毎回Exportした場合と、一度ExportしたXcodeプロジェクトからGodotフォルダ参照する場合ではResourceに関する挙動が異なるので注意する必要がある。

GodotからXcodeプロジェクトをExportすると、プロジェクト内で使用される画像やSceneなどのResourceを一つにまとめたリソースパック(pckファイル)が生成される。このpckファイルには、GodotでResourceとして扱われるファイルのみが含まれている。例えば2022年9月現在Godot 3.5ではJSONファイルはResourceファイルとしては扱われないため、プロジェクトフォルダ内に存在したとしてもpckファイルには含まれない。(2022年9月4日のissues #65295でJSONをResourceとして扱うための変更が提案されMergeされたため、将来はJSONもpckファイルに含まれるようになる予定)

一方でフォルダ参照をしている場合は、Godotのプロジェクトディレクトリ内にある全てのファイルをres://プロトコルのURLで参照することができる。Godotでは本来Resourceとして扱われないJSONファイル等もフォルダ参照の場合は参照することができてしまう。そのようなコードを書いてしまうと、フォルダ参照時は動作するのにpckファイル読み込み時は動作しない、という現象を引き起こしてしまうことになる。

一見全て参照できる分、基本的にはフォルダ参照にしておいた方が便利そうな気もするが、そうとも言えない。AppStoreへのサブミット時にリジェクト要因になってしまうファイルがバイナリに組み込まれてしまう場合があるためだ。例えばアプリ内課金を利用する場合、公式ドキュメントのやり方に従うとプロジェクトのres://ios/pluginsにiOSプラグインのファイルを配置することになるが、このプラグインのファイルをフォルダ参照で含めたままAppStoreにサブミットしようとするとInvalid Bundle Structureエラーが発生してしまう。Xcodeのビルド設定で特定のファイルを明示的に除外することでエラーを回避することも可能かもしれないが、開発をしているとプラグイン追加のタイミングなどXcodeプロジェクト自体を出力し直す機会が時々発生し、その度にこの辺りの細かい設定するのはできれば避けたい。

おそらく、現実的に無難な選択肢はpckファイルのみを更新するやり方だろう。Godotには、pckファイルのみをExportする機能が用意されている。

ワンステップ挟むことになるのでフォルダ参照に比べると多少不便ではあるが、この方法なら「フォルダ参照で想定外に動いてしまった」というような実装も防げるので、他プラットフォームへの展開なども考えると最も無難なのではないかと思う。

年末年始なのでGodotでゲームでも作ってみる 6日目

今日の目標

  • 音をつける
  • パーティクルシステムを使って軌跡を描く

音をつける

GodotではAudioStreamPlayerというNodeを使うと簡単にサウンドを再生させることができる。GameStageシーンにAudioStreamPlayerを追加する。

Nodeの名前はHitSoundにした。

FinderからFileSystem dockに適当なサウンドファイルを読み込む。

HitSoundのInspectorで、Streamプロパティのところに先ほど読み込んだサウンドファイルを指定する。

func _input(event):
	if event is InputEventMouseButton and event.button_index == BUTTON_LEFT and event.pressed:
		for enemy in get_tree().get_nodes_in_group("enemies"):
			var distance = event.position.distance_to(enemy.position)
			if distance <= ENEMY_RADIUS:
				enemy.position = Vector2(rng.randf_range(-STAGE_MARGIN, get_viewport().size.x + STAGE_MARGIN), -STAGE_MARGIN)
				score += 1
				get_node("HitSound").play(0)

サウンドを再生したいタイミングで、play(0)を呼ぶとそのサウンドが冒頭から再生される。今回のコードでは、標的が叩かれたタイミングでplay(0)を呼び出して効果音が鳴るようにした。

標的の軌跡を描く

パーティクルシステムを使って、標的の軌跡を描いてみる。

各標的から軌跡のパーティクルが放出されるようにしたいので、Hae.tscnにパーティクルシステムを導入する。Godotのパーティクルシステムには2種類のパーティクルシステムが用意されているが、Open GL ES 3系のプロジェクトでは、より高度でGPUで処理可能なParticles2Dノードを使うことができるので、今回はParticle2Dを追加。

Particles2Dノードが追加されたが、動作に必要なMaterialがセットされていないので警告マークが付いている。

ParticleのProcess Material(描画される一つ一つのパーティクルの表現を決めるもの)がemptyになっているので、New ParticlesMaterialを選択して新しいMaterialを作る。Inspectorの下の方にCanvas ItemのMaterialを設定する項目があるが、こちらではないので注意。

次にTexture(パーティクルの描画に使われる画像)を指定する。適当な画像をFileSystemに読み込んで、そこからドラッグで指定できる。

エディタ上でパーティクルシステムの動きが既に確認できるようになっている。現在は縦方向にパーティクルが放出されるだけであまりかっこよくないので、放射状に広がるようにパラメータを調整したい。

このパーティクルの動きは先ほど作ったMaterialの設定から行う。現在パーティクルが放出されると下に落ちていくのはGravityの影響なので、一旦Gravitiyを全て0にセットする。こうすることでパーティクルは生成されても下に落ちて行かなくなる。次にInitial Velocityに適当な値をセットする。この値はパーティクルが放出されるときの初速となり、大きくすれば大きくするほど勢いよくパーティクルが放出されるようになる。奇跡として描く場合はあまり大きくしすぎないほうが良いだろう。最後にDirectionで放出される方向を設定する。今回は特定の方向ではなく全方位に放出されて欲しいので、xyzはそれぞれ0をセットし、放出の範囲を決めるSpreadに180をセットした。

若干面倒ではあるが、Color Rampのところでグラデーションを指定することで、フェードインやフェードアウトも表現することができる。

設定の変更はすぐにエディタ上にも反映され動作を確認することができる。必要に応じて、その他のパラメータを調整して良い感じになるようにする。

最後に、Particles2DノードのDrawing / Local CoordsプロパティをOffにする。これをOnにしていると、放出されたパーティクルは親のNodeの移動に合わせて一緒に移動してしまう。今回は軌跡として表示をしたいので、標的(親ノード)が移動したとしても放出されたパーティクルは放出された場所にとどまって欲しいので、Offにする。

まとめ

一週間あるからある程度形になるだろうと思ってGodotを始めてみたが、前半油断してゆっくりやりすぎたこと、そしてそもそも実は休みが六日間しかなかったことなどもあって後半かなり駆け足になってしまった。とりあえずGodotを使ってゲームを作っていく上での基礎的なことは分かってきた感じがある。Godotはかなりサクサク動くし、Pythonライクな文法のGDScriptもコーディング量が少なく、気軽に遊びには良いゲームエンジンだなと思った。

Godotはクロスプラットフォームであるという点が魅力の一つなので、もうちょっとゲームとして形になったらiOS / Androidでそれぞれ動かしてみるというのを試してみたいと思う。

年末年始なのでGodotでゲームでも作ってみる 5日目

今日の目標

  • Nodeを移動させる
  • ゲームを作り始める

年末年始一週間あると思ってたら6日しかなかった。急いでゲーム作り始めないとまずい。

1フレーム毎にNodeを動かす

Godotで毎フレーム何かしらの処理をしたい場合はIdle processingとPhysics processingという二つの機構が用意されている。後者は基本的に物理演算を使うゲーム用で、今回は物理演算は使っていないので前者のIdle processingの機構を使ってNodeに何かしらの処理を加えてみる。Nodeにアタッチしたスクリプトに_process(delta)という関数を定義すると、1フレーム毎にこの関数が呼び出される。注意しなければいけないのは、フレームレート(FPS)は可変であるため、この中で処理をするときは基本的にdelta(前のフレームからの経過時間)をみて計算を行う必要がある。

func _process(delta):
	self.position = Vector2(self.position.x + delta * 100, self.position.y)

例えば上のようなコードを書くと、フレームが更新されるごとに、自身のNodeの座標(position)が右にdelta * 100ピクセル分移動していく(つまり1秒に100ピクセル)。

4日までに作っていたプロジェクトで、子Sceneの方に上記の_process(delta)を定義して実行すると、それぞれの子Sceneが少しずつ右に動いていく様子。

画面端まで行ったNodeをワープさせる

勉強がてら、もう少しこのプロジェクトをいじってみる。子SceneのNodeが画面右端に到達したら画面の左側にワープさせて、画面内をループするようにしてみたい。

func _process(delta):
	var children = get_tree().get_nodes_in_group("mygroup")
	for child in children:
		if child.position.x >= get_viewport_rect().size.x:
			child.position = Vector2(-100, child.position.y)

今度は親Sceneの方にも_process(delta)を定義した。get_tree().get_nodes_in_group(“mygroup”)で、グループに所属するNode(この場合は生成した子SceneのNode)のリストを取得できるので、それぞれのNodeに対して、位置のx座標(position.x)が画面の右端に到達していないかを調べる。画面のサイズはget_viewport_rect().sizeで取得することができる(get_viewport().sizeを使うと後で画面のストレッチを指定した時におかしくなるので注意)。もし画面の右端に到達していた場合はx座標を-100の位置に移動する。0を指定すると画面左端に急に出現したような感じになってしまうため、100ピクセル分左に移動して画面の外側から出現するようにしている。

スクリプティングまとめ

以上でなんとなくGodotにおけるスクリプトの書き方、Node / Sceneの扱い方は分かったので、これからもう少しゲームらしいものを実際に作っていこうと思う。冬休みはあと1.5日しかないけど。

新しいプロジェクトを作る

新しいプロジェクトを作る。どういうゲームを作るのかについては、今は秘密だが名前はHaetatakiにした。OpenGLはES 3.0を使用。

何を作るかは最後まで秘密にしておきたいが、とりあえずハエの画像が配置されているだけのHae.tscnというSceneを作る。これを敵キャラのSceneとして使いたい。

もう一つ別に空の2D Sceneを作り、GameStage.gdというスクリプトをアタッチする。

extends Node2D

const HaeScene = preload("res://Hae.tscn")

var rng = RandomNumberGenerator.new()

# Called when the node enters the scene tree for the first time.
func _ready():
	rng.randomize()
	
	for n in 10:
		var anInstance = HaeScene.instance()
		anInstance.name = "Hae" + str(n)
		anInstance.position = Vector2(rng.randf_range(0.0, get_viewport_rect().size.x), rng.randf_range(0.0, get_viewport_rect().size.y))
		anInstance.add_to_group("enemies")
		self.add_child(anInstance)

_ready()の中で10個ほどHae.tscnのインスタンスをランダムな位置に追加する。Godotでランダムな数値を使いたいときはRandomNumberGeneratorを使用できる。

まだどんなゲームになるのか予想がつかないが、とりあえずハエが10匹画面上のランダムな位置に表示されるようになった。

標的をランダムな方向に動かしてみる

Hae.tscnにもスクリプトをアタッチする。ファイル名はHae.gdとした。ランダムな方向に動かしたいので、orientationというプロパティを追加し、_process(delta)の中でそのorientationの方向に毎秒30ピクセルのスピードで動くようにコードを書く。

extends Node2D

var orientation = 0

func _process(delta):
	var speed = 30 * delta
	self.position = Vector2(self.position.x + speed * cos(orientation), self.position.y + speed * sin(orientation))

なお、Godotの2D座標系は下方向がY軸プラスになる座標系なので、数学でよくある上方向がプラスになる座標系と逆になっていることに注意したい。

extends Node2D
...
func _ready():
	rng.randomize()
	
	for n in 10:
		var anInstance = HaeScene.instance()
		anInstance.name = "Hae" + str(n)
		anInstance.orientation = rng.randf_range(0.0, PI * 2) // Added
		anInstance.position = Vector2(rng.randf_range(0.0, get_viewport_rect().size.x), rng.randf_range(0.0, get_viewport_rect().size.y))
		anInstance.add_to_group("enemies")
		self.add_child(anInstance)

親Sceneの方で、Haeのインスタンスを生成する際にorientationをランダムに設定するように変更。orientationの値はラジアン値で0.0〜6.28(PI * 2)の間でランダムに決定する。

実行すると、ハエがそれぞれランダムな方向に移動していく。

画面の端に行ったら反対の端にワープさせる処理をかく。

extends Node2D

const HaeScene = preload("res://Hae.tscn")

var rng = RandomNumberGenerator.new()

const STAGE_MARGIN = 100

# Called when the node enters the scene tree for the first time.
func _ready():
	...

func _process(delta):
	var stageEdgeLeft = -STAGE_MARGIN
	var stageEdgeRight = get_viewport_rect().size.x + STAGE_MARGIN
	var stageEdgeTop = -STAGE_MARGIN
	var stageEdgeBottom = get_viewport_rect().size.y + STAGE_MARGIN
	for enemy in get_tree().get_nodes_in_group("enemies"):
		if enemy.position.x < stageEdgeLeft:
			enemy.position = Vector2(stageEdgeRight, enemy.position.y)
		if enemy.position.x > stageEdgeRight:
			enemy.position = Vector2(stageEdgeLeft, enemy.position.y)
		if enemy.position.y < stageEdgeTop:
			enemy.position = Vector2(enemy.position.x, stageEdgeBottom)
		if enemy.position.y > stageEdgeBottom:
			enemy.position = Vector2(enemy.position.x, stageEdgeTop)
		

親Sceneの_process(delta)の中で各ハエのインスタンスを監視し、上下左右それぞれのステージの端に到達していたら反対の端にワープさせる。

タッチ判定を入れる

マウスで標的となるハエがクリックされたことを検知できるようにしてみよう。子SceneのHae.tscn自体に、クリックされたときのコールバックを実装しても良いのだが、今回は親Sceneの方にコールバックを実装する形をとった。理由としては、標的が重なっていたりした場合に複数の標的に同時にクリック判定をしたいことと、標的がいないところをクリックした時にも何かしらの処理を行いたいからだ。

extends Node2D

const HaeScene = preload("res://Hae.tscn")

var rng = RandomNumberGenerator.new()

const STAGE_MARGIN = 100
const ENEMY_RADIUS = 50

...
		
func _input(event):
	if event is InputEventMouseButton and event.button_index == BUTTON_LEFT and event.pressed:
		for enemy in get_tree().get_nodes_in_group("enemies"):
			var distance = event.position.distance_to(enemy.position)
			if distance <= ENEMY_RADIUS:
				enemy.position = Vector2(rng.randf_range(-STAGE_MARGIN, get_viewport_rect().size.x + STAGE_MARGIN), -STAGE_MARGIN)

マウスクリックはコールバック関数_input(event)内で取得できる。_input(event)はマウスクリック以外にもキーボードのキーが押された時やマウスのカーソルが押された時など様々な時に呼び出されるため、まずif文でマウスの左ボタンが押された時のみ処理が走るようにする。

if event is InputEventMouseButton and event.button_index == BUTTON_LEFT and event.pressed:

クリックされた場所は event.position でVector2型の座標として取得できる。Vector2には別のVector2との間の距離を計算するdistance_toメソッドが用意されているので、これを使ってステージ上にいる各標的(ハエ)との距離を調べる。距離がENEMY_RADIUSよりも小さかった場合に「あたり」と判定する。あたりと判定した場合は、とりあえずその標的をステージの上端のどこか見えない場所にランダムでワープさせている。

点数をカウントする

点数を表示させるためのLabelをGameStageに配置する。名前はScoreLabelとしておいた。

extends Node2D

const HaeScene = preload("res://Hae.tscn")

var rng = RandomNumberGenerator.new()

const STAGE_MARGIN = 100
const ENEMY_RADIUS = 50

var score = 0

...
		
func _input(event):
	if event is InputEventMouseButton and event.button_index == BUTTON_LEFT and event.pressed:
		for enemy in get_tree().get_nodes_in_group("enemies"):
			var distance = event.position.distance_to(enemy.position)
			if distance <= ENEMY_RADIUS:
				enemy.position = Vector2(rng.randf_range(-STAGE_MARGIN, get_viewport_rect().size.x + STAGE_MARGIN), -STAGE_MARGIN)
				score += 1
		get_node("ScoreLabel").text = "Score: " + str(score)

scoreという変数を用意し、標的がクリックされる毎に1を追加する。そして、マウスクリックがある度に最新のscoreの値をScoreLabelに表示する。

実際に動かして、点数が加算されていくことを確認。

まとめ

急に駆け足になってしまったが、一旦ゲームっぽい物の骨格を作ることができた。明日はこれをベースにもうちょっとゲームっぽくしていきたいと思う。

年末年始なのでGodotでゲームでも作ってみる 4日目

今日の目標

  • スクリプトを書いてみる

Godotで使えるスクリプトについて

GodotではGDScriptの他にVisualScript、C#、C++でスクリプトを書くことができる。メイン言語はGDScriptということなので、今回もGDScriptを使うことにする。

テスト用のSceneの作成

公式ドキュメントのScriptingの内容に従って、LabelとButtonが配置されたSceneを新しく作成する。

Root Nodeに対して右クリックメニューからAttach Scriptを選択。

ScriptのPath(保存先のファイル名)を適当に指定してCreateする。

追加されたScriptファイルを編集する画面が開く。別の編集画面を開いてしまった場合は、Scene dockの赤枠で囲ったScriptマークを押すとこのスクリプト編集画面に戻ってくることができる。

extends Node2D

func _ready():
	get_node("Button").connect("pressed", self, "_on_Button_pressed")

func _on_Button_pressed():
	get_node("Label").text = "HELLO!"

ドキュメントに従って、上記のコードを記述する。

_ready()はAttachされたNode(この場合はRoot NodeのNode2D)がScene上でアクティブになったタイミングで呼ばれるコールバック関数。get_node()はそのNodeの子Nodeの中から、名前が一致するNodeを検索する関数。Sceneに配置されたボタンの名前(Scene dock上にリストアップされている名前)はButtonとなっているので、get_note(“Button”)でこのボタンへの参照が取得できる。get_nodeは子Nodeに対して検索をする関数なので、もしスクリプトが別の場所にアタッチされていたり、Buttonが別の場所(Labelの子Nodeになっていたり)にあったりすると検索に失敗するので注意が必要。

connect(“pressed”, self, “_on_Button_pressed”)では、ボタンにおける”pressed”というSignalがあった場合に、自身(_self)の”_on_Button_pressed”という関数を実行させる、という設定を行っている。この設定はコードで書く代わりにエディタ右側の「Node」からUI上で設定することもできるが、慣れないうちは「どこで何をやっているか」分からなくなりがちなので全てコード上で管理した方が混乱しなくて良さそうだ。

get_node(“Label”).text = “HELLO!” は書いてある通りだが、Labelの文字列をHELLO!に書き換えるためのコード。

この状態でSceneを実行し、ボタンを押してみるとLabelの文字列が変わることが確認できた。

カウンターにしてみる(変数を使ってみる)

コードを少しいじってカウンターを作ってみる。

extends Node2D

var counter = 0

func _ready():
	get_node("Button").connect("pressed", self, "_on_Button_pressed")

func _on_Button_pressed():
	counter += 1
	get_node("Label").text = str(counter)

var counterでカウントアップ用の変数を用意し、_on_Button_pressed()内でカウントアップする。counterの中身はintだが、LabelのtextはString型なので代入する際にはstr関数を使ってintからStringへの変換が必要になる。

実行してボタンを何度か押してみるとちゃんとカウントアップされていることが確認できた。

複数のSceneインスタンスがあった場合の挙動

複数のSceneインスタンスがあった場合の挙動も確認しておこう。

新しい2D Sceneを作成し、そこに先ほど作ったLabelとButtonから構成されるSceneのインスタンスを複数配置して実行してみる。

ボタンを押してみると、それぞれのインスタンスごとに独立してカウンターがカウントアップされていくのを確認できた。

親Sceneから子SceneのNodeを操作する

親Sceneから子SceneのNodeを操作してみる。親SceneにButtonを設置しスクリプトをAttachし、コードを記述する。

extends Node2D

func _ready():
	get_node("Button").connect("pressed", self, "_on_Button_pressed")

func _on_Button_pressed():
	get_node("Node2D3/Label").text = "Nantekottai..."

get_node()ではスラッシュ(/)を使うことで階層を指定してNodeを探索することができる。get_node(“Node2D3/Label”)は親SceneにあるNode2D3というNodeの子NodeのLabelというNodeを探索する。

上記のコードで、子SceneのNodeを操作できることを確認できた。ただし、親Sceneが子Sceneの構造を指定して操作するやり方は、後の子Sceneの構造変更の可能性などを考えるとあまり良いやり方ではないので、基本的には子Sceneの方に関数を定義しておいて、それを呼び出す形にした方が良いだろう。

// 子Sceneのコード
extends Node2D


func sayNantekottai():
	get_node("Label").text = "Nantekottai!"


// 親Sceneのコード
extends Node2D

func _ready():
	get_node("Button").connect("pressed", self, "_on_Button_pressed")

func _on_Button_pressed():
	get_node("Node2D3").sayNantekottai()

コード内から子Sceneのインスタンスを追加してみる

エディタ上で子Sceneを追加し、それらに対して親Sceneから操作を行う方法は分かったが、実際のゲームではインスタンスはコードから生成したいことが多いと思うので、その方法についても調べておく。

extends Node2D

const ChildScene = preload("res://LabelAndButton.tscn")

var instanceNumber = 0

func _ready():
	get_node("Button").connect("pressed", self, "_on_Button_pressed")

func _on_Button_pressed():
	var anInstance = ChildScene.instance()
	anInstance.name = "ChildInstance" + str(instanceNumber)
	anInstance.position = Vector2(instanceNumber * 100, 0)
	instanceNumber += 1
	self.add_child(anInstance)

子Sceneを追加するには、まずpreloadを使って予めtscnファイルの中身をコード上で読み込んでおく必要がある。tscnファイルのパスはFileSystem dockから確認することができる。

上のコードではボタンが押される度にインスタンスが追加されるようになっている。上で読み込んだtscnファイルに対して、instance()を呼ぶことで新しいインスタンスが作られる。作られたインスタンスは後から参照できるようにユニークなnameをつけておきたいので、追加した順番にChildInstance0, ChildInstance1…といったnameをつけるようにした。親Sceneのself.add_childを呼ぶことで、生成した新しい子Sceneインスタンスを親Sceneに追加することができるのだが、同じ場所にインスタンスを追加すると追加されたのかがわかりにくいので、表示位置を表すpositionプロパティに2次元座標(Vector2D)でx: instanceNumber * 100, y: 0の位置に追加されるようにした。

実行してみた様子。親SceneのAdd child sceneボタンを押す度に子Sceneのインスタンスが追加されていく。子Sceneのインスタンスはエディタで追加した時同様独立して動作するので、ボタンを押すとカウンターがそれぞれカウントアップされる。

子SceneをGroupで管理する

子SceneはGroupという機能でグルーピングして管理することができる。親Sceneに別のButton(Button2)を追加して、押されたら全ての子Sceneに対してsayNantekottai関数を呼び出すようにしてみた。

extends Node2D

const ChildScene = preload("res://LabelAndButton.tscn")

var instanceNumber = 0

func _ready():
	get_node("Button").connect("pressed", self, "_on_Button_pressed")
	get_node("Button2").connect("pressed", self, "_on_Button2_pressed")

func _on_Button_pressed():
	var anInstance = ChildScene.instance()
	anInstance.add_to_group("mygroup")
	anInstance.name = "ChildInstance" + str(instanceNumber)
	anInstance.position = Vector2(instanceNumber * 100, 0)
	instanceNumber += 1
	self.add_child(anInstance)

func _on_Button2_pressed():
	get_tree().call_group("mygroup", "sayNantekottai")

上のコードを少し変更し、生成した子Sceneのインスタンスに対して、add_to_group(“mygroup”)という関数を呼んでおくと、インスタンスがmygroupという名前のグループに追加されて管理される。グループ名は文字列で自由に決めて設定できる。タグのような感覚で使えば良い。

グループに追加されたインスタンスに対してはget_tree().call_group()関数を使うことで、一括で関数を呼び出すことができる。get_tree().call_group(“mygroup”, “sayNantekottai”)ではmygroupに登録されている全てのインスタンスのsayNantekottai関数を呼び出す。

実行してみた様子。mygroupに追加した全てのインスタンスに対して一括でsayNantekottaiを呼び出すことができた。

まとめ

スクリプトを使う基本的な方法と、スクリプト上でInstance/Nodeを操作する方法を確認した。ペース配分を間違えたので後3日でゲームが完成するのか不安になってきた。