Godot4で3D風２D縦スクロールシューティングゲームを作る　5回目 ノードを使ってシナリオを実装する

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

# ゲームのシナリオをシーンのリストで管理する
　ゲームでは様々なイベントが発生します。シューティングゲームの場合は、敵キャラやアイテムの発生、スクロール速度の変更などのイベントが考えられますが、基本的に条件による分岐はせず、決まった順番にイベントを発生させることができれば、ゲームとして成立すると思います。
　イベントをシナリオ要素とし、シナリオ要素を順番に並べたものをシナリオと定義します。
　実装方法はいろいろあると思いますが、今回は、各シナリオ要素をシーンとして実装し、シーンのリストをシナリオとして表現しようと思います。

 ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691050/e12308ac-1f69-7696-f70d-643598b56b9a.png)

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

https://github.com/footinglow/woden/tree/005_SCENARIO_SYSTEM

# 方針
・各イベントをシナリオ要素と呼び、シーンとして実装する
・ステージ毎に一つのシナリオをシーンとして作成して、実行する順番に子ノードとして、シナリオ要素のシーンを並べる
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691050/2fb88af6-4807-e801-a14d-0aac2473b4fe.png)

・シナリオを実行するのはscenario_executorスクリプト
・scenario_executorは、シナリオ要素を上から順番に実行する。現在実行中のシナリオ要素に次のシナリオ要素に進んでよいか問い合わせをして、良い場合、次のシナリオ要素を開始する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691050/e3084fbc-cb23-37cd-8d45-7b5766e22fdb.png)


# 設計

## クラス図風に表現したシーンとスクリプト
　PlantUMLを使用して、クラス図風に表現してみました。クラス図内のひとつのクラスは、GDScriptのみ、もしくはGDScriptとシーンのセットを示しています。吹き出しに対応するファイル名を示しています。

＜ポイント＞
・scenario_executorをアタッチしているシーンがシナリオ
・シナリオ要素はふたつのメソッドを実装する必要がある

```plantuml
class scenario_executor{
drive_scenario()
}

class シナリオ要素{
start_element()
is_blocking_to_move_on_next_scenario()
}

game "1" o-- "1" scenario_executor : drive
scenario_executor "1" o-- "1..*" シナリオ要素 : start
シナリオ要素 <|-- wait_specified_time
シナリオ要素 <|-- produce_twin_body

note right of game : スクリプト：game.gd\nシーン：game.tscn
note right of scenario_executor : ステージのシナリオ\nスクリプト：scenario_executor.gd\nシーン：scenario_stage01.tscn
note right of シナリオ要素 : 実装時は継承せずダックタイピングで実現している\nシナリオ要素が持つべきメソッドを表している
note bottom of wait_specified_time : シナリオ要素\nスクリプト：wait_specified_time.gd\nシーン：wait_specified_time.tscn
note bottom of produce_twin_body : シナリオ要素\nスクリプト：produce_twin_body.gd\nシーン：produce_twin_body.tscn
```

・gameシーンは１つのシナリオを持ち、周期的にscenario_executorを駆動する（drive_scenarioメソッド）
・scenario_executorはシナリオ要素に開始を指示する（start_elementメソッド）
・scenario_executorはシナリオ要素のis_blocking_to_move_on_next_scenarioメソッドの実行結果がfalseの場合、次のシナリオ要素を実行する
## シーケンス図
　シーケンスにすると下記のようになります。
　シナリオ要素１を実行後、シナリオ要素２を実行開始する例です。
　初期化時に、先頭のシナリオ要素のstart_elementメソッドを実行しています。
```plantuml
game->scenario_executor:create

activate scenario_executor
activate scenario_executor
activate scenario_executor

scenario_executor->シナリオ要素1 : start_element


game->scenario_executor : drive_scenario
scenario_executor->シナリオ要素1 : is_blocking_to_move_on_next_scenario
シナリオ要素1-->scenario_executor : true

game->scenario_executor : drive_scenario
scenario_executor->シナリオ要素1 : is_blocking_to_move_on_next_scenario
シナリオ要素1-->scenario_executor : false(次のシナリオ要素を開始）
scenario_executor->シナリオ要素2 : start_element

game->scenario_executor : drive_scenario
scenario_executor->シナリオ要素2 : is_blocking_to_move_on_next_scenario
シナリオ要素2-->scenario_executor : true
```

# 実装の前に、前回の記事の最後に、動作確認用に追加したシーンを削除
　前回の記事の最後に、twin_bodyの動作を確認するため、gameシーンにtwin_body.tscnを追加したので、下図のようになっていると思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691050/1abd1acf-8a15-a621-17ee-f3d2931322d9.png)
不要なので、gameシーンからtwin_bodyは削除します。

# 実装
　設計に基づいて、下図の赤枠内の３つのシーンファイルと、４つのGDScriptファイルを追加します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691050/0111a1ee-f937-1e81-a141-77234e8c66ad.png)

# シナリオ要素 : 指定時間待つためシーンを追加
　一つ目のシナリオ要素を実装します。シナリオ要素は、２つのメソッド（start_elementとis_blocking_to_move_on_next_scenario）を実装する必要があります。
　res://scenario/elementsフォルダを作成し、新規シーンでwait_specified_time.tscnを作成します。
　ルートノードはNodeにしました。子ノードにTimerノードを追加して「Timer_for_wait_specified_time」に名称を変更しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691050/a13bbfc5-92b9-e1d9-a90c-5ba6c9bccc7f.png)
またインスペクタのOne shotをONにして、１回のみのタイマーにします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691050/0b1da554-a8d0-dbdd-cbec-ed9fe9b3f20f.png)

　スクリプトをアタッチして下記のように修正します。
 ```GDScript
extends Node

@export var _d_wait_time_sec = 5.0

func start_element():
	$Timer_for_wait_specified_time.start(_d_wait_time_sec)

func is_blocking_to_move_on_next_scenario() -> bool:
	# タイマー稼働中は、シナリオをブロックするためtrueを返す
	return !$Timer_for_wait_specified_time.is_stopped()
 ```
　待ち時間はパラメータにしたいので、@exportで宣言します。
　scenario_executorがstart_elementを実行したときに、タイマーを開始します。
　scenario_executorが周期的にis_blocking_to_move_on_next_scenario()を起動し、次のシナリオ要素に進むのをブロック中か問い合わせるのに対し、タイマー稼働中は待たせたいのでtrueを返し、タイマー停止後はfalseを返すように実装しています。

# シナリオ要素 : 敵キャラtwin_bodyの生産用シーンを追加
　シナリオ要素なので、同様に２つのメソッド、start_elementとis_blocking_to_move_on_next_scenarioを実装する必要があります。
　res://scenario/elementsフォルダに、produce_twin_body.tscnシーンファイルを追加します。Timerノードを追加して、Timer_for_produceに名称を変更します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691050/549e28a6-c2bc-0fe4-f77f-e254fa278cbb.png)
　タイマーは周期的に起動したいので、ディフォルトのままです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691050/b64560f7-568a-31ad-52a8-4d783ae59882.png)
　ルートノードにシナリオをアタッチして、下記のように修正します。

 ```GDScript
extends Node

@export	var _i_produce_num = 10
@export var _d_produce_interval_sec = 0.5
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.position.z = -200
	ins.position.x = randf_range(-25, 25)
	add_child(ins)
	
	# 生産完了したら、タイマーを停止する
	_i_produced_num += 1
	if ( _i_produced_num >= _i_produce_num ):
		$Timer_for_produce. stop()
```
　スクリプトの先頭から順番に説明します。
　下記のように、発生する数と周期をパラメータにしました。合計10機のtwin_Bodyを0.5秒毎に生産します。
```GDScript
@export	var _i_produce_num = 10
@export var _d_produce_interval_sec = 0.5
```
　scenario_executorがstart_elementメソッドを起動すると、タイマーを起動したいので、下記のようにします。
　タイマーが0.5秒周期でタイムアウトを繰り返します。
```GDScript
func start_element():
	$Timer_for_produce.start(_d_produce_interval_sec)
```
　twin_Bodyの生産数が、パラメータで決めた数以上になった場合、次のシナリオ要素に進むようにしました。
```GDScript
func is_blocking_to_move_on_next_scenario() -> bool:
	# 敵キャラの生産が完了したら次のシナリオ要素に進ませるためfalseにする
	return ( _i_produced_num < _i_produce_num )
```
　タイムアウトイベントを受けるため、TimerノードのTimer_for_produceを選択して、インスペクターの右のノード/シグナルのtime_out()をダブルクリックして、_on_timer_for_produce_timeoutメソッドに接続します。
　twin_bodyを生産したいので、インスタンスを生成して、座標を設定してadd_childします。座標はとりあえず直値を設定していますが、後で直します。
　生産した数は_i_produced_numで管理します。パラメータで決めた数、生産した場合はタイマーを停止して、生産を停止します。
```GDScript
func _on_timer_for_produce_timeout():
	# 敵キャラクターのインスタンスを生成、位置を設定してシーンに追加する
	var ins = _scn_enemy.instantiate()
	ins.position.z = -200
	ins.position.x = randf_range(-25, 25)
	add_child(ins)
	
	# 生産完了したら、タイマーを停止する
	_i_produced_num += 1
	if ( _i_produced_num >= _i_produce_num ):
		$Timer_for_produce. stop()
```

## シナリオの実装
　res://scenarioフォルダを作成して、「scenario_stage01」という名前でシーンを作成します。ルートノードはNodeにします。
　子ノードにシナリオ要素を追加します。下記のようにしました。
| シーンファイル | パラメータ |
|:-|:-|
| wait_specified_time.tscn  | D Wait Time(sec) = 5  |
| produce_twin_body.tscn  | I Produce Num=10, D Produce Interval(sec)=1  |
| wait_specified_time.tscn  | D Wait Time(sec) : 10  |
| produce_twin_body.tscn  | I Produce Num=10, D Produce Interval(sec)=0.5  |

シーン名を変更して、わかりやすくします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691050/091fd7ce-4426-52e9-6217-97a9358020e0.png)


 スクリプトをアタッチして、scenario_executor.gdを作成し、以下のように編集します。
```GDSCript
extends Node

var _i_started_element_idx = 0		# 実行中のシナリオ要素のインデックス

func _ready():
	# 初期設定時に、先頭のシナリオ要素を開始する
	get_child(0).start_element()
	
func drive_scenario():
	if get_child(_i_started_element_idx).is_blocking_to_move_on_next_scenario():
		# シナリオ要素は進めない
		pass
	else:
		if _i_started_element_idx < ( get_child_count() - 1 ):
			# 次のシナリオ要素を開始する
			_i_started_element_idx += 1
			get_child(_i_started_element_idx).start_element()
		else:
			# シナリオ要素無し
			pass
```
初期化時に、先頭のシナリオ要素（子要素の0番目）を開始します。
```GDScript
func _ready():
	# 初期設定時に、先頭のシナリオ要素を開始する
	get_child(0).start_element()
```
　周期的にdrive_scenarioメソッドが起動されるので、現在実行中にシナリオ要素に、次のシナリオ要素に進んでよいか問い合わせをします。
　現在実行中のシナリオ要素のインデックスは「_i_started_element_idx」なので、「get_child(_i_started_element_idx).is_blocking_to_move_on_next_scenario()」で問い合わせをします。trueの場合、シナリオ要素は進めません。falseの場合は、(ある場合）次のシナリオ要素に進めて、start_elementメソッドで開始を指示します。
```GDScript
func drive_scenario():
	if get_child(_i_started_element_idx).is_blocking_to_move_on_next_scenario():
		# シナリオ要素は進めない
		pass
	else:
		if _i_started_element_idx < ( get_child_count() - 1 ):
			# 次のシナリオ要素を開始する
			_i_started_element_idx += 1
			get_child(_i_started_element_idx).start_element()
```

## gameの実装
　game.scnにスクリプトをアタッチして、下記のように修正します。

```GDScript
extends Node3D

var _scn_scenario_stage = preload("res://scenario/scenario_stage01.tscn")

var _node_scenario_ins

func _ready():
	_node_scenario_ins = _scn_scenario_stage.instantiate()
	add_child(_node_scenario_ins)

func _process(delta):
	# シナリオを駆動する
	_node_scenario_ins.drive_scenario()
```
　_readyメソッドで、シナリオシーンを生成して、カレントツリーに追加します。カレントツリーに追加すると、シナリオ要素のadd_childなどが成功するようになります。
　_processメソッドで周期的に、scenario_executorのdrive_scenarioメソッドを駆動します。

# 終わりに
　自機と敵が出てきたので、次回は当たり判定を追加したいと思います。

 以上です。