Godot4で3D風２D縦スクロールシューティングゲームを作る　3回目 レーザーを発射する

Godot4.1.3でシューティングゲームを作っていきます。

# レーザーを発射する
 画面をタッチしている間、一定の間隔で敵を倒すためのレーザーを発射します。

# github
　本記事で実装したものをgithubで公開しています。

 https://github.com/footinglow/woden/tree/003_FIRE_LASERS

# レーザーシーンを作成する
 res://character/player配下に、レーザー用のシーン、my_laserを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691050/80bfad57-41c1-a87f-a038-d83099c5279b.png)

　レーザーのルートノードはArea3Dです。
 ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691050/322df4b0-2ae7-ebab-8136-91a111077307.png)
　子ノードのlaser_leftとlaser_rightはMeshInstance3Dです。新規CapsuleMeshを設定した後、細長くして黄色にして、左右に配置しました。
　CollisionShape3Dは、新規BoxShape3Dを設定し、2本のレーザーをカバーするような長方形(6m×1m×5m）をサイズに設定しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691050/52c533c2-4b97-7260-aa41-b9d9d3999b15.png)
　レーザーが移動して画面外に消えたときにレーザーシーンを削除したいので、VisibleOnScreenNotifier3Dノードを追加します。サイズを設定できますが、あまり厳密ではなくてよいので、ディフォルト値のままです。　
　![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691050/47fe181c-e0d6-da29-21c9-3a966d50b148.png)

# レーザーのスクリプトを実装する
　ルートノードのmy_laserにスクリプトをアタッチして下記のように修正します。

```GDScript
extends Area3D

@export var _d_speed_mps = 25.0		# 単位はm/s　
@export var _v_dir = Vector3(0, 0, -1)

func set_pos(player_pos):
	global_position = player_pos
	
func _physics_process(delta):
	global_position += _v_dir * _d_speed_mps * delta

# 画面外に出た場合、消える
func _on_visible_on_screen_notifier_3d_screen_exited():
	queue_free()
```

　レーザーの移動速度_d_speed_mpsと移動する向き_v_dirは、後で変更するパラメータであることを明示するため@exportしています。
　set_posメソッドはレーザーシーンを生成したときに、初期位置を設定するために使用されます。

# レーザーが移動して画面外に消えた場合に、シーンを削除する
　レーザーが画面外に消えた場合、VisibleOnScreenNotifier3Dノードのscreen_exitedイベントで検知することができますので、screen_exitedイベントをmy_laserスクリプトに接続します。
　レーザーシーンmy_laser.scnを開き、VisibleOnScreenNotifier3Dを選択した状態で、インスペクターの横のノードのシグナルを選択します。screen_exited()をダブルクリックしてmy_laserに接続します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691050/d15338d1-9800-fb5a-66ae-5ca8ed12c43b.png)
　_on_visible_on_screen_notifier_3d_screen_exited()関数の左に黄色のアイコンが表示されて、接続されたことがわかります(ノード名をVisibleOnScreenNotifier3Dから変更している場合メソッド名は変わります）。
 ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691050/c35712db-9efe-3b97-0896-36f0b35fa5dc.png)

# Playerスクリプトを修正する
## レーザーシーンとレーザー発射間隔を管理するためのメンバ変数を追加する
　Player.gdを開き、下記の差分のように、メンバ変数を追加します。_scn_laserには後でレーザーシーン、my_laser.scnをドラッグして登録します。また、発射間隔のパラメータと次に発射するまでの残り時間を記憶するためのメンバ変数を追加しました。
 
 ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691050/b23a39e2-3ff1-fc15-143b-fec62e6031d8.png)

## PlayerシーンのScn Laserプロパティにレーザーのシーンを登録する
　player.gdに「@export var _scn_laser : PackedScene」を追加して、保存した後、Playerシーンのルートノードを選択すると、インスペクタに「Scn Laser」プロパティが表示されます。そこにmy_laser.scnをドラッグアンドドロップして登録します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691050/62e2b07d-1d79-9767-c5a4-7025c2366efd.png)

## タッチしている間、レーザーシーンを生成する処理を実装する
　Player.gdの最後に下記の処理を実装します。
 
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691050/10fa2b6c-eb92-ff41-f461-6629964b861d.png)
「_f_is_screen_touch」フラグでタッチしている状態を判断します。
_d_firing_remain_time_secは次の発射までの残り時間です。残り時間がある場合は、経過時間を減算します。残り時間が無くなったらレーザーを発射して、発射後に残り時間を設定します。
　下記の3行は、レーザーシーンのインスタンスを生成して、Playerの位置をレーザーの位置として設定し、add_siblingでレーザーインスタンスをPlayerと同じ階層に追加します。
　この時add_siblingではなく、add_childするとPlayerの配下にレーザーシーンが追加されるので、Playerが移動するとレーザーも移動するという意図しない動作になります。
```GDScript
			# レーザーを発射する
			var scn : Area3D = _scn_laser.instantiate()
			scn.set_pos(global_position)
			add_sibling(scn)
```

　実行するとこのようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691050/4979442a-fed6-4a7d-0c0a-be12e3a902e3.png)

## キーを押下すると落ちる問題の対処
 _input(event)メソッドの中で、「event.position」を常に取得してタッチ位置を更新していましたが、キー押下のeventのように、positionを持たない入力イベントが発生すると落ちることがわかりました。
 冗長な感じもしますが、下記のように、タッチしているときのみ、positionを取得するように変更しました。
```GDScript
func _input(event):
	# 画面にタッチしている状態と、タッチしていない状態を判断する
	if event is InputEventScreenTouch:
		_f_is_screen_touch = event.is_pressed()
		# ドラッグ位置（タッチ位置）を更新する
		_v2_drag_pos = event.position	
	elif event is InputEventScreenDrag:
		# ドラッグしているということは、タッチしている。（参考：InputEventScreenDragの場合、event.is_pressed()は常にfalse)
		_f_is_screen_touch = true
		# ドラッグ位置（タッチ位置）を更新する
		_v2_drag_pos = event.position	
	else:
		pass
```

## Player.gd全体
　Player.gd全体は下記のようになります。

```GDscript
extends CharacterBody3D

# Player
@export var _d_speed_mps = 100.0

# ドラッグ操作検知用
var _f_is_screen_touch = false		# 画面にタッチ（ドラッグ含む）している場合true、タッチしていない場合はfalse
var _v2_drag_pos = Vector2.ZERO		# 最新のタッチ位置・ドラッグ位置

# タッチしたときの、３D上のタッチ位置とPlayer位置
var _v_first_touch_pos   = null
var _v_first_touch_player_pos = Vector3.ZERO

# 画面をタッチした位置から、検索する３Dオブジェクトまでの最大距離
@export var _d_ray_length_m = 1000		# [m]十分な長さにする

# レーザーの発射タイミング
@export var _scn_laser : PackedScene
@export var _d_firing_interval_sec = 0.2
var _d_firing_remain_time_sec = 0

func _input(event):
	# 画面にタッチしている状態と、タッチしていない状態を判断する
	if event is InputEventScreenTouch:
		_f_is_screen_touch = event.is_pressed()
		# ドラッグ位置（タッチ位置）を更新する
		_v2_drag_pos = event.position	
	elif event is InputEventScreenDrag:
		# ドラッグしているということは、タッチしている。（参考：InputEventScreenDragの場合、event.is_pressed()は常にfalse)
		_f_is_screen_touch = true
		# ドラッグ位置（タッチ位置）を更新する
		_v2_drag_pos = event.position	
	else:
		pass


func _physics_process(delta):
	if _f_is_screen_touch:
		var camera = get_viewport().get_camera_3d()

		# カメラを利用して3D空間のカメラの３D位置と、カメラからタッチしたピクセルを見た方向の1000m先の３D位置を計算する
		var from3d = camera.project_ray_origin(_v2_drag_pos)
		var to3d = from3d + camera.project_ray_normal(_v2_drag_pos) * _d_ray_length_m

		# 3D ray physics queryの作成
		var query = PhysicsRayQueryParameters3D.create(from3d, to3d)
		query.collide_with_areas = true		# Area3Dを検知できるようにする
		
		#　spaceと呼ばれる、物理３D空間状態の情報を利用して、Area3Dを含む衝突位置を計算する
		var space_state = get_world_3d().direct_space_state
		var result = space_state.intersect_ray(query)
		if result:
			# 衝突位置を取得
			var v_drag_pos = result.position
			v_drag_pos.y = global_position.y	# Y方向には移動しないための設定
			if _v_first_touch_pos == null :
				# 初めてタッチしたときの、3D位置を記憶する
				_v_first_touch_pos = v_drag_pos
				# 初めてタッチしたときの、プレイヤーの3D位置を記憶する
				_v_first_touch_player_pos = global_position
			else:
				pass
			
			# ３D空間の移動目標位置
			var v_target_pos = _v_first_touch_player_pos + ( v_drag_pos - _v_first_touch_pos )
			
			# Player位置から移動目標位置までの相対位置を計算
			var v_target : Vector3 = v_target_pos - global_position
			# 移動目標位置に向かう速度ベクトルを生成
			var v_velocity : Vector3 = v_target.normalized() * _d_speed_mps
			if v_target.length() < ( v_velocity * delta ).length():
				# 目的地までの距離が、delta当たりの移動量よりも小さい場合は、飛び越えてしまうため、Playerの位置を目標位置にする
				global_position = v_target_pos
			else:
				# CharacterBody3Dのvelocityに速度を設定して動かす
				velocity = v_velocity
				move_and_slide()
		else:
			# タッチ位置に衝突したものが無い
			pass
	else:
		# タッチしていない場合はnullを設定することで、次にタッチしたときに、新しいタッチ位置とPlayer位置を記憶する
		_v_first_touch_pos = null
		
	if _f_is_screen_touch:
		# タイミングをとりながらlaser発射
		if _d_firing_remain_time_sec > 0:
			# 次の発射までの残り時間を減算するのみ
			_d_firing_remain_time_sec -= delta
		else:
			# レーザーを発射する
			var scn : Area3D = _scn_laser.instantiate()
			scn.set_pos(global_position)
			add_sibling(scn)
			# 発射間隔を設定
			_d_firing_remain_time_sec = _d_firing_interval_sec
	else:
		# タッチしたときにすぐ、レーダーが発射するようにする
		_d_firing_remain_time_sec = 0
```

# まとめ
　レーザーを発射すると、シューティングゲームっぽくなりますね。

 以上です。