Godot4で3D風２D縦スクロールシューティングゲームを作る 10回目 敵のパラメータとベースクラスの設計

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

# 敵のパラメータとベースクラスの設計
　敵のパラメータについて整理するのと、敵のスクリプトのクラス設計をします。

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

 https://github.com/footinglow/woden/tree/010_refactoring_enemy_parameters_and_class_design

# 開発スタンスについて
　必要なコードのみを最小限で実装し、不要なものは削除する。とっ散らかったり、いびつに感じる部分があれば、リファクタリングするというスタンスで開発をしています。
　たまに先々必要になりそうだからとか、使わなくなったけど念のため残しておいたコードは、たいていそのままでは役に立たなかったり、ゴミになります。そういうコードは後で忘れたころに見たときに、何に使用するかわからず考えてしまって時間を無駄にする原因にもなるので、勇気をもって削除しようと思います。
 
# やりたいこと
　今回やりたいことが３つあります。

## (1) 敵と敵生成に関するパラメータは「シナリオ要素」に設定する
　現時点で、敵キャラクターのHPやスコア、速度などは、敵スクリプトに実装し、発生数、周期はシナリオ要素のスクリプトに実装しました。
　しかし、パラメータを確認するためにあっちこっちのシーンを見て回るのは大変なので、パラメータはすべてシナリオ要素に設定するようにします。

　言葉だけではイメージしにくいので、例として、下図、Stage1のシナリオシーンである、scenario_stage01.tscnを使用します。子ノードは「シナリオ要素」です。上から順番に実行されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691050/cbbbb2b7-b666-11cd-cb2d-6fc83a002a52.png)
　　　　　　　図. シナリオのシーン（scenario_stage01.tscn）

　シナリオシーンの子ノードのシナリオ要素を選択して、インスペクターを表示すると、敵や敵の生成に関するすべてのパラメータを設定および確認することができるようにする、というのが今回の目的です。
　またシナリオ要素のノードの名称もパラメータをある程度反映すると、インスペクターを見る前にシナリオが把握できて良さそうです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691050/5b63d157-2acf-572e-d144-830660c83bb3.png)
　　　　　　　図. シナリオ要素のインスペクターのパラメータの例
 
## (2) 敵スクリプトのベースクラスを作る
　下記は敵スクリプトとして実装したtwin_body.gdですが、敵共通の処理と、twin_body固有の処理が混在しています。以下のコードで、マイナス(-)の差分で表示した部分は、HPの計算やスコアの機能など、敵として共通で持つべき機能なので、今回新規で作成する敵共通のクラスに移動します。 
```diff_gd_script
extends Area3D

-signal add_to_score(int)

@export var _d_speed_mps = 20.0
-@export var _d_hp = 1
-@export var _i_score = 100

func _physics_process(delta):
	global_position += Vector3(0, 0, 1) * _d_speed_mps * delta
-	if _d_hp <= 0:
-		add_to_score.emit(_i_score)
-		queue_free()

func _on_visible_on_screen_notifier_3d_screen_exited():
	queue_free()

-# HPからダメージポイントを引き算する。余った分はreturn値で返す
-func calc_damage(laser_power) -> int:
-	var remainder = 0
-	if _d_hp <= laser_power:
-		# ダメージの方が大きい
-		remainder = laser_power - _d_hp
-		_d_hp = 0
-	else:
-		# HPの方が大きい
-		_d_hp -= laser_power
-		remainder = 0
-	# 余ったダメージポイントを返す
-	return remainder
```
## (3)直値をやめる
　twin_bodyの初期位置は直値で設定していますしていますが、今後変更があるので、グローバル変数で宣言したものを使用するようにします。

## シーケンス
　敵twin_Bodyのインスタンス生成のシーケンスは下図のようになります。
　敵スクリプトのベースクラスは、EnemyBaseで、HPとスコア関係はEnemyBaseが責務を持ちます。
```plantuml
participant game
participant シナリオ
participant produce_twin_body
participant twin_body
participant EnemyBase

activate produce_twin_body
produce_twin_body->twin_body **: <<create>>\ninstantiate
activate twin_body
twin_body->EnemyBase **: <<create>>
activate EnemyBase

produce_twin_body->EnemyBase : set d_enemy_hp
produce_twin_body->EnemyBase : set i_enemy_score
produce_twin_body->twin_body : set position
produce_twin_body->EnemyBase : add_to_score.connect
produce_twin_body->game : add_child\n(twin_bodyのインスタンス)
activate game
note over game : 追加先\n$DynamicNodes/enemy/bodies
deactivate game

```
_physics_processのシーケンスは以下です。
```plantuml
participant GodotEngine
participant twin_body
participant EnemyBase

GodotEngine->twin_body : _physics_process()
activate twin_body
twin_body -> EnemyBase :check_destroyed
activate EnemyBase
deactivate EnemyBase
twin_body -> twin_body : global_positionの更新
twin_body-->GodotEngine : return
```

レーザーで撃墜された場合のシーケンスは以下です。
HPとスコアの責務を持つEnemyBaseがmay_laserから呼ばれています。
```plantuml
participant GodotEngine
participant twin_body
participant EnemyBase
participant my_laser



activate my_laser
my_laser ->> EnemyBase : calc_damage()
deactivate my_laser

activate EnemyBase
EnemyBase->EnemyBase : d_enemy_hp計算
note over EnemyBase : HPが0になった
EnemyBase -->> my_laser: return
deactivate EnemyBase
deactivate my_laser

GodotEngine->twin_body : _physics_process()
activate twin_body
twin_body ->> EnemyBase :check_destroyed()
activate EnemyBase
deactivate twin_body
EnemyBase->EnemyBase:HPが0のため\n撃墜判定True
EnemyBase->GodotEngine : queue_free()
EnemyBase -->> twin_body : return
deactivate EnemyBase
activate twin_body

twin_body -> twin_body : global_positionの更新
twin_body-->>GodotEngine : return
deactivate twin_body

note over GodotEngine : IDLEになってからtwin_body\nインスタンスを解放する

GodotEngine-->twin_body : ノードを解放
activate twin_body
   twin_body-->EnemyBase : 解放 
activate EnemyBase
destroy twin_body
destroy EnemyBase
```

# 実装変更

## 敵スクリプトのベースクラスの作成
　res://character/enemyに新規スクリプトでenemy_base.gdを作成し、下記のようにしました。基本的には、twin_body.gdから、敵として共通であるべき処理を抜き出したものです。hp、scoreの変数名を少し変更しました。
```gdscript
class_name EnemyBase extends Area3D

# 敵が共通して持つべきシグナル
signal add_to_score(int)

# 敵が共通して持つべきプロパティ
var d_enemy_hp = 1
var i_enemy_score = 100

# 撃墜チェック
# 敵 派生クラスの_physics_processからコールされることを想定
func check_destroyed():
	if d_enemy_hp <= 0:
		add_to_score.emit(i_enemy_score)
		queue_free()

# ダメージ計算処理
# HPからダメージポイントを引き算する。余った分はreturn値で返す
# レーザースクリプトからコールされることを想定
func calc_damage(laser_power) -> int:
	var remainder = 0
	if d_enemy_hp <= laser_power:
		# ダメージの方が大きい
		remainder = laser_power - d_enemy_hp
		d_enemy_hp = 0
	else:
		# HPの方が大きい
		d_enemy_hp -= laser_power
		remainder = 0
	# 余ったダメージポイントを返す
	return remainder
```

## twin_Bodyスクリプトの修正
　敵ベースクラスに移動したコードが多かったので、残ったコードはだいぶすっきりしました。
　twin_body固有の移動処理と、イベント処理が残りました。
　また、敵ベースクラスに移動した_physics_processメソッドの一部の処理は、「super.check_destroyed()」で呼び出します。

```gdscript
extends EnemyBase

# 固有のプロパティ
var d_speed_mps = 25.0

func _physics_process(delta):
	# 撃墜チェック
	super.check_destroyed()
	# 移動処理
	global_position += Vector3(0, 0, 1) * d_speed_mps * delta
	
func _on_visible_on_screen_notifier_3d_screen_exited():
	queue_free()
```




# 敵登場位置の直値をグローバル変数で宣言
　twin_bodyの初期位置は、スクリプトに直値で-200m、-25m～25mと書いてありましたが、他の敵キャラも使うのと、今後変更がありそうなので、グローバル変数に定義します。

res://global/global.gd

```diff_gd_script
extends Node3D

+# ゲーム画面の見える範囲
+# ゲーム画面の上端をtop、下端をbotommとする
+var d_visible_top_y_m = -200.0
+var d_visible_top_min_x_m = -25.0
+var d_visible_top_max_x_m =  25.0

# gameのインスタンス
var node_game : Node3D = null 

# 生成したインスタンスの追加先
var node_lasers : Node3D = null
var node_enemies : Node3D = null 
var node_bullets : Node3D  = null

# Playerのインスタンス
var node_player : Area3D = null 
```

# シナリオ要素の修正
　敵及び敵キャラの生成のパラメータは、このシナリオ要素ですべて定義します。追加で定義した敵のパラメータは、敵インスタンスを生成したときに設定します。
　また、初期位置の設定に使用していた直値を、グローバル変数に変更しました。
res://scenario/elements/produce_twin_body.gd
```diff_gd_script
extends Node

# 敵生成パラメータ
@export	var _i_produce_num = 10
@export var _d_produce_interval_sec = 0.5

+# 敵のパラメータ
+@export var _d_hp = 1
+@export var _i_score = 100

var _scn_enemy = preload("res://character/enemy/twin_body.tscn")

var _i_produced_num = 0;	# 生産した数

func start_element():
	$Timer_for_produce.start(_d_produce_interval_sec)

func is_blocking_to_move_on_next_scenario() -> bool:
	# 敵キャラの生産が完了したら次のシナリオ要素に進ませるためfalseにする
	return ( _i_produced_num < _i_produce_num )

func _on_timer_for_produce_timeout():
	# 敵キャラクターのインスタンスを生成
	var ins = _scn_enemy.instantiate()

+	# 敵パラメータの設定
+	ins.d_enemy_hp = _d_hp
+	ins.i_enemy_score = _i_score

-	ins.position.z = -200
-	ins.position.x = randf_range(-25, 25)
+	ins.position.z = g_val.d_visible_top_y_m
+	ins.position.x = randf_range(	g_val.d_visible_top_min_x_m,
+									g_val.d_visible_top_max_x_m)

	# add_to_scoreシグナルをgame.gdに接続する
	ins.add_to_score.connect( g_val.node_game._on_add_to_score )

	# 敵インスタンスをシーンツリーに追加
	g_val.node_enemies.add_child(ins)
	
	# 生産完了したら、タイマーを停止する
	_i_produced_num += 1
	if ( _i_produced_num >= _i_produce_num ):
		$Timer_for_produce. stop()
```

# 終わりに
　記事を書くと手間がかかりますが、細かいところまで考える必要があるので良いと思います。

以上です。