とりとめも

藻類が学んだり感じたりしたことを未来の自分のために書き留めるところ

【Godotお勉強メモ】2Dチュートリアルその後 HPを持った敵を作って倒す!

はじめに

自ら生み出した生命を打ち倒します。

前回のあらすじ

marimomemo.hatenablog.jp

マップと、CharacterBody2Dでプレイヤーキャラクターを作りました。

今回は

前回作ったマップに敵を配置します。
また、プレイヤーキャラクターはその敵を攻撃できるようにします。

最終的に出来上がったのが下図のような画面です。うーん、面白くなさそう!!

赤い敵を攻撃するとダメージが発生します

100ダメージ与えると消滅します

やることを箇条書きにすると以下の通り。

  • 攻撃範囲シーンを作る
    • プレイヤーキャラクターに攻撃範囲の図形をくっつける
    • 攻撃範囲は自動的にマウスカーソルのほうを向く
  • 敵キャラクターを作る
    • 見た目はとりあえず赤い四角
    • 移動はしない
    • 内部的にHPを持つ
  • 攻撃機能を作る
    • 左クリックしたら攻撃!
      • 攻撃範囲内に敵がいたら、敵のHPを減らす
      • 発生したダメージはアニメーションで表示
      • HPが0になった敵はちょっとしたアニメーションとともに消滅

プレイヤーキャラクターの攻撃範囲を作る

まずは先に扇形の攻撃範囲を作って、プレイヤーに扇形が追従するようにします。

AttackRangeシーンを作る

Area2Dノードを使って、ArrackRangeシーンを作ります。
また、子ノードとしてPolygon2DとCollisionPolygon2Dを作ります。

よくある感じ

扇形の形状を作る

Polygon2Dのプロパティを変更して、扇形の形状にします。

プロパティの設定はインスペクターからも行えますが、扇形の自作はなかなか難しそうです。
GPT君にスクリプトで作ってもらうことにしました。
このあたりの視覚的な要素についてもいずれは身に着けたいですが・・・。

func _ready():
    # Polygon2Dの頂点を設定
    var polygon2D = $Polygon2D
    polygon2D.polygon = calculate_fan_shape()

    polygon2D.color = Color(0, 0, 1, 0.5) 

    # CollisionPolygon2Dノードについても、Polygon2Dと同じ形状にする
    var collision_polygon = $CollisionPolygon2D
    collision_polygon.polygon = polygon2D.polygon

func calculate_fan_shape() -> PackedVector2Array:
    var center = Vector2()
    var radius = 100.0
    var angle_deg = 90.0
    var points = PackedVector2Array([center])
    var angle_step = angle_deg / 10
    for i in range(11):
        var rad = deg_to_rad(angle_step * i - angle_deg / 2)
        points.append(center + Vector2(cos(rad), sin(rad)) * radius)
    return points

コードの中では、Polygon2Dの形状を定義すると同時に、同じ内容でCollisionPolygon2Dも定義しています。

扇形がマウスカーソルの方向に傾く様にする

次に扇形がマウスカーソルを追いかけてぐるぐる回るようにします。
より具体的に言うと、扇形からマウスカーソルへの角度を毎フレーム計算し、AttackRangeノードのrotationを書き換えてあげます。

  • マウスカーソルの位置はget_global_mouse_position()
  • ノード(今回は扇形の攻撃範囲)の位置は、組み込み変数のglobal_position で取れるみたいです。

そんなわけで先ほどの扇形作成コードに以下を追加しました。

func _process(delta):
    # 扇形の角度を設定
    rotation = calculate_angle_to_mouse()


# マウスカーソルへの角度を計算する
func calculate_angle_to_mouse():
    var mouse_position = get_global_mouse_position()
    var fan_position = global_position

    # 扇形からマウスカーソルへの方向ベクトルを計算
    var direction = (mouse_position - fan_position).normalized()

    # 方向ベクトルから角度を計算
    var angle = atan2(direction.y, direction.x)
    return angle

Playerのインスタンスにする

このAttackRangeをPlayeシーンの子ノードにすることで、攻撃範囲がプレイヤーに追従するようになります。

Playerにくっつける

敵キャラクターを作る

次に敵を作って、マップに表示するようにします。

Enemyシーンを作る

前回のPlayer作成と重複する部分があるので、駆け足で書きます。
やることは以下。

  • Enemy用画像を作る(赤い四角)
  • CharacterBody2Dノードを使ってEnemyシーンを作る
  • 子ノードにSprite2DノードとCollisionShape2Dノードを作る
  • Sprite2Dに画像を読み込む
  • CollisionShape2Dを設定する
    • ShapeをRectangle2Dにする
    • ScaleをSprite2Dに合わせる

Enemyシーンに体力を設定する

Enemyシーンにスクリプトをアタッチします。
デフォルトのコードはまるっと消して、以下の記述だけ入れておきます。

extends CharacterBody2D

@export var max_hp = 100
var current_hp = max_hp

func _physics_process(delta):
    pass

HPが減ったり倒れたりする処理はあとで書きます。

Enemyがマップに現れるようにします

MainシーンでEnemyをインスタンス化します。 マップのランダムな位置に5体生み出すことにします。

# Enemyシーンをプリロード
var enemy_scene: PackedScene = preload("res://enemy.tscn")

func _ready():
    spawn_enemies(5)  # 5体の敵を生成

func spawn_enemies(number_of_enemies):
    for i in range(number_of_enemies):
        var enemy = enemy_scene.instantiate()
        add_child(enemy) 

        # 敵をランダムな位置に配置
        enemy.position = Vector2(randf_range(50, 600), randf_range(50, 600))

メモ:シーンのロードについて

今回、Enemyシーンをロードする方法として、preload()を使用してファイルシステム内のenemy.tscnファイルを直接指定しています。
チュートリアルでは、@export var mob_scene: PackedSceneとして空のスクリプト変数を宣言し、インスペクター上でシーンファイルを指定していました。

これらの方法は、PackedScene型の変数を格納するという点では同じですが、ロードのタイミングが異なるようです。
このあたりは機会があれば別の記事でまとめたいです。

Enemyシーンに対する攻撃機能を追加する

次に、Playerシーンに攻撃機能を追加します。

インプットマップに「attack」を定義

左クリックにattackという名前を付けます。

Playerシーンのスクリプトに攻撃処理を追加

@onready var attack_range = $AttackRange  # 攻撃範囲を示すノードを参照

func _process(delta):
    if Input.is_action_just_pressed("attack"):
        attack()

func attack():
    for body in attack_range.get_overlapping_bodies():
        if body.is_in_group("enemies"):  
            body.receive_damage(10)  

attack関数の中では、AttackRangeに衝突しているノードであることに加えて、enemiesグループに所属しているのノードを対象に、ダメージ発生処理を行うようにしています。
なので、Enemyシーン側の処理を実装していきます。

Enemyシーンに攻撃を受けるための実装を追加する

Enemyシーンにグループを設定

ノードタブのグループから、enemiesグループに追加。

ダメージを受ける関数を設定

Playerシーンで呼び出していたEnemyのreceive_damage関数を作ります。

func receive_damage(damage):
    current_hp -= damage
    if current_hp <= 0:
        die()

func die():
    queue_free()

まだhpを減らす処理と、0以下になったときに消滅する処理しか書いていません。
ここに、「ダメージを表示させる処理」と「消滅時のエフェクト」を追加させます。

メモ:ノード間のパラメータや数値の受け渡しについて

今回は、Playerノードが攻撃処理の主体となりつつ、ダメージの発生(hpの減算)はEnemyノード側の関数に任せています。
しかし、このようにお互いの関数を呼び出し合うのはわかりづらい印象があります。
そもそもEnemyノードの変数としてhpパラメータを直接持たせているのが綺麗ではないですよね。
チュートリアルでも同様のやり方でしたが、現実のゲーム開発では別の方法をとっているはずです。
方法はいろいろ考えられるので、今後もう少しゲームらしい見た目になってきたら、現実的な設計で作っていきたいです。

ダメージ量を浮かび上がらせる

まず、ダメージの数値をアニメーションさせるためのシーンを作ります。

  • DamagePopupシーンをRichTextLabelノードで作成
  • 子ノードにAnimationPlyaerノードを追加

続けて、以下の手順でアニメーションを設定します。

  • AnimationPlayerのエディタで「damage_popup」というアニメーションを新規作成。
  • トラックの追加から、以下を設定
    • プロパティトラック > DamagePopup > self_modulate
    • プロパティトラック > DamagePopup > positon
  • アニメーションの時間を0.5秒に
  • self_modulateを、0秒は真っ赤にし、最終的に透明になるようにいい感じに設定
  • positionを、yが50から40に推移するように設定

このアニメーションは、AnimationPlayerノードでplay("damage_popup")することで実行できます。
ただし今回は、Enemyがダメージを受ける都度、DamagePopupシーンをインスタンス化してアニメーションさせるため、DamagePopupのスクリプトで一工夫することにします。
なのでDamagePopupに以下のスクリプトをアタッチ。

extends RichTextLabel

func _ready():
    $AnimationPlayer.connect("animation_finished", Callable(self, "_on_AnimationPlayer_animation_finished"))

func play_and_queue_free():
    $AnimationPlayer.play("damage_popup")

func _on_AnimationPlayer_animation_finished(animation_name):
    queue_free()  # アニメーションが終了したらインスタンスを削除

それぞれ以下を行っています。

_ready()

AnimationPlayerノードのアニメーション終了時のシグナルを接続。

play_and_queue_free()

アニメーションの実行。
EnemyでDamagePopupをインスタンス化したあと、直接AnimationPlayerを参照してアニメーションを実行させることもできますが、Enemyシーンのスクリプトからシンプルに実行させたいのと、アニメーション実行後のインスタンス削除も織り込まれていることをEnemy側でも読み取れることを意図しています。
こうした可読性や保守性のあたりは、どうするのが正しいかわからない(これが正解とも思えない)ですが、いろいろ試していきたいと考えています。

_on_AnimationPlayer_animation_finished:

AnimationPlayerのシグナルを受けてインスタンスを終了します。

Enemyシーンからアニメーションを表示するようにする

Enemyシーンに作ったreceive_damage()に、アニメーション表示処理を追加します。
以下のように変更しました。

var damage_popup_scene: PackedScene  = preload("res://damage_popup.tscn")
func receive_damage(damage):
    var damage_popup = damage_popup_scene.instantiate()
    damage_popup.text = str(damage)
    add_child(damage_popup)
    damage_popup.play_and_queue_free()
    current_hp -= damage
    if current_hp <= 0:
        die()

これで、Playerが敵キャラクターを殴るとダメージがポップアップしていきます。

こんな感じで、攻撃をうけるたびにインスタンス化する必要がありました。攻撃を連打されたときに重複してダメージを表示しないといけないので。

敵のHPが0になったときに消滅するアニメーションを作る

先ほどと同じ要領で、EnemyシーンにAnimationPlayerノードを追加します。
そしてenemy_dieという名前のアニメーションを追加します。

敵が消滅するときのアニメーションは適当にこんな感じにします。
* 0.3秒かけてscaleを(1, 0.5)に変化(高さが半分になる) * 0.5秒かけてself_modulateを透明に変化

続けて、Enemyシーンのdie関数等をいじっていきます。Enemyに以下のコードを追加・変更します。

func _ready():
    $AnimationPlayer.connect("animation_finished", Callable(self, "_on_AnimationPlayer_animation_finished"))

func die():
    $AnimationPlayer.play("enemy_die")

func _on_AnimationPlayer_animation_finished(animation_name):
    queue_free()

これで、die関数でインスタンスが終了される前にアニメーションが実行されるようになり、DamagePopupのときと同様に、きちんとアニメーション終了シグナルも受け取ってくれます。

おしまい

だいぶ長くなりましたが、これで今回作るものは完成しました。
敵を出現させたり消滅させることで、シーン間のインスタンスのやり取りについて理解が深まった気がします。

今後は、途中でも書いたように、パラメータをインスタンスに持たせた設計なども意識していきたいです。が、それはまだもう少し先の話かもしれないですね。

直近でやっていきたいことはいろいろありますが、

  • 敵の移動
    • いくつかのパターンの移動。難しそう
  • 敵の攻撃
    • 上に続いて難しそうですし、Playerとの数値のやり取りがいっそう複雑になるので、前述のパラメータ管理の見直しもあわせて行いたいですね
  • マップをスクリプトで生成する

あたりでしょうか。ほかにもたくさんあった気がするのですが、こうして書いていると思い出せません。

どれもそれなりに重そうなので、もっと手近で小規模なところから手をつけていきたいですね。

とにかくやれるところから頑張りましょう。