結論: 敵を一定間隔で出し続けるだけでなく、時間経過をウェーブとして管理します。ウェーブが進むほど敵の出現間隔を短くし、同時に画面へ出せる敵の数には上限を設けると、難易度の上昇と処理の安定性を両立できます。
このシリーズでは、Brotatoのようなアリーナ型ゲームで使われる仕組みを、Godotで一つずつ小さく再現しています。前回は、レベルが上がるほど必要経験値を増やし、強化選択でゲームが止まる頻度を調整しました。
プレイヤーが強くなるなら、敵側にも少しずつ変化が必要です。今回は敵のHPや速度は変更せず、時間によって出現間隔だけを調整します。敵の数が増えすぎた場合に備えて、同時出現数の上限も追加します。
- 対象:前回記事のランダム強化選択プロジェクトを動かせる人
- 使用バージョン:Godot 4.7 系(4.7.x)
- 完成状態:20秒ごとにウェーブが進み、敵の出現間隔が短くなる
1. ウェーブのルールを決める
まず、時間と出現間隔の関係を定数にします。
const WAVE_DURATION := 20.0
const START_SPAWN_INTERVAL := 1.0
const SPAWN_INTERVAL_DECREASE := 0.05
const MIN_SPAWN_INTERVAL := 0.35
const MAX_ENEMY_COUNT := 40
今回の設定では、次のように変化します。
| ウェーブ | 経過時間 | 敵の出現間隔 |
|---|---|---|
| 1 | 0〜19秒 | 1.00秒 |
| 2 | 20〜39秒 | 0.95秒 |
| 3 | 40〜59秒 | 0.90秒 |
| 4 | 60〜79秒 | 0.85秒 |
出現間隔は最終的に 0.35 秒より短くなりません。下限を置くことで、長時間プレイしたときにTimerが極端に短くなることを防ぎます。
2. Mainにウェーブの状態を持たせる
main.gd の定数と @onready 変数の近くに、次の変数を追加します。
@onready var spawn_timer: Timer = $SpawnTimer
var elapsed_time := 0.0
var wave := 1
elapsed_time はゲーム開始からの経過時間、wave は現在のウェーブ番号です。敵を倒した数ではなく時間を基準にするため、敵の撃破処理へ新しいシグナルを追加する必要はありません。
3. 時間経過でウェーブを進める
Main の _process() で経過時間を更新します。
func _process(delta: float) -> void:
elapsed_time += delta
var next_wave := int(elapsed_time / WAVE_DURATION) + 1
if next_wave == wave:
return
wave = next_wave
_apply_wave_settings()
_refresh_status_label()
int(elapsed_time / WAVE_DURATION) は、20秒ごとに1ずつ増えます。たとえば経過時間が19.9秒ならウェーブ1、20.0秒ならウェーブ2です。
ゲームを一時停止すると、現在の Main は通常プレイ中だけ処理する設定なので、_process() と SpawnTimer も停止します。強化を選んで再開したあと、経過時間の続きからウェーブ処理が再開されます。
4. SpawnTimerの待ち時間を更新する
ウェーブ番号から、現在の出現間隔を計算します。
func _apply_wave_settings() -> void:
var next_interval := START_SPAWN_INTERVAL - float(wave - 1) * SPAWN_INTERVAL_DECREASE
spawn_timer.wait_time = maxf(MIN_SPAWN_INTERVAL, next_interval)
spawn_timer.start()
wave - 1 によって、ウェーブ1では減少量を0回、ウェーブ2では1回適用します。maxf() が下限を守るため、ウェーブが進んでも出現間隔は0.35秒で止まります。
wait_time を変更したあとに start() を呼ぶと、新しい間隔でTimerを再開できます。現在のカウント途中から変更するより、ウェーブの切り替えを境に間隔をそろえやすくなります。
_ready() の最後では、開始時の設定も同じ関数で適用します。
func _ready() -> void:
player.stats_changed.connect(_on_player_stats_changed)
player.level_up_requested.connect(_on_player_level_up_requested)
_apply_wave_settings()
_refresh_status_label()
既存の初期HUD処理を残す場合は、_apply_wave_settings() と _refresh_status_label() を、プレイヤーの初期値を渡したあとに呼び出してください。
5. 同時に出せる敵の数を制限する
敵の撃破が追いつかないまま出現だけを続けると、画面上の敵が増え続けます。_on_spawn_timer_timeout() の最初に、敵数の上限を確認します。
func _on_spawn_timer_timeout() -> void:
var enemies := get_tree().get_nodes_in_group("enemies")
if enemies.size() >= MAX_ENEMY_COUNT:
return
var enemy: Node2D = ENEMY_SCENE.instantiate()
add_child(enemy)
var angle := randf() * TAU
enemy.global_position = player.global_position + Vector2.from_angle(angle) * 700.0
上限に達したタイミングでは、新しい敵を追加せず、次のTimer通知を待ちます。敵が倒れて数が40未満になれば、次の通知から再び出現します。
上限値は、画面に見える敵だけでなく、プレイヤーの周囲700ピクセルの位置に生成された敵も含みます。敵が画面外にいるように見えても、グループへ追加されていれば数に含まれます。
6. HUDにウェーブを表示する
ウェーブの変化を確認しやすくするため、既存のHUDへウェーブ番号を追加します。表示処理を関数に分けると、プレイヤーのステータス更新とウェーブ更新の両方から呼び出せます。
func _on_player_stats_changed(
level: int,
experience: int,
experience_to_next: int,
attack_interval: float,
bullet_damage: int,
move_speed: float,
bullet_speed: float
) -> void:
_update_status_label(level, experience, experience_to_next, attack_interval, bullet_damage, move_speed)
func _update_status_label(
level: int,
experience: int,
experience_to_next: int,
attack_interval: float,
bullet_damage: int,
move_speed: float
) -> void:
status_label.text = "Wave %d Level %d XP %d/%d Attack %.1fs Damage %d Move %d" % [
wave,
level,
experience,
experience_to_next,
attack_interval,
bullet_damage,
move_speed
]
_refresh_status_label() では、現在のプレイヤーの値を読み取って同じ関数へ渡します。
func _refresh_status_label() -> void:
_update_status_label(
player.level,
player.experience,
player.experience_to_next_level(),
player.attack_interval,
player.bullet_damage,
player.move_speed
)
ウェーブが変わるときには、敵の出現間隔とHUDを同じ場所から更新します。画面上の表示と実際のゲーム内の変化をそろえられます。
7. よくあるミス
ウェーブが進まない
Main の _process() が呼ばれているか確認します。Main の process_mode が「一時停止中のみ処理」になっていると、通常プレイ中の経過時間が増えません。今回のプロジェクトでは、Main は通常プレイ中に処理する設定を使います。
20秒になっても敵の間隔が変わらない
_apply_wave_settings() の中で、spawn_timer.wait_time を更新しているか確認します。SpawnTimer の別名を使っている場合は、@onready のノードパスも一致させます。
敵が40体より多く見える
すでに生成されている敵のうち、enemy.gd の _ready() で enemies グループへ追加されていないノードは数えられません。敵シーンのスクリプトに、次の処理があるか確認します。
func _ready() -> void:
add_to_group("enemies")
強化選択画面から戻るとTimerが急に進む
SpawnTimer の process_mode が親の設定を継承しているか確認します。ゲームを一時停止している間もTimerが動く設定になっていると、選択中に敵の出現処理が進むことがあります。
8. 実行して確認する
前回記事のプロジェクトを複製して、main.gd を変更します。次の順番で確認します。
- Godot 4.7でプロジェクトを開く
- F6またはF5でゲームを実行する
- HUDが
Wave 1と表示されることを確認する - 敵が約1秒間隔で出現することを確認する
- 20秒経過後にHUDが
Wave 2へ変わることを確認する - ウェーブ2で敵の出現間隔が少し短くなることを確認する
- 長くプレイしても、敵が40体を超えて増え続けないことを確認する
- レベルアップでゲームを一時停止し、強化選択後にウェーブ処理が再開することを確認する
時間経過によるウェーブ変更は、敵を倒す速度によって結果が変わりません。プレイヤーが強化を選んでいる間は時間も敵の出現も止まるため、選択画面の操作が難易度上昇に影響しにくい構成です。
9. まとめ
今回は、敵の出現処理へ時間経過によるウェーブを追加しました。
elapsed_timeでゲーム開始からの経過時間を管理するWAVE_DURATIONごとにウェーブ番号を進める- ウェーブに応じて
SpawnTimer.wait_timeを短くする MIN_SPAWN_INTERVALで出現間隔の下限を守るMAX_ENEMY_COUNTで敵の増えすぎを防ぐ- HUDへウェーブ番号を表示して、難易度の変化を確認する
敵のHPや速度を変えなくても、出現間隔を調整するだけでゲームの緊張感を作れます。次は、ウェーブごとに敵の種類やHPを変え、時間に応じて難易度を段階的に設計できます。
参考:Godot 4とGDScriptの入門書
Godot 4とGDScriptの基本から2Dゲーム開発を学びたい場合は、今回のようなシーン構成やスクリプトの分割を体系的に確認できる書籍も参考になります。
※本記事にはアフィリエイトリンクが含まれています。
Godot 4 & GDScriptではじめる 2Dゲーム開発レシピ(Amazonで詳細を見る)
※本リンクはアフィリエイトリンクです。
統合版プロジェクトとの対応
この記事で扱う機能は、企画全体をまとめたauto_attack_final_verified.zipにも組み込んでいます。この記事のコードは段階的に追加するための説明なので、途中の記事と統合版全体のファイル構成は一致しない場合があります。
統合版ではウェーブに応じて敵のHPと速度も増えます。また、経過時間を保持してウェーブを更新し、stats_changedには辞書を渡します。この記事のコードは途中段階の例です。統合版には後続記事の機能も含まれます。記事の機能を確認するときは該当する操作を先に試し、最後にGodot 4.7でF5起動、入力、セーブ・ロード、再起動後の復元を確認してください。
