第5回目の今回は、さらに敵キャラクターの種類を増やしていく。それぞれの敵キャラクターの動きに違いを持たせ、それらをタイルマップ上に複数配置してゲームの難易度を高めていこう。具体的に今回は以下の敵キャラクターを作成していく。

  • バニー(うさぎ)
  • カメレオン
  • プラント(植物)

Memo:
過去のシリーズをまだご覧になっていない方は、そちらを先にご覧いただくことをおすすめします。
Godot で作るプラットフォーマー



Bunny シーンを作る

まず追加する最初の敵キャラクターはバニーだ。Part 4 でマッシュルームを作った時のおさらいだと思って進めていこう。では「Enemy」シーンを継承して「Bunny」シーンを作るところから開始する。以下の手順でシーンを作成して保存しよう。

  1. 「シーン」メニュー>「新しい継承シーン」を選択する。
  2. 継承元として「Enemy.tscn」を選択して開く。
  3. ルートノードを「Bunny」に変更する。
  4. シーンを保存する。この時「res://Enemies/」に「Bunny」フォルダを作成して、そこに「Bunny.tscn」という名前で保存しよう。ファイルパスは「res://Enemies/Bunny/Bunny.tscn」になる。

これで「Bunny」シーンが用意できた。
Bunnyシーン



各ノードのプロパティを編集する

ルートノードの Script Variables を編集する

「Bunny」ルートノードの Script Variables を以下のように変更しよう。

  • Gravity: 512
  • speed: 48

AnimatedSprite ノードのアニメーションを編集する

まず忘れずに行いたいのが「AnimatedSprite」の「Frames」プロパティを 「ユニーク化」 することだ。ユニーク化できたら、「SpriteFrames」をクリックしてアニメーションを編集していく。

以下の内容でアニメーションを用意しよう。

  • アニメーション名: fall
    • 速度: 24 FPS
    • スプライトシート: res://Assets/Enemies/Bunny/Fall.png
    • ループ: オン
      fallアニメーション
  • アニメーション名: hit
    • 速度: 24 FPS
    • スプライトシート: res://Assets/Enemies/Bunny/Hit (34x44).png
    • ループ: オフ
      hitアニメーション
  • アニメーション名: jump
    • 速度: 24 FPS
    • スプライトシート: res://Assets/Enemies/Bunny/Jump.png
    • ループ: オン
      jumpアニメーション
  • アニメーション名: run
    • 速度: 24 FPS
    • スプライトシート: res://Assets/Enemies/Bunny/Run (34x44).png
    • ループ: オン
      runアニメーション

なお「res://Assets/Enemies/Bunny/」フォルダには「Idle (34x44).png」も用意してあるが、このチュートリアルでは不要だ。

もしスプライトシートの画像にブラーがかかっている(ぼやけている)場合は、該当のファイルをファイルシステムドックで選択した状態で、インポートドックから「プリセット」>「2D Pixel」選択 > 「再インポート」を実施しておこう。


CollisionShape2D ノードのコリジョン形状を設定する

「Bunny」ルートノード直下の「CollisionShape2D」ノードから編集する。こちらも忘れないうちに「Shape」プロパティの値を 「ユニーク化」 しておこう。

続けて、コリジョン形状を調整する。バニーは少し縦長のデザインなので「CapsuleShape2D」の形を当てはめやすいだろう。

スプライトテクスチャに対して、だいたい以下のような形、配置になればOKだ。前回のマッシュルーム同様、足先は地面との衝突検知のためにピッタリ合わせ、頭のてっぺんは別のHitBoxの方の「CollisionShape2D」を配置するので少し空けている。なお、便宜上、下の画像ではシーンドックで不要なノードを非表示にしてコリジョン形状を見やすくしている。
Bunny>CollisionShape2Dのコリジョン形状

このコリジョン形状の編集によって、インスペクター上の関係するプロパティの値はそれぞれ、以下のようになっている。2Dワークスペースでの調整が苦手な場合は、これらの数値を直接インスペクターで入力しても構わない。

  • Radius: 8
  • Height: 10
    Radius, Heightプロパティ
  • Position: (0, 9)
    Positionプロパティ

次に「HitBox」ノードの子である「CollisionShape2D」ノードのコリジョン形状を編集する。まずは「Shape」プロパティの値を 「ユニーク化」 しておこう。

こちらのコリジョン形状は、頭の上に配置し、先に設定したルートノード直下の「CollisionShape2D」より幅を狭くし、また形状が重ならないようにする。
HitBox>CollisionShape2Dのコリジョン形状

コリジョン形状の編集により関係するプロパティは以下のようになっている。

  • Extents: (5, 2)
    Extentsプロパティ
  • Position: (0, -6)
    Positionプロパティ

VisibilityEnabler2D ノードの形状を設定する

最後に「VisibilityEnabler2D」ノードの形状も編集しておこう。調整作業自体は2Dワークスペースでドラッグ操作で行うのが直感的でわかりやすいだろう。下のスクリーンショットのように、だいたいバニーのスプライトテクスチャと同じくらいにした。
VisibilityEnabler2Dの形状調整
関連するプロパティの具体的な値は以下のようになった。

  • Position: (0, 2)
  • Scale: (0.875, 1.25)
    VisibilityEnabler2DのPositionとScale

以上でプロパティの編集は終わりだ。次はスクリプトをアタッチして、コードで動きを制御していこう。


Bunny シーンに新しいノードを追加する

「Bunny」ルートノードに、「Enemy」シーンにはなかった「Area2D」ノードを追加しよう。この「Area2D」ノードには「CollisionShape2D」ノードを追加しよう。
BunnyにArea2Dノードとその子にCollisionShape2Dノードを追加

これはバニーの動きに変化をつけるための簡単な仕掛けだ。バニーにプレイヤーキャラクターが一定の距離まで近づいたら、「Area2D」のシグナルを利用して、バニーの動きを変えるというものだ。具体的には、バニーがプレイヤーキャラクターから離れている間は「AnimatedSprite」の「run」アニメーションで走らせるが、一定距離内に近づくと「jump」&「fall」アニメーションに切り替えて、ジャンプして移動させる。

どれくらいの距離でアニメーションを切り替えるかは「Area2D」ノードの子「CollisionShape2D」のコリジョン形状を決定する「Radius」プロパティ次第だ。今回は値を 100 とした。「Position」プロパティはデフォルトの (0, 0) のままなので、単純にプレイヤーキャラクターが半径 100 px 以内に近づくとバニーがジャンプで移動し始めるという動きを想定している。
BunnyにArea2Dノードとその子にCollisionShape2Dノードを追加



Bunny.gd スクリプトをアタッチして編集する

前回の Part 4 では、「Mushroom」シーンで「Mushroom」ノードから「Enemy.gd」スクリプトを最初にデタッチして、その後、「Enemy.gd」を継承した「Mushroom.gd」スクリプトをアタッチしていた。

しかし、このあと数種類の敵キャラクターを作るごとに「Enemy.gd」スクリプトをデタッチする作業が煩わしいので、このタイミングで「Enemy」シーンの「Enemy」ルートノードからスクリプトをデタッチしておこう。そうすれば、「Enemy」シーンを継承して作成したシーンで、毎回スクリプトをデタッチする必要がなくなる。

では「Enemy.tscn」シーンを開いて、「Enemy」ルートノードを右クリックし、「スクリプトをデタッチ」を選択しよう。
Enemyノードからスクリプトをデタッチ

次に「Bunny.tscn」シーンを開いて、「Bunny」ルートノードにスクリプトをアタッチする。

継承元を「res://Enemies/Enemy.gd」、ファイルパスを「res://Enemies/Bunny/Bunny.gd」としてスクリプトを作成してアタッチしよう。
Enemy.gdを継承してBunny.gdスクリプトをBunnyノードにアタッチ

スクリプトがアタッチできたら、コードを編集していこう。

extends "res://Enemies/Enemy.gd"


export var jump_force = 200 # バニーのジャンプ力
var is_jumping = false # ジャンプで移動しているかどうかのステータス

バニーはジャンプ移動するので、ジャンプ力を示すプロパティとしてjump_forceを定義した。値は200としている。プレイヤーキャラクターよりややジャンプ力を低くしている。

is_jumpingプロパティは、走って移動しているのか、ジャンプして移動しているのかを示すステータスの役割として定義した。最初は走って移動なので値をfalseにしている。プレイヤーキャラクターが「Area2D」ノードの範囲に入ったらtrueに変更する予定だ。

次に_readyメソッドで最初に再生するアニメーションとして、「run」アニメーションを指定した。

func _ready():
	sprite.play("run")

続いて_physics_processメソッドを編集していく。

func _physics_process(delta):
	# Mushroom と同じ
	if is_on_wall():
		speed *= -1
		sprite.flip_h = !sprite.flip_h
	
	# ジャンプで移動中の場合
	if is_jumping:
		if is_on_floor():
			velocity.y = -jump_force 
		else:
			if velocity.y < 0:
				sprite.play("jump")
			else:
				sprite.play("fall")
        
	# Mushroom と同じ
	velocity.x = -speed
	velocity.y += gravity * delta
	velocity = move_and_slide(velocity, Vector2.UP)

if is_jumping:のブロックが Part 4 で作ったマッシュルームと違うところだ。

冒頭で定義したis_jumpingプロパティの値がtrueだった場合にブロック内のコードを実行する。そしてすぐにネストされたif / else構文になる。

if is_on_floor(): で地面に接している場合、ということになるが、この場合はvelocity.yにジャンプ力であるjump_force-をつけて代入している。これにより、バニーがジャンプする。

ジャンプしている間はis_on_floorメソッドの戻り値がfalseになるので、elseブロックに入る。ここでさらにネストされたif / else構文になっている。

一旦ジャンプ力とイコールになったvelocity.yの値が、毎フレーム重力の影響を受け、やがて0に近づいていく。そして0になった時がジャンプの高さの頂点だ。つまりif velocity.y < 0: は『ジャンプの頂点に達するまでは』という条件であり、その間は「jump」アニメーションを再生するよう指定している。

そしてelseブロックはvelociy.yの値が0以下、つまり『ジャンプの頂点から地面に落ちている間は』という条件になる。この間は「fall」アニメーションを再生するように指定している。

さて、is_jumpingプロパティの値の切り替えを行うには、「Area2D」のシグナルを利用する。一旦、シーンドックに戻って「Area2D」ノードを選択し、ノードドック>「シグナル」タブにて、以下の2つのシグナルを「Bunny.gd」スクリプトに接続しよう。

  • body_entered(body)
  • body_exited(body)

Area2Dノードのシグナル追加

接続すると、スクリプトにメソッドが追加されたはずだ。そのメソッドは以下のように編集する。

# Area2D ノードのシグナル「body_entered(body)」を接続
func _on_Area2D_body_entered(body):
	if body.is_in_group("Players"):
		is_jumping = true 

# Area2D ノードのシグナル「body_exited(body)」を接続
func _on_Area2D_body_exited(body):
		is_jumping = false
		sprite.play("run")

どちらもメソッドのブロック内冒頭でif body.is_in_group("Players"):を記述している。以前のチュートリアルで、「Player」シーンの「Player」ノードを「Players」ノードグループに追加したので、その設定を再利用している。プレイヤーキャラクター以外にも、他の敵キャラクターが近くにいる場合が想定できるので、この if 構文が必要なのだ。ちなみにif body.name == "Player":としてもらっても良い(「Player」ノードの名前を変えない限り)。

そして「Area2D」のコリジョン範囲に入ったらis_jumpingプロパティをtrueとしてジャンプでの移動に切り替え、範囲から出たらis_jumpingプロパティをfalseとしつつ「run」アニメーションを再生させている。

ここまでできたら「Bunny.tscn」および「Bunny.gd」を保存して、バニーの動きを確認していこう。


Level1 シーンに Bunny シーンのインスタンスノードを追加する

では、バニーが想定通りの動きをするか見ていこう。「Level1」ノードに「Bunny.tscn」のインスタンスノードを追加する。

Level1ノードにBunnyインスタンスノードを追加

今回もひとまず2Dワークスペースで、すぐに動作を確認できそうな位置に「Bunny」ノードを配置しよう。
2DワークスペースでBunnyを配置

配置できたらプロジェクトを実行して挙動を確認してみよう。
バニーの動きをデバッグ



Chameleon シーンを作る

次は別の敵キャラクターのカメレオンを作っていく。バニーの手順と同様にサクサク進めていこう。

  1. 「シーン」メニュー>「新しい継承シーン」を選択する。
  2. 継承元として「Enemy.tscn」を選択して開く。
  3. ルートノードを「Chameleon」に変更する。
  4. シーンを保存する。フォルダを作成して、ファイルパスは「res://Enemies/Chameleon/Chameleon.tscn」とする。

Chameleonシーン


各ノードのプロパティを編集する

スクリプトを作成してルートノードにアタッチする

「Enemy.gd」スクリプトに記述している Script Variables をカメレオンにも適用して、値を編集したいので、先に「Enemy.gd」を継承したスクリプトを作成、アタッチしよう。

「Chameleon」ルートノードを選択して、スクリプトをアタッチする。以下の設定でスクリプトを作成すること。

  • 継承元: res://Enemies/Enemy.gd
  • パス: res://Enemies/Chameleon/Chameleon.gd

ルートノードの Script Variables を編集する

「Bunny」ルートノードの Script Variables を以下のように変更しよう。

  • Gravity: 512
  • speed: 16

AnimatedSprite ノードのアニメーションを編集する

まずは「AnimatedSprite」の「Frames」プロパティを 「ユニーク化」 しよう。ユニーク化できたら、「SpriteFrames」をクリックしてアニメーションを編集していく。

以下の内容でアニメーションを用意しよう。

  • アニメーション名: attack
    • 速度: 12 FPS
    • スプライトシート: res://Assets/Enemies/Chameleon/Attack (84x38).png
    • ループ: オン
      attackアニメーション
  • アニメーション名: hit
    • 速度: 24 FPS
    • スプライトシート: res://Assets/Enemies/Chameleon/Hit (84x38).png
    • ループ: オフ
      hitアニメーション
  • アニメーション名: idle
    • 速度: 24 FPS
    • スプライトシート: res://Assets/Enemies/Chameleon/Idle (84x38).png
    • ループ: オン
      idleアニメーション
  • アニメーション名: run
    • 速度: 24 FPS
    • スプライトシート: res://Assets/Enemies/Chameleon/Run (84x38).png
    • ループ: オン
      runアニメーション

繰り返しになるが、もしスプライトシートの画像にブラーがかかっている(ぼやけている)場合は、該当のファイルをファイルシステムドックで選択した状態で、インポートドックから「プリセット」>「2D Pixel」選択 > 「再インポート」を実施しておこう。

あとは、カメレオンのスプライトテクスチャが、カメレオンの体をやや右寄りにデザインされているので、体の中心がだいたい y 軸上にくるように「AnimatedSprite」ノードの「Position」プロパティの x の値を -16 に変更しておく。
CollisionShape2Dのposition.xを7に変更


CollisionShape2D ノードのコリジョン形状を設定する

まずは「Chameleon」ルートノード直下の「CollisionShape2D」から編集する。

カメレオンの体に合わせてコリジョン形状を編集する。「idle」アニメーション時の足元をピッタリ合わせて、頭のてっぺんは「HitBox」のコリジョン形状を配置のために少し空けておこおう。
CollisionShape2Dのコリジョン形状を編集

コリジョン形状に関わるプロパティの値は以下の通りだ。

  • Radius: 11
  • Height: 2
    CollisionShape2DのRadiusとHeight
  • Position: (7, 0)
    CollisionShape2DのPosition

続けて「HitBox」ノード下の「CollisionShape2D」ノードのコリジョン形状を編集しよう。これまで通り、頭のてっぺんに配置し、幅をルートノード直下の「CollisionShape2D」より狭くして重ならないようにする。
HitBox下のCollisionShape2Dのコリジョン形状

コリジョン形状に関わるプロパティの値は以下の通りだ。

  • Extents: (7, 2)
    HitBox下のCollisionShape2DのExtents
  • Position: (0, -7)
    HitBox下のCollisionShape2DのPosition

VisibilityEnabler2D ノードの形状を設定する

「VisibilityEnabler2D」ノードの形状もカメレオンのスプライトテクスチャに合わせて調整しよう。
VisibilityEnabler2Dの形状編集

関連するプロパティの値は以下の通りだ。

  • Position: (4.5, 3)
  • Scale: (1.031, 1)
    VisibilityEnabler2DのPositionとScale


Chameleon ノードに RayCast2D ノードを追加する

「AnimatedSprite」ノードの「attack」アニメーションを見てみると、カメレオンは舌を伸ばして攻撃してくるデザインだ。そこで、プレイヤーキャラクターが、カメレオンに対して水平( x 軸)方向に一定距離近づくと、近寄ってきて、さらに一定距離近づくと舌を伸ばして攻撃してくる、という動きにしていく。

プレイヤーキャラクターとの水平方向の距離を検知するために「Chameleon」ルートノードに「RayCast2D」クラスのノードを追加しよう。このノードは指定した直線距離での衝突判定機能を提供してくれるので非常に便利だ。
ルートノードにRayCast2Dを追加

「RayCast2D」は2Dワークスペース上では、矢印で表示されている。この矢印をカメレオンが「attack」アニメーションで出した時の舌の向きと位置に合わせる。この作業のために一時的に「AnimatedSprite」ノードのプロパティを以下のように編集しよう。

  • Animation: attack
  • Frame: 6
  • Playing: オフ

AnimatedSpriteのプロパティ編集

これで2Dワークスペース上には、舌を伸ばした状態のカメレオンのスプライトテクスチャが表示されているはずだ。このデザインに合わせて「RayCast2D」を調整していく。

関係するプロパティを以下のように編集しよう。

  • Enabled: オン > RayCast2Dが有効にする
  • Cast To: (-120, 0)> 左向きの長さ 120 px の矢印にする
    RayCast2DのEnabledおよびCast Toプロパティ編集
  • Position: (0, 6) > カメレオンの舌と同じ高さにする
    RayCast2DのPosition編集

2Dワークスペース上では以下のスクリーンショットのようになっているはずだ。
2DワークスペースでRayCast2Dをスプライトテクスチャに合わせる


Chameleon.gd スクリプトを編集する

さっき追加した「RayCast2D」ノードを利用しつつ「Chameleon.gd」スクリプトをコーディングしていく。

まずは必要なプロパティの定義から始めよう。「RayCast2D」ノードにアクセスしやすくするため、onreadyキーワード付きでraycastプロパティにこのノードの参照を渡した。

extends "res://Enemies/Enemy.gd"

onready var raycast = $RayCast2D

次に_readyメソッドで、「AnimatedSprite」ノードの最初のアニメーションを「idle」に指定した。

func _ready():
	sprite.play("idle")

続いて_physics_processメソッドを定義する。

func _physics_process(delta):
	velocity.y += gravity * delta
	if raycast.is_colliding():
		if raycast.get_collider().name == "Player":
			if position.distance_to(raycast.get_collision_point()) > 80: 
				run()
			else:
				attack()
				velocity.x = 0
	else:
		sprite.play("idle")
		velocity.x = 0
	velocity.y += gravity * delta
	velocity = move_and_slide(velocity, Vector2.UP)

冒頭からif構文だ。

「RayCast2D」のis_collidingメソッドは、「RayCast2D」ノードの矢印に何らかのオブジェクトが衝突していればtrue、そうでなければfalseを返す。つまり最初のif構文は、『「RayCast2D」の矢印にオブジェクトが衝突していれば』という条件だ。

次のネストされたif構文を確認しよう。get_colliderメソッドは、衝突しているオブジェクトを返す。そのnameプロパティにはそのオブジェクトの名前が入っている。つまりこのif構文は、衝突したオブジェクトの名前が「Player」だったら、という条件になっている。ちなみにis_on_groupメソッドを使いたいところだが、生憎、get_colliderで取得するオブジェクトにはそのメソッドが含まれないので使用できない。

そしてさらにネストされた3つ目のif構文を見てみよう。メソッドの引数に他のメソッドが入っているのでややこしく見えるが、一つずつ確認していく。

まずpositionは「Chameleon」ノードの「Position」プロパティで、どこにカメレオンがいるのかを x, y 座標の Vector2 型の値で保持している。Vector2 クラスにはdistance_toというメソッドがある。このメソッドは自身の x, y 座標と、引数で渡した Vextor2 の座標までの長さ(直線距離)を float 型で返す。今回その引数には別のメソッドget_collision_pointを渡している。このメソッドは「RayCast2D」ノードがオブジェクトと衝突している位置を Vector2 型の値で返す。

つまり、この3つ目のif構文は、『「Chameleon」ノードの位置から「RayCast2D」ノードがオブジェクトと衝突した位置までの距離が80px より大きかったら』という条件になっている。そのif条件を満たした場合、runメソッドが呼ばれる。このrunメソッドはこのあと定義する。

一方、オブジェクトとカメレオンとの直線距離が80px 以下の場合はelseブロックに入り、「attack」メソッドが呼ばれる。このattackメソッドもこのあと定義する。そのすぐ後にvelociy.x = 0としている。舌で攻撃を仕掛けてくる時は移動はしない、ということだ。

そして、2ブロック戻って、最初のif条件を満たさない場合、つまり「RayCast2D」ノードの矢印がオブジェクトに衝突していない場合は、ただ「idle」アニメーションを再生するのみで、移動もしない。

一番外側のif / elseブロックを抜けると、velociy.yの値に重力を適用し、最後に現在のvelocityの値を引数にとってmove_and_slideメソッドによりカメレオンの動きが制御される。

ちなみにif構文を2つネストさせたが、and&&でつないで全て一行のifブロックにすることも可能だ。今回は可読性を重視してこのような形にした。

では、次に新しいメソッドを定義しておく。まずはrunメソッドから。

func run():
	sprite.play("run")
	if is_on_wall():
		speed *= -1
		sprite.flip_h = !sprite.flip_h
		sprite.position.x *= -1
		raycast.cast_to.x *= -1
	velocity.x = -speed

実はこれは Part 4 で作ったマッシュルームの_physics_processメソッド内の内容とほとんど同じだ。このスクリプトの場合、_physics_processメソッド内のコード量が多くなり、ゴチャゴチャするので、スッキリさせるためにただ分離した格好だ。反対にメソッドに分けない方が良い人はこのメソッドを定義せず、中のコードをそのままrunを実行している箇所に移動させても良い。

念のため、内容を確認しておこう。まず最初に「run」アニメーションを再生する。

続いてis_on_wallで壁に当たったらという条件のifブロックに入る。壁に当たったら、進行方向を x 軸方向に反転、スプライトテクスチャも同じく反転、スプライトの位置も元々左に-16ずらしているので、sprite.position.x *= -1として反転させる。そして今回追加した「RayCast2D」ノードの矢印の向きも同じく反転させた(cast_to.x *= -1)。

あとは、velociy.xは常にspeedプロパティの値で一定(左右の方向は変わる)、velociy.yには常に重力gravityがかかっている。このvelociyを引数にしてmove_and_slideメソッドによりカメレオンがゲーム画面上を移動することになる。

繰り返しになるが、このメソッドはカメレオンの「RayCast2D」の矢印とプレイヤーキャラクターが衝突していて(矢印の長さ 120px 以下)、かつカメレオンとプレイヤーキャラクターとの距離が80pxより大きい場合に呼ばれる。

続いてattackメソッドを定義しておこう。

func attack():
	sprite.play("attack")
	if sprite.frame == 6 or sprite.frame == 7:
		if raycast.is_colliding() and raycast.get_collider().name == "Player":
			if position.distance_to(raycast.get_collision_point()) < 50:
				print("Player is hit")

まず「attack」アニメーションを再生する。

次はif構文だ。『attack」アニメーションのフレームが67だったら』という条件だがピンとくるだろうか。実は「attack」アニメーションのフレーム67だけがカメレオンが舌を伸ばしているデザインのスプライトテクスチャになっているのだ。

ネストされた2つ目のif構文を見ると、またis_collidingメソッドでオブジェクトと衝突しているか、get_collider().name == "Player"で衝突したオブジェクトの名前は「Player」かをチェックしている。これは厳密にカメレオンが舌を伸ばしている時に「RayCast2D」がプレイヤーキャラクターを捉えているか、を判定するためだ。

そしてさらにネストされた3つ目のif構文だ。position.distance_to(raycast.get_collision_point()) はさっき_physics_process内にあったコードと同じだ。『カメレオンの位置からプレイヤーキャラクターの位置までの直線距離が50px 未満だったら』という条件になっている。この50px というのは実はフレーム6および7のスプライトテクスチャの伸ばしている舌の長さとほぼイコールなのだ。

この3つ目のif条件を満たした場合に、本来はプレイヤーキャラクターをゲームオーバーにするかライフを減らしたいところだが、そこはまだ未実装なので、今の時点ではprintメソッドで「Player is hit」とだけ出力されるようにした。



Level1 シーンに Chameleon シーンのインスタンスノードを追加する

では、カメレオンが想定通りの動きをするか見ていこう。「Level1」ノードに「Chameleon.tscn」のインスタンスノードを追加する。「Mushroom」と「Bunny」は確認の邪魔になるので一時的に非表示にしておこう。

Level1ノードにChameleonインスタンスノードを追加

今回もひとまず2Dワークスペースで、すぐに動作を確認できそうな位置に「Chameleon」ノードを配置しよう。
2DワークスペースでChameleonを配置

配置できたらプロジェクトを実行して挙動を確認してみよう。
カメレオンの動きをデバッグ

以下の動きが確認できた。

  • プレイヤーキャラクターとの距離が 120 px 以下になるまで「idle」アニメーションで移動はしない
  • プレイヤーキャラクターとの距離が 120 - 80 px では「run」アニメーションで近づいてくる
  • プレイヤーキャラクターとの距離が 80 px 以下で舌を伸ばしてくる(移動はしない)

なお、舌がプレイヤーキャラクターに当たった時、出力パネルには以下のように表示された。本来ならゲームオーバーかもしれないが、動きとしては想定通りだ。
出力パネル

これでカメレオンはひとまず完成としておこう。なかなかタフなチュートリアルになってきた。まだ敵キャラクターを作るのかと、信じられない気持ちかもしれない。しかし頑張るのだ。



Plant シーンを作る

次はプラントという敵キャラクターを作ろう。プラントはその名の通り植物のキャラクターだ。だから、移動はしない。その代わりに、遠隔攻撃を仕掛けてくる厄介なキャラクターにしよう。

ほとんどの手順はこれまでに作ってきた敵キャラクターと同じなので、スクリーンショットやすでに説明済みの内容は省かせていただく。

では以下の手順で「Plant」シーンを作成しよう。

  1. 「Enemy.tscn」を継承してシーンを作成
  2. ルートノードの名前を「Plant」に変更
  3. 「res://Enemies/Plant/Plant.tscn」のパスでシーンを保存

各ノードのプロパティを編集する

スクリプトを作成してルートノードにアタッチする

  1. 「Plant」ルートノードにスクリプトをアタッチする
  2. この時、継承元として「Enemy.gd」を選択する
  3. パスは「res://Enemies/Plant/Plant.gd」とする

ルートノードの Script Variables を編集する

以下の値で設定する。

  • Gravity: 512
  • Speed: 0

AnimatedSprite ノードのアニメーションを編集する

まずは「Frames」プロパティの「SpriteFrames」をユニーク化しておこう。そのあとは以下の内容でアニメーションを作成しよう。

  • attack
    • 速度: 8 FPS
    • スプライトシート: res://Assets/Enemies/Plant/Attack (44x42).png
    • ループ: オン
  • hit
    • 速度: 24 FPS
    • スプライトシート: res://Assets/Enemies/Plant/Hit (44x42).png
    • ループ: オフ
  • idle
    • 速度: 12 FPS
    • スプライトシート: res://Assets/Enemies/Plant/Idle (44x42).png
    • ループ: オン

プラントのスプライトテクスチャのデザインも若干右に本体が寄っているので、インスペクターで「Position」プロパティを(-4, 0)に変更しておこう。


CollisionShape2D ノードのコリジョン形状を設定する

まずは「Plant」ルートノード直下の「CollisionShape2D」ノードから編集する。

  • Radius: 10
  • Height: 12
  • Position: (0, 5)

ルートノード直下CollisionShape2Dのコリジョン形状調整

続いて「HitBox」直下の「CollisionShape2D」ノードを編集する。

  • Extents: (6, 2)
  • Position: (0, -13)

HitBox直下CollisionShape2Dのコリジョン形状調整


VisibilityEnabler2D ノードの形状を設定する

「VisibilityEnabler2D」の形状もスプライトテクスチャに合わせておこう。

  • Position: (-4, 0)
  • Scale: (1, 1)

VisibilityEnabler2Dの形状調整


Plant ノードに RayCast2D ノードを追加する

カメレオンの時と同様に「Plant」ルートノードに「RayCast2D」ノードを追加する。プロパティは以下の通りにする。カメレオンよりさらに矢印の射程を長くして「Cast To」プロパティの x の値を 300 px とした。矢印に衝突したら遠隔攻撃してくる仕様にする予定だ。

  • Enabled: オン
  • Cast To: (300, 0)

RayCast2Dの形状調整


Plant.gd スクリプトを編集する

「Plant.gd」スクリプトを編集してプラントの動きを作っていこう。スクリプトは以下のようになった。今回は説明を掻い摘んで、スクリプトに直接コメントで入れている。

extends "res://Enemies/Enemy.gd"

# RayCast2D ノードの参照を raycast プロパティに渡す
onready var raycast = $RayCast2D

# _ready メソッド
func _ready():
  # 最初は idle アニメーションを再生する
	sprite.play("idle")
	
#_physics _process メソッド
func _physics_process(delta):
  # もし RayCast2D の矢印にオブジェクトが衝突して、それが「Player」だったら attack メソッドを呼び出す
	if raycast.is_colliding() and raycast.get_collider().name == "Player":
		attack()
  # もし RayCast2D の矢印のオブジェクトに何も衝突しなければ idle アニメショーンを再生するのみ
	else:
		sprite.play("idle")

# attack メソッド
func attack():
  # attack アニメーションを再生
	sprite.play("attack")
  # スプライトのフレームが 4 だったら
	if sprite.frame == 4:
    # spawn_bullet メソッドを呼び出す
		spawn_bullet()

# spawn_bullet メソッド
func spawn_bullet():
  # 一旦 print メソッドで書き出しのみ
	print("Spawn Bullet!!!")

ということで、カメレオンの時よりシンプルなコードになった。attackメソッドにより「attack」アニメーションを再生し、そのフレームが4の時にspawn_bulletメソッドが呼ばれる。このspawn_bulletは後ほど更新するが、口から種を前方に飛ばすアクションだ。これには別途、種のシーンを作成し、そのインスタンスを都度「Plant」ルートノードに追加する必要があるが、その作業は一旦置いておいて、ここまでのコードに問題がないか確認しよう。


Level1 シーンに Plant シーンのインスタンスノードを追加する

コード確認のため、ひとまず「Level1」シーンのわかりやすい位置に「Plant」シーンのインスタンスを作って追加しよう。

Level1にPlantのインスタンスを追加

ではプロジェクトを実行してプラントの動きを確認しよう。今回は「デバッグ」メニュー>「コリジョン形状を表示」をオンにして、「RayCast2D」の矢印が見えるようにしておこう。
プロジェクトを実行して確認

プレイヤーキャラクターが「RayCast2D」の矢印に当たっている時は、プラントの「attack」アニメーションが再生され、出力パネルには「Spawn Bullet!!!」が表示された。高台に乗って「RayCast2D」の矢印から逃れるとプラントの「idle」アニメーションが再生された。概ね問題なさそうだ。
出力パネル


Seed シーンを作成する

シーンを作成してノードを追加する

プラントが口から飛ばす種のシーンとして「Seed」シーンを新規で作成しよう。

  1. 「シーン」メニュー>「新規シーン」を選択する
  2. 「ルートノードを生成」で「その他のノード」を選択する
  3. 「Area2D」クラスのノードをルートノードにする
  4. 「Area2D」ノードの名前を「Seed」に変更する
  5. 「Seed」ルートノードに「Sprite」ノードを追加する
  6. 「Seed」ルートノードに「CollisionShape2D」ノードを追加する
  7. 「Seed」ルートノードに「VisibilityNotifier2D」ノードを追加する
  8. 「res://Enemies/Plant/Seed.tscn」をパスとしてシーンを保存する

必要なノードは揃ったので、それぞれのプロパティを編集していこう。


ノードのプロパティを編集する

以下の手順で「Seed」シーンのノードのプロパティを編集しよう。

  1. 「Sprite」ノードの「Texture」プロパティにアセットの「res://Assets/Enemies/Plant/Bullet.png」を適用する
  2. 「CollisionShape2D」ノードの「Shape」プロパティに「新規 CircleShape2D」を適用する
  3. 「CollisionShape2D」ノードのコリジョン形状を調整する(「Radius」プロパティの値を 4 にする)
    CollisionShape2Dのコリジョン形状を調整
  4. 「VisibilityNotifier2D」ノードの形状を調整する(「Scale」プロパティの値を(0.4, 0.4)にする)
    VisibilityNotifier2Dの形状を調整

これでインスペクターでのプロパティの編集は終わりだ。続けてスクリプトをアタッチして種の動きをコーディングしていこう。


Seed.gd スクリプトをアタッチして編集する

「Seed」ルートノードに新規でスクリプトをアタッチする。パスは「res://Enemies/Plant/Seed.gd」としておこう。

スクリプトの内容はひとまず以下のようにした。単純だがこれで種の移動はOKだ。

extends Area2D # Added @ Part 5

# 種の移動速度(px/second)
export var speed = 150

# 毎フレーム、種のx軸方向の位置が左に (spped * delta) 分だけ進む
func _physics_process(delta):
	position.x -= speed * delta

続いて、以下のシグナルをスクリプトに接続する。

  • 「Seed」ルートノードの「body_entered(body)」シグナルを接続する
  • 「VisibilityNotifier2D」ノードの「viewport_exited(viewport)」シグナルを接続する

すると、スクリプトにそれぞれのシグナルによって呼ばれるメソッドが追加される。その中身を以下のようにコーディングする。

# 種が何らかの body と衝突したらシグナル発信により呼ばれるメソッド
func _on_Seed_body_entered(body):
	# プレイヤーキャラクターに衝突したら出力
	if body.name == "Player":
		print("Player is hit!!!")
	# 種自体消える
	queue_free()

# 画面から消えたらシグナル発信により呼ばれるメソッド
func _on_VisibilityNotifier2D_viewport_exited(viewport):
	print("viewport_exited method called")
	# 種自体消える
	queue_free()

今はまだプレイヤーキャラクターのダメージやゲームオーバーの部分が未実装なので、ひとまず種がプレイヤーキャラクターに当たったらprintメソッドで「Player is hit!!!」とだけ出力されるようにしておく。その時、種も同時にqueue_freeメソッドが呼ばれて消える。

また、種が画面外に出た場合も「VisibilityNotifier2D」ノードのシグナルをきっかけにqueue_freeメソッドが呼ばれて消える。


Plant シーンに Seed シーンのインスタンスノードを追加する

種はプラントの「attack」アニメーション中に画面上に初めて現れるので、最初からシーンドックで追加しておくわけにはいかない。スクリプトで「Seed.tscn」ファイルを読み込んでおき、必要なタイミングでそれをインスタンス化して「Plant.tscn」シーンに追加する、というスクリプトを記述する必要がある。

では「Plant.gd」スクリプトを改めて編集していく。

まずはプロパティを2つ追加する。

var is_spawning = false # 追加
onready var spawned_seed = preload("res://Enemies/Plant/Seed.tscn") # 追加

attackアニメーションは 8 FPS(毎秒8フレームの速さ)に設定したが、一方、_physics_processメソッドの物理プロセスのフレームはデフォルトの設定が 60 FPS となっている。ということは、attackアニメーションの 1 フレームが描画されている間に_physics_processのフレームが複数回読み込まれる。そのため、「attack」アニメーションの 4 フレーム目で種を飛ばす、というコードにしているだけでは、4フレーム目が描画されている間に複数の種が飛んでしまうのだ。この問題を回避して、アニメーション一回につき種を一つだけ飛ばすために、このis_spawningというステータスプロパティを使用する。

もう一つの追加したプロパティspawned_seedは、「Seed.tscn」シーンファイルを事前に読み込んでおくためのものだ。preloadが事前にリソースファイルを読み込んでおくためのメソッドになっている。ちなみにプロパティ名をseedとしたいところだが、GDScript の予約語と被ってしまうので使用できない。

次にattackメソッドを少し更新した。

func attack():
	sprite.play("attack")
	if sprite.frame == 4 and is_spawning == false:
		spawn_bullet()
		is_spawning = true
	elif sprite.frame == 5 and is_spawning:
		is_spawning = false

「attack」アニメーションの 4 フレーム目で、かつis_spawningプロパティが false の場合にだけspawn_bulletメソッドが呼ばれて種を飛ばす、というコードになっている。種を飛ばしたあとすぐにis_spawningtrueに変更する。そのあとアニメーションの 5 フレーム目になって、is_spawningtrueの場合は(絶対そうなるのだが)、次のアニメーションに備えてis_spawningをもとのfalseに戻す。これで、アニメーション一回につき種を一つだけ飛ばす動きになる。

最後にspawn_bulletメソッドを更新して、種を飛ばすコードを実装した。

func spawn_bullet():
	print("Spawn Bullet!!!")
	var seed_instance = spawned_seed.instance()
	add_child(seed_instance)
	seed_instance.global_position = Vector2(position.x - 24, position.y)

事前にspawned_seedプロパティで、「Seed.tscn」を読み込んでいるので、そのインスタンスを生成するのにinstance()メソッドを実行している。これをseed_instanceというプロパティに代入した。

add_childメソッドの引数にそのインスタンスを渡すことで、「Plant」ノードの子ノードとして「Seed.tscn」のインスタンスノードが追加される。

子ノードに追加されたらすぐに、画面上の位置を指定している。プラントの種を飛ばすときのテクスチャデザインに合わせて、プラントのposition.xより左に24px ずらした位置に配置した。その位置に種が現れるとすぐにそこから真っ直ぐ左方向へ飛んでいく。

それではプロジェクトを実行して、最終的なプラントの動きを確認しておこう。

プラントの最終動作確認

  • アニメーション一回につき一つだけ種を飛ばした
  • 種はちょうど良い速度で飛んでいる
  • プラントの口から種が飛んでいるように見えるので種の最初の位置も問題ない
  • viewport(画面)から消えたら「Seed.tscn」のインスタンスノードは消えている

出力パネル

これでプラントは完成としよう。



Level1 シーンに敵キャラクターを複数配置する

ここまでで Part 4 で作ったマッシュルームを含めて四種類の敵キャラクターを用意した。これらを「Level1」シーンに適当に配置して、実際にプレイしてみよう。

すでにそれぞれの敵キャラクターの動作確認で「Level1」シーンに 1 つずつインスタンスノードを追加している。そのため、さらに敵キャラクターのインスタンスノードを追加するときは、シーンドックで追加したい同じ種類のインスタンスノードを選択してショートカットキー操作で複製(Windows: Ctrl + D / macOS: Cmd + D)すると簡単だ。ノードの名前も、例えば「Mushroom」ノードを複製したら「Mushroom2」という具合に、自動的に末尾に番号を追加してくれるので非常に便利だ。

今回はそれぞれの敵キャラクターのインスタンスノードを2つずつ「Level1」シーンに追加して配置した。
Level1シーンツリー

サンプルとして、以下のような配置にしている。これはもちろん、何度かプレイしてみて多少バランスを調整した後の配置だ。あなたもご自身で作ったタイルマップに合わせて、敵キャラクターの数や配置を調整してほしい。
Level1シーン敵キャラクター配置サンプル

納得のいく敵キャラクターの配置になるまで、プロジェクトを実行しながら調整してみよう。
敵キャラクター配置後のデバッグ

上のGIF画像では、プラントの種やカメレオンの舌を何度かくらっているが、敵キャラクターの配置はひとまずこれで完了とする。



Part 5 で編集したスクリプトのコード

最後に今回の Part 5 で編集したスクリプトのコードを共有しておくので、必要に応じて確認してほしい。

Bunny.gd の全コード
extends "res://Enemies/Enemy.gd" # Added @ Part 5


export var jump_force = 200
var is_jumping = false


func _ready():
	sprite.play("run") # Set default animation
	

func _physics_process(delta):
	if is_on_wall():
		speed *= -1
		sprite.flip_h = !sprite.flip_h
	
	if is_jumping:
		if is_on_floor():
			velocity.y = -jump_force
		else:
			if velocity.y < 0:
				sprite.play("jump")
			else:
				sprite.play("fall")
	
	velocity.x = -speed
	velocity.y += gravity * delta
	velocity = move_and_slide(velocity, Vector2.UP)


func _on_Area2D_body_entered(body):
	if body.is_in_group("Players"):
		is_jumping = true


func _on_Area2D_body_exited(body):
	if body.is_in_group("Players"):
		is_jumping = false
		sprite.play("run")
Chameleon.gd の全コード
extends "res://Enemies/Enemy.gd" # Added @ Part 5


onready var raycast = $RayCast2D


func _ready():
	sprite.play("idle")
	

func _physics_process(delta):
	if raycast.is_colliding():
		if raycast.get_collider().name == "Player":
			if position.distance_to(raycast.get_collision_point()) > 80: 
				run(delta)
			else:
				attack()
	else:
		sprite.play("idle")


func run(delta):
	sprite.play("run")
	if is_on_wall():
		speed *= -1
		sprite.flip_h = !sprite.flip_h
		raycast.cast_to.x *= -1
	velocity.x = -speed
	velocity.y += gravity * delta
	velocity = move_and_slide(velocity, Vector2.UP)


func attack():
	sprite.play("attack")
	if sprite.frame == 6 or sprite.frame == 7:
		if raycast.is_colliding() and raycast.get_collider().name == "Player":
			if position.distance_to(raycast.get_collision_point()) < 50:
				print("Player is hit")
Plant.gd の全コード
extends "res://Enemies/Enemy.gd" # Added @ Part 5

var is_spawning = false
onready var spawned_seed = preload("res://Enemies/Plant/Seed.tscn")
onready var raycast = $RayCast2D


func _ready():
	sprite.play("idle")
	

func _physics_process(delta):
	if raycast.is_colliding() and raycast.get_collider().name == "Player":
		attack()
	else:
		sprite.play("idle")


func attack():
	sprite.play("attack")
	if sprite.frame == 4 and is_spawning == false:
		spawn_bullet()
		is_spawning = true
	elif sprite.frame == 5 and is_spawning:
		is_spawning = false


func spawn_bullet():
	print("Spawn Bullet!!!")
	var seed_instance = spawned_seed.instance()
	add_child(seed_instance)
	seed_instance.global_position = Vector2(position.x - 24, position.y)
Seed.gd の全コード
extends Area2D # Added @ Part 5


export var speed = 150


func _physics_process(delta):
	position.x -= speed * delta


func _on_Seed_body_entered(body):
	if body.name == "Player":
		print("Player is hit!!!")
	queue_free()


func _on_VisibilityNotifier2D_viewport_exited(viewport):
	print("viewport_exited method called")
	queue_free()


おわりに

以上で Part 5 は完了だ。今回は敵キャラクターを 3 種類追加した。なかなかハードだったと思うが、その分、作業手順には慣れていただけたのではないだろうか。やはり敵キャラクターの種類が増えて、それぞれ動きが違ってくると、ゲームの奥深さは断然違ってくる。今回のチュートリアルで要領を得ていただけたなら幸いだ。

ところで、今のままだと、ただただ敵キャラクターが待ち受けている地獄のような世界になっているため、プレイヤーキャラクターにとって嬉しい要素があまりない。

そこで次回のチュートリアルでは、レベルシーンにアイテム(スーパーマリオシリーズのコインのような)を追加して、ちょっとは嬉しい気持ちになれるようにゲームをアップデートする予定なので、お楽しみに。



UPDATE:
2022-02-25 「Chameleon.gd」スクリプトの_physics_processメソッド、runメソッドのコードを更新して、カメレオンが空中でも落下しない問題を修正