diff --git a/level/scenes/enemies/basic_enemy.tscn b/level/scenes/enemies/basic_enemy.tscn new file mode 100644 index 0000000..4bdd449 --- /dev/null +++ b/level/scenes/enemies/basic_enemy.tscn @@ -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") diff --git a/level/scenes/enemy_spawner.tscn b/level/scenes/enemy_spawner.tscn new file mode 100644 index 0000000..4101694 --- /dev/null +++ b/level/scenes/enemy_spawner.tscn @@ -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) diff --git a/level/scenes/level.tscn b/level/scenes/level.tscn index 53c5e89..d2f3083 100644 --- a/level/scenes/level.tscn +++ b/level/scenes/level.tscn @@ -1,9 +1,10 @@ -[gd_scene load_steps=21 format=3 uid="uid://dugaivbj1o66n"] +[gd_scene load_steps=14 format=3 uid="uid://dugaivbj1o66n"] [ext_resource type="Script" uid="uid://d0dgljwwl463n" path="res://level/scripts/level.gd" id="1_e1sh7"] [ext_resource type="PackedScene" uid="uid://db06e8q8f8bdq" path="res://level/scenes/Player_Lilguy.tscn" id="1_uvcbi"] -[ext_resource type="PackedScene" path="res://level/scenes/enemies/practice_dummy.tscn" id="3_i7s07"] +[ext_resource type="PackedScene" uid="uid://dif4t1y3c07ax" path="res://level/scenes/enemies/practice_dummy.tscn" id="3_i7s07"] [ext_resource type="FontFile" uid="uid://wipqjhfqeuwd" path="res://assets/fonts/Kurland.ttf" id="3_icc4p"] +[ext_resource type="PackedScene" path="res://level/scenes/enemy_spawner.tscn" id="3_spawner"] [ext_resource type="PackedScene" uid="uid://chkrcwlprbn88" path="res://assets/Objects/Colosseum_10.fbx" id="4_u750a"] [ext_resource type="PackedScene" uid="uid://hd6pq287rgye" path="res://level/scenes/weapons/world_weapon_testsword.tscn" id="5_cwx4m"] [ext_resource type="PackedScene" uid="uid://8c4l6s6x67vh" path="res://level/scenes/weapons/world_weapon_applecorer.tscn" id="6_xerh7"] @@ -15,29 +16,7 @@ size = Vector2(90, 90) albedo_color = Color(0, 0.321569, 0.172549, 1) [sub_resource type="BoxShape3D" id="BoxShape3D_x3h1o"] -size = Vector3(90, 0.05, 90) - -[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_lc35d"] -albedo_color = Color(0, 0, 0, 1) - -[sub_resource type="BoxMesh" id="BoxMesh_8pl0k"] - -[sub_resource type="BoxShape3D" id="BoxShape3D_f43m5"] - -[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_womqi"] -albedo_color = Color(0, 0, 0, 1) - -[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_taagp"] -albedo_color = Color(0, 0, 0, 1) - -[sub_resource type="BoxMesh" id="BoxMesh_q5fs2"] -size = Vector3(25, 1, 1.5) - -[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_fs7ud"] -albedo_color = Color(0, 0, 0, 1) - -[sub_resource type="BoxShape3D" id="BoxShape3D_epsao"] -size = Vector3(25, 1, 1.5) +size = Vector3(288.0893, 0.05, 350.08374) [sub_resource type="Environment" id="Environment_qb4jd"] fog_enabled = true @@ -56,67 +35,19 @@ practice_dummy_scene = ExtResource("3_i7s07") collision_layer = 2 [node name="MeshInstance3D" type="MeshInstance3D" parent="Environment/Floor"] +transform = Transform3D(3.140456, 0, 0, 0, 1, 0, 0, 0, 3.8321724, 0, 0, 0) mesh = SubResource("PlaneMesh_r5xs5") surface_material_override/0 = SubResource("StandardMaterial3D_o02aj") [node name="CollisionShape3D" type="CollisionShape3D" parent="Environment/Floor"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.6853943, 0, 1.6468506) shape = SubResource("BoxShape3D_x3h1o") -[node name="Box_1" type="StaticBody3D" parent="Environment"] -collision_layer = 2 - -[node name="MeshInstance3D" type="MeshInstance3D" parent="Environment/Box_1"] -transform = Transform3D(0.9, 0, 0, 0, 0.9, 0, 0, 0, 0.9, 2.91206, 0.456, 6.91607) -material_override = SubResource("StandardMaterial3D_lc35d") -mesh = SubResource("BoxMesh_8pl0k") - -[node name="CollisionShape3D" type="CollisionShape3D" parent="Environment/Box_1"] -transform = Transform3D(0.9, 0, 0, 0, 0.9, 0, 0, 0, 0.9, 2.91167, 0.456274, 6.91607) -shape = SubResource("BoxShape3D_f43m5") - -[node name="Box_2" type="StaticBody3D" parent="Environment"] -collision_layer = 2 - -[node name="MeshInstance3D" type="MeshInstance3D" parent="Environment/Box_2"] -transform = Transform3D(0.9, 0, 0, 0, 0.9, 0, 0, 0, 0.9, 2.91206, 2.456, 9.916) -mesh = SubResource("BoxMesh_8pl0k") -surface_material_override/0 = SubResource("StandardMaterial3D_womqi") - -[node name="CollisionShape3D" type="CollisionShape3D" parent="Environment/Box_2"] -transform = Transform3D(0.9, 0, 0, 0, 0.9, 0, 0, 0, 0.9, 2.91167, 2.456, 9.916) -shape = SubResource("BoxShape3D_f43m5") - -[node name="Box_3" type="StaticBody3D" parent="Environment"] -collision_layer = 2 - -[node name="MeshInstance3D" type="MeshInstance3D" parent="Environment/Box_3"] -transform = Transform3D(0.9, 0, 0, 0, 0.9, 0, 0, 0, 0.9, 2.91206, 4.456, 12.916) -mesh = SubResource("BoxMesh_8pl0k") -surface_material_override/0 = SubResource("StandardMaterial3D_taagp") - -[node name="CollisionShape3D" type="CollisionShape3D" parent="Environment/Box_3"] -transform = Transform3D(0.9, 0, 0, 0, 0.9, 0, 0, 0, 0.9, 2.91167, 4.456, 12.916) -shape = SubResource("BoxShape3D_f43m5") - -[node name="Box_4" type="StaticBody3D" parent="Environment"] -transform = Transform3D(0.9, 0, 0, 0, 0.9, 0, 0, 0, 0.9, 0, 0, 0) -collision_layer = 2 - -[node name="MeshInstance3D" type="MeshInstance3D" parent="Environment/Box_4"] -transform = Transform3D(0.9, 0, 0, 0, 0.9, 0, 0, 0, 0.9, -3.52997, 5.947, 17.398) -layers = 2 -mesh = SubResource("BoxMesh_q5fs2") -surface_material_override/0 = SubResource("StandardMaterial3D_fs7ud") - -[node name="CollisionShape3D" type="CollisionShape3D" parent="Environment/Box_4"] -transform = Transform3D(0.9, 0, 0, 0, 0.9, 0, 0, 0, 0.9, -3.52997, 5.947, 17.398) -shape = SubResource("BoxShape3D_epsao") - [node name="WorldEnvironment" type="WorldEnvironment" parent="Environment"] environment = SubResource("Environment_qb4jd") [node name="DirectionalLight3D" type="DirectionalLight3D" parent="Environment"] -transform = Transform3D(1, 0, 0, 0, -0.5, 0.866025, 0, -0.866025, -0.5, 0, 4, 0) +transform = Transform3D(1, 0, 0, 0, -0.50000024, 0.8660253, 0, -0.8660253, -0.50000024, 0, 18.907759, 0) shadow_enabled = true shadow_blur = 0.5 @@ -348,7 +279,7 @@ offset_right = 40.0 offset_bottom = 40.0 [node name="Colosseum_10" parent="." instance=ExtResource("4_u750a")] -transform = Transform3D(15, 0, 0, 0, 15, 0, 0, 0, 15, 1.301034, -1.2294581, 2.0630608) +transform = Transform3D(30, 0, 0, 0, 30, 0, 0, 0, 30, 1.301034, -2.3844016, 2.0630608) [node name="WeaponsContainer" type="Node3D" parent="."] @@ -360,6 +291,23 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.32151043, 5.2709904) [node name="EnemiesContainer" type="Node3D" parent="."] +[node name="EnemySpawner" parent="." instance=ExtResource("3_spawner")] +spawn_radius = 100.0 + +[node name="PlayerSpawnPoints" type="Node3D" parent="."] + +[node name="PlayerSpawn1" type="Node3D" parent="PlayerSpawnPoints"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 21.496109, 0, 12.60405) + +[node name="PlayerSpawn2" type="Node3D" parent="PlayerSpawnPoints"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 21.496109, 0, -13.144485) + +[node name="PlayerSpawn3" type="Node3D" parent="PlayerSpawnPoints"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -16.29047, 0, -13.144485) + +[node name="PlayerSpawn4" type="Node3D" parent="PlayerSpawnPoints"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -16.29047, 0, 13.986315) + [connection signal="pressed" from="Menu/MainContainer/MainMenu/Buttons/Host" to="." method="_on_host_pressed"] [connection signal="pressed" from="Menu/MainContainer/MainMenu/Buttons/Join" to="." method="_on_join_pressed"] [connection signal="pressed" from="Menu/MainContainer/MainMenu/Option4/Quit" to="." method="_on_quit_pressed"] diff --git a/level/scripts/basic_enemy.gd b/level/scripts/basic_enemy.gd new file mode 100644 index 0000000..935cca5 --- /dev/null +++ b/level/scripts/basic_enemy.gd @@ -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") diff --git a/level/scripts/basic_enemy.gd.uid b/level/scripts/basic_enemy.gd.uid new file mode 100644 index 0000000..b4f7907 --- /dev/null +++ b/level/scripts/basic_enemy.gd.uid @@ -0,0 +1 @@ +uid://cd87rsuiqhdav diff --git a/level/scripts/enemy_spawner.gd b/level/scripts/enemy_spawner.gd new file mode 100644 index 0000000..baca0e8 --- /dev/null +++ b/level/scripts/enemy_spawner.gd @@ -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() diff --git a/level/scripts/enemy_spawner.gd.uid b/level/scripts/enemy_spawner.gd.uid new file mode 100644 index 0000000..7f4c17f --- /dev/null +++ b/level/scripts/enemy_spawner.gd.uid @@ -0,0 +1 @@ +uid://dim3dvik1fd27 diff --git a/level/scripts/hit_box.gd b/level/scripts/hit_box.gd index 6e56593..5b54482 100644 --- a/level/scripts/hit_box.gd +++ b/level/scripts/hit_box.gd @@ -14,6 +14,9 @@ signal hit_landed(target: Node, damage: float, knockback: float, attacker_pos: V ## Owner entity (used to prevent self-damage and identify attacker) @export var owner_entity: Node = null +## Global debug visibility toggle (static-like via class name access) +static var debug_visible: bool = true + ## Whether hitbox is currently active (only deals damage when active) var is_active: bool = false ## Tracks entities hit this attack (prevents multi-hit) @@ -61,10 +64,15 @@ func _create_debug_visualization(collision_shape: CollisionShape3D): if mesh: _debug_mesh.mesh = mesh _debug_mesh.material_override = _debug_material + _debug_mesh.visible = HitBox.debug_visible # Don't set transform - it inherits from parent CollisionShape3D collision_shape.add_child(_debug_mesh) func _physics_process(_delta): + # Update debug visibility + if _debug_mesh: + _debug_mesh.visible = HitBox.debug_visible + if not is_active: return diff --git a/level/scripts/hurt_box.gd b/level/scripts/hurt_box.gd index e2f5c19..f09bc3a 100644 --- a/level/scripts/hurt_box.gd +++ b/level/scripts/hurt_box.gd @@ -5,6 +5,9 @@ class_name HurtBox ## Attach to any entity that can be damaged (players, enemies, destructibles) ## NOTE: This is a passive detection zone - HitBox handles the collision detection +## Global debug visibility toggle (static-like via class name access) +static var debug_visible: bool = true + ## The entity that owns this hurtbox (should be a BaseUnit or similar) @export var owner_entity: Node = null ## Debug mesh for visualization @@ -35,6 +38,10 @@ func _ready(): _create_debug_visualization() func _process(delta): + # Update debug visibility + if _debug_mesh: + _debug_mesh.visible = HurtBox.debug_visible + # Handle hit flash timer if _hit_flash_timer > 0.0: _hit_flash_timer -= delta @@ -75,6 +82,7 @@ func _create_debug_visualization(): if mesh: _debug_mesh.mesh = mesh _debug_mesh.material_override = _debug_material + _debug_mesh.visible = HurtBox.debug_visible # Don't set transform - it inherits from parent CollisionShape3D child.add_child(_debug_mesh) break diff --git a/level/scripts/level.gd b/level/scripts/level.gd index f70b33c..83d59c8 100644 --- a/level/scripts/level.gd +++ b/level/scripts/level.gd @@ -6,6 +6,8 @@ extends Node3D @onready var players_container: Node3D = $PlayersContainer @onready var weapons_container: Node3D = $WeaponsContainer @onready var enemies_container: Node3D = $EnemiesContainer +@onready var enemy_spawner: EnemySpawner = $EnemySpawner +@onready var player_spawn_points: Node3D = $PlayerSpawnPoints @onready var menu: Control = $Menu @onready var main_menu: VBoxContainer = $Menu/MainContainer/MainMenu @export var player_scene: PackedScene @@ -17,6 +19,8 @@ var _weapon_spawn_counter: int = 0 var _active_weapons: Dictionary = {} # weapon_id -> WorldWeapon reference # Track if we've already initialized to prevent double-spawning var _multiplayer_initialized: bool = false +# Track next spawn point for round-robin spawning (server-side only) +var _next_spawn_index: int = 0 # multiplayer chat @onready var message: LineEdit = $MultiplayerChat/VBoxContainer/HBoxContainer/Message @@ -342,8 +346,22 @@ func _spawn_player_local(id: int, spawn_pos: Vector3): # rpc("sync_player_position", id, player.position) func get_spawn_point() -> Vector3: - var spawn_point = Vector2.from_angle(randf() * 2 * PI) * 10 # spawn radius - return Vector3(spawn_point.x, 0, spawn_point.y) + # Use PlayerSpawnPoint container if available + if player_spawn_points and player_spawn_points.get_child_count() > 0: + var spawn_children = player_spawn_points.get_children() + + # Use round-robin to distribute players across spawn points + var spawn_index = _next_spawn_index % spawn_children.size() + _next_spawn_index = (spawn_index + 1) % spawn_children.size() + + var spawn_node = spawn_children[spawn_index] + print("[Level] Using spawn point ", spawn_index, " at ", spawn_node.global_position) + return spawn_node.global_position + else: + # Fallback to random circle spawn if no spawn points defined + print("[Level] Warning: No PlayerSpawnPoint container found, using fallback random spawn") + var spawn_point = Vector2.from_angle(randf() * 2 * PI) * 10 # spawn radius + return Vector3(spawn_point.x, 0, spawn_point.y) func _remove_player(id): if not multiplayer.is_server() or not players_container.has_node(str(id)): @@ -388,6 +406,16 @@ func _input(event): toggle_chat() elif event is InputEventKey and event.keycode == KEY_ENTER: _on_send_pressed() + elif event is InputEventKey and event.keycode == KEY_N and event.pressed: + # Start next wave (server only, press N) + if multiplayer.is_server() and enemy_spawner: + enemy_spawner.start_next_wave() + print("[Level] Starting wave via N key") + elif event is InputEventKey and event.keycode == KEY_H and event.pressed: + # Toggle hitbox/hurtbox debug visualization + HitBox.debug_visible = not HitBox.debug_visible + HurtBox.debug_visible = not HurtBox.debug_visible + print("[Level] Hitbox/Hurtbox debug visibility: ", HitBox.debug_visible) func _on_send_pressed() -> void: var trimmed_message = message.text.strip_edges()