parent
b606563f4d
commit
689181ac30
10 changed files with 699 additions and 79 deletions
@ -0,0 +1,63 @@ |
|||||||
|
[gd_scene load_steps=8 format=3 uid="uid://byknup31d2b53"] |
||||||
|
|
||||||
|
[ext_resource type="Script" uid="uid://cd87rsuiqhdav" path="res://level/scripts/basic_enemy.gd" id="1_basic_enemy"] |
||||||
|
[ext_resource type="Script" uid="uid://bj3uepduxvgju" path="res://level/scripts/hurt_box.gd" id="2_hurtbox"] |
||||||
|
[ext_resource type="ArrayMesh" uid="uid://dy0xld0fpulmk" path="res://assets/characters/Lobster/10029_Lobster_v1_iterations-2.obj" id="2_obxet"] |
||||||
|
[ext_resource type="Script" uid="uid://jyas86y3f0jp" path="res://level/scripts/hit_box.gd" id="3_hitbox"] |
||||||
|
|
||||||
|
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"] |
||||||
|
height = 1.869873 |
||||||
|
|
||||||
|
[sub_resource type="SphereShape3D" id="SphereShape3D_hitbox"] |
||||||
|
radius = 2.0 |
||||||
|
|
||||||
|
[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_enemy"] |
||||||
|
properties/0/path = NodePath(".:position") |
||||||
|
properties/0/spawn = true |
||||||
|
properties/0/replication_mode = 1 |
||||||
|
properties/1/path = NodePath(".:rotation") |
||||||
|
properties/1/spawn = true |
||||||
|
properties/1/replication_mode = 1 |
||||||
|
|
||||||
|
[node name="BasicEnemy" type="CharacterBody3D"] |
||||||
|
collision_mask = 3 |
||||||
|
script = ExtResource("1_basic_enemy") |
||||||
|
move_speed = 3.5 |
||||||
|
attack_damage = 5.0 |
||||||
|
detection_range = 500.0 |
||||||
|
max_health = 20.0 |
||||||
|
|
||||||
|
[node name="Mesh" type="MeshInstance3D" parent="."] |
||||||
|
transform = Transform3D(-0.29981995, -0.01038597, -0.00033419454, 0.00636219, -0.19110087, 0.23117083, -0.008215994, 0.23102501, 0.19120643, 0, 0.41641736, 0) |
||||||
|
mesh = ExtResource("2_obxet") |
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="."] |
||||||
|
transform = Transform3D(1.5, 0, 0, 0, 1.5, 0, 0, 0, 1.5, 0, 1, 0) |
||||||
|
shape = SubResource("CapsuleShape3D_body") |
||||||
|
|
||||||
|
[node name="HurtBox" type="Area3D" parent="." node_paths=PackedStringArray("owner_entity")] |
||||||
|
transform = Transform3D(1.5, 0, 0, 0, 1.5, 0, 0, 0, 1.5, 0, -0.47265148, 0) |
||||||
|
collision_layer = 16 |
||||||
|
collision_mask = 0 |
||||||
|
script = ExtResource("2_hurtbox") |
||||||
|
owner_entity = NodePath("..") |
||||||
|
|
||||||
|
[node name="HurtBoxShape" type="CollisionShape3D" parent="HurtBox"] |
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9349365, 0) |
||||||
|
shape = SubResource("CapsuleShape3D_body") |
||||||
|
|
||||||
|
[node name="HitBox" type="Area3D" parent="." node_paths=PackedStringArray("owner_entity")] |
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.8, -1.2) |
||||||
|
collision_layer = 0 |
||||||
|
collision_mask = 16 |
||||||
|
script = ExtResource("3_hitbox") |
||||||
|
damage = 15.0 |
||||||
|
knockback = 10.0 |
||||||
|
owner_entity = NodePath("..") |
||||||
|
|
||||||
|
[node name="HitBoxShape" type="CollisionShape3D" parent="HitBox"] |
||||||
|
transform = Transform3D(0.8, 0, 0, 0, 0.8, 0, 0, 0, 0.8, 0, 0.13992286, 0) |
||||||
|
shape = SubResource("SphereShape3D_hitbox") |
||||||
|
|
||||||
|
[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="."] |
||||||
|
replication_config = SubResource("SceneReplicationConfig_enemy") |
||||||
@ -0,0 +1,27 @@ |
|||||||
|
[gd_scene load_steps=4 format=3 uid="uid://blm8lav3xh2yw"] |
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://level/scripts/enemy_spawner.gd" id="1_spawner"] |
||||||
|
[ext_resource type="PackedScene" uid="uid://byknup31d2b53" path="res://level/scenes/enemies/basic_enemy.tscn" id="2_basic_enemy"] |
||||||
|
|
||||||
|
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_indicator"] |
||||||
|
albedo_color = Color(1, 0.5, 0, 0.3) |
||||||
|
transparency = 1 |
||||||
|
cull_mode = 2 |
||||||
|
shading_mode = 0 |
||||||
|
|
||||||
|
[node name="EnemySpawner" type="Node3D"] |
||||||
|
script = ExtResource("1_spawner") |
||||||
|
spawn_radius = 20.0 |
||||||
|
spawn_height = 0.5 |
||||||
|
enemies_per_wave = 3 |
||||||
|
auto_start_next_wave = false |
||||||
|
wave_delay = 5.0 |
||||||
|
enemy_scenes = Array[PackedScene]([ExtResource("2_basic_enemy")]) |
||||||
|
|
||||||
|
[node name="SpawnRadiusIndicator" type="MeshInstance3D" parent="."] |
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.1, 0) |
||||||
|
visible = false |
||||||
|
material_override = SubResource("StandardMaterial3D_indicator") |
||||||
|
|
||||||
|
[node name="CenterMarker" type="MeshInstance3D" parent="."] |
||||||
|
transform = Transform3D(0.5, 0, 0, 0, 2, 0, 0, 0, 0.5, 0, 1, 0) |
||||||
@ -0,0 +1,285 @@ |
|||||||
|
extends BaseEnemy |
||||||
|
class_name BasicEnemy |
||||||
|
|
||||||
|
## A basic melee enemy that chases and attacks players |
||||||
|
## Server-authoritative AI with multiplayer support |
||||||
|
|
||||||
|
## Movement |
||||||
|
@export var move_speed: float = 3.0 |
||||||
|
@export var chase_range: float = 15.0 |
||||||
|
@export var attack_range: float = 2.5 |
||||||
|
|
||||||
|
## Combat |
||||||
|
@export var attack_damage: float = 15.0 |
||||||
|
@export var attack_knockback: float = 10.0 |
||||||
|
@export var attack_cooldown: float = 1.5 |
||||||
|
@export_category("Attack Timing") |
||||||
|
@export var attack_startup: float = 0.3 # Wind-up before hit |
||||||
|
@export var attack_active: float = 0.4 # Hit window duration |
||||||
|
|
||||||
|
## References |
||||||
|
var _hitbox: HitBox = null |
||||||
|
var _hurtbox: HurtBox = null |
||||||
|
var _mesh: MeshInstance3D = null |
||||||
|
|
||||||
|
## AI State |
||||||
|
var _attack_timer: float = 0.0 |
||||||
|
var _is_attacking: bool = false |
||||||
|
var _original_material: Material = null |
||||||
|
var _hit_flash_timer: float = 0.0 |
||||||
|
const HIT_FLASH_DURATION: float = 0.2 |
||||||
|
|
||||||
|
func _enter_tree(): |
||||||
|
# Enemies are always server-authoritative |
||||||
|
set_multiplayer_authority(1) |
||||||
|
|
||||||
|
func _ready(): |
||||||
|
super._ready() |
||||||
|
|
||||||
|
# Find mesh, hitbox, and hurtbox |
||||||
|
_mesh = get_node_or_null("Mesh") |
||||||
|
_hitbox = get_node_or_null("HitBox") |
||||||
|
_hurtbox = get_node_or_null("HurtBox") |
||||||
|
|
||||||
|
# Store original material for hit flash |
||||||
|
if _mesh: |
||||||
|
_original_material = _mesh.get_surface_override_material(0) |
||||||
|
if not _original_material and _mesh.mesh: |
||||||
|
_original_material = _mesh.mesh.surface_get_material(0) |
||||||
|
|
||||||
|
# Setup hitbox |
||||||
|
if _hitbox: |
||||||
|
_hitbox.owner_entity = self |
||||||
|
_hitbox.set_stats(attack_damage, attack_knockback) |
||||||
|
_hitbox.hit_landed.connect(_on_hitbox_hit) |
||||||
|
|
||||||
|
# Setup hurtbox |
||||||
|
if _hurtbox: |
||||||
|
_hurtbox.owner_entity = self |
||||||
|
|
||||||
|
func _process(delta): |
||||||
|
# Countdown timers |
||||||
|
if _attack_timer > 0: |
||||||
|
_attack_timer -= delta |
||||||
|
|
||||||
|
# Handle hit flash |
||||||
|
if _hit_flash_timer > 0: |
||||||
|
_hit_flash_timer -= delta |
||||||
|
if _hit_flash_timer <= 0: |
||||||
|
_reset_material() |
||||||
|
|
||||||
|
func _physics_process(delta): |
||||||
|
super._physics_process(delta) |
||||||
|
|
||||||
|
# Only server runs AI |
||||||
|
if not multiplayer.is_server(): |
||||||
|
return |
||||||
|
|
||||||
|
if is_dead: |
||||||
|
return |
||||||
|
|
||||||
|
# Apply gravity |
||||||
|
if not is_on_floor(): |
||||||
|
velocity.y -= ProjectSettings.get_setting("physics/3d/default_gravity") * delta |
||||||
|
|
||||||
|
# AI behavior |
||||||
|
if current_target and is_instance_valid(current_target): |
||||||
|
_ai_combat(delta) |
||||||
|
else: |
||||||
|
# Stop moving if no target |
||||||
|
velocity.x = 0 |
||||||
|
velocity.z = 0 |
||||||
|
|
||||||
|
move_and_slide() |
||||||
|
|
||||||
|
## Override to find nearest player without range limit |
||||||
|
func get_nearest_player() -> Node: |
||||||
|
# Find all players in a very large range (essentially unlimited) |
||||||
|
var players = get_players_in_range(1000.0) # 1000m range - basically unlimited |
||||||
|
|
||||||
|
if players.is_empty(): |
||||||
|
return null |
||||||
|
|
||||||
|
var nearest_player = null |
||||||
|
var nearest_distance = INF |
||||||
|
|
||||||
|
for player in players: |
||||||
|
var distance = global_position.distance_to(player.global_position) |
||||||
|
if distance < nearest_distance: |
||||||
|
nearest_distance = distance |
||||||
|
nearest_player = player |
||||||
|
|
||||||
|
return nearest_player |
||||||
|
|
||||||
|
## Update target - called by BaseEnemy |
||||||
|
func _update_target(): |
||||||
|
if not is_aggressive: |
||||||
|
return |
||||||
|
|
||||||
|
# Always find and target the nearest player (no range limit) |
||||||
|
var nearest = get_nearest_player() |
||||||
|
|
||||||
|
if nearest: |
||||||
|
# Update target if it changed |
||||||
|
if current_target != nearest: |
||||||
|
current_target = nearest |
||||||
|
target_changed.emit(nearest) |
||||||
|
else: |
||||||
|
# No players exist |
||||||
|
if current_target != null: |
||||||
|
current_target = null |
||||||
|
target_changed.emit(null) |
||||||
|
|
||||||
|
## Combat AI behavior |
||||||
|
func _ai_combat(delta): |
||||||
|
if not current_target or not is_instance_valid(current_target): |
||||||
|
return |
||||||
|
|
||||||
|
var target_pos = current_target.global_position |
||||||
|
var direction = (target_pos - global_position).normalized() |
||||||
|
var distance = global_position.distance_to(target_pos) |
||||||
|
|
||||||
|
# Face the target |
||||||
|
if direction.length() > 0.01: |
||||||
|
var look_dir = Vector3(direction.x, 0, direction.z) |
||||||
|
if look_dir.length() > 0.01: |
||||||
|
look_at(global_position + look_dir, Vector3.UP) |
||||||
|
|
||||||
|
# If in attack range, attack |
||||||
|
if distance <= attack_range: |
||||||
|
# Stop moving when attacking |
||||||
|
velocity.x = 0 |
||||||
|
velocity.z = 0 |
||||||
|
|
||||||
|
# Try to attack |
||||||
|
if _attack_timer <= 0 and not _is_attacking: |
||||||
|
_perform_attack() |
||||||
|
else: |
||||||
|
# Always chase the target (no range limit) |
||||||
|
velocity.x = direction.x * move_speed |
||||||
|
velocity.z = direction.z * move_speed |
||||||
|
|
||||||
|
## Perform melee attack |
||||||
|
func _perform_attack(): |
||||||
|
if _is_attacking or not multiplayer.is_server(): |
||||||
|
return |
||||||
|
|
||||||
|
# Calculate total attack duration |
||||||
|
var total_duration = attack_startup + attack_active |
||||||
|
var cooldown = max(attack_cooldown, total_duration) |
||||||
|
|
||||||
|
_attack_timer = cooldown |
||||||
|
_is_attacking = true |
||||||
|
|
||||||
|
# Play attack animation on all clients |
||||||
|
rpc("_sync_attack_animation") |
||||||
|
|
||||||
|
# Activate hitbox |
||||||
|
_activate_hitbox() |
||||||
|
|
||||||
|
## Activate hitbox for attack |
||||||
|
func _activate_hitbox(): |
||||||
|
if not _hitbox or not multiplayer.is_server(): |
||||||
|
_is_attacking = false |
||||||
|
return |
||||||
|
|
||||||
|
# STARTUP PHASE - Wait before activating |
||||||
|
if attack_startup > 0: |
||||||
|
await get_tree().create_timer(attack_startup).timeout |
||||||
|
|
||||||
|
if not _hitbox or not is_instance_valid(_hitbox) or is_dead: |
||||||
|
_is_attacking = false |
||||||
|
return |
||||||
|
|
||||||
|
# ACTIVE PHASE - Hitbox on |
||||||
|
_hitbox.activate() |
||||||
|
|
||||||
|
await get_tree().create_timer(attack_active).timeout |
||||||
|
|
||||||
|
# RECOVERY PHASE - Hitbox off |
||||||
|
if _hitbox and is_instance_valid(_hitbox): |
||||||
|
_hitbox.deactivate() |
||||||
|
|
||||||
|
_is_attacking = false |
||||||
|
|
||||||
|
## Called when hitbox hits something |
||||||
|
func _on_hitbox_hit(target: Node, damage_amount: float, knockback_amount: float, attacker_pos: Vector3): |
||||||
|
if not target or not multiplayer.is_server(): |
||||||
|
return |
||||||
|
|
||||||
|
# Flash target's hurtbox |
||||||
|
if target is Node: |
||||||
|
var hurtbox = target.find_child("HurtBox", true, false) |
||||||
|
if hurtbox and hurtbox.has_method("flash_hit"): |
||||||
|
hurtbox.flash_hit() |
||||||
|
|
||||||
|
# Server applies damage directly |
||||||
|
if target is BaseUnit: |
||||||
|
target.take_damage(damage_amount, 1, knockback_amount, global_position) |
||||||
|
|
||||||
|
## Play attack animation (synced to all clients) |
||||||
|
@rpc("any_peer", "call_local", "reliable") |
||||||
|
func _sync_attack_animation(): |
||||||
|
# Visual feedback for attack |
||||||
|
# Could play animation here if you have an AnimationPlayer |
||||||
|
pass |
||||||
|
|
||||||
|
## Override hurt animation to flash red |
||||||
|
@rpc("any_peer", "call_local", "reliable") |
||||||
|
func _play_hurt_animation(): |
||||||
|
if _mesh: |
||||||
|
_flash_red() |
||||||
|
|
||||||
|
## Flash red when hit |
||||||
|
func _flash_red(): |
||||||
|
if not _mesh: |
||||||
|
return |
||||||
|
|
||||||
|
_hit_flash_timer = HIT_FLASH_DURATION |
||||||
|
|
||||||
|
# Create red material |
||||||
|
var red_material = StandardMaterial3D.new() |
||||||
|
red_material.albedo_color = Color(1.5, 0.3, 0.3) |
||||||
|
|
||||||
|
# Copy properties from original if exists |
||||||
|
if _original_material and _original_material is StandardMaterial3D: |
||||||
|
var orig = _original_material as StandardMaterial3D |
||||||
|
red_material.metallic = orig.metallic |
||||||
|
red_material.roughness = orig.roughness |
||||||
|
red_material.albedo_texture = orig.albedo_texture |
||||||
|
|
||||||
|
_mesh.set_surface_override_material(0, red_material) |
||||||
|
|
||||||
|
## Reset material |
||||||
|
func _reset_material(): |
||||||
|
if _mesh and _original_material: |
||||||
|
_mesh.set_surface_override_material(0, _original_material.duplicate()) |
||||||
|
|
||||||
|
## Death callback |
||||||
|
func _on_enemy_died(killer_id: int): |
||||||
|
super._on_enemy_died(killer_id) |
||||||
|
|
||||||
|
# Hide when dead |
||||||
|
if _mesh: |
||||||
|
_mesh.visible = false |
||||||
|
|
||||||
|
# Deactivate hitbox |
||||||
|
if _hitbox: |
||||||
|
_hitbox.deactivate() |
||||||
|
|
||||||
|
print("[BasicEnemy ", name, "] killed by ", killer_id) |
||||||
|
|
||||||
|
## Respawn callback |
||||||
|
func _on_enemy_respawned(): |
||||||
|
super._on_enemy_respawned() |
||||||
|
|
||||||
|
# Show mesh |
||||||
|
if _mesh: |
||||||
|
_mesh.visible = true |
||||||
|
_reset_material() |
||||||
|
|
||||||
|
# Reset state |
||||||
|
_attack_timer = 0.0 |
||||||
|
_is_attacking = false |
||||||
|
|
||||||
|
print("[BasicEnemy ", name, "] respawned") |
||||||
@ -0,0 +1 @@ |
|||||||
|
uid://cd87rsuiqhdav |
||||||
@ -0,0 +1,251 @@ |
|||||||
|
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("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 |
||||||
|
|
||||||
|
## 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() |
||||||
|
|
||||||
|
## 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) |
||||||
|
|
||||||
|
# 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) |
||||||
|
|
||||||
|
# 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) |
||||||
|
|
||||||
|
# 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 |
||||||
|
if not multiplayer.is_server(): |
||||||
|
return |
||||||
|
|
||||||
|
# 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() |
||||||
@ -0,0 +1 @@ |
|||||||
|
uid://dim3dvik1fd27 |
||||||
Loading…
Reference in new issue