【Godot4.x】アクションゲームの基本ギミックの実装方法

このページでは、アクションゲームを作るときに使えそうな基本ギミックの実装方法について書きます。

アクションゲームの基本ギミックの実装方法

このページでは以下のギミック(とアクション)についての作り方を説明します。

  • 一方通行床
  • ハシゴ
  • 登れる(降りれる)壁
  • ダッシュ
  • 壊せる壁

サンプルプロジェクト

サンプルプロジェクトは GitHubにアップロードしています。

タイルマップの設定方法

今回のサンプルでは TileMap を使用します。TileMapの設定方法や基本的な使い方は以下の記事に書いています。

【Godot4.x】タイルマップの基本的な使い方

タイルマップの Physics Layer の設定方法

タイルマップの使い方の説明ページにも書いたのですが、タイルマップには複数の Physics Layer を持たせることができます。

以下は 2D物理の設定です。

番号レイヤー名説明
Layer 1Playerプレイヤー
Layer 2Floor床(壁)
Layer 3Ladderハシゴ
Layer 4Wall2登れる(降りれる)壁
Layer 5Shieldシールド
Layer 6Block壊せる壁
Layer 7Oneway一方通行床
タイルマップの Physic Layer 一覧

登れる壁が「Wall2」としているのはあまり良い名前ではないので、もしこのプロジェクトを参考に作るのであれば「Climbing Wall」といったわかりやすい名前にした方が良いかもしれません。

続けてタイルセットリソース (tile_set_tres) の Physics Layers の設定を見ると以下のようになっています。

タイルマップが衝突する対象はすべてプレイヤーなので、Collsion Mask はすべて 0 (Player) としています。(もし敵キャラを登場させるなら、ここに追加が必要となります)。

そして、Collision Layer にはそれぞれ以下のレイヤー番号を割り当てています。

Physics Layerの番号Collision Layerの番号
02 (床・壁)
14 (登れる壁)
26 (壊せる壁)
37 (一方通行床)
タイルセットのCollision Layerの番号一覧

なお壊せる壁についてはタイルでの当たり判定は行っていないので、厳密には不要となります。(消し忘れです…)

一方通行床

一方通行床の実装については以下のページに書いていますので、ここでは省略します。

【Godot4.x】タイルマップに一方通行床を設定する方法

ハシゴ

衝突判定はタイルを使わずにオブジェクト化する

ハシゴはタイルマップでは(おそらく)実装できないギミックです。

そのため Main.gd_create_obj_from_tile() でタイル情報を調べて、それがハシゴのタイルである場合には Ladder オブジェクトを生成するようにしています。

## タイルからオブジェクトを作る.
func _create_obj_from_tile() -> void:

	for j in range(Map.height):
		for i in range(Map.width):
			var pos = Map.grid_to_world(Vector2(i, j))
			var type = Map.get_floor_type(pos)
			if type == Map.eType.NONE:
				continue # 床タイプが未設定なタイルは何もしない
			
			# 以下タイルマップの機能では対応できないタイルの処理.
			match type:
				Map.eType.LADDER:
					# Ladder(ハシゴ)オブジェクトを生成.
					var obj = LADDER_OBJ.instantiate()
					obj.position = pos
					_bg_layer.add_child(obj)
					Map.erase_cell_from_world(pos)
					
					# 上を調べてコリジョンがなければ一方通行床を置く.
					_check_put_oneway(i, j)

衝突判定は「Area2D」

そして Ladderオブジェクトは「Area2D」としています。

ハシゴは衝突応答 (押し戻し) が不要で、衝突検知のみ行うだけで良いからです。

ハシゴ側のスクリプト (Ladder.gd) は以下のような実装となっています。

extends Area2D
# =========================================
# はしご
# =========================================
class_name Ladder

# -----------------------------------------
# signals.
# -----------------------------------------
## PhysicsBody2Dが接触.
func _on_body_entered(body: Node2D) -> void:
	if not body is Player:
		return # プレイヤー以外何もしない
	
	var player = body as Player
	# はしご接触数をカウントアップ.
	player.increase_ladder_count()

## PhysicsBody2Dが離れた.
func _on_body_exited(body: Node2D) -> void:
	if not body is Player:
		return # プレイヤー以外何もしない
	
	var player = body as Player
	# はしご接触数を減らす.
	player.decrease_ladder_count()

Godot での衝突に関するシグナルは境界チェック(領域内に入った、領域外に出た)という判定のみだったので、衝突カウンタ(ハシゴとの接触数)を Player オブジェクトにカウントさせてそれによりハシゴに接触しているかどうか、という判定で実装しています。

Player.gd 側の実装は以下のようになっています。(一部を抜粋)

## はしご接触数.
var _ladder_count = 0

## はしご接触数のカウント
func increase_ladder_count() -> void:
	_ladder_count += 1
func decrease_ladder_count() -> void:
	_ladder_count -= 1
	
# はしごを掴めるかどうか.
func _can_grab_ladder() -> bool:
	return _ladder_count > 0

移動状態の管理

ハシゴの処理を実装するにあたって、移動状態を管理する必要がありそうだったので、プレイヤーの状態に「移動状態」を定義するようにしました。

## 移動状態.
enum eMoveState {
	LANDING, # 地上.
	AIR, # 空中.
	GRABBING_LADDER, # はしごに掴まっている
	CLIMBING_WALL, # 壁登り中.
}

## 移動状態.
var _move_state = eMoveState.AIR

そして _update_move_state() でハシゴに掴める状態で「上下キー」を押すとハシゴに掴める実装となっています。

# 移動状態の更新.
func _update_move_state() -> void:

	...

	if _is_grabbing_ladder() == false:
		# はしごチェック.
		if _can_grab_ladder():
			if Input.get_axis("ui_up", "ui_down") != 0:
				# はしご開始.
				_move_state = eMoveState.GRABBING_LADDER
				# 着地 (着地アニメなし)
				_just_landing(false)

後はハシゴに掴まり状態であれば上下移動できるようにすると実装できます。

## 移動処理.
func _update_moving(delta:float) -> void:
	...

	if _is_grabbing_ladder():
		# 上下ではしご移動.
		var v = Input.get_axis("ui_up", "ui_down")
		velocity.y = 0
		if v != 0:
			velocity.x = 0 # X方向を止める.
			velocity.y = _config.ladder_move_speed * v
		
		if _check_jump():
			# ジャンプ開始.
			var is_wall_jump = _is_climbing_wall()
			_start_jump(is_wall_jump)
			_move_state = eMoveState.AIR

ただ、上方向に移動してハシゴがなくなったときに、以下の問題が発生します。

  • 1. 上方向に移動してハシゴがなくなる
  • 2. ハシゴの位置まで落下する
  • 3. 上方向に移動する(1に戻る)

要は落下とハシゴ移動を繰り返してしまい、振動が発生している……という現象です。

この問題を解消するには、一方通行床をハシゴの一番上に配置します。

これにより落下状態が解消(床に着地)して、振動が発生しなくなります。

それが、Main.gd _check_put_oneway() で、ハシゴの上に何もない場合に一方通行床を配置する処理をしています。

## 上を調べてコリジョンがなければ一方通行床を置く.
## @note ハシゴの後ろに隠れている一方通行床がチラチラ見える不具合がある.
func _check_put_oneway(i:int, j:int) -> void:
	var pos = Map.grid_to_world(Vector2i(i, j))
	var pos2 = Map.grid_to_world(Vector2(i, j-1))
	var col_cnt = Map.get_tile_collision_polygons_count(Vector2(i, j-1), Map.eTileLayer.GROUND)
	if col_cnt > 0:
		return # コリジョンがあるので何もしない.
	
	var type = Map.get_floor_type(pos2)
	if type == Map.eType.LADDER:
		return # 上がハシゴなので何もしない.
	
	# 重ねるのはハシゴの上 (一方通行床で置き換える).
	Map.replace_cell_from_world(pos, Vector2i(4, 0))

ただコードコメントに書いてあるように、ハシゴの裏に一方通行床があるのがチラチラ見える問題があるので、タイルを非表示にする処理が必要になると思います。

補足:スーパーマリオメーカー2でのツタ

スーパーマリオメーカー2ではハシゴではなく「ツタ」というギミックによって自由に上下移動できます。

スーパーマリオメーカー2
(出典:任天堂)

そしてツタの上に一方通行床がない場合は、その上には移動できないようになっています(掴まり状態のまま)。そしてツタの下まで移動するとそのまま落下します。

登れる壁

登れる壁はハシゴに機能的に似ていて(上下に登れる・降りれる)、ただ衝突応答(壁のような押し戻し)があるギミックとなります。

実装方法は move_and_slide() で接触したタイルのカスタムデータを取得して判定します。具体的には Player.gd_update_collision_post() で以下のような記述で判定をしています。

func _update_collision_post() -> void:
	# 衝突したコリジョンに対応するフラグを設定する.
	var dist = 99999 # 一番近いオブジェクトだけ処理する.
	_touch_tile = Map.eType.NONE # 処理するタイル.
	for i in range(get_slide_collision_count()):
		var col:KinematicCollision2D = get_slide_collision(i)
		# 衝突位置.
		var pos = col.get_position()
		var v = Map.get_floor_type(pos)
		if v == Map.eType.NONE:
			continue # 何もしない.
		if v == Map.eType.SPIKE:
			_is_damage = true # ダメージ処理は最優先.
			continue # 移動処理に直接の影響はない.
		
		var d = abs(pos.x - position.x)
		if d < dist:
			# より近い.
			dist = d
			_touch_tile = v

get_slide_collision_count() を使用すると move_and_slide() で衝突したコリジョンの数を取得できます。その数ぶんだけ for文で回して、get_slide_collision() で衝突したコリジョンの情報を取得します。そしてコリジョンの座標を取得してタイルマップからその座標にあるタイルを取得します。最後にタイルからカスタムデータに設定されている値を取得することで、そのタイルがどの種類のタイルなのかがわかります。

少し回りくどい方法なので、ひょっとしたらもっと簡単に判定できるかもしれませんが、とりあえずこの方法で実装できているのでこのやり方を採用しています。それと一番近いタイルのみ判定していますが、接触したすべてのタイルを記録しておいてそれらすべてで判定した方が抜けがなくて良いのかもしれません…(このあたりもひとまず動いているので良しとしています)。

なおカスタムデータはここの部分で設定しています。

なおカスタムデータについても以下のページで解説しています。

【Godot4.x】タイルマップの基本的な使い方

上下の移動に制限をつける

マップの作り方次第で移動制限できますが、上下に登り壁がないときに移動できなくする方法についても書いておきます。

登り壁の判定は正面に登り壁があるかどうかで判定するため、登り壁がなくなると落下してしまいます。

これを防ぐには移動先の正面が登り壁かどうかという判定を行い、登り壁でない場合は上下移動しない、という処理が必要となります。

Player.gd_can_climb() でその判定をしています。

## 登り・降りるができるかどうか.
func _can_climb(up_down:float) -> bool:
	# マージン用に方向を決める.
	var dir = 0.0
	if up_down > 0:
		dir = 1.0
	elif up_down < 0:
		dir = -1.0
	
	if _is_climbing_wall():
		# 登り壁.
		var n = get_wall_normal() * -1 # 法線の逆.
		# 前方1マスを調べる.
		var center = center_position
		var pos = Vector2()
		pos.x = center.x + (n.x * Map.get_tile_size())
		pos.y = center.y + up_down + (dir * CLIMB_WALL_MARGIN)
		var grid_pos = Map.world_to_grid(pos)
		if Map.get_tile_collision_polygons_count(grid_pos, Map.eTileLayer.GROUND) > 0:
			# 壁がある.
			var type = Map.get_floor_type(pos)
			if type == Map.eType.CLIMBBING_WALL:
				# 登り壁なら上下移動可能.
				return true
			else:
				# それ以外は移動不可.
				return false
	return true

get_wall_normal() で衝突しているコリジョンの法線 (押し返している方向) が取得できるので、その逆方向が正面となります。それにより1マス先のタイルの位置がわかるので、タイルマップ (正確には TileSet や TileData を参照しています) からそのタイルにコリジョンが設定されているかどうかを判定して、もしコリジョンがあるならカスタムデータからタイルの種類を取得し、それが登り床である場合のみ上下移動できるようにしています。

補足:スーパーマリオメーカー2での登り壁

スーパーマリオメーカー2では登り壁という概念がなく「ネコマリオ状態」であればすべての壁を登ることができます。

スーパーマリオメーカー2
(出典:任天堂)

そのため、壁がなくなればジャンプをしてよじ登る・飛び降りる、という挙動となっています。

ダッシュ

ダッシュはプログラム的にはあまり難しくなく、一定時間、横方向(もしくは任意の方向)に重力無視で一定の速度で移動させるだけです。

## 左右移動の更新.
func _update_horizontal_moving(can_move:bool=true, force_direction:int=0, force_direction_multipuly:float=0.0) -> void:

	...

	if _is_dash():
		# ダッシュ中.
		var DASH_ACC_RATIO = _config.ground_acc_ratio * DASH_SPEED_RATIO
		velocity = _dash_direction * MOVE_SPEED * DASH_ACC_RATIO
		return
	
	...

## 重力の影響を受ける移動状態かどうか.
func _is_add_gravity() -> bool:
	if _is_dash():
		return false # ダッシュ中は重力の影響を受けない.
	
	...

それとダッシュが無限にできてしまうと無限に移動できてしまうので、着地するまでダッシュ可能回数が回復しない、といった制限を入れる必要があります。

## 着地した瞬間.
func _just_landing(is_scale_anim:bool) -> void:
	if is_scale_anim:
		# 着地演出.
		_jump_scale = eJumpScale.LANDING
		_jump_scale_timer = JUMP_SCALE_TIME
	_jump_cnt = 0 # ジャンプ回数をリセット.
	_dash_cnt = 0 # ダッシュ回数をリセット.

壊せるブロック

壊せるブロックはタイルで実装するのが難しそうだったので、オブジェクト化で実装しました。

今回のサンプルでは Shieldオブジェクト (ダッシュ中のシールド)と衝突すると破壊できるようにしたので、Shield.gdon_body_entered シグナルで衝突対象が Blockオブジェクトだった場合に破壊処理を呼び出すようにしています。

## Body2Dと衝突.
func _on_body_entered(body: Node2D) -> void:
	if not body is Block:
		return # 念のため.
	
	var block = body as Block
	var deg = 360 - rotation_degrees + 180
	block.vanish(deg)
	block.queue_free()
	
	# ヒットストップ開始.
	Common.start_hit_stop()
	
	# 少し画面を揺らす.
	Common.start_camera_shake(0.1, 0.5)

注意点

今回の実装はすべての地形のパターンに対応できているものではありません。

例えば登り壁の上に壊せるブロックがある場合です。

登り壁の移動制限の判定は「タイル」のみで行っているため、移動制限がかからずに落下してしまいます。これは壊せるブロックをオブジェクトとしてしまったためで、移動制限の対象に含まれていないためです。

他にも登り壁の上に一方通行床を置くと、移動制限されてしまい上に移動できなくなります。

対処方法としては「壊れるブロックも移動制限の対象とする」「一方通行床は移動制限をかけないタイルとする」とすれば実装できそうですが、それなりに面倒な問題です。

こういった問題にどこまで対応するかについては、以下の方針を採用するのが良いと思います。

  • 対応が必要なケース:簡単に対応できそう。この組み合わせで表現したいアイデアがある
  • 対応をスキップできるケース:実装が大変。この組み合わせや配置を使わなくてもレベルデザイン上問題ない

特に個人開発であれば、問題が発生する配置や組み合わせは避けるなど、ある程度自由に作れるので、あまり問題に深入りせずにゲームを作ることに集中した方が良いのではないかと思います。

おすすめの資料

スーパーマリオメーカー2

今さらながら「スーパーマリオメーカー2」を購入して色々分析しているのですが、2Dアクションにおけるギミックが大量に含まれているので、アイデア集・実装例のサンプルとしてかなり参考になっておすすめです。