第14回目の今回は、プレイヤーキャラクターのアクションをアップデートしていく。具体的には以下にリストアップしたジャンプとダッシュの動きや演出を追加していく。
- 落下時のアニメーション
- 壁ジャンプ
- ダブルジャンプ(2段ジャンプ)
- 走っている時の砂埃
- ダッシュ時のゴーストエフェクト(残像効果)
おまけのような内容だが、作って実際にプレイすると非常に楽しいところなので、是非やってみてほしい。
Memo:
過去のシリーズをまだご覧になっていない方は、そちらを先にご覧いただくことをおすすめします。
Godot で作るプラットフォーマー
落下時のアニメーション
実はチュートリアル Part 1 で実装しておけばいいのに放置したままなのが、この落下時のプレイヤーキャラクターのアニメーションだ。例えば、プレイヤーキャラクターがジャンプした時、現時点では地面から離れてまた地面に着地するまでずっと同じアニメーションだ。しかし今回、プレイヤーキャラクターがジャンプして一番高い位置まで達したあと、次に地面に着地するまでは別のアニメーションを再生するようにアップデートする。
実はアニメーション自体はチュートリアル Part 1 で、「Player」シーンの「AnimatedSprite」ノードに「fall」というアニメーションを作成済みだ。
この作成済みのアニメーションを特定のタイミングで再生するようにスクリプトで制御する。
では「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() / else
のelse
ブロックの中に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」ノードに適当な幅でブロックの壁を左右に作ると確認しやすい。
ダブルジャンプを実装する
ダブルジャンプ(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」シーンで試してみよう。
走っている時の砂埃を実装する
次は砂埃だ。現実には走って砂埃が大袈裟に舞うことはあまりないが、わかりやすい演出なので、プラットフォーマーゲームではよく採用されている。
実装の流れとしては、まず、砂埃のシーンを「Particles2D」クラスのノードで作成する。続いて、スクリプトで、走っている時だけ砂埃のシーンをインスタンス化して画面上に表示させる。
ではまず砂埃のシーン作成からやっていこう。
砂埃のシーンを作る
「シーン」>「新規シーン」で表示される「ルートノード生成」で「その他のノード」を選択し、「Particles2D」クラスのノードをルートノードとして追加しよう。名前は「Dust」に変更する。これ以外のノードは不要だ。
名前を変更したら、そのままシーンを保存しておこう。ファイルパスを「res://Player/Dust.tscn」として保存しよう。
Dust ノードのプロパティを編集する
「Particles2D」クラスはプロパティが多いので少し大変だが、以下の通りに編集してみてほしい。編集するプロパティ以外はデフォルトのままにしておくこと。
まずは「Particles2D」クラスのプロパティから編集する。パーティクルの見た目や動きに関わる部分だ。
Emitting: オフ(他のプロパティの設定が終わるまでは確認しやすいようにオンしておいて良い)
Amount: 4
Time:
- Lifetime: 0.2
- One Shot: オン(他のプロパティの設定が終わるまでは確認しやすいようにオフにしておいて良い)
- Speed Scale: 0.2
Textures:
- Texture: ファイルシステムからリソース「res://Assets/Other/Dust Particle.png」を適用する
*インスペクターでは少し下の方にあるプロパティだが 2D ワークスペースで見た目を確認するには先に設定すべき項目である
- Texture: ファイルシステムからリソース「res://Assets/Other/Dust Particle.png」を適用する
Process Material:
- Material: 新規 ParticlesMaterial を適用する
以下は「Material」プロパティに適用したリソースのプロパティ編集- Gravity:
- Gravity: (0, -32, 0)
- Gravity: (0, -32, 0)
- Initial velocity:
- Velocity: 160
- Velocity: 160
- Scale:
- Scale: 2
- Scale Random: 1
- Color:
- Color: #a0ffffff
- Color Ramp: 新規 GradientTexture を適用する
以下は「Color Ramp」プロパティに適用したリソースのプロパティ編集- Gradient: 新規 Gradient
以下は「Gradient」プロパティに適用したリソースのプロパティ編集- Offset: PoolFloatArray(size 3)
- サイズ: 3
- 0: 0.003
- 1: 0.711
- 2: 1
- Colors: PoolColorArray(size 3)
- サイズ: 3
- 0: #00ffffff
- 1: #50ffffff
- 2: #4affffff
- Offset: PoolFloatArray(size 3)
- Gradient: 新規 Gradient
- Gravity:
- Material: 新規 ParticlesMaterial を適用する
最後に「Node2D」クラスのプロパティを編集する。これはプレイヤーキャラクターより常に背面に表示させるための設定だ。
- Z Index:
Z Index: -1
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」ルートノードに⚠️マークが表示されている。これは「AnimatedSprite」クラスのノードには「Frames」プロパティに「SpriteFrames」リソースを適用する必要があるからだ。しかし、今回はシーンをインスタンス化する時に、スクリプトでリソースを適用するので、インスペクター上「Frames」プロパティは[空]のままで良い。
少し詳しく解説しておこう。ゴーストエフェクトは、ゴーストが生成される時点でのプレイヤーキャラクターの見た目、位置、向きをそのままコピーして生成する必要がある。つまり「Ghost」シーンをインスタンス化するタイミングで、「Player」シーンの「AnimatedSprite」ノードの下記プロパティの値を「Ghost」ルートノードの同じプロパティに割り当てる必要があるのだ。
- 「Frames」プロパティの値(「SpriteFrames」リソース)
- 「Animation」プロパティの値(「run」アニメーション)
- 「Frame」プロパティの値
- 「Position」プロパティの値(正確には「global_position」プロパティの値)
- 「Flip H」プロパティの値
これらの値はゴーストが生成されるその時々によって変わるため、前もってインスペクター上で設定することができないのだ。
一方、インスペクター上で編集すべきプロパティはあるので順番に見ていこう。
「Z Index」>「Z Index」プロパティの値を -2 にしておく。これはプレイヤーキャラクターより背面に表示させている砂埃のインスタンス(こちらの「Z Index」は -1)よりさらに背面に表示させたいからだ。
そして生成されるゴーストの色味も設定しておきたい。「Visibility」>「Modulate」プロパティの値を変更しよう。あなたのお好みの色に設定していただいて構わない。このチュートリアルでは、#5073a0ff を選択した。不透明度を下げた寒色系の色だ。
Ghost ルートノードにスクリプトをアタッチする
「Ghost」ルートノードにスクリプトをアタッチしよう。少しコーディングして以下の制御を実装する。
- ゴーストが生成されたら徐々に消えるアニメーションを演出する
- ゴーストが完全に消えたらゴーストのインスタンスを解放する
先にも少し説明したが「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」ノードのプロパティを以下のように編集する。
- Wait Time: 0.1
- One Shot: オン
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