第14回目の今回は、プレイヤーキャラクターのアクションをアップデートしていく。具体的には以下にリストアップしたジャンプとダッシュの動きや演出を追加していく。

  • 落下時のアニメーション
  • 壁ジャンプ
  • ダブルジャンプ(2段ジャンプ)
  • 走っている時の砂埃
  • ダッシュ時のゴーストエフェクト(残像効果)

おまけのような内容だが、作って実際にプレイすると非常に楽しいところなので、是非やってみてほしい。

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



落下時のアニメーション

実はチュートリアル Part 1 で実装しておけばいいのに放置したままなのが、この落下時のプレイヤーキャラクターのアニメーションだ。例えば、プレイヤーキャラクターがジャンプした時、現時点では地面から離れてまた地面に着地するまでずっと同じアニメーションだ。しかし今回、プレイヤーキャラクターがジャンプして一番高い位置まで達したあと、次に地面に着地するまでは別のアニメーションを再生するようにアップデートする。

実はアニメーション自体はチュートリアル Part 1 で、「Player」シーンの「AnimatedSprite」ノードに「fall」というアニメーションを作成済みだ。

Memo:
Godot で作るプラットフォーマー Part 1:プレイヤーキャラクターを作ろう!


この作成済みのアニメーションを特定のタイミングで再生するようにスクリプトで制御する。

では「Player.gd」スクリプトを開いて編集しよう。編集するのはスクリプトの中の_physics_processメソッドだ。このメソッドの最後の方に「# 追加」とコメントしているところが更新箇所だ。

func _physics_process(delta):
	velocity.y += gravity * delta
	var x_input = Input.get_action_strength("move_right") - Input.get_action_strength("move_left")
	if x_input != 0:
		velocity.x += x_input * acceleration
		if Input.is_action_pressed("dash"):
			velocity.x = clamp(velocity.x, -max_dash_speed, max_dash_speed)
		else:
			velocity.x = clamp(velocity.x, -max_speed, max_speed)
		sprite.flip_h = x_input < 0

	# 地面にいる場合
	if is_on_floor():
		if x_input == 0:
			sprite.play("idle")
			velocity.x = lerp(velocity.x, 0, friction)
		else:
			sprite.play("run")
		
		if Input.is_action_just_pressed("jump"):
			sprite.play("jump")
			velocity.y = -jump_force
			jump_sfx.play()
	# 空中にいる場合
	else:
		if x_input == 0:
			velocity.x = lerp(velocity.x, 0, air_resistance)
		
		if Input.is_action_just_released("jump") and velocity.y < -jump_force / 2:
			velocity.y = -jump_force / 2
		# y軸方向の速度が 0 より大きければ(下向きに落下)「fall」アニメーションを再生
		if velocity.y > 0: # 追加
			sprite.play("fall")
	
	velocity = move_and_slide(velocity, Vector2.UP)

	if position.x < 16:
		position.x = 16

コードの編集はこれだけだ。「Level1.tscn」シーンファイルを開いてシーンを実行して確認してみよう。一番高い位置までジャンプしたあとの落下時は「fall」アニメーションが再生されているのがわかるだろう。

落下時のアニメーション確認でシーンを実行



壁ジャンプを実装する

壁ジャンプというのは、プレイヤーキャラクターが壁に接触している状態からジャンプすることだ。これを利用して、マップによっては左右の壁を蹴りながら上方向に進むことができたり、画面下に落下しそうになった時に壁ジャンプで難を逃れたりすることができる。

壁ジャンプのアニメーションも、チュートリアル Part 1 ですでに作成済みだ。「Player」シーンの「AnimatedSprite」ノードの「wall_jump」アニメーションがそれだ。特定のタイミングでこのアニメーションを再生するように、のちほどスクリプトを編集する。


ところで、壁ジャンプを実装するにはプレイヤーキャラクターが壁に接触しているかどうかを判定する必要がある。これには「KinematicBody2D」クラスでもともと用意されているis_on_wallメソッドを使えば良い、と考えてしまいそうだが、実は少し扱いにくい。

このメソッドは、move_and_slideメソッドが最後に呼ばれた時(_physics_processメソッドで1フレーム前に呼ばれる)に壁と衝突していたらtrueを返すようだ。実際に検証してみたところ、常に壁に向かってプレイヤーキャラクターが移動しようとしている状態でなければ、検知し続けてくれないようだ。例えば、右側の壁にジャンプしてis_on_wallメソッドでtrueを返させるにはには、空中で右矢印キーを押し続けなければならないが、壁ジャンプする時は壁と反対側にジャンプしたいものだ。右を押し続けている状態からジャンプすると、その直後はまだ右を押しているので、反対の左へ飛びにくい。この操作性の悪さから、このチュートリアルではis_on_wallメソッドを利用する方法は不採用だ。

代わりの方法としてよく使われているのが、「Area2D」または「RayCast2D」クラスで壁との衝突を検出する方法だ。このチュートリアルでは、ちょうど「Player」ルートノードに「HitBox」という「Area2D」クラスのノードを追加している。このコリジョン形状は元々敵キャラクターとの衝突を検出するために利用しているが、壁との衝突にも流用してしまおうというわけだ。

では「Player.gd」スクリプトを更新していく。

まずは壁ジャンプが可能かどうかのステートを格納するプロパティcan_wall_jumpを定義する。

# 壁ジャンプ可能かどうかを示すプロパティ(壁ジャンプ可能な場合は true)
var can_wall_jump = false # 追加

次に、壁が「Area2D」のコリジョン形状に入った時と出た時に発信されるシグナルを利用して、プレイヤーキャラクターが壁に接触しているかどうかを判定させたい。壁がコリジョン形状に入った時のシグナル「body_entered」はすでに接続済みだ。出た時のシグナル「body_exited」を新たにスクリプトに接続しよう。

これで_on_HitBox_body_entered(body)メソッドと、_on_HitBox_body_exited(body)メソッドの2つがスクリプト上で定義されている状態になったはずだ。

なお、タイルマップに使っているタイルセットの内、ブロック系のタイルはコリジョン形状を設定済み(チュートリアル Part 2 )なので、物理ボディとして検知可能である。

ではそれぞれのメソッドを以下のように編集してほしい。

# 物理ボディがコリジョン形状に入った時に呼ばれるメソッド
func _on_HitBox_body_entered(body):
	if body.is_in_group("Enemies"):
		print("Enemy hit player. Damage is ", body.damage)
		emit_signal("enemy_hit", body.damage)
		sprite.play("hit")
		damage_sfx.play()
	# 衝突した物理ボディがプレイヤーキャラクター自身ではなかったら
	elif not body.name == "Player":: # 追加
		# 地面に衝突していなければ(空中だったら)
		if not is_on_floor():
			# 壁ジャンプが可能な状態なのでステートを true にする
			can_wall_jump = true

# 物理ボディがコリジョン形状から出て行った時に呼ばれるメソッド
func _on_HitBox_body_exited(body): # 追加
	# 壁ジャンプが不可の状態なのでステートを false にする
	can_wall_jump = false

elif not body.name == "Player":の部分がわかりにくいかもしれないので少し詳しく解説しておきたいと思う。

「Player」シーンにおいて、「Player」ルートノードは「KinematicBody2D」クラス、つまり物理ボディだ。この「Player」のコリジョン形状は、壁の検知に再利用している「HitBox」のコリジョン形状と重なっている。そのため「HitBox」には常にこの「Player」が物理ボディとして検知されている状態になる。壁のみを検知したいので、elifの条件を『検知されたボディが「Player」以外だったら』という内容にしている。

ちなみに、もしこのelifの条件文がただのelse文だと、空中だったらいつでも壁ジャンプができてしまう状態になるので、スーパーマリオブラザーズ3のしっぽマリオのように、ジャンプボタンを連打しておけば壁ジャンプで永遠に飛行できてしまうのだ。それはそれで面白いので、その状態を GIF 画像でお見せしておこう。

壁ジャンプで永久飛行


では_physics_processメソッドを更新して、適切なタイミングでのみ壁ジャンプができるようにしていこう。「# 追加」のコメント箇所をみていただきたい。if is_on_floor() / elseelseブロックの中にif can_wall_jumpブロックを追加した。

func _physics_process(delta):
	velocity.y += gravity * delta
	var x_input = Input.get_action_strength("move_right") - Input.get_action_strength("move_left")
	if x_input != 0:
		velocity.x += x_input * acceleration
		if Input.is_action_pressed("dash"):
			velocity.x = clamp(velocity.x, -max_dash_speed, max_dash_speed)
		else:
			velocity.x = clamp(velocity.x, -max_speed, max_speed)
		sprite.flip_h = x_input < 0

	
	if is_on_floor():
		if x_input == 0:
			sprite.play("idle")
			velocity.x = lerp(velocity.x, 0, friction)
		else:
			sprite.play("run")
		
		if Input.is_action_just_pressed("jump"):
			sprite.play("jump")
			velocity.y = -jump_force
			jump_sfx.play()
	
	else:
		if x_input == 0:
			velocity.x = lerp(velocity.x, 0, air_resistance)
		
		if Input.is_action_just_released("jump") and velocity.y < -jump_force / 2:
			velocity.y = -jump_force / 2
		
		if velocity.y > 0:
			sprite.play("fall")
		
		# 壁ジャンプ可能ステートであれば
		if can_wall_jump: # 追加
			# jump キー(上矢印)を押したら    
			if Input.is_action_just_pressed("jump"):
				# 速度のy軸方向の値にジャンプ力(上方向なのでマイナス)を適用する
				velocity.y = -jump_force
				# 壁ジャンプのアニメーションを再生する
				sprite.play("wall_jump")
				# ジャンプ時の SFX を再生する
				jump_sfx.play()
		
	velocity = move_and_slide(velocity, Vector2.UP)

	if position.x < 16:
		position.x = 16

壁ジャンプがうまく実装できたか「Level1」シーンを実行して見てみよう。テスト用に「Level1」シーンの「TileMap」ノードに適当な幅でブロックの壁を左右に作ると確認しやすい。

壁ジャンプの確認のためLevel1シーンを実行



ダブルジャンプを実装する

ダブルジャンプ(2段ジャンプ)は、ジャンプ中に空中でさらにもう一回ジャンプするという、2Dアクションゲームではよく使われる定番のアクションである。

ダブルジャンプのアニメーションも、チュートリアル Part 1 ですでに作成済みだ。特定のタイミングで「AnimatedSprite」ノードの該当のアニメーションを再生するように、のちほどスクリプトを編集する。

ダブルジャンプがいつでも何回でもできてしまわないように、プレイヤーキャラクターが空中にいる間に一回だけできるように制御する必要がある。

では「Player.gd」スクリプトを編集していこう。

まずは、壁ジャンプと同様にダブルジャンプのステート用にプロパティを1つ定義する。

# ダブルジャンプ可能かどうかを示すプロパティ(可能な場合は true)
var can_double_jump = false # 追加

続いて_physics_processメソッドをさらに更新する。ちょっとコードが長くなってきたが更新箇所は2箇所だけだ。「# 追加」のコメントを目印にして確認してほしい。

func _physics_process(delta):
	velocity.y += gravity * delta
	var x_input = Input.get_action_strength("move_right") - Input.get_action_strength("move_left")
	if x_input != 0:
		velocity.x += x_input * acceleration
		if Input.is_action_pressed("dash"):
			velocity.x = clamp(velocity.x, -max_dash_speed, max_dash_speed)
		else:
			velocity.x = clamp(velocity.x, -max_speed, max_speed)
		sprite.flip_h = x_input < 0

	
	if is_on_floor():
		if x_input == 0:
			sprite.play("idle")
			velocity.x = lerp(velocity.x, 0, friction)
		else:
			sprite.play("run")
		
		if Input.is_action_just_pressed("jump"):
			# ダブルジャンプ可能ステートに変更
			can_double_jump = true  # 追加
			sprite.play("jump")
			velocity.y = -jump_force
			jump_sfx.play()
	
	else:
		if x_input == 0:
			velocity.x = lerp(velocity.x, 0, air_resistance)
		
		if Input.is_action_just_released("jump") and velocity.y < -jump_force / 2:
			velocity.y = -jump_force / 2
		
		if velocity.y > 0:
			sprite.play("fall")
		
		if can_wall_jump:
			if Input.is_action_just_pressed("jump"):
				velocity.y = -jump_force
				sprite.play("wall_jump")
				jump_sfx.play()
		else: # 追加
			# もしダブルジャンプ可能ステートだったら
			if can_double_jump:
				# jump アクションのキーを押したら
				if Input.is_action_just_pressed("jump"):
					# ダブルジャンプ不可ステートに変更
					can_double_jump = false
					# AnimatedSprite ノードの double_jump アニメーションを再生
					sprite.play("double_jump")
					# 速度のy軸方向の値にジャンプ力を適用
					velocity.y = -jump_force
					# ジャンプの SFX を再生
					jump_sfx.play()

まずif is_on_floor() / else構文のifブロック内でネストされたif Input.is_action_just_pressed("jump")ブロックにて、can_double_jumpプロパティをtrueにしてダブルジャンプ可能ステートにしている。これで、地面に接している状態からジャンプするときにダブルジャンプ可能ステートにリセットされる仕組みだ。

if is_on_floor() / else構文のelseブロック内でネストされているif can_wall_jumpの箇所にelseブロックを追加した。そしてそこにif can_double_jumpの条件分岐をさらにネストした。これは空中ではダブルジャンプより壁ジャンプを優先するためだ。

if can_double_jumpのブロック内で、さらにネストしたif Input.is_action_just_pressed("jump")(jump アクションキーを押したら)の条件分岐を追加した。このif構文のブロック内にダブルジャンプ実行時に必要なコードを記述した。処理内容の詳細はコード内のコメントを参照いただきたい。

さてこれでダブルジャンプが実装できたはずだ。空中で1回だけダブルジャンプできるか「Level1」シーンで試してみよう。

ダブルジャンプの確認のためLevel1シーンを実行



走っている時の砂埃を実装する

次は砂埃だ。現実には走って砂埃が大袈裟に舞うことはあまりないが、わかりやすい演出なので、プラットフォーマーゲームではよく採用されている。

実装の流れとしては、まず、砂埃のシーンを「Particles2D」クラスのノードで作成する。続いて、スクリプトで、走っている時だけ砂埃のシーンをインスタンス化して画面上に表示させる。

ではまず砂埃のシーン作成からやっていこう。


砂埃のシーンを作る

「シーン」>「新規シーン」で表示される「ルートノード生成」で「その他のノード」を選択し、「Particles2D」クラスのノードをルートノードとして追加しよう。名前は「Dust」に変更する。これ以外のノードは不要だ。
ダブルジャンプの確認のためLevel1シーンを実行

名前を変更したら、そのままシーンを保存しておこう。ファイルパスを「res://Player/Dust.tscn」として保存しよう。


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

「Particles2D」クラスはプロパティが多いので少し大変だが、以下の通りに編集してみてほしい。編集するプロパティ以外はデフォルトのままにしておくこと。

まずは「Particles2D」クラスのプロパティから編集する。パーティクルの見た目や動きに関わる部分だ。

  • Emitting: オフ(他のプロパティの設定が終わるまでは確認しやすいようにオンしておいて良い)

  • Amount: 4
    Particles2Dノードのプロパティ1

  • Time:

    • Lifetime: 0.2
    • One Shot: オン(他のプロパティの設定が終わるまでは確認しやすいようにオフにしておいて良い)
    • Speed Scale: 0.2
      Particles2Dノードのプロパティ2
  • Textures:

    • Texture: ファイルシステムからリソース「res://Assets/Other/Dust Particle.png」を適用する
      *インスペクターでは少し下の方にあるプロパティだが 2D ワークスペースで見た目を確認するには先に設定すべき項目である
      Particles2Dノードのプロパティ3
  • Process Material:

    • Material: 新規 ParticlesMaterial を適用する
      Particles2Dノードのプロパティ4
      以下は「Material」プロパティに適用したリソースのプロパティ編集
      • Gravity:
        • Gravity: (0, -32, 0)
          Particles2Dノードのプロパティ5
      • Initial velocity:
        • Velocity: 160
          Particles2Dノードのプロパティ5
      • Scale:
        • Scale: 2
        • Scale Random: 1
          Particles2Dノードのプロパティ6
      • Color:
        • Color: #a0ffffff
        • Color Ramp: 新規 GradientTexture を適用する
          Particles2Dノードのプロパティ7
          以下は「Color Ramp」プロパティに適用したリソースのプロパティ編集
          • Gradient: 新規 Gradient
            Particles2Dノードのプロパティ7
            以下は「Gradient」プロパティに適用したリソースのプロパティ編集
            • Offset: PoolFloatArray(size 3)
              • サイズ: 3
              • 0: 0.003
              • 1: 0.711
              • 2: 1
                Particles2Dノードのプロパティ7
            • Colors: PoolColorArray(size 3)
              • サイズ: 3
              • 0: #00ffffff
              • 1: #50ffffff
              • 2: #4affffff
                Particles2Dノードのプロパティ7

最後に「Node2D」クラスのプロパティを編集する。これはプレイヤーキャラクターより常に背面に表示させるための設定だ。

  • Z Index:
    Z Index: -1
    Particles2Dノードのプロパティ7

2Dワークスペースでは以下のようなアニメーションになっているはずだ。
パーティクルの確認

確認し終わったら「Emitting」プロパティをオフに、「Time」>「One Shot」プロパティをオンすることを忘れないようにしよう。


では今作った「Dust.tscn」シーンのインスタンスを「Player」シーンに追加する。ただし、プレイヤーキャラクターが走っている時だけ都度インスタンスを追加したいので、「Player.gd」スクリプト内にそれをコーディングしよう。

「Player.gd」スクリプトを開いたら、まずはpreloadした「Dust.tscn」シーンファイルを参照するプロパティを定義しよう。

# Dust.tscn シーンのプリロード
onready var dust_tscn = preload("res://Player/Dust.tscn") # 追加

次に以下のspawn_dustメソッドを新たに定義する。

# 砂埃を生み出すメソッド
func spawn_dust():
	# プリロードした Dust.tscn シーンをインスタンス化
	var dust = dust_tscn.instance()
	# Player ノードではなくその親(Level_ノード)の子として Dust.tscn のインスタンスノードを追加
	get_parent().add_child(dust)
	# Dust の位置を Player の位置の y 軸方向に 11 だけ下の位置に配置(足元に来るように)
	dust.global_position = Vector2(global_position.x, global_position.y + 11)
	# ダッシュ中の場合
	if Input.is_action_pressed("dash"):
		# 砂埃のマテリアルのサイズを最大4倍にする
		dust.process_material.scale = 4
	# ダッシュ中ではない場合
	else:
		# 砂埃のマテリアルのサイズを最大2倍にする
		dust.process_material.scale = 2
  # Dust の Emitting プロパティをオンにする
	dust.emitting = true
  # Dust の One Shot の Emitting が終わるまで待機
	yield(get_tree().create_timer(dust.amount * dust.lifetime), "timeout")
  # 追加した Dust のインスタンスを解放
	dust.queue_free()

このメソッドを少し解説しておく。まず「Dust」シーンをインスタンス化した後、それを「Player」ノードではなく「Player」の親ノード(つまり「Level_」ノード)の子として追加されている。つまり「Player」ノードと「Dust」ノードはシーンツリー上、同階層になる。

なぜ「Player」ノードに直接追加しないかというと、子ノードの位置は親ノードとの相対的な位置を維持し続けるため、もし「Player」ノードの子として「Dust」ノードを追加すると、プレイヤーキャラクターの位置が移動するのに連動して、生成された砂埃も一緒に移動してしまうからだ。砂埃には生成されたらその場に止まってもらう必要があるので、敢えて「Player」ではなく一階層上の「Level_」シーンのルートノードの子にしているというわけだ。


さて、ここからはまた_physics_processメソッドを編集する。

上記spawn_dustメソッドをプレイヤーキャラクターが走っている時に呼ぶようにするには、if is_on_floor()ブロック内でネストされたif x_input == 0 / else構文のelseブロック内のsprite.play("run")のコードの後にspawn_dustメソッドを追加しよう。これで、走っている間だけ砂埃が舞うようになる。

if x_input != 0ブロックのネストされたif Input.is_action_pressed("dash")ブロックのコードを、ダッシュ時は「AnimatedSprite」の「run」アニメーションが2倍速で再生されるように更新しておく。これで「プレイヤーキャラクターの動きが速くなったから砂埃も増えた」という演出になる。「# 追加」とコメントしている箇所を確認してほしい。

func _physics_process(delta):
	velocity.y += gravity * delta
	var x_input = Input.get_action_strength("move_right") - Input.get_action_strength("move_left")
	if x_input != 0:
		velocity.x += x_input * acceleration
		if Input.is_action_pressed("dash"):
			velocity.x = clamp(velocity.x, -max_dash_speed, max_dash_speed)
			# ダッシュ時はAnimatedSpriteのアニメーションのスピードを2倍にする
			sprite.speed_scale = 2 # 追加
		else:
			velocity.x = clamp(velocity.x, -max_speed, max_speed)
			# ダッシュしていない時はAnimatedSpriteのアニメーションのスピードを通常にする
			sprite.speed_scale = 1 # 追加
		sprite.flip_h = x_input < 0
	
	if is_on_floor():
		if x_input == 0:
			sprite.play("idle")
			velocity.x = lerp(velocity.x, 0, friction)
		else:
			sprite.play("run")
			# 砂埃を発生するメソッドを呼ぶ
			spawn_dust() # 追加
		
		if Input.is_action_just_pressed("jump"):
			# 中略
	
	else:
		# 中略
		
	velocity = move_and_slide(velocity, Vector2.UP)

	if position.x < 16:
		position.x = 16

では「Level1」シーンで確認してみよう。下のGIF画像は 12 FPS なので少しわかりづらいところがあるが、実際のダッシュ時のアニメーションはスムーズだ。
砂埃の確認



ダッシュ時にゴーストエフェクトを追加する

さて、最後はゴーストエフェクトだ。残像効果とも言う。プラットフォーマー、メトロイドバニア、キャッスルバニアなど横スクロールアクションのジャンルで多用される、視覚的にとてもかっこいい演出だ。

こちらも砂埃を実装した時と考え方は同様で、まずゴーストの雛形となるシーンを作成し、そのインスタンスをダッシュ時にキャラクターの位置に配置し、一定時間経過後に追加したインスタンスを解放するという手法だ。

ではシーンを作るところから始めよう。


ゴーストのシーンを作る

「シーン」>「新規シーン」で表示される「ルートノード生成」で「その他のノード」を選択し、「Player」シーンのノードに合わせて、「AnimatedSprite」クラスのノードをルートノードとして追加しよう。名前を「Ghost」に変更したら、ファイルパスを「res://Player/Ghost.tscn」としてシーンを保存しよう。

次に「Ghost」ルートノードに「Tween」クラスのノードを一つ追加する。このノードはシンプルなアニメーション(トランジション)をお手軽に実装するためによく使用される。

公式オンラインドキュメント
Tween

シーンツリーは以下のスクリーンショットのようになったはずだ。
Ghostシーンツリー


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

シーンツリードックを見ると「Ghost」ルートノードに⚠️マークが表示されている。これは「AnimatedSprite」クラスのノードには「Frames」プロパティに「SpriteFrames」リソースを適用する必要があるからだ。しかし、今回はシーンをインスタンス化する時に、スクリプトでリソースを適用するので、インスペクター上「Frames」プロパティは[空]のままで良い。
GhostノードFramesプロパティは[空]

少し詳しく解説しておこう。ゴーストエフェクトは、ゴーストが生成される時点でのプレイヤーキャラクターの見た目、位置、向きをそのままコピーして生成する必要がある。つまり「Ghost」シーンをインスタンス化するタイミングで、「Player」シーンの「AnimatedSprite」ノードの下記プロパティの値を「Ghost」ルートノードの同じプロパティに割り当てる必要があるのだ。

  • 「Frames」プロパティの値(「SpriteFrames」リソース)
  • 「Animation」プロパティの値(「run」アニメーション)
  • 「Frame」プロパティの値
  • 「Position」プロパティの値(正確には「global_position」プロパティの値)
  • 「Flip H」プロパティの値

これらの値はゴーストが生成されるその時々によって変わるため、前もってインスペクター上で設定することができないのだ。


一方、インスペクター上で編集すべきプロパティはあるので順番に見ていこう。

「Z Index」>「Z Index」プロパティの値を -2 にしておく。これはプレイヤーキャラクターより背面に表示させている砂埃のインスタンス(こちらの「Z Index」は -1)よりさらに背面に表示させたいからだ。
GhostのZ Indexプロパティ

そして生成されるゴーストの色味も設定しておきたい。「Visibility」>「Modulate」プロパティの値を変更しよう。あなたのお好みの色に設定していただいて構わない。このチュートリアルでは、#5073a0ff を選択した。不透明度を下げた寒色系の色だ。
GhostのZ Indexプロパティ


Ghost ルートノードにスクリプトをアタッチする

「Ghost」ルートノードにスクリプトをアタッチしよう。少しコーディングして以下の制御を実装する。

  1. ゴーストが生成されたら徐々に消えるアニメーションを演出する
  2. ゴーストが完全に消えたらゴーストのインスタンスを解放する

先にも少し説明したが「Tween」クラスは特定のノードにおける一つのプロパティの値を滑らかに変化させるアニメーションが得意だ。シンプルなアニメーションなら、わざわざ「AnimationPlayer」クラスを利用しなくても「Tween」で表現できるということだ。

では「Ghost」ルートノードにスクリプトを新規作成してアタッチしよう。スクリプトのパスは「res://Player/Ghost.gd」とする。

スクリプトエディタが開いたら、そのままコーディングしていこう。

extends AnimatedSprite

# Tween ノードの参照
onready var tween = $Tween


func _ready():
	# Ghost ノードの Modulate プロパティを #c85578b4 から #00ffffff へ 0.5秒かけて変化させる設定
	tween.interpolate_property(self, "modulate", Color("c85578b4"), Color("00ffffff"), 0.5)
	# tween を開始
	tween.start()

これで「Ghost」シーンのインスタンスが生成された瞬間からゴーストの色味が次第に透明になる。「Tween」ノードのinterpolate_propertyメソッドの引数の多さに引き気味になりそうなのでこれを少し解説しておこう。

  • 第1引数: アニメーションさせたい対象のノードを指定する。ここではselfつまりこのスクリプトがアタッチされている「Ghost」ルートノードを指定している。
  • 第2引数: 対象のプロパティを指定する。ここでは「Modulate」プロパティを指定した。
  • 第3引数: アニメーション開始時のプロパティの値を指定する。ここでは先ほどインスペクターで編集した色と同じ #c85578b4 を指定している。
  • 第4引数: アニメーション終了時のプロパティの値を指定する。今回、ゴーストには最終的に完全に消えて欲しいので、不透明度が 0 の #00ffffff を指定した。
  • 第5引数: アニメーションにかける時間を秒単位で指定する。ここでは 0.5 秒とした。
  • 第6引数(今回は省略): アニメーションのトランジションタイプを組み込みの enum のパラメータから指定する。デフォルトで 0 が指定されている。0 は TRANS_LINEAR で、常に一定の変化でアニメーションする。
  • 第7引数(今回は省略): アニメーションのイーズタイプを組み込みの enum のパラメータから指定する。デフォルトでは 2 が指定されている。2 は EASE_IN_OUT で、アニメーションの最初と最後の変化がゆっくりになる。
  • 第8引数(今回は省略): どれくらいアニメーションの再生を遅らせるかを秒単位で指定する。デフォルトで 0 が指定されている。

そして、生成された「Ghost」シーンのインスタンスをそのまま放置すると見た目は透明で見えなくても、実際には一つ一つのゴーストがメモリを消費したままの状態になる。これを避けるために、完全に透明になったらインスタンスを解放するようにしたい。ありがたいことに「Tween」には、アニメーション終了時に発信するシグナルが備わっているので、これを利用する。

では「Tween」ノードの「tween_completed(object: Object, key: NodePath)」を「Ghost.gd」スクリプトに接続しよう。接続したら自動生成された_on_Tween_tween_completedメソッドを以下のように編集して欲しい。

# Tween ノードの tween が完了したらシグナルで呼ばれるメソッド
func _on_Tween_tween_completed(object, key):
	# Ghost ノードを解放する
	queue_free()

これで、ゴーストが透明になったらそのインスタンス(とメモリ)が解放される。

以上で、「Ghost.gd」スクリプトの編集は完了だ。


Player シーンに Timer を追加する

次に、一定間隔でゴーストを生成するためのタイマーを用意する。「Player」シーンの「Player」ルートノードに「Timer」クラスのノードを追加しよう。名前は「GhostTimer」に変更しておく。
GhostTimerノードを追加


インスペクターで「GhostTimer」ノードのプロパティを以下のように編集する。

  • Wait Time: 0.1
  • One Shot: オン

GhostTimerのプロパティ


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

ではここから「Player.gd」スクリプトを編集していこう。ここで制御したいのは、プレイヤーキャラクターがダッシュしている間は、「GhostTimer」の残り時間が 0 秒になるたびに(0.1秒おきに)「Ghost」シーンのインスタンスを生成する、という内容だ。

まずはプロパティを2つ新たに定義する。

# GhostTimerノードの参照
onready var ghost_timer = $GhostTimer # 追加
# Ghost.tscnリソースファイルのプリロード
onready var ghost_tscn = preload("res://Player/Ghost.tscn") # 追加

続いて「Ghost」シーンのインスタンスを生成するメソッドspawn_ghostを新たに定義する。

func spawn_ghost():
	# プリロードしていた Ghost.tscn シーンをインスタンス化
	var ghost = ghost_tscn.instance()
	# Ghost インスタンスノードを親ノード(Level_ノード)の子として追加
	get_parent().add_child(ghost)
	# Ghost ノードの位置を Player ノードと同じにする
	ghost.global_position = global_position
	# Ghost ノードの Frames プロパティ(SpriteFramesリソース)を Player シーンの AnimatedSprite ノードと同じにする
	ghost.frames = sprite.frames
	# Ghost ノードの Animation プロパティを Player シーンの AnimatedSprite ノードと同じにする
	ghost.animation = sprite.animation
	# Ghost ノードの Frame プロパティを Player シーンの AnimatedSprite ノードと同じにする
	ghost.frame = sprite.frame
	# Ghost ノードの Flip H プロパティを Player シーンの AnimatedSprite ノードと同じにする
	ghost.flip_h = sprite.flip_h
	# GhostTimer ノードのタイマーを開始する
	ghost_timer.start()

「Ghost」シーンをインスタンス化した後、それを「Player」ノードではなく「Player」の親ノード(つまり「Level_」ノード)の子として追加しているのは、砂埃の時と同様だ。つまり、プレイヤーキャラクターの位置が移動しても、生成されたゴーストにはその場に止まってもらうようにするためだ。

その後の処理としては、「Ghost」ノードの位置や「AnimatedSprite」クラスのプロパティである「SpriteFrames」のリソース、再生するアニメーション、アニメーションのフレーム数、そして左右反転するかどうかの「Flip H」プロパティの値を、「Player」ノードの子「AnimatedSprite」ノードのそれらの値と同じにしている。これで、その瞬間のプレイヤーキャラクターの見た目、位置、向きをそっくりそのまま適用したゴーストを生成することができる。

そして最後に「GhostTimer」をスタートさせている。

次に、定義したspawn_ghostメソッドを呼び出すあたりを実装していこう。編集するのは今回も_physics_processメソッドだ。

func _physics_process(delta):
	velocity.y += gravity * delta
	var x_input = Input.get_action_strength("move_right") - Input.get_action_strength("move_left")
	if x_input != 0:
		velocity.x += x_input * acceleration
		if Input.is_action_pressed("dash"):
			velocity.x = clamp(velocity.x, -max_dash_speed, max_dash_speed)
			sprite.speed_scale = 2
			# ダッシュ時に GhostTimer の残り時間が 0 の時にゴーストを生成する
			if ghost_timer.time_left <= 0: # 追加
				spawn_ghost()
		else:
			velocity.x = clamp(velocity.x, -max_speed, max_speed)
			sprite.speed_scale = 1
		sprite.flip_h = x_input < 0
	
	# 以下省略

上記編集により、プレイヤーキャラクターがダッシュしている間だけ、「GhostTimer」ノードの残り時間が 0 になるたび(0.1秒おき)にゴーストが生成される。

以上でゴーストエフェクトに関するコーディングは完了だ。これも「Level1」シーンを実行して動作を確認しておこう。
ゴーストエフェクトの確認


最後にプロジェクトを実行して、今回実装した以下のプレイヤーキャラクターの追加要素をまとめて確認してみよう。

  • 落下時のアニメーション
  • 壁ジャンプ
  • ダブルジャンプ
  • 砂埃
  • ゴーストエフェクト

プロジェクトを実行して全ての追加要素をまとめて確認



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

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

Player.gd の全コード
extends KinematicBody2D # Created @ Part 1

signal enemy_hit(damage) # Added @ Part 8
signal item_hit(point) # Added @ Part 8

export var acceleration = 256
export var max_speed = 64
export var max_dash_speed = 200
export var friction = 0.1
export var gravity = 512
export var jump_force = 224
export var air_resistance = 0.02

var velocity = Vector2()
var can_wall_jump = false # Added @ Part 14
var can_double_jump = false # Added @ Part 14

onready var sprite = $AnimatedSprite
onready var anim_player = $AnimationPlayer # Added @ Part 7
onready var step_sfx = $StepSFX # Added @ Part 13
onready var jump_sfx = $JumpSFX # Added @ Part 13
onready var damage_sfx = $DamageSFX # Added @ Part 13
onready var die_sfx = $DieSFX # Added @ Part 13
onready var ghost_timer = $GhostTimer # Added @ Part 14
onready var ghost_tscn = preload("res://Player/Ghost.tscn") # Added @ Part 14
onready var dust_tscn = preload("res://Player/Dust.tscn") # Added @ Part 14


func _ready(): # Added @ Part 7
	sprite.position = Vector2(0, 0)
	sprite.scale = Vector2(1, 1)
	sprite.modulate = Color(1, 1, 1, 1)


func _physics_process(delta):
	velocity.y += gravity * delta
	var x_input = Input.get_action_strength("move_right") - Input.get_action_strength("move_left")
	if x_input != 0:
		velocity.x += x_input * acceleration
		if Input.is_action_pressed("dash"):
			velocity.x = clamp(velocity.x, -max_dash_speed, max_dash_speed)
			sprite.speed_scale = 2 # Added @ Part 14
			if ghost_timer.time_left <= 0: # Added @ Part 14
				spawn_ghost()
		else:
			velocity.x = clamp(velocity.x, -max_speed, max_speed)
			sprite.speed_scale = 1 # Added @ Part 14
		sprite.flip_h = x_input < 0
	
	if is_on_floor():
		if x_input == 0:
			sprite.play("idle")
			velocity.x = lerp(velocity.x, 0, friction)
		else:
			sprite.play("run")
			spawn_dust() # Added @ Part 14
		
		if Input.is_action_just_pressed("jump"):
			can_double_jump = true  # Added @ Part 14
			sprite.play("jump")
			velocity.y = -jump_force
			jump_sfx.play() # Added @ Part 13
	
	else:
		if x_input == 0:
			velocity.x = lerp(velocity.x, 0, air_resistance)
		
		if Input.is_action_just_released("jump") and velocity.y < -jump_force / 2:
			velocity.y = -jump_force / 2
		
		if velocity.y > 0: # Added @ Part 14
			sprite.play("fall")
		
		if can_wall_jump: # Added @ Part 14
			if Input.is_action_just_pressed("jump"):
				print("wall jumped.")
				velocity.y = -jump_force
				sprite.play("wall_jump")
				jump_sfx.play()
		else: # Added @ Part 14
			if can_double_jump:
				if Input.is_action_just_pressed("jump"):
					print("double jumped.")
					can_double_jump = false
					sprite.play("double_jump")
					velocity.y = -jump_force
					jump_sfx.play()
		
	velocity = move_and_slide(velocity, Vector2.UP)

	# Added @ Part 3
	if position.x < 16:
		position.x = 16


func _on_HitBox_body_entered(body): # Added @ Part 8
	if body.is_in_group("Enemies"):
		print("Enemy hit player. Damage is ", body.damage)
		emit_signal("enemy_hit", body.damage)
		sprite.play("hit")
		damage_sfx.play() # Added @ Part 13
	elif not body.name == "Player": # Added @ Part 14
		if not is_on_floor():
			can_wall_jump = true
			#print("can_wall_jump: ", can_wall_jump)


func _on_HitBox_body_exited(body): # Added @ Part 14
	can_wall_jump = false
	#print("can_wall_jump: ", can_wall_jump)


func _on_AnimatedSprite_frame_changed(): # Added @ Part 13
	if sprite.animation == "run":
		if sprite.frame == 4  or sprite.frame == 10:
			step_sfx.play()


func spawn_dust():
	var dust = dust_tscn.instance()
	get_parent().add_child(dust)
	dust.global_position = Vector2(global_position.x, global_position.y + 11)
	if Input.is_action_pressed("dash"):
		dust.process_material.scale = 4
	else:
		dust.process_material.scale = 2
	dust.emitting = true
	yield(get_tree().create_timer(dust.amount * dust.lifetime), "timeout")
	dust.queue_free()


func spawn_ghost(): # Added @ Part 14
	var ghost = ghost_tscn.instance()
	get_parent().add_child(ghost)
	ghost.global_position = global_position
	ghost.frames = sprite.frames
	ghost.animation = sprite.animation
	ghost.frame = sprite.frame
	ghost.flip_h = sprite.flip_h
	ghost_timer.start()

Ghost.gd の全コード
extends AnimatedSprite


onready var tween = $Tween


func _ready():
	tween.interpolate_property(self, "modulate", Color("c85578b4"), Color("00ffffff"), 0.5)
	tween.start()


func _on_Tween_tween_completed(object, key):
	queue_free()


おわりに

以上で Part 14 は完了だ。

今回はプレイヤーキャラクターの動きをアップデートする要素をいくつか追加で実装した。壁ジャンプ、ダブルジャンプ、砂埃、ゴーストエフェクト、はどれもプラットフォーマーゲームでは定番のアクションだ。実装してプレイしてみると、操作している時の爽快感が飛躍的に上がる印象を受けた方は多いのではないだろうか。デベロッパーがこぞって実装したくなる理由を体感いただけたのではないだろうか。

壁ジャンプとダブルジャンプは、それぞれのステート用の bool 型プロパティを用意し、該当のジャンプアクションが可能な時とそうでない時で true と false を切り替えて制御した。このようにステート用のプロパティを用いてプログラムを作ることはゲームのジャンルを問わずかなり多い。true と false の2択では賄えないステートを扱う場合は enum を利用したりもする。ちなみに、ステートによってキャラクターの動きを制御するようなプログラムをステートマシンまたはステートデザインパターンと呼ぶ。Google で検索すると資料がたくさん出てくるので、興味があれば是非確認してみよう。

公式オンラインドキュメント:
State design pattern

一方、砂埃やゴーストエフェクトは、オブジェクトのシーンを別で用意しておき、然るべきタイミングでそれらのインスタンスを連続的に追加し、一定時間後に解放する、という方法で実装した。砂埃の方は「Particles2D」のインスタンスは一つにして「One Shot」プロパティをオフにしても実装できそうだ。ゴーストエフェクトでは、インスタンスを敢えて「Player」ノードではなくその親ノードに追加した。プログラムの複雑さはできるだけ抑えたいものだが、目的に合わせて柔軟にプログラミングすることも重要である。

さて、次回は最終回になる予定だ。内容は、ゲームクリア画面やポーズ画面という案もあったが、スタート画面やゲームオーバー画面の応用でしかないので、却下だ。データ保存やゲームのエクスポートもプラットフォーマーじゃなくてもできることなので却下だ。せっかくプラットフォーマーのチュートリアルなので、最後はステージ上にいくつかのギミックを追加してみようと思う。例えば、動く床、火が出る装置、スパイク、バネ仕掛けの床などはインポート済みのアセットにテクスチャがあるので、ボリュームが大きくなりすぎない範囲で実装してみたいと思う。

それでは次回もお楽しみに。

References:
How to make a Wall jump properly ? - Godot Engine - Q&A
Godot 3 - Make Your Character Double Jump / UmaiPixel - YouTube
【Godot】残像エフェクトの作り方 / 2dgames.jp - Blog
【Godot】Timerを使った残像エフェクトの作り方【Ghost Trail】/ tatsuya_ゲーム制作 - note
Make a 2D Ghost effect in Godot / Mister Taft Creates - YouTube