Turns the sandbox into a survival roguelike loop: outfit your gladiator with banked gold, enter the arena, earn gold from kills and wave clears, die, keep 10% of your total value (floored at the 500 fresh-start). Economy (GameState autoload): - banked_gold, owned items, and loadout persist to user://save.cfg - Kill gold (basic 10g, armed 25g) credited server-side to the killer - Wave-clear bonus (25 + 5/wave) for all players; wave synced to peers - Death settlement liquidates gear, guarded against double-fire Character creation / shop: - Menu reframed: armory shop + live Character Sheet loadout preview - Character Sheet gains preview mode (renders from loadout, no player) - Buy/Equip with hand rules; two-handers and off-hands displace each other symmetrically (shop and in-game pickups) - Chosen loadout auto-equips on spawn via existing RPC path Combat feel: - Attacks snap to camera facing and lock direction for the swing - Dash commits to its direction for the full duration - Weapon slots show name text when no icon is set - Hitbox/hurtbox debug meshes hidden by default (H toggles) Death is final: players no longer respawn; a results screen shows waves, time, kills, and the settlement, then returns to camp. Armed enemies no longer respawn (were an infinite gold farm and respawned invisible).
290 lines
8.9 KiB
GDScript
290 lines
8.9 KiB
GDScript
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
|
|
|
|
## 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)
|
|
|
|
# Spawn enemies
|
|
for i in range(enemies_per_wave):
|
|
_spawn_enemy(i, enemies_per_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)
|
|
|
|
## 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):
|
|
# 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
|
|
|
|
# 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()
|