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 @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 @export_category("Enemy Pool") @export var enemy_scenes: Array[PackedScene] = [] ## List of enemy scenes to spawn from @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 ## 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 var _auto_waves_active: bool = false var _auto_wave_timer: float = 0.0 ## 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() ## 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 ## 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) # Broadcast wave number to every peer's GameState (for HUD + run stats) GameState.server_sync_wave(current_wave) # 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)) ## 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 rpc("_spawn_enemy_on_client", enemy_name, spawn_pos, enemy_scene_path, _wave_health_mult(current_wave), _wave_damage_mult(current_wave)) ## RPC to spawn enemy on all clients (including server via call_local) @rpc("any_peer", "call_local", "reliable") func _spawn_enemy_on_client(enemy_name: String, spawn_pos: Vector3, scene_path: String, health_mult: float = 1.0, damage_mult: float = 1.0): # 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 # 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 # 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) # 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) # 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) # 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) # 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 # Server spawning logic (check peer is assigned first) if multiplayer.multiplayer_peer == null or not multiplayer.is_server(): return # 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() # 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()