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

Godot Engine でアクションゲームを作るとき、あると良さそうなギミックの作り方をまとめてみました。

この記事は以下の内容の続きとなります。

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

アクションゲームの基本ギミックの実装方法 (Part.2)

プロジェクトファイル

プロジェクトファイルは Github にアップロードしていて、自由にダウンロードできます。

今回紹介するギミック

今回紹介するギミックは以下の5つとなります。

  • 1. 一方通行カベ
  • 2. 落下床
  • 3. バネ床
  • 4. びっくりブロック
  • 5. ワープ

1. 一方通行カベ

一方通行カベとは、左右のどちらのみ通行可能なカベです。例えば以下のような右向きの矢印があるカベは、右からのみ通行が可能です。

実装は “OneWayWall.tscn” / .gd” で行っています。

この実装は簡単で、コリジョン (CollisionShapde2D) を回転すると、一方通行コリジョンの向きを変えることができます。

改めて確認して気がついたのですが、コリジョンが正方形でないと回転したときズレが発生するので、そのあたりは調整が必要そうです。

2. 落下床

落下床はプレイヤーが上に乗ると落下する床です。

実装は “FallingFloor.tscn / .gd”、”FallingFloorBody.gd” で行っています。

GDScriptが2つある理由としては、落下する前の位置を覚えておいて落下床が画面外に消えたら最初の場所に復活する、という実装をしているためです。

ゲームメカニクスやレベルデザインによりますが、落下床が落下した後に復活しないと先に進めなくなってしまうため、このような復活処理を実装しました。

具体的には FallingFloorBody.gd でカメラの内外判定を行い、カメラの視野外に出たら eState.HIDE (消えた) 状態にするという処理を行っています。

## 更新.
func _physics_process(delta: float) -> void:
	if _state == eState.FALLING:
		# 落下
		velocity.y += GRAVITY
		if velocity.y > MAX_SPEED:
			velocity.y = MAX_SPEED
		
		# 上にプレイヤーがいたら位置を調整する.
		var col = move_and_collide(velocity * delta)
		_fit_player(col, delta)
		
		if Common.is_in_camera(global_position, Map.get_tile_size(), 2.0) == false:
			# 画面外で消える.
			_state = eState.HIDE
			visible = false

もう1つのポイントとして、床の落下によりプレイヤーの足元から離れてしまうと接地判定が取れなくなってジャンプできなくなる問題があります。

そのため上にプレイヤーがいる場合は、床が落下した移動量を同じ値でプレイヤーを動かす必要があります。

具体的には FallingFloorBody.gd_fit_player() です。

## 落下時にプレイヤーにフィットさせる.
func _fit_player(col:KinematicCollision2D, delta:float) -> void:
	if col == null:
		return
	var collider = col.get_collider()
	if collider is Player:
		# 対象はプレイヤーのみ.
		var player = collider as Player
		player.position.y += velocity.y * delta

これを呼び出しているのが以下の処理です。

## 更新.
func _physics_process(delta: float) -> void:
		...
		
		# 上にプレイヤーがいたら位置を調整する.
		var col = move_and_collide(velocity * delta)
		_fit_player(col, delta)
		
		...

move_and_collide() により衝突処理を行い、その結果が衝突したオブジェクトのコリジョン(KinematicCollision2D)となるので、そこからプレイヤーを取り出す (KinematicCollision2D.get_collider()) という流れとなります。

なお些細な不具合として、move_and_collide() をすると全方向に衝突処理が行われるので、プレイヤーの上に乗っかってしまう問題があります。

色々調べてみてもよくわからず…。ただゲームデザイン上、問題はなさそうなので、この問題は保留としています。

落下床のアレンジ案(ちくわブロック)

今回は不自由な落下床(即座に落下する)としましたが、実現したいゲーム性によっては「ちくわブロック」を採用するのもありです。

ちくわブロックとは、プレイヤーが一定時間乗り続けたときだけ落下する床です。こちらの方がプレイヤーの行動に猶予が生まれるぶん、アクションの自由度が高くなります。

3. バネ床

バネ床はプレイヤーが上に乗ると、プレイヤー押し戻す力が働くギミックです。

このギミックは “SpringFloor.tscn / .gd” で Area2D を使って実装しています。

そのため基本的に通り抜け可能なので、下に床を置くなど通り抜けを防ぐ処理が必要となります(今回は下に床を置く方法で対処しました)。

具体的な実装方法としては、Area2Dとの重なりが発生しているときに上方向の力をプレイヤーに与えることで実装しています。

func _physics_process(delta: float) -> void:
	_spr.scale.y = 1.0
	if is_instance_valid(_player) == false:
		return # プレイヤー領域内にいないので何もしない.
	
	# バネで押し返す.
	## バネの底とプレイヤーの位置との差を求めて弾力を求める.
	var size = Map.get_tile_size() # 1タイルあたりのサイズ.
	var bottom = position.y + (size / 2.0) # 基準位置を底に移動.
	var d = bottom - _player.position.y
	if d <= 0:
		d = 0 # 最大の力.
	
	## 正規化.
	var damp_rate = 1.0 * (size - d) / size
	
	## バネの見た目を伸縮する.
	_spr.scale.y = 1 - damp_rate
	if _spr.scale.y < 0.2:
		_spr.scale.y = 0.2
	# 弾力補正値.
	damp_rate += 0.5
	if Input.is_action_pressed("action"):
		# ジャンプボタンを押していたら弾力を2倍にする.
		damp_rate *= 2.0
	
	# バネ速度を設定.
	var damp_velocity = Vector2.UP * (POWER * damp_rate * delta)
	_player.set_spring_velocity(damp_velocity)

プレイヤーとバネの底の位置が近くなるほど、”damp_rate” という係数で上方向の力を強くしています。

また、バネ床の特徴として「ジャンプボタンを押していたらジャンプ力をアップする」という機能がよくあるので、今回はジャンプボタンを押しているときは弾力を2倍にすることで実装しました。

4. びっくりブロック

びっくりブロックとは、上にあるスイッチを踏むことで一定時間ブロックを出現させるブロックです。

かなりややこしい実装となっているので、細かく説明していきます。

マップデータの構造とスクリプトの解釈

マップデータは以下のように配置しています。

黄色いスイッチを開始地点として隣り合った「!」がなくなるところまでを経路探索する…という実装となっています。

びっくりスイッチを作る処理は “Main.gd” の _create_exclamation_block() に記述しています。

## びっくりスイッチを作る.
func _create_exclamation_block(i:int, j:int) -> void:
	var type = Map.eType.EXCLAMATION_BLOCK # びっくりブロックを探す処理.
	var p = Vector2i(i, j)
	# まずは下を調べる.
	p.y += 1
	if Map.get_floor_type(p) != type:
		return # 下がびっくりブロックでなければ何もしない.
	# 見つかったのタイルを消しておく.
	Map.erase_cell(p)
	
	# びっくりブロックを生成.
	var block = EXCLAMATION_BLOCK_OBJ.instantiate()
	block.position = Map.grid_to_world(p)
	
	# 座標リスト.
	var pos_list = _create_passage_list(p, Map.eType.EXCLAMATION_BLOCK)
	_bg_layer.add_child(block)
	block.setup(pos_list)

この関数内で _create_passage_list() を呼び出して経路を作っています。

## 経路リストを作る.
## @param base 開始位置.
## @param search_type 経路とみなすタイル.
## @param end_point_type 終端とするタイル.
func _create_passage_list(base:Vector2i, search_type:int, end_point_type:int=-1) -> Array:
	var ret = []
	
	var p = base # 基準座標をコピーして使う.
	for idx in range(64): # 最大64としておく.
		# 見つかったかどうか.
		var found = false
		var is_end = false # 終了. 
		# 上下左右を探す.
		for dir in [Vector2i.LEFT, Vector2i.UP, Vector2i.RIGHT, Vector2i.DOWN]:
			var p2 = p + dir
			var type = Map.get_floor_type(p2)
			if type == end_point_type:
				# 終端タイル.
				is_end = true
				# 終端座標を結果に含めたいのでbreakしない.
				# break
			elif type != search_type:
				continue # 検索対象のタイルでない.
				
			# 見つかったタイルを消しておく.
			Map.erase_cell(p2)
			ret.append(p2 - base) # 基準からの相対座標.
			found = true
			# 次の座標から調べる.
			p = p2
			# 1方向だけ見つかれば良いのでbreak.
			break
			
		if is_end:
			# おしまい.
			break
		if found == false:
			# おしまい.
			break
	
	return ret

長い処理ですがやっていることは上下左右のタイルを調べて、目標となるタイル(ここでは「!タイル」)が存在する方向に進んでいく…というシンプルなものです(プログラムに慣れていないと難しい考え方かもしれませんが)。

なお、こういった処理は再帰呼出しで書くとスッキリするので、プログラムが得意な方は再帰呼出しに書き換えてみても良いかもしれません。

こうして見つかった「!タイル」の座標のリストExclamationBlockクラス ( ExclamationBlock.gd ) に渡しています。

## びっくりスイッチを作る.
func _create_exclamation_block(i:int, j:int) -> void:
	...

	# 座標リスト.
	var pos_list = _create_passage_list(p, Map.eType.EXCLAMATION_BLOCK)
	_bg_layer.add_child(block)
	block.setup(pos_list)

ExclamationBlockのシーンノードの構造

びっくりブロックのシーンノードの構造は以下のとおりです。

“ExclamationBlock” は動かないブロックなので “StaticBody2D”“ExclamationSwitch” は踏める (=通過できる) ので “Area2D” としました。

このブロックの本体は “Block” なのですが、プレイヤーが踏んだかどうかの判定は “Switch” 側で行う、という実装です。

3つの状態での制御

びっくりブロックには以下の3つの状態があります。

enum定義名説明
eState.NOT_PRESSスイッチが踏まれていない。
ブロックが出現していないが、
PRESSに遷移するときにブロックが出現する
eState.PRESSスイッチが踏まれている。
ブロックが出現している
eState.PRESS_TO_RELEASEスイッチが踏まれていない。
ブロックが消滅するまでのカウントダウン実行。
時間切れでブロック消滅

状態遷移のコード

状態遷移を行っているコードは、ExclamationBlock.gd_process() です。

## 更新.
func _process(delta: float) -> void:
	var pressed = _is_pressed_switch()
	
	match _state:
		eState.NOT_PRESS:
			if pressed:
				# スイッチを踏んだ.
				Common.play_se("switch")
				_create_blocks()
		eState.PRESS:
			if pressed == false:
				# スイッチから離れたら時間経過.
				_state = eState.PRESS_TO_RELEASE
				_count_timer = TIMER_COUNT_DOWN
		eState.PRESS_TO_RELEASE:
			if pressed:
				# 再び押したらカウントやり直し.
				if _count_timer != TIMER_COUNT_DOWN:
					Common.play_se("switch", 4)
				_count_timer = TIMER_COUNT_DOWN
			else:
				_count_timer -= delta
			
			if _count_timer <= 0:
				# 時間切れ.
				_erase_blocks()
	
	# アニメ更新.
	_update_anim()

この処理の中で呼び出している主要な関数は以下の3つですので、それを説明していきます。

  • _is_pressed_switch(): スイッチを踏んでいるかどうか
  • _create_blocks(): ブロックを生成する
  • _erase_blocks(): ブロックを消滅する

スイッチを踏んでいるかどうか

スイッチを踏んでいるかどうかは、メンバ変数の “_player” が「有効」かつ「着地状態」かどうかで判定しています。

## スイッチを踏んでいるかどうか.
func _is_pressed_switch() -> bool:
	if is_instance_valid(_player) == false:
		# プレイヤーが離れていたら踏んでいないことにする.
		return false
	
	if _player.is_landing():
		return true # 着地していたら踏んでいるものとする.
	
	return false

スイッチコリジョンは Area2D なので、”body_entered” / “body_exited” シグナルでプレイヤーとの衝突を判定できます。

## プレイヤーが衝突.
func _on_exclamation_switch_body_entered(body: Node2D) -> void:
	if not body is Player:
		return # Player以外は除外.
	_player = body

## プレイヤーが離れた.
func _on_exclamation_switch_body_exited(body: Node2D) -> void:
	if not body is Player:
		return # Player以外は除外.
	_player = null

ブロックの生成

生成するブロックの位置は “_pos_list” に入っている座標を使います。

それぞれのブロックを出現位置までワープさせても良かったのですが、「!ブロック」からニョキニョキ出るようにしたかったので、経路情報をそれぞれのブロックにコピーしています。

## ブロックを生成する.
func _create_blocks() -> void:
	if _state == eState.PRESS:
		return # すでに押していたら何もしない.
	if _state == eState.PRESS_TO_RELEASE:
		return # すでに押していたら何もしない.
	
	var base = Vector2i.ZERO
	var idx = 1
	for grid_pos in _pos_list:
		var wall = WALL_OBJ.instantiate()
		# 「!ブロック」からの移動経路を作る.
		var p_list = [base]
		for i in range(idx):
			p_list.append(_pos_list[i])
		# 後で消すために "Walls"ノードに登録.
		_walls.add_child(wall)
		wall.setup_moving(p_list)
		
		idx += 1
	
	_state = eState.PRESS

それと時間切れ時に消滅させたいので、生成したブロックは”_walls“変数に add_child() で登録しています。

ブロックの消滅

ブロックの消滅は “_walls” に登録したすべてのブロックの消滅処理を呼び出しているだけです。

## ブロックを消す.
func _erase_blocks() -> void:
	if _state == eState.NOT_PRESS:
		return # すでに消されていたら何もしない.
	
	for block in _walls.get_children():
		block.vanish()
	_state = eState.NOT_PRESS

ブロックの移動処理

見た目の演出として、生成したブロックの移動の計算を簡単に説明します。

ブロックの移動処理は “Wall.gd” の “_update_moving()” で実装しています。

## 移動の更新.
func _update_moving(delta:float) -> void:
	# 移動タイマー経過.
	_moving_timer += delta
	
	# タイマーを正規化.
	var rate = _moving_timer / MOVING_TIMER
	# イージングで動かす.
	rate = Ease.cube_out(rate)
	if rate >= 1.0:
		# 次の地点に到達した.
		_moving_timer = 0.0
		rate = 0.0
		_pos_list.pop_front()
		if _pos_list.size() == 1:
			# ゴールした.
			Common.play_se("block")
			position = Map.grid_to_world(_pos_list[0], false)
			_pos_list.clear()
			# 四角エフェクトの発生.
			var p =ParticleUtil.add(global_position, ParticleUtil.eType.RECTLINE, 0, 0, 1.0, 0.5)
			p.color = Color.CHOCOLATE
			return
	
	# _pos_list[0]から[1]へ lerp で動かす.
	var a = Vector2(_pos_list[0])
	var b  = Vector2(_pos_list[1])
	a = a.lerp(b, rate)
	# マップ座標系をワールド座標に変換.
	# 親オブジェクトにぶら下がっている (親からの相対座標) のでこれで良い.
	position = Map.grid_to_world(a, false)

これは_pos_listの先頭の座標とその次の座標を線形補間して移動させる処理です。

2点間の補間を繰り返して最後の1点にたどりついたらゴールとしています。

5. ワープ

ワープは2つの地点を自動で移動する処理となります。

基本的な実装方法はびっくりブロックに近いです。というものタイル情報は以下のように青色のタイルから水色のタイルを順番にたどるようになっているからです。

ただ大きな違いとしては、青色のタイルが双方向に作用する(お互いから行き来できる)という特徴があります。

この部分の実装の違いについて説明をします。

ワープ渦巻きオブジェクトの生成と経路探索

Main.gd _create_obj_from_tile() ではタイル情報からオブジェクトを生成を行っていますが、Map.erase_cell_from_world() で先にワープ渦巻きの情報をタイルから消してから _create_vortex_warp() を呼び出しています。

## タイルからオブジェクトを作る.
func _create_obj_from_tile() -> void:
	for j in range(Map.height):
		# ハシゴにフタをするため下から処理する.
		j = Map.height - (j + 1)
		for i in range(Map.width):
			var pos = Map.grid_to_world(Vector2(i, j))
			var type = Map.get_floor_type_from_world(pos)
			if type == Map.eType.NONE:
				continue
			
			# 以下タイルマップの機能では対応できないタイルの処理.
			match type:
				...
					
				Map.eType.VORTEX_WARP:
					# ワープ渦巻き.
					# 終端判定があるので先に消しておく.
					Map.erase_cell_from_world(pos)
					_create_vortex_warp(i, j)
					
				...

これはどういうことかというと、ワープ渦巻きタイルを経路の終端として扱うため、開始地点の渦巻きタイルをゴールとして扱わないようにするためです。

移動元には移動しないという判定を入れる方法もありますが、ここではシンプルにタイルを消すことで移動先の対象から除外するようにしました。

移動経路情報の複製について

Main.gd_create_vortex_warp() で移動経路のリストを作ってワープ渦巻きに渡していて、行きと帰りの情報をまとめて作っています。

## ワープ渦巻きの経路を作る.
func _create_vortex_warp(i:int, j:int) -> void:
	var search_type = Map.eType.VORTEX_PASSAGE # 渦巻き通路を探す処理.
	var end_type = Map.eType.VORTEX_WARP # ワープ渦巻きがゴール.
	var p = Vector2i(i, j)
	
	# ワープ渦巻きを生成.
	var vortex1 = VORTEX_OBJ.instantiate()
	
	# 座標リスト.
	var pos_list = _create_passage_list(p, search_type, end_type)
	pos_list.push_front(Vector2i.ZERO) # 開始地点を入れておく.
	#print(pos_list)
	_bg_layer.add_child(vortex1)
	vortex1.setup(p, pos_list, -8)
	
	# 終端用のワープ渦巻きを作っておく.
	var pos_list2 = pos_list.duplicate() # 複製.
	# 終端を取得.
	var end_pos = pos_list2.back()
	# 終端が開始.
	var p2 = p + end_pos
	var vortex2 = VORTEX_OBJ.instantiate()
	# 逆順にする.
	pos_list2.reverse()
	pos_list2 = pos_list2.map(func(a): return a-end_pos) # end_pos基準に変換.
	#print(pos_list2)
	_bg_layer.add_child(vortex2)
	vortex2.setup(p2, pos_list2, 8)

開始地点から終了地点までの座標リストを作る部分は、びっくりブロックと同じですが、問題は「帰り」の経路を作っているところです。

まず行きと帰りの経路情報がどのようになっているのかについて説明すると、_create_passage_list() で経路情報を作ったときには以下の状態となっています。

本来は X座標とY座標が含まれていますが、図では簡略化のためにX座標のみとしています。「+数字」は開始タイルからの相対的な座標情報です。

ただ、びっくりブロックで使っている処理を流用したため、開始地点の座標情報が含まれていないので、pos_list.push_front(Vector2i.ZERO) で、開始地点の座標を追加しています。

「行き」の経路はこれで良いのですが、問題は「帰り」です。「帰り」は「行き」の経路リストを逆にしたいので、pos_list2.reverse() でリストの並びを逆順にしています。

合わせて経路リストの記載がなかったので追記しました。[n] がリストの要素番号となります。

マイナス方向への移動なので、終端に進むほど値が減っているのは正しそうですが、どうも基準値が正しくなさそうです。これは扱っている座標系が「行き」の開始地点を基準座標とした相対座標であるためです。

そこで「帰り」の開始地点を基準座標に変換する必要があります。その座標変換を行っているところを抜粋したのが以下のコードです。

	# 終端を取得.
	var end_pos = pos_list2.back()
	# 逆順にする.
	pos_list2.reverse()
	# end_pos基準に変換.
	pos_list2 = pos_list2.map(func(a): return a-end_pos)

「行き」の終端が「帰り」の開始地点となるため、終端 (end_pos) を保持しておき、逆順にしたあと end_pos でそれぞれを減算することで、end_pos からの相対座標に変換されます。

正しく変換したもの

なお map() という関数は Godot4 から追加されたもので、Arrayの全要素に一括で処理を行いたい場合に処理をラムダ式として渡すことで使えます。ここでやっている処理は以下のコードを一行で書けるようにしただけです。

# pos_list2 = pos_list2.map(func(a): return a-end_pos)
# と同じ処理.

# 結果格納用.
var ret = []

for a in pos_list2:
  # end_posで減算した値を格納.
  ret.append(a - end_pos)

# 結果を代入.
pos_list2 = ret

Array.map(). は ワープ渦巻きを実装している Vortex.gdbody_entered シグナルでも使用しています。

## プレイヤーとの衝突判定.
func _on_body_entered(body: Node2D) -> void:
	if not body is Player:
		return # プレイヤー以外は何もしない.
	
	var player = body as Player
	# 渦巻きからの相対なので絶対座標に置き換える.
	var p_list = _pos_list.map(func(a): return a + _grid_pos)
	player.start_warp(p_list)

結局、プレイヤーにワープ経路を渡すときに絶対座標に変換しているので、最初から絶対座標でも良かったのかも…という気がしなくもないです。

プレイヤーのワープ処理

プレイヤーのワープ処理は、ここまでで作成した経路情報をプレイヤーに渡し、それに沿って移動する…というシンプルなものなので説明は省略します。

ただ見ての通り、少しカクカクしているような動きなので、今後どこかで滑らかな処理に変更するかもしれません。

参考

前回の記事にも書きましたが、2Dアクションゲームを作りたいのであれば、「スーパーマリオメーカー2」はとても良い参考書となるのでかなりおすすめとなります。

今回作ったすべてのギミックは、動作仕様と見た目をかなり参考にさせてもらいました。