From 7629342540cb0ab11ac6f34b68dec7cf1683b24f Mon Sep 17 00:00:00 2001 From: Twirpytherobot Date: Tue, 17 Feb 2026 22:21:17 +0000 Subject: [PATCH] Armed guys! --- level/resources/weapon_applecorer.tres | 3 +- level/resources/weapon_lobsteraxe.tres | 14 +- level/resources/weapon_sword.tres | 10 +- level/scenes/enemies/armed_enemy.tscn | 124 +++++ level/scenes/level.tscn | 6 +- level/scenes/weapons/LobsterAxeMesh.tscn | 7 +- level/scripts/armed_enemy.gd | 616 +++++++++++++++++++++++ level/scripts/armed_enemy.gd.uid | 1 + level/scripts/base_enemy.gd | 4 +- level/scripts/base_weapon.gd | 73 +-- level/scripts/enemy_spawner.gd | 4 +- level/scripts/level.gd | 107 +++- level/scripts/lilguy_body.gd | 18 +- level/scripts/player.gd | 14 +- 14 files changed, 928 insertions(+), 73 deletions(-) create mode 100644 level/scenes/enemies/armed_enemy.tscn create mode 100644 level/scripts/armed_enemy.gd create mode 100644 level/scripts/armed_enemy.gd.uid diff --git a/level/resources/weapon_applecorer.tres b/level/resources/weapon_applecorer.tres index e716a3d..9c2d1fc 100644 --- a/level/resources/weapon_applecorer.tres +++ b/level/resources/weapon_applecorer.tres @@ -10,9 +10,8 @@ description = "yum" damage = 20.0 attack_range = 3.5 attack_cooldown = 0.6 -attack_animation = "Attack_TwoHandSwing" knockback_force = 12.0 startup_time = 0.2 -active_time = 0.15 +active_time = 1.0 mesh_scene = ExtResource("1_1ytxi") weight = 2.0 diff --git a/level/resources/weapon_lobsteraxe.tres b/level/resources/weapon_lobsteraxe.tres index f689a67..e3c23f1 100644 --- a/level/resources/weapon_lobsteraxe.tres +++ b/level/resources/weapon_lobsteraxe.tres @@ -1,19 +1,17 @@ -[gd_resource type="Resource" script_class="WeaponData" load_steps=3 format=3] +[gd_resource type="Resource" script_class="WeaponData" load_steps=3 format=3 uid="uid://dyae861vxd8it"] -[ext_resource type="Script" path="res://level/scripts/weapon_data.gd" id="1"] -[ext_resource type="PackedScene" path="res://level/scenes/weapons/LobsterAxeMesh.tscn" id="2"] +[ext_resource type="Script" uid="uid://d2homvlmrg6xs" path="res://level/scripts/weapon_data.gd" id="1"] +[ext_resource type="PackedScene" uid="uid://cq8r5mkn3wvxj" path="res://level/scenes/weapons/LobsterAxeMesh.tscn" id="2"] [resource] script = ExtResource("1") weapon_name = "Lobster Axe" description = "A heavy-hitting axe shaped like a lobster claw. Surprisingly quick for its size." damage = 18.0 -attack_range = 3.0 attack_cooldown = 0.7 +attack_animation = "Attack_TwoHandSwing" knockback_force = 14.0 -attack_animation = "Attack1" -startup_time = 0.18 -active_time = 0.18 +startup_time = 0.2 +active_time = 1.0 mesh_scene = ExtResource("2") -pickup_radius = 1.5 weight = 2.5 diff --git a/level/resources/weapon_sword.tres b/level/resources/weapon_sword.tres index f6404d7..2e73bb8 100644 --- a/level/resources/weapon_sword.tres +++ b/level/resources/weapon_sword.tres @@ -1,7 +1,7 @@ -[gd_resource type="Resource" script_class="WeaponData" load_steps=3 format=3] +[gd_resource type="Resource" script_class="WeaponData" load_steps=3 format=3 uid="uid://cuwjmtrp4silp"] -[ext_resource type="Script" path="res://level/scripts/weapon_data.gd" id="1"] -[ext_resource type="PackedScene" path="res://level/scenes/weapons/sword_mesh.tscn" id="2"] +[ext_resource type="Script" uid="uid://d2homvlmrg6xs" path="res://level/scripts/weapon_data.gd" id="1"] +[ext_resource type="PackedScene" uid="uid://dyjfaq654xne3" path="res://level/scenes/weapons/sword_mesh.tscn" id="2"] [resource] script = ExtResource("1") @@ -11,9 +11,7 @@ damage = 15.0 attack_range = 3.5 attack_cooldown = 0.6 knockback_force = 12.0 -attack_animation = "Attack1" startup_time = 0.12 -active_time = 0.2 +active_time = 1.0 mesh_scene = ExtResource("2") -pickup_radius = 1.5 weight = 2.0 diff --git a/level/scenes/enemies/armed_enemy.tscn b/level/scenes/enemies/armed_enemy.tscn new file mode 100644 index 0000000..e74d8c9 --- /dev/null +++ b/level/scenes/enemies/armed_enemy.tscn @@ -0,0 +1,124 @@ +[gd_scene load_steps=5 format=3] + +[ext_resource type="Script" path="res://level/scripts/armed_enemy.gd" id="1_armed_enemy"] +[ext_resource type="PackedScene" uid="uid://b22ou40sbkavj" path="res://assets/characters/player/LilguyRigged.glb" id="2_lilguy"] +[ext_resource type="Script" uid="uid://cf7jky1bcs560" path="res://level/scripts/lilguy_body.gd" id="3_body"] +[ext_resource type="Script" uid="uid://bj3uepduxvgju" path="res://level/scripts/hurt_box.gd" id="4_hurtbox"] + +[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"] +radius = 0.35796 +height = 1.73092 + +[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_hurtbox"] +radius = 0.4 +height = 1.8 + +[node name="ArmedEnemy" type="CharacterBody3D" node_paths=PackedStringArray("_body", "_weapon_attachment", "_weapon_container", "_offhand_attachment", "_offhand_container")] +collision_mask = 3 +script = ExtResource("1_armed_enemy") +_body = NodePath("LilguyRigged/Armature") +_weapon_attachment = NodePath("LilguyRigged/Armature/Skeleton3D/WeaponPoint") +_weapon_container = NodePath("LilguyRigged/Armature/Skeleton3D/WeaponPoint/WeaponContainer") +_offhand_attachment = NodePath("LilguyRigged/Armature/Skeleton3D/OffhandPoint") +_offhand_container = NodePath("LilguyRigged/Armature/Skeleton3D/OffhandPoint/OffhandContainer") +move_speed = 4.0 +detection_range = 100.0 +max_health = 50.0 +respawn_delay = 10.0 + +[node name="LilguyRigged" parent="." instance=ExtResource("2_lilguy")] + +[node name="Armature" parent="LilguyRigged" index="0" node_paths=PackedStringArray("_character", "animation_player")] +transform = Transform3D(0.003, 0, 0, 0, -1.3113416e-10, -0.003, 0, 0.003, -1.3113416e-10, 0, 0, 0) +script = ExtResource("3_body") +_character = NodePath("../..") +animation_player = NodePath("../AnimationPlayer") + +[node name="Skeleton3D" parent="LilguyRigged/Armature" index="0"] +bones/0/position = Vector3(-0.32859802, 2.9141626, -546.76843) +bones/0/rotation = Quaternion(-0.6608289, 0.28933656, -0.19178493, 0.66543835) +bones/1/position = Vector3(0.054167695, 63.219894, -3.33786e-06) +bones/1/rotation = Quaternion(0.015321612, 0.025352655, 0.0947179, 0.99506336) +bones/2/position = Vector3(-1.8112361e-05, 73.7566, -1.621247e-05) +bones/2/rotation = Quaternion(0.034659874, 0.050472155, 0.051301125, 0.99680465) +bones/3/position = Vector3(-3.0510128e-05, 84.29319, 9.059899e-06) +bones/3/rotation = Quaternion(0.029244598, 0.05379088, -0.051849354, 0.99677634) +bones/4/position = Vector3(3.8038404e-05, 94.83001, 1.9073414e-06) +bones/4/rotation = Quaternion(0.0006217413, 0.08164966, 0.020404326, 0.9964521) +bones/5/position = Vector3(-0.25257444, 72.84532, -7.644296e-06) +bones/5/rotation = Quaternion(0.037355006, 0.19943582, -0.04159446, 0.9783148) +bones/6/position = Vector3(-0.606337, 174.89494, 7.152558e-06) +bones/7/position = Vector3(-0.19949026, 76.75483, 52.286175) +bones/7/rotation = Quaternion(0.80360717, -0.09628771, 0.10672592, 0.57754105) +bones/8/position = Vector3(4.5403274e-05, 110.91907, 9.404198e-05) +bones/8/rotation = Quaternion(0.25522023, -0.08967148, 0.029356971, 0.9622681) +bones/9/position = Vector3(2.3064584e-05, 173.66367, 5.063071e-05) +bones/9/rotation = Quaternion(0.08784258, -0.16096693, 0.24338366, 0.95243776) +bones/10/position = Vector3(-2.2947788e-05, 166.48767, -1.2734416e-05) +bones/11/position = Vector3(0.23053212, 76.75536, -52.28617) +bones/11/rotation = Quaternion(0.14271267, -0.5852636, 0.7822879, 0.15851) +bones/12/position = Vector3(1.532285e-05, 110.91911, 4.0430357e-05) +bones/12/rotation = Quaternion(0.32197043, 0.13412262, 0.2711233, 0.89712787) +bones/13/position = Vector3(1.5523525e-05, 173.6661, 0.00010698747) +bones/13/rotation = Quaternion(0.090376236, 0.10155637, -0.39819276, 0.90717196) +bones/14/position = Vector3(-2.0682812e-05, 166.48976, 3.939679e-05) +bones/15/position = Vector3(0.6496186, -35.1185, 49.84838) +bones/15/rotation = Quaternion(0.38543156, 0.16380574, 0.82174975, 0.38644233) +bones/16/position = Vector3(8.771768e-06, 312.91962, 7.4840264e-06) +bones/16/rotation = Quaternion(-0.053004134, 0.17209636, 0.39056766, 0.90279037) +bones/17/position = Vector3(-1.8137518e-05, 301.05597, -2.1670077e-05) +bones/17/rotation = Quaternion(0.2498236, 0.64725155, -0.67117685, 0.26110402) +bones/18/position = Vector3(-3.026353e-05, 14.185886, -1.4917823e-06) +bones/18/rotation = Quaternion(0.11539694, 0.017187497, -0.010002339, 0.9931204) +bones/19/position = Vector3(-4.351055e-06, 11.391233, -2.5032205e-06) +bones/20/position = Vector3(0.014209064, -35.118507, -49.848385) +bones/20/rotation = Quaternion(-0.07370261, -0.18747209, 0.94122386, 0.27114522) +bones/21/position = Vector3(2.8756085e-05, 312.91974, 5.14377e-06) +bones/21/rotation = Quaternion(-0.037353504, -0.04220154, 0.46134973, 0.88542664) +bones/22/position = Vector3(2.2092872e-05, 301.0575, 1.8114511e-05) +bones/22/rotation = Quaternion(0.79332, 0.1285891, -0.36227074, 0.47208923) +bones/23/position = Vector3(1.3624241e-05, 15.034077, 9.790485e-06) +bones/23/rotation = Quaternion(0.11885707, 0.009522018, -0.0077985795, 0.9928351) +bones/24/position = Vector3(-2.4847686e-06, 11.913359, -6.198885e-06) + +[node name="WeaponPoint" type="BoneAttachment3D" parent="LilguyRigged/Armature/Skeleton3D" index="1"] +transform = Transform3D(-0.4329258, -0.61284786, 0.6610537, 0.7782944, 0.11585927, 0.6171174, -0.45478824, 0.78166056, 0.42681772, -352.385, -73.56995, -531.9614) +bone_name = "mixamorig_RightHand" +bone_idx = 14 + +[node name="WeaponContainer" type="Node3D" parent="LilguyRigged/Armature/Skeleton3D/WeaponPoint"] +transform = Transform3D(36.6912, 297.2667, 16.921356, 46.72698, 11.0892515, -296.13126, -294.05847, 38.85366, -44.94499, 24.08223, -7.4241333, 7.098694) + +[node name="OffhandPoint" type="BoneAttachment3D" parent="LilguyRigged/Armature/Skeleton3D" index="2"] +transform = Transform3D(0.62123704, -0.004605159, -0.7836091, -0.62031674, 0.6081372, -0.49535444, 0.4788229, 0.79381835, 0.3749406, 135.65929, 334.35745, -511.27094) +bone_name = "mixamorig_LeftHand" +bone_idx = 10 + +[node name="OffhandContainer" type="Node3D" parent="LilguyRigged/Armature/Skeleton3D/OffhandPoint"] +transform = Transform3D(-17.74905, -295.46814, -48.82108, 21.019196, -50.01525, 295.05362, -298.73593, 14.035805, 23.660797, 0.005859375, 0.39337158, 0.06616211) + +[node name="AnimationPlayer" parent="LilguyRigged" index="1"] +speed_scale = 2.0 + +[node name="CollisionShape3D" type="CollisionShape3D" parent="."] +transform = Transform3D(2, 0, 0, 0, 2, 0, 0, 0, 2, -0.066, 1.647685, 0.01) +shape = SubResource("CapsuleShape3D_body") + +[node name="HurtBox" type="Area3D" parent="." node_paths=PackedStringArray("owner_entity")] +collision_layer = 16 +collision_mask = 0 +script = ExtResource("4_hurtbox") +owner_entity = NodePath("..") + +[node name="HurtBoxShape" type="CollisionShape3D" parent="HurtBox"] +transform = Transform3D(1.9228287, 0, 0, 0, 1.4454772, 0, 0, 0, 1.4906956, -0.066, 2.0836046, 0.01) +shape = SubResource("CapsuleShape3D_hurtbox") + +[node name="EnemyLabel" type="Label3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 4.2, 0) +billboard = 1 +modulate = Color(1, 0.3, 0.3, 1) +outline_modulate = Color(0, 0, 0, 0.4) +text = "Armed Enemy" + +[editable path="LilguyRigged"] diff --git a/level/scenes/level.tscn b/level/scenes/level.tscn index 9d692ff..b609441 100644 --- a/level/scenes/level.tscn +++ b/level/scenes/level.tscn @@ -1,14 +1,15 @@ -[gd_scene load_steps=15 format=3 uid="uid://dugaivbj1o66n"] +[gd_scene load_steps=16 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" uid="uid://dif4t1y3c07ax" path="res://level/scenes/enemies/practice_dummy.tscn" id="3_i7s07"] +[ext_resource type="PackedScene" path="res://level/scenes/enemies/armed_enemy.tscn" id="4_armed"] [ext_resource type="FontFile" uid="uid://wipqjhfqeuwd" path="res://assets/fonts/Kurland.ttf" id="3_icc4p"] [ext_resource type="PackedScene" uid="uid://blm8lav3xh2yw" 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"] -[ext_resource type="PackedScene" path="res://level/scenes/weapons/world_weapon_lobsteraxe.tscn" id="7_lobster"] +[ext_resource type="PackedScene" uid="uid://dpk7n3q8mwx2r" path="res://level/scenes/weapons/world_weapon_lobsteraxe.tscn" id="7_lobster"] [sub_resource type="PlaneMesh" id="PlaneMesh_r5xs5"] size = Vector2(90, 90) @@ -29,6 +30,7 @@ color = Color(0, 0, 0, 0) script = ExtResource("1_e1sh7") player_scene = ExtResource("1_uvcbi") practice_dummy_scene = ExtResource("3_i7s07") +armed_enemy_scene = ExtResource("4_armed") [node name="Environment" type="Node3D" parent="."] diff --git a/level/scenes/weapons/LobsterAxeMesh.tscn b/level/scenes/weapons/LobsterAxeMesh.tscn index c5610fb..dcb0e6c 100644 --- a/level/scenes/weapons/LobsterAxeMesh.tscn +++ b/level/scenes/weapons/LobsterAxeMesh.tscn @@ -1,17 +1,18 @@ [gd_scene load_steps=4 format=3 uid="uid://cq8r5mkn3wvxj"] -[ext_resource type="PackedScene" uid="uid://bk5akj878m2a3" path="res://level/scenes/weapons/LobsterAxe.glb" id="1_lobster"] +[ext_resource type="PackedScene" uid="uid://cejg4ixtc5xsf" path="res://level/scenes/weapons/LobsterAxe.glb" id="1_lobster"] [ext_resource type="Script" uid="uid://jyas86y3f0jp" path="res://level/scripts/hit_box.gd" id="2_hitbox"] [sub_resource type="BoxShape3D" id="BoxShape3D_lobster"] -size = Vector3(2.0, 3.2, 0.6) +size = Vector3(2, 3.2, 0.6) [node name="LobsterAxeMesh" type="Node3D"] [node name="LobsterAxe" parent="." instance=ExtResource("1_lobster")] -transform = Transform3D(0.3, 0, 0, 0, 0.3, 0, 0, 0, 0.3, 0, -1.5884135, 0) +transform = Transform3D(-1.3113416e-08, 0, 0.29999998, 0, 0.29999998, 0, -0.29999998, 0, -1.3113416e-08, 0, 0.72785115, 0) [node name="HitBox" type="Area3D" parent="."] +transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, 0, 2.1950727, 0) script = ExtResource("2_hitbox") [node name="CollisionShape3D" type="CollisionShape3D" parent="HitBox"] diff --git a/level/scripts/armed_enemy.gd b/level/scripts/armed_enemy.gd new file mode 100644 index 0000000..83a503f --- /dev/null +++ b/level/scripts/armed_enemy.gd @@ -0,0 +1,616 @@ +extends BaseEnemy +class_name ArmedEnemy + +## An enemy that uses the player model, animations, and can equip weapons +## Drops equipped weapons on death using the existing world weapon spawn system + +## Movement +@export var move_speed: float = 4.0 +@export var chase_range: float = 20.0 +@export var attack_range: float = 2.5 + +## Combat (unarmed fallback) +@export var unarmed_damage: float = 10.0 +@export var unarmed_knockback: float = 5.0 +@export var attack_cooldown: float = 1.0 +@export_category("Unarmed Attack Timing") +@export var unarmed_startup: float = 0.15 +@export var unarmed_active: float = 0.2 + +## Weapon system +@export_category("Weapons") +@export var starting_weapon: WeaponData = null ## Weapon to equip on spawn +@export var starting_offhand: WeaponData = null ## Off-hand weapon to equip on spawn + +## Body reference (LilguyBody for animations) +@export var _body: Node3D = null +@export var _weapon_attachment: BoneAttachment3D = null +@export var _weapon_container: Node3D = null +@export var _offhand_attachment: BoneAttachment3D = null +@export var _offhand_container: Node3D = null + +## Runtime weapon state +var equipped_weapon: BaseWeapon = null +var equipped_offhand: BaseWeapon = null + +## AI State +var _attack_timer: float = 0.0 +var _is_attacking: bool = false +var _unarmed_hitbox: HitBox = null + +## Visual feedback +var _hit_flash_timer: float = 0.0 +const HIT_FLASH_DURATION: float = 0.2 + +## Position sync (manual sync instead of MultiplayerSynchronizer for dynamic spawning) +var _sync_timer: float = 0.0 +const SYNC_INTERVAL: float = 0.05 # 20 times per second + +func _enter_tree(): + # Enemies are always server-authoritative + set_multiplayer_authority(1) + +func _ready(): + super._ready() + + # Auto-find body if not set + if _body == null: + if has_node("LilguyRigged/Armature"): + _body = get_node("LilguyRigged/Armature") + + # Auto-find weapon attachments + if _weapon_attachment == null: + _weapon_attachment = get_node_or_null("LilguyRigged/Armature/Skeleton3D/WeaponPoint") + + if _weapon_container == null and _weapon_attachment: + _weapon_container = _weapon_attachment.get_node_or_null("WeaponContainer") + + if _offhand_attachment == null: + _offhand_attachment = get_node_or_null("LilguyRigged/Armature/Skeleton3D/OffhandPoint") + + if _offhand_container == null and _offhand_attachment: + _offhand_container = _offhand_attachment.get_node_or_null("OffhandContainer") + + # Setup unarmed hitbox + call_deferred("_setup_unarmed_hitbox") + + # Equip starting weapons + # Server will equip and send RPC to sync; clients also equip directly to handle late-join + call_deferred("_equip_starting_weapons_local") + +## Equip starting weapons - server uses RPC to sync, clients equip directly +func _equip_starting_weapons_local(): + # Wait a frame to ensure everything is ready + await get_tree().process_frame + + # Check if multiplayer peer is assigned + if multiplayer.multiplayer_peer == null: + # No multiplayer yet, just equip locally + if starting_weapon: + _equip_weapon(starting_weapon, false) + if starting_offhand: + _equip_weapon(starting_offhand, true) + return + + if multiplayer.is_server(): + # Server equips via RPC to sync to all clients + if starting_weapon: + print("[ArmedEnemy ", name, "] Server equipping starting weapon: ", starting_weapon.resource_path) + rpc("_equip_weapon_sync", starting_weapon.resource_path, false) + if starting_offhand: + print("[ArmedEnemy ", name, "] Server equipping starting offhand: ", starting_offhand.resource_path) + rpc("_equip_weapon_sync", starting_offhand.resource_path, true) + else: + # Client equips directly (for late-join clients who won't receive server's initial RPC) + # Skip if already equipped (from server RPC) + if starting_weapon and not equipped_weapon: + print("[ArmedEnemy ", name, "] Client equipping starting weapon directly") + _equip_weapon(starting_weapon, false) + if starting_offhand and not equipped_offhand: + print("[ArmedEnemy ", name, "] Client equipping starting offhand directly") + _equip_weapon(starting_offhand, true) + +## Equip weapon on all clients +@rpc("any_peer", "call_local", "reliable") +func _equip_weapon_sync(weapon_data_path: String, is_offhand: bool): + print("[ArmedEnemy ", name, "] _equip_weapon_sync called on peer ", multiplayer.get_unique_id(), " path: ", weapon_data_path) + if weapon_data_path == "": + push_error("[ArmedEnemy] Empty weapon path!") + return + var data = load(weapon_data_path) as WeaponData + if data: + _equip_weapon(data, is_offhand) + else: + push_error("[ArmedEnemy] Failed to load weapon data from: ", weapon_data_path) + +func _equip_weapon(data: WeaponData, is_offhand: bool = false): + # Unequip current weapon in that hand first + if is_offhand: + if equipped_offhand: + _unequip_weapon(true) + else: + if equipped_weapon: + _unequip_weapon(false) + + # Determine attachment point + var attach_point: Node3D + if is_offhand: + attach_point = _offhand_container if _offhand_container else _offhand_attachment + else: + attach_point = _weapon_container if _weapon_container else _weapon_attachment + + if not attach_point: + push_error("[ArmedEnemy] No weapon attachment point found") + return + + # Create weapon instance + var weapon = BaseWeapon.new() + weapon.weapon_data = data + weapon.name = "EquippedOffHand" if is_offhand else "EquippedWeapon" + + # Add to scene first (so _ready is called and hitbox is set up) + attach_point.add_child(weapon) + + # Set owner for damage routing (must be after add_child so hitbox exists) + weapon.set_owner_character(self) + + # Store reference + if is_offhand: + equipped_offhand = weapon + else: + equipped_weapon = weapon + + print("[ArmedEnemy ", name, "] Equipped: ", data.weapon_name) + +func _unequip_weapon(is_offhand: bool = false): + if is_offhand: + if equipped_offhand: + equipped_offhand.queue_free() + equipped_offhand = null + else: + if equipped_weapon: + equipped_weapon.queue_free() + equipped_weapon = null + +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 and movement (check peer is assigned first) + if multiplayer.multiplayer_peer == null or 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() + + # Rotate body to face movement direction (like player does) + var body_rotation_y: float = 0.0 + if _body and _body.has_method("apply_rotation") and velocity.length() > 0.1: + _body.apply_rotation(velocity) + if _body: + body_rotation_y = _body.rotation.y + + # Animate body + if _body and _body.has_method("animate"): + _body.animate(velocity) + + # Sync position, rotation, and animation to clients periodically + _sync_timer -= delta + if _sync_timer <= 0: + _sync_timer = SYNC_INTERVAL + var current_anim = "" + if _body: + var anim_player = _body.get_node_or_null("../AnimationPlayer") as AnimationPlayer + if anim_player: + current_anim = anim_player.current_animation + rpc("_sync_transform", global_position, body_rotation_y, current_anim) + +## Sync position, body rotation, and animation from server to clients +@rpc("authority", "call_remote", "unreliable") +func _sync_transform(pos: Vector3, body_rot_y: float, anim_name: String = ""): + # Only apply on clients (server is authoritative) + if multiplayer.is_server(): + return + + global_position = pos + if _body: + _body.rotation.y = body_rot_y + + # Sync animation + if anim_name != "": + var anim_player = _body.get_node_or_null("../AnimationPlayer") as AnimationPlayer + if anim_player and anim_player.has_animation(anim_name): + if anim_player.current_animation != anim_name: + anim_player.play(anim_name) + +## Override to find nearest player +func get_nearest_player() -> Node: + var players = get_players_in_range(1000.0) # Essentially 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 +func _update_target(): + if not is_aggressive: + return + + var nearest = get_nearest_player() + + if nearest: + if current_target != nearest: + current_target = nearest + target_changed.emit(nearest) + else: + if current_target != null: + current_target = null + target_changed.emit(null) + +## Combat AI +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) + + # Get attack range - use a close fixed range to ensure hits connect + # The hitbox is on the weapon in the enemy's hand, so we need to be close + var current_attack_range = 2.0 # Fixed close range for melee + + # If in attack range, attack + if distance <= current_attack_range: + velocity.x = 0 + velocity.z = 0 + + # Face target while attacking (use body rotation like player does) + if _body and _body.has_method("apply_rotation"): + var face_dir = Vector3(direction.x, 0, direction.z) * move_speed + _body.apply_rotation(face_dir) + + if _attack_timer <= 0 and not _is_attacking: + _perform_attack() + else: + # Chase target - velocity direction will be used for body rotation + velocity.x = direction.x * move_speed + velocity.z = direction.z * move_speed + +## Perform attack +func _perform_attack(): + if _is_attacking or not multiplayer.is_server(): + return + + # Use weapon if equipped + if equipped_weapon and equipped_weapon.weapon_data: + _perform_weapon_attack() + else: + _perform_unarmed_attack() + +func _perform_weapon_attack(): + var weapon = equipped_weapon + var data = weapon.weapon_data + + var total_duration = data.startup_time + data.active_time + var cooldown = max(data.attack_cooldown, total_duration) + + _attack_timer = cooldown + _is_attacking = true + + # Play animation on all clients + var anim_name = data.attack_animation if data.attack_animation else "Attack_OneHand" + rpc("_sync_attack_animation", anim_name) + + # Use weapon's built-in attack activation + _activate_weapon_hitbox_direct(weapon) + +func _perform_unarmed_attack(): + var total_duration = unarmed_startup + unarmed_active + var cooldown = max(attack_cooldown, total_duration) + + _attack_timer = cooldown + _is_attacking = true + + # Play animation + rpc("_sync_attack_animation", "Attack_OneHand") + + # Activate unarmed hitbox + _activate_unarmed_hitbox() + +## Activate weapon hitbox for attack (direct access to weapon's internal hitbox) +func _activate_weapon_hitbox_direct(weapon: BaseWeapon): + if not weapon or not multiplayer.is_server(): + _is_attacking = false + return + + var data = weapon.weapon_data + + # Access weapon's internal hitbox + var hitbox = weapon._hitbox + + if not hitbox: + print("[ArmedEnemy] No hitbox found on weapon, trying to find it") + hitbox = weapon.get_node_or_null("HitBox") as HitBox + + if not hitbox: + push_error("[ArmedEnemy] Cannot find hitbox on weapon!") + _is_attacking = false + return + + # Make sure hitbox has correct owner + hitbox.owner_entity = self + + # STARTUP PHASE + if data.startup_time > 0: + await get_tree().create_timer(data.startup_time).timeout + + if not is_instance_valid(hitbox) or is_dead: + _is_attacking = false + return + + # ACTIVE PHASE + hitbox.activate() + + await get_tree().create_timer(data.active_time).timeout + + # RECOVERY PHASE + if hitbox and is_instance_valid(hitbox): + hitbox.deactivate() + + _is_attacking = false + +## Setup unarmed hitbox +func _setup_unarmed_hitbox(): + _unarmed_hitbox = HitBox.new() + _unarmed_hitbox.name = "UnarmedHitBox" + _unarmed_hitbox.owner_entity = self + _unarmed_hitbox.set_stats(unarmed_damage, unarmed_knockback) + + # Add collision shape BEFORE adding hitbox to tree (so _ready can find it) + var collision = CollisionShape3D.new() + var sphere = SphereShape3D.new() + sphere.radius = attack_range + collision.shape = sphere + collision.position = Vector3(0, 0.8, -attack_range * 0.75) + _unarmed_hitbox.add_child(collision) + + # Now attach the fully configured hitbox to body + if _body: + _body.add_child(_unarmed_hitbox) + else: + add_child(_unarmed_hitbox) + + # Connect hit signal + _unarmed_hitbox.hit_landed.connect(_on_hitbox_hit) + +func _activate_unarmed_hitbox(): + if not _unarmed_hitbox or not multiplayer.is_server(): + _is_attacking = false + return + + # STARTUP PHASE + if unarmed_startup > 0: + await get_tree().create_timer(unarmed_startup).timeout + + if not _unarmed_hitbox or not is_instance_valid(_unarmed_hitbox) or is_dead: + _is_attacking = false + return + + # ACTIVE PHASE + _unarmed_hitbox.activate() + + await get_tree().create_timer(unarmed_active).timeout + + # RECOVERY PHASE + if _unarmed_hitbox and is_instance_valid(_unarmed_hitbox): + _unarmed_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() + + # Apply damage directly (we're server) + if target is BaseUnit: + target.take_damage(damage_amount, 1, knockback_amount, global_position) + +## Sync attack animation +@rpc("any_peer", "call_local", "reliable") +func _sync_attack_animation(anim_name: String): + if _body and _body.has_method("play_attack"): + _body.play_attack(anim_name) + +## Override hurt animation +@rpc("any_peer", "call_local", "reliable") +func _play_hurt_animation(): + _flash_red() + +## Flash red when hit +func _flash_red(): + _hit_flash_timer = HIT_FLASH_DURATION + + # Flash all mesh instances in body + if _body: + var meshes = _find_mesh_instances(_body) + for mesh in meshes: + _apply_red_flash(mesh) + +func _find_mesh_instances(node: Node) -> Array[MeshInstance3D]: + var meshes: Array[MeshInstance3D] = [] + if node is MeshInstance3D: + meshes.append(node) + for child in node.get_children(): + meshes.append_array(_find_mesh_instances(child)) + return meshes + +func _apply_red_flash(mesh: MeshInstance3D): + if not mesh: + return + + for i in range(mesh.get_surface_override_material_count()): + var material = mesh.get_surface_override_material(i) + if not material: + material = mesh.mesh.surface_get_material(i) + if material: + material = material.duplicate() + mesh.set_surface_override_material(i, material) + + if material and material is StandardMaterial3D: + material.albedo_color = Color(1.5, 0.3, 0.3) + +func _reset_material(): + # Reset to a neutral color after flash + if _body: + var meshes = _find_mesh_instances(_body) + for mesh in meshes: + _reset_mesh_material(mesh) + +func _reset_mesh_material(mesh: MeshInstance3D): + if not mesh: + return + + for i in range(mesh.get_surface_override_material_count()): + var material = mesh.get_surface_override_material(i) + if material and material is StandardMaterial3D: + # Reset to a default enemy color (reddish) + material.albedo_color = Color(0.8, 0.3, 0.3) + +## Death callback - drop weapons +func _on_enemy_died(killer_id: int): + super._on_enemy_died(killer_id) + + # Hide body + if _body: + _body.visible = false + + # Deactivate hitboxes + if _unarmed_hitbox: + _unarmed_hitbox.deactivate() + + # Drop equipped weapons (server only) + if multiplayer.is_server(): + _drop_all_weapons() + + print("[ArmedEnemy ", name, "] killed by ", killer_id) + +## Drop all equipped weapons +func _drop_all_weapons(): + if not multiplayer.is_server(): + return + + # Drop main hand weapon + if equipped_weapon and equipped_weapon.weapon_data: + _spawn_dropped_weapon(equipped_weapon.weapon_data, false) + + # Drop off-hand weapon + if equipped_offhand and equipped_offhand.weapon_data: + _spawn_dropped_weapon(equipped_offhand.weapon_data, true) + + # Clear equipped weapons on all clients + rpc("_clear_equipped_weapons") + +## Spawn a dropped weapon in the world +func _spawn_dropped_weapon(data: WeaponData, is_offhand: bool): + if not multiplayer.is_server(): + return + + var resource_path = data.resource_path + if resource_path == "": + push_error("[ArmedEnemy] WeaponData has no resource path!") + return + + # Calculate spawn position with slight offset and upward velocity + var offset = Vector3.ZERO + if is_offhand: + offset = transform.basis.x * -0.5 # Left side + else: + offset = transform.basis.x * 0.5 # Right side + + var spawn_pos = global_position + offset + spawn_pos.y += 1.5 # Spawn above death position + + # Random velocity to scatter weapons + var velocity = Vector3( + randf_range(-2.0, 2.0), + randf_range(3.0, 5.0), # Upward + randf_range(-2.0, 2.0) + ) + + # Use level's weapon spawning system + var level = get_tree().get_current_scene() + if level and level.has_method("spawn_world_weapon"): + level._weapon_spawn_counter += 1 + level.rpc("spawn_world_weapon", resource_path, spawn_pos, velocity, level._weapon_spawn_counter) + print("[ArmedEnemy ", name, "] Dropped weapon: ", data.weapon_name) + +## Clear equipped weapons on all clients +@rpc("any_peer", "call_local", "reliable") +func _clear_equipped_weapons(): + _unequip_weapon(false) + _unequip_weapon(true) + +## Respawn callback +func _on_enemy_respawned(): + super._on_enemy_respawned() + + # Show body + if _body: + _body.visible = true + _reset_material() + + # Reset state + _attack_timer = 0.0 + _is_attacking = false + + # Re-equip starting weapons + call_deferred("_equip_starting_weapons_local") + + print("[ArmedEnemy ", name, "] respawned") + +## Set enemy color (hue-based like player) +func set_enemy_color(hue: float): + if _body and _body.has_method("set_character_color"): + _body.set_character_color(hue) diff --git a/level/scripts/armed_enemy.gd.uid b/level/scripts/armed_enemy.gd.uid new file mode 100644 index 0000000..3fb7f54 --- /dev/null +++ b/level/scripts/armed_enemy.gd.uid @@ -0,0 +1 @@ +uid://deefoag762nvc diff --git a/level/scripts/base_enemy.gd b/level/scripts/base_enemy.gd index 9cd2fd8..4554754 100644 --- a/level/scripts/base_enemy.gd +++ b/level/scripts/base_enemy.gd @@ -25,8 +25,8 @@ func _ready(): respawned.connect(_on_enemy_respawned) func _physics_process(delta): - # Only server handles enemy AI - if not multiplayer.is_server(): + # Only server handles enemy AI (check peer is assigned first) + if multiplayer.multiplayer_peer == null or not multiplayer.is_server(): return if is_dead: diff --git a/level/scripts/base_weapon.gd b/level/scripts/base_weapon.gd index 3044f4c..09d2773 100644 --- a/level/scripts/base_weapon.gd +++ b/level/scripts/base_weapon.gd @@ -11,7 +11,7 @@ signal hit_connected(target: Node) @export var weapon_data: WeaponData # Runtime references -var owner_character: Character = null +var owner_character: Node = null # Can be Character or ArmedEnemy var _mesh_instance: Node3D = null var _attack_timer: float = 0.0 var _hitbox: HitBox = null @@ -57,10 +57,8 @@ func _setup_hitbox(): # Create hitbox dynamically based on weapon range _hitbox = HitBox.new() _hitbox.name = "HitBox" - add_child(_hitbox) - # Add collision shape based on attack range - use sphere for consistent detection - # regardless of weapon orientation during animations + # Add collision shape BEFORE adding hitbox to tree (so _ready can find it) var collision = CollisionShape3D.new() var sphere = SphereShape3D.new() var range_val = weapon_data.attack_range if weapon_data else 1.5 @@ -68,6 +66,9 @@ func _setup_hitbox(): collision.shape = sphere _hitbox.add_child(collision) + # Now add the fully configured hitbox to the scene + add_child(_hitbox) + _configure_hitbox() ## Configure hitbox with weapon stats and owner @@ -102,24 +103,30 @@ func _on_hitbox_hit(target: Node, damage_amount: float, knockback_amount: float, # Route damage through server var attacker_id = multiplayer.get_unique_id() - if multiplayer.is_server(): - # We are server, apply directly - owner_character._server_apply_damage( - target.name, - damage_amount, - attacker_id, - knockback_amount, - attacker_pos - ) + # Check if owner has _server_apply_damage (Character has it, ArmedEnemy doesn't) + if owner_character.has_method("_server_apply_damage"): + if multiplayer.is_server(): + # We are server, apply directly + owner_character._server_apply_damage( + target.name, + damage_amount, + attacker_id, + knockback_amount, + attacker_pos + ) + else: + # Send to server + owner_character.rpc_id(1, "_server_apply_damage", + target.name, + damage_amount, + attacker_id, + knockback_amount, + attacker_pos + ) else: - # Send to server - owner_character.rpc_id(1, "_server_apply_damage", - target.name, - damage_amount, - attacker_id, - knockback_amount, - attacker_pos - ) + # ArmedEnemy or other entity - apply damage directly if we're server + if multiplayer.is_server() and target is BaseUnit: + target.take_damage(damage_amount, 1, knockback_amount, attacker_pos) ## Perform an attack with this weapon ## Called by the character who owns this weapon @@ -141,20 +148,24 @@ func perform_attack() -> bool: _attack_timer = cooldown _is_attacking = true - # Notify owner character of attack cooldown (for UI) - if owner_character and owner_character.is_multiplayer_authority(): + # Notify owner character of attack cooldown (for UI) - only for Characters + if owner_character.is_multiplayer_authority() and "_attack_timer" in owner_character: owner_character._attack_timer = cooldown # Play attack animation on owner (use weapon's animation) - if owner_character._body: + if "_body" in owner_character and owner_character._body: var anim_name = weapon_data.attack_animation if weapon_data.attack_animation else "Attack_OneHand" - owner_character._body.play_attack(anim_name) - # Sync animation to other clients - owner_character._sync_attack_animation.rpc(anim_name) + if owner_character._body.has_method("play_attack"): + owner_character._body.play_attack(anim_name) + # Sync animation to other clients if method exists + if owner_character.has_method("_sync_attack_animation"): + owner_character._sync_attack_animation.rpc(anim_name) # Activate hitbox for the attack duration - # Only activate on authority - they detect hits and send to server - if owner_character.is_multiplayer_authority(): + # For players: only activate on authority + # For enemies: they are server-authoritative, so check if we're server + var should_activate = owner_character.is_multiplayer_authority() or multiplayer.is_server() + if should_activate: _activate_hitbox() attack_performed.emit() @@ -197,8 +208,8 @@ func _activate_hitbox(): func can_attack() -> bool: return _attack_timer <= 0 and not _is_attacking -## Set the character who owns this weapon -func set_owner_character(character: Character): +## Set the character who owns this weapon (can be Character or ArmedEnemy) +func set_owner_character(character: Node): owner_character = character # Update hitbox owner if _hitbox: diff --git a/level/scripts/enemy_spawner.gd b/level/scripts/enemy_spawner.gd index baca0e8..147c62b 100644 --- a/level/scripts/enemy_spawner.gd +++ b/level/scripts/enemy_spawner.gd @@ -236,8 +236,8 @@ func _process(_delta): if _debug_circle: _debug_circle.visible = show_spawn_radius - # Server spawning logic - if not multiplayer.is_server(): + # Server spawning logic (check peer is assigned first) + if multiplayer.multiplayer_peer == null or not multiplayer.is_server(): return # Handle wave delay timer diff --git a/level/scripts/level.gd b/level/scripts/level.gd index 83d59c8..514a053 100644 --- a/level/scripts/level.gd +++ b/level/scripts/level.gd @@ -12,6 +12,7 @@ extends Node3D @onready var main_menu: VBoxContainer = $Menu/MainContainer/MainMenu @export var player_scene: PackedScene @export var practice_dummy_scene: PackedScene +@export var armed_enemy_scene: PackedScene # Weapon spawning counter (server-side only) var _weapon_spawn_counter: int = 0 @@ -92,6 +93,9 @@ func initialize_multiplayer(): # Spawn practice dummies _spawn_practice_dummies() + # Spawn armed enemies + _spawn_armed_enemies() + # Spawn the host player (peer ID 1) print("[Level] Spawning host player") var host_info = Network.players.get(1, {"nick": "Host", "skin": "blue"}) @@ -242,12 +246,24 @@ func _on_player_connected(peer_id, player_info): if enemies_container: for enemy in enemies_container.get_children(): if enemy is BaseEnemy: - # Extract ID from name (e.g., "PracticeDummy_1" -> 1) - var enemy_name_parts = enemy.name.split("_") - if enemy_name_parts.size() >= 2: - var enemy_id = enemy_name_parts[-1].to_int() - print("[Server] Syncing enemy ", enemy.name, " at position ", enemy.global_position, " to peer ", peer_id) - rpc_id(peer_id, "_spawn_dummy_local", enemy_id, enemy.global_position) + # Check if it's an ArmedEnemy or PracticeDummy + if enemy.name.begins_with("ArmedEnemy_"): + # Sync armed enemy with its weapons + var main_weapon_path = "" + var offhand_weapon_path = "" + if "equipped_weapon" in enemy and enemy.equipped_weapon and enemy.equipped_weapon.weapon_data: + main_weapon_path = enemy.equipped_weapon.weapon_data.resource_path + if "equipped_offhand" in enemy and enemy.equipped_offhand and enemy.equipped_offhand.weapon_data: + offhand_weapon_path = enemy.equipped_offhand.weapon_data.resource_path + print("[Server] Syncing armed enemy ", enemy.name, " to peer ", peer_id) + rpc_id(peer_id, "_spawn_armed_enemy_local", enemy.name, enemy.global_position, main_weapon_path, offhand_weapon_path) + elif enemy.name.begins_with("PracticeDummy_"): + # Extract ID from name (e.g., "PracticeDummy_1" -> 1) + var enemy_name_parts = enemy.name.split("_") + if enemy_name_parts.size() >= 2: + var enemy_id = enemy_name_parts[-1].to_int() + print("[Server] Syncing enemy ", enemy.name, " at position ", enemy.global_position, " to peer ", peer_id) + rpc_id(peer_id, "_spawn_dummy_local", enemy_id, enemy.global_position) # Sync equipped weapons for all existing players to the newly joined player print("[Server] Syncing equipped weapons to newly connected peer: ", peer_id) @@ -630,3 +646,82 @@ func _client_spawn_weapon(weapon_data_path: String, spawn_position: Vector3, ini # Call the regular spawn function to create the weapon print("[Client ", multiplayer.get_unique_id(), "] Calling spawn_world_weapon locally") spawn_world_weapon(weapon_data_path, spawn_position, initial_velocity, weapon_id) + +# ---------- ARMED ENEMY SPAWNING ---------- +var _armed_enemy_counter: int = 0 + +## Spawn an armed enemy (server only, replicates to all clients) +func spawn_armed_enemy(spawn_pos: Vector3, main_weapon_path: String = "", offhand_weapon_path: String = ""): + if not multiplayer.is_server(): + return + + if not armed_enemy_scene: + push_warning("[Level] Armed enemy scene not assigned!") + return + + _armed_enemy_counter += 1 + var enemy_name = "ArmedEnemy_" + str(_armed_enemy_counter) + + rpc("_spawn_armed_enemy_local", enemy_name, spawn_pos, main_weapon_path, offhand_weapon_path) + +## Spawn armed enemy on all clients +@rpc("any_peer", "call_local", "reliable") +func _spawn_armed_enemy_local(enemy_name: String, spawn_pos: Vector3, main_weapon_path: String, offhand_weapon_path: String): + if not armed_enemy_scene: + push_error("[Level] Armed enemy scene not loaded!") + return + + if not enemies_container: + push_error("[Level] EnemiesContainer not found!") + return + + # Don't spawn duplicates + if enemies_container.has_node(enemy_name): + print("[Peer ", multiplayer.get_unique_id(), "] Armed enemy ", enemy_name, " already exists") + return + + print("[Peer ", multiplayer.get_unique_id(), "] Spawning armed enemy ", enemy_name, " at ", spawn_pos) + + var enemy = armed_enemy_scene.instantiate() + enemy.name = enemy_name + enemy.position = spawn_pos + + # Set multiplayer authority to server + enemy.set_multiplayer_authority(1) + + # Set starting weapons (for server to trigger RPCs) + if main_weapon_path != "": + var weapon_data = load(main_weapon_path) as WeaponData + if weapon_data: + enemy.starting_weapon = weapon_data + + if offhand_weapon_path != "": + var offhand_data = load(offhand_weapon_path) as WeaponData + if offhand_data: + enemy.starting_offhand = offhand_data + + enemies_container.add_child(enemy, true) + print("[Peer ", multiplayer.get_unique_id(), "] Armed enemy ", enemy_name, " spawned successfully") + +## Spawn initial armed enemies when server starts +func _spawn_armed_enemies(): + if not multiplayer.is_server(): + return + + if not armed_enemy_scene: + push_warning("[Level] Armed enemy scene not assigned - skipping armed enemy spawn") + return + + # Wait a frame for everything to be ready + await get_tree().process_frame + + print("[Server] Spawning armed enemies") + + # Spawn armed enemies at different positions with weapons + var enemy_configs = [ + {"pos": Vector3(15, 0, 5), "weapon": "res://level/resources/weapon_sword.tres", "offhand": ""}, + {"pos": Vector3(-15, 0, 5), "weapon": "res://level/resources/weapon_sword.tres", "offhand": "res://level/resources/weapon_shield.tres"}, + ] + + for config in enemy_configs: + spawn_armed_enemy(config["pos"], config["weapon"], config["offhand"]) diff --git a/level/scripts/lilguy_body.gd b/level/scripts/lilguy_body.gd index 0acf25e..021c0a6 100644 --- a/level/scripts/lilguy_body.gd +++ b/level/scripts/lilguy_body.gd @@ -19,13 +19,16 @@ func animate(_velocity: Vector3) -> void: if animation_player.is_playing() and animation_player.current_animation.begins_with("Attack"): return - # Check if we're dashing - if _character._is_dashing: + # Check if we're dashing (defensive check for enemies that don't have this) + if _character and "_is_dashing" in _character and _character._is_dashing: if animation_player.current_animation != "Jump": _play_animation("Jump") return - if not _character.is_on_floor(): + # Check if on floor (works for any CharacterBody3D) + var on_floor = _character.is_on_floor() if _character else true + + if not on_floor: if _velocity.y < 0: # Falling - use FallIdle animation _play_animation("FallIdle") @@ -34,7 +37,14 @@ func animate(_velocity: Vector3) -> void: return if _velocity: - if _character.is_running() and _character.is_on_floor(): + # Check if running (defensive check - enemies don't have is_running) + var is_running_val = false + if _character and _character.has_method("is_running"): + is_running_val = _character.is_running() and on_floor + elif _velocity.length() > 5.0: # Fallback: high speed = running + is_running_val = on_floor + + if is_running_val: # Sprint animation = Run for Lilguy _play_animation("Run") return diff --git a/level/scripts/player.gd b/level/scripts/player.gd index 835df10..28fa5d2 100644 --- a/level/scripts/player.gd +++ b/level/scripts/player.gd @@ -623,13 +623,7 @@ func _setup_unarmed_hitbox(): _unarmed_hitbox.owner_entity = self _unarmed_hitbox.set_stats(attack_damage, unarmed_knockback) - # Attach to body so it rotates with player facing direction - if _body: - _body.add_child(_unarmed_hitbox) - else: - add_child(_unarmed_hitbox) - - # Add collision shape - larger sphere in front of player + # Add collision shape BEFORE adding hitbox to tree (so _ready can find it) var collision = CollisionShape3D.new() var sphere = SphereShape3D.new() sphere.radius = attack_range # Full attack range as radius @@ -638,6 +632,12 @@ func _setup_unarmed_hitbox(): collision.position = Vector3(0, 0.8, -attack_range * 0.75) _unarmed_hitbox.add_child(collision) + # Now attach the fully configured hitbox to body so it rotates with player facing direction + if _body: + _body.add_child(_unarmed_hitbox) + else: + add_child(_unarmed_hitbox) + # Connect hit signal _unarmed_hitbox.hit_landed.connect(_on_unarmed_hit)