2025-11-29 20:39:30 +00:00
|
|
|
extends Node3D
|
|
|
|
|
class_name EnemySpawner
|
|
|
|
|
|
|
|
|
|
## Spawns enemies in waves around the arena edge
|
|
|
|
|
## Server-authoritative spawning with multiplayer sync
|
|
|
|
|
|
|
|
|
|
signal wave_started(wave_number: int)
|
|
|
|
|
signal wave_completed(wave_number: int)
|
|
|
|
|
signal enemy_spawned(enemy: Node)
|
|
|
|
|
signal all_enemies_defeated()
|
|
|
|
|
|
|
|
|
|
## Spawn configuration
|
|
|
|
|
@export_category("Spawn Settings")
|
|
|
|
|
@export var spawn_radius: float = 20.0 ## Distance from spawner center to spawn enemies
|
|
|
|
|
@export var spawn_height: float = 0.5 ## Height above ground to spawn
|
|
|
|
|
@export var enemies_per_wave: int = 3 ## How many enemies spawn per wave
|
|
|
|
|
@export var auto_start_next_wave: bool = false ## Auto-start next wave when all defeated
|
|
|
|
|
@export var wave_delay: float = 5.0 ## Delay before next wave auto-starts
|
|
|
|
|
|
2026-06-30 22:57:11 +01:00
|
|
|
@export_category("Timed Waves")
|
|
|
|
|
@export var auto_waves: bool = true ## Spawn a fresh wave on a repeating timer
|
|
|
|
|
@export var wave_interval: float = 15.0 ## Seconds between timed waves
|
|
|
|
|
@export var first_wave_delay: float = 3.0 ## Delay before the very first timed wave
|
|
|
|
|
|
2025-11-29 20:39:30 +00:00
|
|
|
@export_category("Enemy Pool")
|
|
|
|
|
@export var enemy_scenes: Array[PackedScene] = [] ## List of enemy scenes to spawn from
|
|
|
|
|
|
2026-07-02 22:27:14 +01:00
|
|
|
@export_category("Escalation")
|
|
|
|
|
@export var wave_count_growth: float = 1.0 ## Extra enemies added per wave
|
|
|
|
|
@export var max_enemies_per_wave: int = 20 ## Hard cap on wave size
|
|
|
|
|
@export var health_growth_per_wave: float = 0.15 ## +15% enemy health per wave
|
|
|
|
|
@export var damage_growth_per_wave: float = 0.10 ## +10% enemy damage per wave
|
|
|
|
|
@export var boss_wave_interval: int = 5 ## Every Nth wave is a boss wave (0 = never)
|
|
|
|
|
@export var boss_scene: PackedScene = null ## The Snippest itself
|
|
|
|
|
|
2025-11-29 20:39:30 +00:00
|
|
|
## Wave tracking
|
|
|
|
|
var current_wave: int = 0
|
|
|
|
|
var active_enemies: Array[Node] = []
|
|
|
|
|
var _wave_delay_timer: float = 0.0
|
|
|
|
|
var _waiting_for_next_wave: bool = false
|
|
|
|
|
var _enemy_id_counter: int = 0
|
2026-06-30 22:57:11 +01:00
|
|
|
var _auto_waves_active: bool = false
|
|
|
|
|
var _auto_wave_timer: float = 0.0
|
2025-11-29 20:39:30 +00:00
|
|
|
|
|
|
|
|
## Debug visualization
|
|
|
|
|
@export var show_spawn_radius: bool = false ## Show spawn radius circle in game
|
|
|
|
|
var _debug_circle: MeshInstance3D = null
|
|
|
|
|
|
|
|
|
|
func _ready():
|
|
|
|
|
print("[EnemySpawner] Ready. Server: ", multiplayer.is_server())
|
|
|
|
|
_create_debug_circle()
|
|
|
|
|
|
2026-06-30 22:57:11 +01:00
|
|
|
## Begin spawning waves automatically on a timer (server only)
|
|
|
|
|
func start_auto_waves():
|
|
|
|
|
if not multiplayer.is_server() or not auto_waves:
|
|
|
|
|
return
|
|
|
|
|
_auto_waves_active = true
|
|
|
|
|
_auto_wave_timer = first_wave_delay
|
|
|
|
|
print("[EnemySpawner] Auto waves enabled - first wave in ", first_wave_delay, "s, then every ", wave_interval, "s")
|
|
|
|
|
|
|
|
|
|
## Stop the timed-wave loop
|
|
|
|
|
func stop_auto_waves():
|
|
|
|
|
_auto_waves_active = false
|
|
|
|
|
|
2025-11-29 20:39:30 +00:00
|
|
|
## Start a new wave
|
|
|
|
|
func start_wave():
|
|
|
|
|
if not multiplayer.is_server():
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if not is_inside_tree():
|
|
|
|
|
push_error("[EnemySpawner] Cannot start wave - spawner not in scene tree!")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if enemy_scenes.is_empty():
|
|
|
|
|
push_error("[EnemySpawner] No enemy scenes configured!")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
current_wave += 1
|
|
|
|
|
print("[EnemySpawner] Starting wave ", current_wave)
|
|
|
|
|
wave_started.emit(current_wave)
|
|
|
|
|
|
2026-07-01 23:37:56 +01:00
|
|
|
# Broadcast wave number to every peer's GameState (for HUD + run stats)
|
|
|
|
|
GameState.server_sync_wave(current_wave)
|
|
|
|
|
|
2026-07-02 22:27:14 +01:00
|
|
|
# Boss milestone waves field the Snippest instead of a normal wave
|
|
|
|
|
if _is_boss_wave(current_wave):
|
|
|
|
|
print("[EnemySpawner] BOSS WAVE ", current_wave)
|
|
|
|
|
_spawn_boss()
|
|
|
|
|
else:
|
|
|
|
|
var count = _wave_enemy_count(current_wave)
|
|
|
|
|
for i in range(count):
|
|
|
|
|
_spawn_enemy(i, count)
|
|
|
|
|
|
|
|
|
|
## Every Nth wave, if a boss scene is configured
|
|
|
|
|
func _is_boss_wave(wave: int) -> bool:
|
|
|
|
|
return boss_wave_interval > 0 and boss_scene != null and wave % boss_wave_interval == 0
|
|
|
|
|
|
|
|
|
|
## Wave size grows linearly, capped
|
|
|
|
|
func _wave_enemy_count(wave: int) -> int:
|
|
|
|
|
return mini(enemies_per_wave + int((wave - 1) * wave_count_growth), max_enemies_per_wave)
|
|
|
|
|
|
|
|
|
|
func _wave_health_mult(wave: int) -> float:
|
|
|
|
|
return 1.0 + (wave - 1) * health_growth_per_wave
|
|
|
|
|
|
|
|
|
|
func _wave_damage_mult(wave: int) -> float:
|
|
|
|
|
return 1.0 + (wave - 1) * damage_growth_per_wave
|
|
|
|
|
|
|
|
|
|
## Spawn the boss at a random point on the spawn circle (server only)
|
|
|
|
|
func _spawn_boss():
|
|
|
|
|
if not multiplayer.is_server():
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
_enemy_id_counter += 1
|
|
|
|
|
var boss_name = "Boss_" + str(current_wave) + "_" + str(_enemy_id_counter)
|
|
|
|
|
|
|
|
|
|
var angle = randf() * TAU
|
|
|
|
|
var spawn_pos = global_position + Vector3(
|
|
|
|
|
cos(angle) * spawn_radius,
|
|
|
|
|
spawn_height,
|
|
|
|
|
sin(angle) * spawn_radius
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
rpc("_spawn_enemy_on_client", boss_name, spawn_pos, boss_scene.resource_path,
|
|
|
|
|
_wave_health_mult(current_wave), _wave_damage_mult(current_wave))
|
2025-11-29 20:39:30 +00:00
|
|
|
|
|
|
|
|
## Spawn a single enemy at a position around the circle
|
|
|
|
|
func _spawn_enemy(index: int, total: int):
|
|
|
|
|
if not multiplayer.is_server():
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if not is_inside_tree():
|
|
|
|
|
push_error("[EnemySpawner] Cannot spawn enemy - spawner not in scene tree!")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if enemy_scenes.is_empty():
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Pick a random enemy scene from the pool
|
|
|
|
|
var enemy_scene = enemy_scenes[randi() % enemy_scenes.size()]
|
|
|
|
|
|
|
|
|
|
# Calculate spawn position in a circle
|
|
|
|
|
var angle = (TAU / total) * index # Evenly distribute around circle
|
|
|
|
|
var offset = Vector3(
|
|
|
|
|
cos(angle) * spawn_radius,
|
|
|
|
|
spawn_height,
|
|
|
|
|
sin(angle) * spawn_radius
|
|
|
|
|
)
|
|
|
|
|
var spawn_pos = global_position + offset
|
|
|
|
|
|
|
|
|
|
# Generate unique name for multiplayer sync
|
|
|
|
|
_enemy_id_counter += 1
|
|
|
|
|
var enemy_name = "Enemy_" + str(current_wave) + "_" + str(_enemy_id_counter)
|
|
|
|
|
|
|
|
|
|
# Spawn on all clients via RPC (call_local will spawn on server too)
|
|
|
|
|
var enemy_scene_path = enemy_scene.resource_path
|
2026-07-02 22:27:14 +01:00
|
|
|
rpc("_spawn_enemy_on_client", enemy_name, spawn_pos, enemy_scene_path,
|
|
|
|
|
_wave_health_mult(current_wave), _wave_damage_mult(current_wave))
|
2025-11-29 20:39:30 +00:00
|
|
|
|
|
|
|
|
## RPC to spawn enemy on all clients (including server via call_local)
|
|
|
|
|
@rpc("any_peer", "call_local", "reliable")
|
2026-07-02 22:27:14 +01:00
|
|
|
func _spawn_enemy_on_client(enemy_name: String, spawn_pos: Vector3, scene_path: String,
|
|
|
|
|
health_mult: float = 1.0, damage_mult: float = 1.0):
|
2025-11-29 20:39:30 +00:00
|
|
|
# Load the enemy scene
|
|
|
|
|
var enemy_scene = load(scene_path)
|
|
|
|
|
if not enemy_scene:
|
|
|
|
|
push_error("[EnemySpawner] Failed to load enemy scene: ", scene_path)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Instantiate enemy
|
|
|
|
|
var enemy = enemy_scene.instantiate()
|
|
|
|
|
enemy.name = enemy_name
|
|
|
|
|
enemy.position = spawn_pos
|
|
|
|
|
|
2026-07-02 22:27:14 +01:00
|
|
|
# Wave escalation - set BEFORE add_child so _ready picks the values up
|
|
|
|
|
# (current_health = max_health in BaseUnit, hitbox set_stats in BasicEnemy).
|
|
|
|
|
# Applied identically on every peer; the boss's own 5x stacks on top.
|
|
|
|
|
if health_mult != 1.0 and "max_health" in enemy:
|
|
|
|
|
enemy.max_health *= health_mult
|
|
|
|
|
if damage_mult != 1.0 and "attack_damage" in enemy:
|
|
|
|
|
enemy.attack_damage *= damage_mult
|
|
|
|
|
|
2025-11-29 20:39:30 +00:00
|
|
|
# Find enemies container
|
|
|
|
|
var level = get_tree().get_current_scene()
|
|
|
|
|
var enemies_container = null
|
|
|
|
|
if level and level.has_node("EnemiesContainer"):
|
|
|
|
|
enemies_container = level.get_node("EnemiesContainer")
|
|
|
|
|
else:
|
|
|
|
|
push_error("[EnemySpawner] EnemiesContainer not found!")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Add to scene
|
|
|
|
|
enemies_container.add_child(enemy, true)
|
|
|
|
|
|
|
|
|
|
# Only server tracks active enemies and connects signals
|
|
|
|
|
if multiplayer.is_server():
|
|
|
|
|
active_enemies.append(enemy)
|
|
|
|
|
|
|
|
|
|
# Connect death signal if enemy is BaseEnemy
|
|
|
|
|
if enemy is BaseEnemy:
|
|
|
|
|
enemy.died.connect(_on_enemy_died.bind(enemy))
|
|
|
|
|
|
|
|
|
|
enemy_spawned.emit(enemy)
|
|
|
|
|
|
|
|
|
|
print("[EnemySpawner] Spawned ", enemy.name, " at ", enemy.global_position, " on peer ", multiplayer.get_unique_id())
|
|
|
|
|
|
|
|
|
|
## Called when an enemy dies
|
|
|
|
|
func _on_enemy_died(killer_id: int, enemy: Node):
|
|
|
|
|
if not multiplayer.is_server():
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
print("[EnemySpawner] Enemy ", enemy.name, " defeated by ", killer_id)
|
|
|
|
|
|
2026-07-01 23:37:56 +01:00
|
|
|
# Credit kill gold to the killing player (server decides, credits their peer)
|
|
|
|
|
if killer_id > 0 and Network.players.has(killer_id) and enemy is BaseEnemy:
|
|
|
|
|
GameState.server_credit_kill(killer_id, enemy.gold_reward)
|
|
|
|
|
|
2025-11-29 20:39:30 +00:00
|
|
|
# Will be cleaned up in _update_active_enemies
|
|
|
|
|
|
|
|
|
|
## Update list of active enemies and check if wave complete
|
|
|
|
|
func _update_active_enemies():
|
|
|
|
|
if not multiplayer.is_server():
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Remove dead/invalid enemies from active list
|
|
|
|
|
var alive_count = 0
|
|
|
|
|
for enemy in active_enemies:
|
|
|
|
|
if is_instance_valid(enemy) and enemy is BaseEnemy and not enemy.is_dead:
|
|
|
|
|
alive_count += 1
|
|
|
|
|
|
|
|
|
|
# Check if wave is complete
|
|
|
|
|
if active_enemies.size() > 0 and alive_count == 0:
|
|
|
|
|
_on_wave_completed()
|
|
|
|
|
|
|
|
|
|
## Called when all enemies in wave are defeated
|
|
|
|
|
func _on_wave_completed():
|
|
|
|
|
if not multiplayer.is_server():
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
print("[EnemySpawner] Wave ", current_wave, " completed!")
|
|
|
|
|
wave_completed.emit(current_wave)
|
|
|
|
|
|
2026-07-01 23:37:56 +01:00
|
|
|
# Wave-clear bonus for every living player (scales with wave number)
|
|
|
|
|
var wave_bonus: int = 25 + current_wave * 5
|
|
|
|
|
for peer_id in Network.players.keys():
|
|
|
|
|
GameState.server_credit_gold(peer_id, wave_bonus)
|
|
|
|
|
|
2025-11-29 20:39:30 +00:00
|
|
|
# Clean up dead enemies after a delay
|
|
|
|
|
await get_tree().create_timer(2.0).timeout
|
|
|
|
|
_cleanup_dead_enemies()
|
|
|
|
|
|
|
|
|
|
all_enemies_defeated.emit()
|
|
|
|
|
|
|
|
|
|
# Auto-start next wave if enabled
|
|
|
|
|
if auto_start_next_wave:
|
|
|
|
|
_waiting_for_next_wave = true
|
|
|
|
|
_wave_delay_timer = wave_delay
|
|
|
|
|
print("[EnemySpawner] Next wave starts in ", wave_delay, " seconds")
|
|
|
|
|
|
|
|
|
|
## Remove dead enemies from the scene
|
|
|
|
|
func _cleanup_dead_enemies():
|
|
|
|
|
var cleaned = 0
|
|
|
|
|
for enemy in active_enemies:
|
|
|
|
|
if is_instance_valid(enemy) and enemy is BaseEnemy and enemy.is_dead:
|
|
|
|
|
enemy.queue_free()
|
|
|
|
|
cleaned += 1
|
|
|
|
|
|
|
|
|
|
active_enemies.clear()
|
|
|
|
|
print("[EnemySpawner] Cleaned up ", cleaned, " defeated enemies")
|
|
|
|
|
|
|
|
|
|
## Manual wave control
|
|
|
|
|
func start_next_wave():
|
|
|
|
|
if not multiplayer.is_server():
|
|
|
|
|
return
|
|
|
|
|
start_wave()
|
|
|
|
|
|
|
|
|
|
func stop_waves():
|
|
|
|
|
_waiting_for_next_wave = false
|
|
|
|
|
|
|
|
|
|
## Get current wave info
|
|
|
|
|
func get_alive_enemy_count() -> int:
|
|
|
|
|
var count = 0
|
|
|
|
|
for enemy in active_enemies:
|
|
|
|
|
if is_instance_valid(enemy) and enemy is BaseEnemy and not enemy.is_dead:
|
|
|
|
|
count += 1
|
|
|
|
|
return count
|
|
|
|
|
|
|
|
|
|
func is_wave_active() -> bool:
|
|
|
|
|
return get_alive_enemy_count() > 0
|
|
|
|
|
|
|
|
|
|
## Create debug visualization circle
|
|
|
|
|
func _create_debug_circle():
|
|
|
|
|
# Create a circle mesh to show spawn radius
|
|
|
|
|
_debug_circle = MeshInstance3D.new()
|
|
|
|
|
_debug_circle.name = "DebugSpawnCircle"
|
|
|
|
|
|
|
|
|
|
# Create circle mesh using ImmediateMesh
|
|
|
|
|
var immediate_mesh = ImmediateMesh.new()
|
|
|
|
|
var material = StandardMaterial3D.new()
|
|
|
|
|
material.albedo_color = Color(1, 0.5, 0, 0.6) # Orange
|
|
|
|
|
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
|
|
|
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
|
|
|
material.cull_mode = BaseMaterial3D.CULL_DISABLED
|
|
|
|
|
|
|
|
|
|
# Draw circle
|
|
|
|
|
immediate_mesh.surface_begin(Mesh.PRIMITIVE_LINE_STRIP)
|
|
|
|
|
var segments = 64
|
|
|
|
|
for i in range(segments + 1):
|
|
|
|
|
var angle = (TAU / segments) * i
|
|
|
|
|
var x = cos(angle) * spawn_radius
|
|
|
|
|
var z = sin(angle) * spawn_radius
|
|
|
|
|
immediate_mesh.surface_add_vertex(Vector3(x, spawn_height, z))
|
|
|
|
|
immediate_mesh.surface_end()
|
|
|
|
|
|
|
|
|
|
_debug_circle.mesh = immediate_mesh
|
|
|
|
|
_debug_circle.material_override = material
|
|
|
|
|
_debug_circle.visible = show_spawn_radius
|
|
|
|
|
add_child(_debug_circle)
|
|
|
|
|
|
|
|
|
|
## Update debug visualization (useful when changing spawn_radius in editor)
|
|
|
|
|
func _process(_delta):
|
|
|
|
|
# Update circle visibility
|
|
|
|
|
if _debug_circle:
|
|
|
|
|
_debug_circle.visible = show_spawn_radius
|
|
|
|
|
|
2026-02-17 22:21:17 +00:00
|
|
|
# Server spawning logic (check peer is assigned first)
|
|
|
|
|
if multiplayer.multiplayer_peer == null or not multiplayer.is_server():
|
2025-11-29 20:39:30 +00:00
|
|
|
return
|
|
|
|
|
|
2026-06-30 22:57:11 +01:00
|
|
|
# Timed waves - spawn a fresh wave on a repeating interval
|
|
|
|
|
if _auto_waves_active:
|
|
|
|
|
_auto_wave_timer -= _delta
|
|
|
|
|
if _auto_wave_timer <= 0:
|
|
|
|
|
_auto_wave_timer = wave_interval
|
|
|
|
|
start_wave()
|
|
|
|
|
|
2025-11-29 20:39:30 +00:00
|
|
|
# Handle wave delay timer
|
|
|
|
|
if _waiting_for_next_wave:
|
|
|
|
|
_wave_delay_timer -= _delta
|
|
|
|
|
if _wave_delay_timer <= 0:
|
|
|
|
|
_waiting_for_next_wave = false
|
|
|
|
|
start_wave()
|
|
|
|
|
|
|
|
|
|
# Check if all enemies defeated
|
|
|
|
|
_update_active_enemies()
|