この記事では、2Dゲームにおけるシンプルな近接攻撃の当たり判定を実装する方法を紹介する。
一般的によく使われる手法で、攻撃するオブジェクト(プレイヤーキャラクターなど)に攻撃時のみ有効になる衝突形状(Hit Box という)を用意し、攻撃を受けるオブジェクト(敵キャラクターや破壊できる樽や木箱、草など)には攻撃を受ける範囲となる衝突形状(Hurt Box という)を用意し、攻撃時のアニメーションに合わせて、Hit Box と Hurt Box の有効/無効の切り替えやサイズ、位置の変更をすることで攻撃の当たり判定を実装することができる。
比較的簡単に実装できるのでさっそくやっていこう。
Environment
Godot のバージョン: 3.5.1
コンピュータのOS: macOS 11.6.5
Basic Articles
以下の記事もお役立てください。
Godot をダウンロードする
Godot のプロジェクトマネージャー
Godot の言語設定
インプットマップの追加
まずは「プロジェクト」>「プロジェクト設定」>「インプットマップ」タブで、以下のアクションを追加する。
- right: D キー
- left: A キー
- down: S キー
- up: W キー
- attack: Space バー
Player シーンを作る
- プレイヤーキャラクターのシーンを作成する。ルートノードを「KinematicBody2D」にして、必要なノードを追加し、以下のようなシーンツリーを作成する。
- Player (KinematicBody2D)
- Sprite
- BodyCollisionShape (CollisionShape2D)
- HitBox (Area2D)
- HitBoxCollisionShape (CollisionShape2D)
- AnimationPlayer
- Player (KinematicBody2D)
- このシーンを「Player.tscn」というファイル名で保存する。
Player シーンのノードを編集する
Sprite ノード
プレイヤーキャラクターの近接攻撃のアニメーションを含むスプライトシートとして、今回は itch.io
から Pixel Art Dwarf Sprites
というアセットをダウンロードして利用させていただいた。特に近接攻撃用のアニメーションとして、3 ~ 5 行目を利用することになる。
- ダウンロードしたスプライトシートをファイルシステムドックにドラッグしてインポートする。そのままだと少しぼやけた感じで表示されるため、ファイルを選択して、インポートドックから、2D Pixel のプリセットを適用して再インポートする。
- インスペクタードックにて「Texture」プロパティにインポートしたスプライトシートを適用する。
- 縦横それぞれ 8 フレームずつのスプライトシートなので、「Animation」>「Hframes」/「Vframes」の値をそれぞれ 8 とする。
2D ワークスペース上に 1 フレーム分のテクスチャが表示されればOKだ。
BodyCollisionShape (CollisionShape2D) ノード
親の「Player」ルートノードは KinematicBody2D クラスなので、必ず衝突形状の設定が必要だ。あとで作成する Hit Box の衝突形状と区別するため、「BodyCollisionShape」と名前を変更している。
- 「Shape」プロパティには「RectangleShape」リソースを適用する。
- 2D ワークスペースにて、衝突形状をスプライトのテクスチャの体部分に合わせる。
- コリジョン形状が視覚的に邪魔な場合は、必要に応じて、シーンドックでノードを非表示に設定すると良い。
HitBox (Area2D) ノード
Hit Box とは、キャラクターが近接攻撃する時の当たり判定を行うための衝突形状である。Hit Box というだけあって、一般的に四角い衝突形状が使われることが多い。Godot で作る 2D ゲームの場合、「Area2D」とその子ノードの「CollisionShape2D」を使用する。
親の「HitBox」ノードの編集は不要だが、あとでスクリプトを作成する際に、このノードのシグナルを利用することになる。
HitBoxCollisionShape (CollisonShape2D) ノード
「BodyCollisionShape」ノードと区別するため、名前を「HitBoxCollisionShape」としている。
- 「Shape」プロパティに新規「RectangleShape」リソースを適用して四角い衝突形状を設定する。サイズや位置は後ほど攻撃時のアニメーションの中で変更させるので、今の時点で「RectangleShape」の「Extents」プロパティはデフォルトのままで良い。
- プレイヤーキャラクターの攻撃時のみ衝突判定を有効にしたいので、「Disabled」プロパティは オン にして、衝突を無効化しておく。
AnimationPlayer ノード(アニメーションの作成)
「AnimationPlayer」ノードで近接攻撃のアニメーションを作成する。
- シーンドックで「AnimationPlayer」ノードを選択する。
- アニメーションパネルを開く
- アニメーションを新規作成し、名前を「right_attack1」とする。
*左右それぞれの向きに対して attack1, 2, 3 まで作る想定である。 - まずは以下の通りに設定する。
- 読み込み時の自動再生: Off
- アニメーションの長さ(秒): 0.6
- アニメーションのループ: Off
- 「Sprite」ノードの「Frame Coords」プロパティのトラックを追加する。0.1 秒間隔で、スプライトシートの 3 行目のテクスチャを左端から順番に 6 列目まで追加する。具体的には、 (0, 2)、(1, 2)、(2, 2)、(3, 2)、(4, 2)、(5, 2) と、y の値(スプライトシートの行)はそのままで x の値(スプライトシートの列)のみ 0 から 5 へ変化させていく。
- このトラックの「Interpolation」の種類を「Nearest」に変更する。
- 「BodyCollisionShape」の「Disabled」プロパティのトラックを追加する。プレイヤーキャラクターは、自分の攻撃時は敵からダメージを受けないようするため、タイムラインの0.3秒の位置で「BodyCollisionShape」の「Disabled」プロパティをオンにし、0.5秒の位置でオフに戻すようにする。
- 「HitBoxCollisionShape」の「Disabled」プロパティのトラックを追加する。さっきと逆で、タイムラインの0.3秒の位置で「Disabled」プロパティをオフにし、0.5秒の位置でオンに戻す。これにより、プレイヤーキャラクターの攻撃アニメーションのうち、まさに斧を振り下ろしているアニメーションフレームの時だけ Hit Box の当たり判定が有効になる。
- 続けて「HitBoxCollisionShape」の「Position」プロパティと「Shape」>「Extents」プロパティのトラックを追加する。タイムラインの0.3秒の斧を振り下ろすアニメーションに合わせて、HitBox の衝突形状のサイズと位置を調整する。「Position」の値は(7.5, -2.25)、「Extents」の値は(9.5, 12.25)とした(若干テクスチャ上の斧の軌跡より大きめにしている)。
最終的に「right_attack1」は以下のようになった。
- Sprite
- frame_coords
- Time: 0 / Value: (0, 2) / Easing: 1.00
- Time: 0.1 / Value: (1, 2) / Easing: 1.00
- Time: 0.2 / Value: (2, 2) / Easing: 1.00
- Time: 0.3 / Value: (3, 2) / Easing: 1.00
- Time: 0.4 / Value: (4, 2) / Easing: 1.00
- Time: 0.5 / Value: (5, 2) / Easing: 1.00
- frame_coords
- BodyCollisionShape
- disabled
- Time: 0.1 / Value: On / Easing: 1.00
- disabled
- HitBoxCollisionShape
- disabled
- Time: 0.3 / Value; Off / Easing: 1.00
- Time: 0.5 / Value; On / Easing: 1.00
- position
- Time: 0.3 / Value: (7.5, -2.25) / Easing: 1.00
- Time: 0.4 / Value: (4, 4) / Easing: 1.00
- shape:extents
- Time: 0.3 / Value: (9.5, 12.25) / Easing: 1.00
- Time: 0.4 / Value: (6, 6) / Easing: 1.00
- disabled
以下のGIF画像は、今作成した「right_attack1」のアニメーションを 0.5 倍速で再生している。斧を振り下ろすタイミングだけ HitBox の衝突形状が有効(緑色)になっているのがおわかりいただけるだろう。
同様の手順で、「right_attack2」のアニメーションを作成した。スプライトシートの 4 行目のテクスチャを使用した。タイムラインの 0.1 ~ 0.4 秒の間、「HitBoxCollisionShape」の「Position」と「Extents」を 0.1 秒おきに少しずつ変化させている。
「right_attack2」を 0.5 倍速で再生するとこのようになる。
さらに、「right_attack3」のアニメーションを作成した。こちらはスプライトシートの 5 行目のテクスチャを使用した。テクスチャは 2 フレームのみだが、その 2 つを 4 回繰り返している。「Sprite」ノードの「Position」プロパティのトラックを追加して、少し前に移動してから戻ってくるようなアニメーションにしている。
「right_attack3」を 0.5 倍速で再生すると以下のようになる。
左向きのアニメーションの作成時は、「Sprite」ノードの「Offset」>「Flip H」プロパティをオンにしてから作業するとやりやすい。先に作った右向きの近接攻撃アニメーションを複製して、左向き用に調整すると簡単だ。
Player ノードにスクリプトをアタッチする
プレイヤーキャラクターの移動と近接攻撃を制御するために、「Player」ルートノードにスクリプトをアタッチしてコーディングしていく。
###Player.gd###
extends KinematicBody2D
# 移動スピード
var speed = 80.0
# 移動速度
var velocity: Vector2
# 攻撃アニメーションの番号(1 ~ 3)
var attack_num = 1
# Sprite ノードの参照
onready var sprite = $Sprite
# AnimationPlayer ノードの参照
onready var anim_player = $AnimationPlayer
# キャラクターの移動に関する入力を制御するメソッド
func move():
velocity = Vector2()
if Input.is_action_pressed("right"):
velocity.x += 1
sprite.flip_h = false
if Input.is_action_pressed("left"):
velocity.x -= 1
sprite.flip_h = true
if Input.is_action_pressed("down"):
velocity.y += 1
if Input.is_action_pressed("up"):
velocity.y -= 1
velocity = velocity.normalized() * speed
# 速度が 0 なら idle アニメーションを再生
if velocity.length() == 0:
anim_player.play("idle")
# 速度が 0 より大きければ run アニメーションを再生
if velocity.length() > 0:
anim_player.play("run")
# 物理プロセスの組み込み関数
func _physics_process(_delta):
move()
# 移動に関する入力をキャラクターの動きとして反映させる
velocity = move_and_slide(velocity)
# 入力の組み込みコールバック関数
func _unhandled_input(event):
# スペースバーが押されたら
if event.is_action_pressed("attack"):
# 移動できないように物理プロセスを停止
set_physics_process(false)
# スプライトが左右反転していたら(左向きだったら)
if sprite.flip_h:
# 左向きの攻撃アニメーションを再生
anim_player.play("left_attack" + str(attack_num))
# スプライトが左右反転していなかったら(右向きだったら)
else:
# 右向きの攻撃アニメーションを再生
anim_player.play("right_attack" + str(attack_num))
# 現在の攻撃アニメーションの番号が 3 未満の場合は番号に + 1
if attack_num < 3:
attack_num += 1
# 現在の攻撃アニメーションの番号が 3 以上の場合は番号を 1 に戻す
else:
attack_num = 1
「AnimationPlayer」ノードの「animation_finished」シグナルをスクリプトに接続し、自動生成されたメソッドを以下のように編集する。
func _on_AnimationPlayer_animation_finished(anim_name):
# アニメーションの名前に「attack」が含まれていたら物理プロセスを再開する
if "attack" in anim_name:
set_physics_process(true)
「Player」シーンを実行してみよう。事前に「デバッグ」メニューから衝突形状を表示する設定を有効にしておくとアニメーション中の衝突形状の変化がわかりやすいだろう。
プレイヤーキャラクターの動きは以下のGIF画像のようなになる。
Enemy シーンを作る
プレイヤーキャラクターの近接攻撃の当たり判定を確認するために「Enemy」シーンを用意する。「Player」シーンと似たような作業内容のため、詳細は割愛させていただく。
まずシーンツリーは以下の通りだ。
- Enemy (KinematicBody2D)
- Sprite
- BodyCollisionShape (CollisionShape2D)
- AnimationPlayer
- ReviveTimer (Timer)
Enemy シーンのノードを編集する
Sprite ノード
「Sprite」ノードの「Texture」プロパティには itch.io - mystic woods からダウンロードさせていただいたアセットのうち、「slime.png」のスプライトシートを適用する。
BodyCollisionShape ノード
「Shape」プロパティには「CircleShape2D」を適用し、スプライトよりやや小さめの衝突形状にする。今回のチュートリアルでは、これがいわゆる Hurt Box となる。この衝突形状に、プレイヤーキャラクターの Hit Box が重なった時に当たり判定が有効となるようにすれば良いというわけだ。その制御は後ほどスクリプトで行う。
AnimationPlayer ノード
アニメーションパネルにて、以下の4つのアニメーションを用意する。全て 0.1 秒おきにスプライトシート上の次のフレームのテクスチャに変更する設定だ。
- jump: 待機時の飛び跳ねるアニメーション(読み込み時に自動再生)
- hurt: ダメージを受けた時のアニメーション
- die: ライフが 0 になって死ぬときのアニメーション
- 「BodyCollisionShape」の「Disabled」をオンにする
- revive: 死んだあと一定時間後に生き返るアニメーション
- 「BodyCollisionShape」の「Disabled」をオフにする
RevieTimer ノード
「One Shot」プロパティを有効にしておく。
Enemy ノードにスクリプトをアタッチする
「Enemy」ルートノードにスクリプトをアタッチして以下のようにコーディングした。
###Enemy.gd###
extends KinematicBody2D
# ライフの最大値
export (int) var max_life = 3
# 現在のライフ
var life: int = max_life
# Playerインスタンスを参照するための変数
var player: KinematicBody2D
# Sprite ノードの参照
onready var sprite = $Sprite
# AnimationPlayer ノードの参照
onready var anim_player = $AnimationPlayer
# ReviveTimer ノードの参照
onready var revive_timer = $ReviveTimer
func _process(_delta):
# プレイヤーの位置により Sprite の向きを反転したりしなかったり
sprite.flip_h = global_position.x > player.global_position.x
# ダメージを受けるメソッド
func hurt():
# ライフを 1 減らす
life -= 1
# アニメーション hurt を再生する
anim_player.play("hurt")
さらに「AnimetionPlayer」の「animation_finished」シグナルと、「ReviveTimer」の「timeout」シグナルをスクリプトに接続し、それぞれの生成されたメソッドを以下のように編集する。
###Enemy.gd###
# AnimationPlayer のアニメーション終了時のシグナルにより呼ばれるメソッド
func _on_AnimationPlayer_animation_finished(anim_name):
# 終了したアニメーションが hurt の場合
if anim_name == "hurt":
# ライフが 0 より大きければ jump アニメーションに戻る
if life > 0:
anim_player.play("jump")
# ライフが 0 以下の場合は die アニメーションを再生する
if life <= 0:
anim_player.play("die")
# 終了したアニメーションが die の場合は ReviveTimer をスタートさせる
if anim_name == "die":
revive_timer.start()
# 終了したアニメーションが revive の場合は
if anim_name == "revive":
anim_player.play("jump")
# ReviveTimer のタイムアウト時のシグナルで呼ばれるメソッド
func _on_ReviveTimer_timeout():
# revive アニメーションを再生する
anim_player.play("revive")
# ライフを最大値に戻す
life = max_life
Player のスクリプトに HitBox のシグナルを追加する
「Player.gd」スクリプトの方に、近接攻撃が当たったら、「HitBox」のシグナルにより「Enemy.gd」のhurt
メソッドを呼び出すようにする。
「Player.tscn」シーンに戻り、「HitBox」ノードの「body_entered(body: Node)」シグナルを「Player.gd」スクリプトに接続する。
###Player.gd###
# 省略
# HitBox に物理ボディが当たった時のシグナルで呼ばれるメソッド
func _on_HitBox_body_entered(body):
# 物理ボディノードの名前が Enemy だったらその hurt メソッドを呼ぶ
if body.name == "Enemy":
body.hurt()
World シーンを作る
最後に「World」シーンを作って、そこに「Player」シーンのインスタンスと「Enemy」のインスタンスを追加する。
シーンツリーはシンプルに以下の通りだ。
- World (Node2D)
- Enemy (Enemy.tscn のインスタンス)
- Player (Player.tscn のインスタンス)
2D ワークスペース上で、それぞれのインスタンスを適当に配置する。
World シーンにスクリプトをアタッチする
World シーンにスクリプトをアタッチして、以下のようにコーディングする。目的は、「World」ルートノードから、「Enemy.gd」で宣言した「Enemy」ノードの変数player
に「Player」インスタンスの参照を渡すことだ。
###World.gd###
extends Node2D
onready var player = $Player
onready var enemy = $Enemy
func _ready():
enemy.player = self.player
プロジェクトを実行する
最後にプロジェクトを実行して近接攻撃の当たり判定の挙動を確認する。
「Player」の「HitBox」ノードの衝突形状が「Enemy」の「BodyCollisionShape」(Hurt Box)と重なった時に当たり判定が有効になり、「Enemy」の「hurt」アニメーションが再生されているのがおわかりいただけるだろうか。
おわりに
今回は2Dゲームにおけるシンプルな近接攻撃の当たり判定についてご紹介した。Hit Box と Hurt Box を使った攻撃の当たり判定の実装は比較的理解しやすく、様々なゲームで応用できるだろう。
ちなみに、実際のゲームでは、体の部位ごとに別々の Hit Box や Hurt Box を設定し、その組み合わせによってより複雑な仕組みを作ることもあるようだ。
例えば、格闘ゲームでは手、足、頭など攻撃する側にいくつかの Hit Box を用意し、攻撃を受ける側にも上段、中段、下段の位置にそれぞれの Hurt Box を設定すれば、それぞれの組み合わせによって、ダメージなどを複雑に変化させることができる。
他にも、ゾンビものの FPS ゲームであれば、ゾンビの頭と体で異なる Hurt Box を用意しておけば、頭を射撃すれば 1 発で倒せるが、他の部位だとそうはいかない、というお決まりのルールを実装することもできるだろう。
この記事が少しでもお役に立てたなら幸いだ。
参考
この記事の作成にあたり、以下のリンク先の資料が大変参考になったのでご紹介させていただきたい。これらも併せてご覧いただくとより理解が深まるはずだ。