年末年始なので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日でゲームが完成するのか不安になってきた。