From 10cc8720b7028f65cd56e8f93938e3a96ce29f65 Mon Sep 17 00:00:00 2001 From: Twirpytherobot Date: Fri, 21 Nov 2025 23:53:28 +0000 Subject: [PATCH] Hithurtboxes --- level/resources/weapon_applecorer.tres | 2 + level/resources/weapon_shield.tres | 2 + level/resources/weapon_sword.tres | 2 + level/resources/weapon_testsword.tres | 2 + level/scenes/Player_Lilguy.tscn | 17 ++- level/scenes/weapons/Applecoremesh.tscn | 13 +- level/scenes/weapons/TestSwordMesh.tscn | 15 ++- level/scenes/weapons/sword_mesh.tscn | 15 ++- level/scripts/base_weapon.gd | 155 +++++++++++++++------- level/scripts/hit_box.gd | 95 ++++++++++++++ level/scripts/hit_box.gd.uid | 1 + level/scripts/hurt_box.gd | 27 ++++ level/scripts/hurt_box.gd.uid | 1 + level/scripts/player.gd | 163 ++++++++++++++++-------- level/scripts/weapon_data.gd | 8 ++ project.godot | 2 + 16 files changed, 418 insertions(+), 102 deletions(-) create mode 100644 level/scripts/hit_box.gd create mode 100644 level/scripts/hit_box.gd.uid create mode 100644 level/scripts/hurt_box.gd create mode 100644 level/scripts/hurt_box.gd.uid diff --git a/level/resources/weapon_applecorer.tres b/level/resources/weapon_applecorer.tres index 45506bd..e716a3d 100644 --- a/level/resources/weapon_applecorer.tres +++ b/level/resources/weapon_applecorer.tres @@ -12,5 +12,7 @@ attack_range = 3.5 attack_cooldown = 0.6 attack_animation = "Attack_TwoHandSwing" knockback_force = 12.0 +startup_time = 0.2 +active_time = 0.15 mesh_scene = ExtResource("1_1ytxi") weight = 2.0 diff --git a/level/resources/weapon_shield.tres b/level/resources/weapon_shield.tres index 9dcf59c..b8dea5f 100644 --- a/level/resources/weapon_shield.tres +++ b/level/resources/weapon_shield.tres @@ -12,6 +12,8 @@ damage = 8.0 attack_range = 2.5 attack_cooldown = 0.8 knockback_force = 15.0 +startup_time = 0.25 +active_time = 0.15 can_block = true block_reduction = 0.7 mesh_scene = ExtResource("2") diff --git a/level/resources/weapon_sword.tres b/level/resources/weapon_sword.tres index f42a824..f6404d7 100644 --- a/level/resources/weapon_sword.tres +++ b/level/resources/weapon_sword.tres @@ -12,6 +12,8 @@ attack_range = 3.5 attack_cooldown = 0.6 knockback_force = 12.0 attack_animation = "Attack1" +startup_time = 0.12 +active_time = 0.2 mesh_scene = ExtResource("2") pickup_radius = 1.5 weight = 2.0 diff --git a/level/resources/weapon_testsword.tres b/level/resources/weapon_testsword.tres index 6b05c27..9dfafd4 100644 --- a/level/resources/weapon_testsword.tres +++ b/level/resources/weapon_testsword.tres @@ -11,5 +11,7 @@ damage = 20.0 attack_range = 3.5 attack_cooldown = 0.6 knockback_force = 12.0 +startup_time = 0.12 +active_time = 0.2 mesh_scene = ExtResource("1_gdc1w") weight = 2.0 diff --git a/level/scenes/Player_Lilguy.tscn b/level/scenes/Player_Lilguy.tscn index 819a155..1d8a8df 100644 --- a/level/scenes/Player_Lilguy.tscn +++ b/level/scenes/Player_Lilguy.tscn @@ -1,14 +1,19 @@ -[gd_scene load_steps=7 format=3 uid="uid://db06e8q8f8bdq"] +[gd_scene load_steps=9 format=3 uid="uid://db06e8q8f8bdq"] [ext_resource type="Script" uid="uid://c2si8gkbnde0c" path="res://level/scripts/player.gd" id="1_player"] [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://bj7yrijm7bppq" path="res://level/scripts/spring_arm_offset.gd" id="4_spring"] +[ext_resource type="Script" path="res://level/scripts/hurt_box.gd" id="5_hurtbox"] [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_yxyay"] radius = 0.35796 height = 1.73092 +[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_hurtbox"] +radius = 0.4 +height = 1.8 + [sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_xbohm"] properties/0/path = NodePath(".:position") properties/0/spawn = true @@ -108,6 +113,16 @@ transform = Transform3D(-17.74905, -295.46814, -48.82108, 21.019196, -50.01525, transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.066, 0.828, 0.01) shape = SubResource("CapsuleShape3D_yxyay") +[node name="HurtBox" type="Area3D" parent="." node_paths=PackedStringArray("owner_entity")] +collision_layer = 16 +collision_mask = 0 +script = ExtResource("5_hurtbox") +owner_entity = NodePath("..") + +[node name="HurtBoxShape" type="CollisionShape3D" parent="HurtBox"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.066, 0.828, 0.01) +shape = SubResource("CapsuleShape3D_hurtbox") + [node name="SpringArmOffset" type="Node3D" parent="." node_paths=PackedStringArray("_spring_arm")] transform = Transform3D(-1, 0, -8.74228e-08, 0, 1, 0, 8.74228e-08, 0, -1, 0, 0, 0) script = ExtResource("4_spring") diff --git a/level/scenes/weapons/Applecoremesh.tscn b/level/scenes/weapons/Applecoremesh.tscn index c1c6228..592710e 100644 --- a/level/scenes/weapons/Applecoremesh.tscn +++ b/level/scenes/weapons/Applecoremesh.tscn @@ -1,8 +1,19 @@ -[gd_scene load_steps=2 format=3 uid="uid://cehc5ckhq2byd"] +[gd_scene load_steps=4 format=3 uid="uid://cehc5ckhq2byd"] [ext_resource type="PackedScene" uid="uid://c3e6e3s2q0uro" path="res://assets/Objects/Applecorer.glb" id="1_yadub"] +[ext_resource type="Script" uid="uid://jyas86y3f0jp" path="res://level/scripts/hit_box.gd" id="2_lq7hu"] + +[sub_resource type="BoxShape3D" id="BoxShape3D_ei1hh"] +size = Vector3(0.19293976, 2.9722443, 0.5605469) [node name="TestSwordMesh" type="Node3D"] [node name="Applecorer" parent="." instance=ExtResource("1_yadub")] transform = Transform3D(-0.3, 0, -2.6226834e-08, 0, 0.3, 0, 2.6226834e-08, 0, -0.3, 0, 0, 0) + +[node name="HitBox" type="Area3D" parent="."] +script = ExtResource("2_lq7hu") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="HitBox"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.0034065247, 2.15522, 0.0234375) +shape = SubResource("BoxShape3D_ei1hh") diff --git a/level/scenes/weapons/TestSwordMesh.tscn b/level/scenes/weapons/TestSwordMesh.tscn index d6906cb..3ae9154 100644 --- a/level/scenes/weapons/TestSwordMesh.tscn +++ b/level/scenes/weapons/TestSwordMesh.tscn @@ -1,8 +1,19 @@ -[gd_scene load_steps=2 format=3 uid="uid://rkvkbxlweo60"] +[gd_scene load_steps=4 format=3 uid="uid://rkvkbxlweo60"] -[ext_resource type="PackedScene" uid="uid://culurukxpqswh" path="res://assets/Objects/TestSword.glb" id="1_4fdvi"] +[ext_resource type="PackedScene" uid="uid://df31n55xyn27i" path="res://assets/Objects/TestSword.glb" id="1_4fdvi"] +[ext_resource type="Script" uid="uid://jyas86y3f0jp" path="res://level/scripts/hit_box.gd" id="2_3qhqw"] + +[sub_resource type="BoxShape3D" id="BoxShape3D_5u25i"] +size = Vector3(0.19293976, 2.2154922, 0.5605469) [node name="TestSwordMesh" type="Node3D"] [node name="TestSword" parent="." instance=ExtResource("1_4fdvi")] transform = Transform3D(0.1, 0, 0, 0, 0.1, 0, 0, 0, 0.1, 0, 0.104662895, 0) + +[node name="HitBox" type="Area3D" parent="."] +script = ExtResource("2_3qhqw") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="HitBox"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.0034065247, 1.3828986, 0.0234375) +shape = SubResource("BoxShape3D_5u25i") diff --git a/level/scenes/weapons/sword_mesh.tscn b/level/scenes/weapons/sword_mesh.tscn index fe3d2e1..9e778b5 100644 --- a/level/scenes/weapons/sword_mesh.tscn +++ b/level/scenes/weapons/sword_mesh.tscn @@ -1,9 +1,20 @@ -[gd_scene load_steps=2 format=3 uid="uid://dyjfaq654xne3"] +[gd_scene load_steps=4 format=3 uid="uid://dyjfaq654xne3"] -[ext_resource type="ArrayMesh" uid="uid://cc1kxfbkvpo2d" path="res://assets/Objects/swordlowpoly.obj" id="1"] +[ext_resource type="ArrayMesh" uid="uid://1wnuqcx2n4xq" path="res://assets/Objects/swordlowpoly.obj" id="1"] +[ext_resource type="Script" uid="uid://jyas86y3f0jp" path="res://level/scripts/hit_box.gd" id="2_wyi6r"] + +[sub_resource type="BoxShape3D" id="BoxShape3D_mhdau"] +size = Vector3(0.19293976, 1.0232315, 0.5605469) [node name="SwordMesh" type="Node3D"] [node name="MeshInstance3D" type="MeshInstance3D" parent="."] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 4.4839063, -2.9492104, -3.4711354) mesh = ExtResource("1") + +[node name="HitBox" type="Area3D" parent="."] +script = ExtResource("2_wyi6r") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="HitBox"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.0034065247, 0.6794083, 0.0234375) +shape = SubResource("BoxShape3D_mhdau") diff --git a/level/scripts/base_weapon.gd b/level/scripts/base_weapon.gd index 7bec717..d9eda18 100644 --- a/level/scripts/base_weapon.gd +++ b/level/scripts/base_weapon.gd @@ -3,9 +3,10 @@ class_name BaseWeapon ## Base class for equipped weapons ## Attached to player's hand via BoneAttachment3D -## Provides common weapon functionality and stats +## Uses HitBox/HurtBox system for damage detection signal attack_performed() +signal hit_connected(target: Node) @export var weapon_data: WeaponData @@ -13,10 +14,12 @@ signal attack_performed() var owner_character: Character = null var _mesh_instance: Node3D = null var _attack_timer: float = 0.0 +var _hitbox: HitBox = null func _ready(): if weapon_data and weapon_data.mesh_scene: _spawn_mesh() + _setup_hitbox() func _process(delta): if _attack_timer > 0: @@ -32,6 +35,85 @@ func _spawn_mesh(): _mesh_instance = weapon_data.mesh_scene.instantiate() add_child(_mesh_instance) + # Check if mesh has a HitBox child, use it instead of auto-generated one + var mesh_hitbox = _mesh_instance.get_node_or_null("HitBox") + if mesh_hitbox and mesh_hitbox is HitBox: + # Use the hitbox from the mesh scene + print("[BaseWeapon] Found manual HitBox in mesh scene") + if _hitbox: + _hitbox.queue_free() + _hitbox = mesh_hitbox + _configure_hitbox() + else: + print("[BaseWeapon] No manual HitBox found, will auto-generate") + +## Setup the hitbox for this weapon +func _setup_hitbox(): + # Skip if we already have a hitbox (e.g., from mesh scene) + if _hitbox: + return + + # 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 + var collision = CollisionShape3D.new() + var sphere = SphereShape3D.new() + var range_val = weapon_data.attack_range if weapon_data else 1.5 + sphere.radius = range_val + collision.shape = sphere + _hitbox.add_child(collision) + + _configure_hitbox() + +## Configure hitbox with weapon stats and owner +func _configure_hitbox(): + if not _hitbox: + return + + # Set damage stats from weapon + if weapon_data: + _hitbox.set_stats(weapon_data.damage, weapon_data.knockback_force) + + # Set owner to prevent self-damage + _hitbox.owner_entity = owner_character + + # Connect to hit signal + if not _hitbox.hit_landed.is_connected(_on_hitbox_hit): + _hitbox.hit_landed.connect(_on_hitbox_hit) + +## Called when hitbox connects with a hurtbox +func _on_hitbox_hit(target: Node, damage_amount: float, knockback_amount: float, attacker_pos: Vector3): + if not target or not owner_character: + return + + hit_connected.emit(target) + + # 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 + ) + else: + # Send to server + owner_character.rpc_id(1, "_server_apply_damage", + target.name, + damage_amount, + attacker_id, + knockback_amount, + attacker_pos + ) + ## Perform an attack with this weapon ## Called by the character who owns this weapon func perform_attack() -> bool: @@ -55,57 +137,39 @@ func perform_attack() -> bool: # Sync animation to other clients owner_character._sync_attack_animation.rpc(anim_name) - # Delay damage until animation hits (roughly 70% through the animation) - # This makes the damage apply when the swing actually connects - var damage_delay = weapon_data.attack_cooldown * 0.4 # Adjust this multiplier to change when damage happens - get_tree().create_timer(damage_delay).timeout.connect(_find_and_damage_targets) + # Activate hitbox for the attack duration + # Only activate on authority - they detect hits and send to server + if owner_character.is_multiplayer_authority(): + _activate_hitbox() attack_performed.emit() return true -## Find targets in range and apply damage -func _find_and_damage_targets(): - if not owner_character: +## Activate the hitbox for attack detection using frame data timing +func _activate_hitbox(): + if not _hitbox: return - # Check if the owner character has authority (not this node) - if not owner_character.is_multiplayer_authority(): + # Update owner reference in case it changed + _hitbox.owner_entity = owner_character + + # STARTUP PHASE - Wait before activating hitbox (wind-up) + var startup = weapon_data.startup_time if weapon_data else 0.15 + if startup > 0: + await get_tree().create_timer(startup).timeout + + if not _hitbox or not is_instance_valid(_hitbox): return - var space_state = get_world_3d().direct_space_state - var query = PhysicsShapeQueryParameters3D.new() - var sphere = SphereShape3D.new() - sphere.radius = weapon_data.attack_range - query.shape = sphere - query.transform = global_transform - query.collision_mask = 1 # Player layer - - var results = space_state.intersect_shape(query) - - for result in results: - var hit_body = result["collider"] - if hit_body != owner_character and hit_body is BaseUnit: - var attacker_id = multiplayer.get_unique_id() - - # If we're the server, apply damage directly - if multiplayer.is_server(): - owner_character._server_apply_damage( - hit_body.name, - weapon_data.damage, - attacker_id, - weapon_data.knockback_force, - owner_character.global_position - ) - else: - # Otherwise, request server to apply damage - owner_character.rpc_id(1, "_server_apply_damage", - hit_body.name, - weapon_data.damage, - attacker_id, - weapon_data.knockback_force, - owner_character.global_position - ) - break # Only hit one target per attack + # ACTIVE PHASE - Hitbox is on, can deal damage + _hitbox.activate() + + var active = weapon_data.active_time if weapon_data else 0.2 + await get_tree().create_timer(active).timeout + + # RECOVERY PHASE - Hitbox off (recovery time is remaining cooldown) + if _hitbox and is_instance_valid(_hitbox): + _hitbox.deactivate() ## Check if weapon can attack func can_attack() -> bool: @@ -114,6 +178,9 @@ func can_attack() -> bool: ## Set the character who owns this weapon func set_owner_character(character: Character): owner_character = character + # Update hitbox owner + if _hitbox: + _hitbox.owner_entity = character ## Get weapon stats func get_damage() -> float: diff --git a/level/scripts/hit_box.gd b/level/scripts/hit_box.gd new file mode 100644 index 0000000..b10e415 --- /dev/null +++ b/level/scripts/hit_box.gd @@ -0,0 +1,95 @@ +extends Area3D +class_name HitBox + +## A component that deals damage to HurtBoxes +## Attach to weapons or attack effects +## Uses direct physics queries for reliable hit detection + +signal hit_landed(target: Node, damage: float, knockback: float, attacker_pos: Vector3) + +## Damage dealt on hit +@export var damage: float = 10.0 +## Knockback force applied +@export var knockback: float = 5.0 +## Owner entity (used to prevent self-damage and identify attacker) +@export var owner_entity: Node = null + +## Whether hitbox is currently active (only deals damage when active) +var is_active: bool = false +## Tracks entities hit this attack (prevents multi-hit) +var _hits_this_attack: Array[Node] = [] +## Shape for queries (extracted from child CollisionShape3D) +var _query_shape: Shape3D = null + +func _ready(): + # Find the collision shape for queries + for child in get_children(): + if child is CollisionShape3D and child.shape: + _query_shape = child.shape + break + +func _physics_process(_delta): + if not is_active: + return + + _check_hits() + +func _check_hits(): + if not _query_shape: + # Fallback: create a default sphere + var sphere = SphereShape3D.new() + sphere.radius = 2.0 + _query_shape = sphere + + # Use physics server for reliable queries + var space_state = get_world_3d().direct_space_state + var query = PhysicsShapeQueryParameters3D.new() + query.shape = _query_shape + query.transform = global_transform + query.collision_mask = 16 # Layer 5 (hurtbox) + query.collide_with_areas = true + query.collide_with_bodies = false + + var results = space_state.intersect_shape(query, 32) + + for result in results: + var collider = result["collider"] + if collider is HurtBox: + _process_hit(collider) + +func _process_hit(hurtbox: HurtBox): + # Don't hit our own hurtbox + if hurtbox.owner_entity == owner_entity: + return + + # Don't hit same entity twice in one attack + if hurtbox.owner_entity in _hits_this_attack: + return + + # Register this hit + var target = hurtbox.owner_entity + if target: + _hits_this_attack.append(target) + + # Get attacker position for knockback direction + var attacker_pos = global_position + if owner_entity and owner_entity is Node3D: + attacker_pos = owner_entity.global_position + + # Emit signal - let the weapon/owner handle damage routing to server + hit_landed.emit(target, damage, knockback, attacker_pos) + +## Activate hitbox (call when attack starts) +func activate(): + is_active = true + _hits_this_attack.clear() + +## Deactivate hitbox (call when attack ends) +func deactivate(): + is_active = false + _hits_this_attack.clear() + +## Set damage stats (usually from weapon data) +func set_stats(new_damage: float, new_knockback: float): + damage = new_damage + knockback = new_knockback diff --git a/level/scripts/hit_box.gd.uid b/level/scripts/hit_box.gd.uid new file mode 100644 index 0000000..82cd29b --- /dev/null +++ b/level/scripts/hit_box.gd.uid @@ -0,0 +1 @@ +uid://jyas86y3f0jp diff --git a/level/scripts/hurt_box.gd b/level/scripts/hurt_box.gd new file mode 100644 index 0000000..e7dfe37 --- /dev/null +++ b/level/scripts/hurt_box.gd @@ -0,0 +1,27 @@ +extends Area3D +class_name HurtBox + +## A component that receives damage from HitBoxes +## Attach to any entity that can be damaged (players, enemies, destructibles) +## NOTE: This is a passive detection zone - HitBox handles the collision detection + +## The entity that owns this hurtbox (should be a BaseUnit or similar) +@export var owner_entity: Node = null + +func _ready(): + # Auto-find owner if not set (traverse up to find BaseUnit) + if owner_entity == null: + var parent = get_parent() + while parent: + if parent is BaseUnit: + owner_entity = parent + break + parent = parent.get_parent() + + # Configure collision - hurtboxes are on layer 5, detect nothing (passive) + collision_layer = 16 # Layer 5 (hurtbox) + collision_mask = 0 # Don't detect anything - hitboxes detect us + + # Ensure we can be detected but don't detect others + monitorable = true + monitoring = false diff --git a/level/scripts/hurt_box.gd.uid b/level/scripts/hurt_box.gd.uid new file mode 100644 index 0000000..61a37e0 --- /dev/null +++ b/level/scripts/hurt_box.gd.uid @@ -0,0 +1 @@ +uid://bj3uepduxvgju diff --git a/level/scripts/player.gd b/level/scripts/player.gd index fcbdad0..290e5f8 100644 --- a/level/scripts/player.gd +++ b/level/scripts/player.gd @@ -42,7 +42,12 @@ var is_blocking: bool = false @export var attack_damage: float = 10.0 @export var attack_range: float = 3.0 @export var attack_cooldown: float = 0.5 +@export var unarmed_knockback: float = 5.0 +@export_category("Unarmed Attack Timing") +@export var unarmed_startup: float = 0.1 # Wind-up before hit +@export var unarmed_active: float = 0.15 # Hit window duration var _attack_timer: float = 0.0 +var _unarmed_hitbox: HitBox = null # Dash system @export var dash_speed_multiplier: float = 2.0 @@ -64,7 +69,10 @@ func _enter_tree(): func _ready(): super._ready() - set_respawn_point(Vector3(0, 5, 0)) + # Set respawn point to current position (where we spawned) - base_unit._ready already does this + # Don't override with a hardcoded position + + print("[Player ", name, "] _ready called. Authority: ", is_multiplayer_authority(), " Position: ", global_position) # Capture mouse for local player if is_multiplayer_authority(): @@ -72,13 +80,18 @@ func _ready(): # Auto-find body node (needed for instanced scenes where @export NodePath doesn't work reliably) if _body == null: - for child in get_children(): - if child.name == "Armature" or child.name == "3DGodotRobot": - _body = child - print("Auto-found _body: ", child.name) - break - if _body == null: - push_error("Could not find body node (Armature or 3DGodotRobot)!") + # Try specific paths first + if has_node("LilguyRigged/Armature"): + _body = get_node("LilguyRigged/Armature") + print("Auto-found _body: LilguyRigged/Armature") + elif has_node("Armature"): + _body = get_node("Armature") + print("Auto-found _body: Armature") + elif has_node("3DGodotRobot"): + _body = get_node("3DGodotRobot") + print("Auto-found _body: 3DGodotRobot") + else: + push_error("Could not find body node!") # Auto-find spring arm offset if _spring_arm_offset == null: @@ -144,6 +157,9 @@ func _ready(): # Setup weapon pickup detection area _setup_weapon_pickup_area() + # Setup unarmed attack hitbox (deferred to avoid multiplayer timing issues) + call_deferred("_setup_unarmed_hitbox") + # Auto-find weapon attachment if not set if _weapon_attachment == null: var bone_attach = get_node_or_null("3DGodotRobot/RobotArmature/Skeleton3D/BoneAttachment3D") @@ -189,19 +205,19 @@ func _physics_process(delta): freeze() return + # Apply gravity when not on floor if not is_on_floor(): velocity.y -= gravity * delta - _body.animate(velocity) - if is_on_floor(): - if Input.is_action_just_pressed("jump"): - velocity.y = JUMP_VELOCITY - else: - velocity.y -= gravity * delta + # Handle jump + if is_on_floor() and Input.is_action_just_pressed("jump"): + velocity.y = JUMP_VELOCITY _move() move_and_slide() - _body.animate(velocity) + + if _body: + _body.animate(velocity) func _process(delta): # Check if multiplayer is ready @@ -269,14 +285,16 @@ func freeze(): velocity.x = 0 velocity.z = 0 _current_speed = 0 - _body.animate(Vector3.ZERO) + if _body: + _body.animate(Vector3.ZERO) func _move() -> void: # If dashing, use dash movement if _is_dashing: velocity.x = _dash_direction.x * _current_speed * dash_speed_multiplier velocity.z = _dash_direction.z * _current_speed * dash_speed_multiplier - _body.apply_rotation(velocity) + if _body: + _body.apply_rotation(velocity) return var _input_direction: Vector2 = Vector2.ZERO @@ -289,12 +307,14 @@ func _move() -> void: var _direction: Vector3 = transform.basis * Vector3(_input_direction.x, 0, _input_direction.y).normalized() is_running() - _direction = _direction.rotated(Vector3.UP, _spring_arm_offset.rotation.y) + if _spring_arm_offset: + _direction = _direction.rotated(Vector3.UP, _spring_arm_offset.rotation.y) if _direction: velocity.x = _direction.x * _current_speed velocity.z = _direction.z * _current_speed - _body.apply_rotation(velocity) + if _body: + _body.apply_rotation(velocity) return velocity.x = move_toward(velocity.x, 0, _current_speed) @@ -406,29 +426,8 @@ func _perform_attack(): # Sync animation to other clients _sync_attack_animation.rpc("Attack_OneHand") - # Find nearest enemy in range - var space_state = get_world_3d().direct_space_state - var query = PhysicsShapeQueryParameters3D.new() - var sphere = SphereShape3D.new() - sphere.radius = attack_range - query.shape = sphere - query.transform = global_transform - query.collision_mask = 1 # Player layer - - var results = space_state.intersect_shape(query) - - for result in results: - var hit_body = result["collider"] - if hit_body != self and hit_body is BaseUnit: - var attacker_id = multiplayer.get_unique_id() - - # If we're the server, apply damage directly (default unarmed knockback) - if multiplayer.is_server(): - _server_apply_damage(hit_body.name, attack_damage, attacker_id, 5.0, global_position) - else: - # Otherwise, request server to apply damage - rpc_id(1, "_server_apply_damage", hit_body.name, attack_damage, attacker_id, 5.0, global_position) - break # Only hit one target per attack + # Activate unarmed hitbox for damage detection + _activate_unarmed_hitbox() ## Server-side damage application @rpc("any_peer", "reliable") @@ -464,11 +463,13 @@ func _on_health_changed(_old_health: float, _new_health: float): _update_health_display() func _on_died(killer_id: int): - # Disable player when dead - set_physics_process(false) - set_process(false) + print("[Player ", name, "] _on_died called. Authority: ", is_multiplayer_authority()) + # Only the authority should disable their own processing + if is_multiplayer_authority(): + set_physics_process(false) + set_process(false) - # Visual feedback - could add death animation here + # Visual feedback - runs on all peers if _body: _body.visible = false @@ -485,18 +486,19 @@ func _on_died(killer_id: int): get_node("HealthUI/HealthText").text = "DEAD - Respawning..." func _on_respawned(): - # Re-enable player - set_physics_process(true) - set_process(true) + print("[Player ", name, "] _on_respawned called. Authority: ", is_multiplayer_authority(), " Position: ", global_position) + # Only the authority should re-enable their own processing + if is_multiplayer_authority(): + set_physics_process(true) + set_process(true) + print("[Player ", name, "] Re-enabled physics processing") + # Visual feedback - runs on all peers if _body: _body.visible = true _update_health_display() - if is_multiplayer_authority(): - print("You respawned!") - ## Dash system func _perform_dash(): if not is_multiplayer_authority() or is_dead or not is_on_floor(): @@ -585,6 +587,63 @@ func _setup_weapon_pickup_area(): pickup_area.area_entered.connect(_on_weapon_area_entered) pickup_area.area_exited.connect(_on_weapon_area_exited) +func _setup_unarmed_hitbox(): + # Create hitbox for unarmed attacks + _unarmed_hitbox = HitBox.new() + _unarmed_hitbox.name = "UnarmedHitBox" + _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 + var collision = CollisionShape3D.new() + var sphere = SphereShape3D.new() + sphere.radius = attack_range # Full attack range as radius + collision.shape = sphere + # Position in front of player (Z is forward for the body) + collision.position = Vector3(0, 0.8, -attack_range * 0.75) + _unarmed_hitbox.add_child(collision) + + # Connect hit signal + _unarmed_hitbox.hit_landed.connect(_on_unarmed_hit) + +func _on_unarmed_hit(target: Node, damage_amount: float, knockback_amount: float, attacker_pos: Vector3): + if not target: + return + + # Route damage through server + var attacker_id = multiplayer.get_unique_id() + + if multiplayer.is_server(): + _server_apply_damage(target.name, damage_amount, attacker_id, knockback_amount, attacker_pos) + else: + rpc_id(1, "_server_apply_damage", target.name, damage_amount, attacker_id, knockback_amount, attacker_pos) + +func _activate_unarmed_hitbox(): + if not _unarmed_hitbox: + return + + # STARTUP PHASE - Wait before activating (wind-up animation) + if unarmed_startup > 0: + await get_tree().create_timer(unarmed_startup).timeout + + if not _unarmed_hitbox or not is_instance_valid(_unarmed_hitbox): + return + + # ACTIVE PHASE - Hitbox on, can deal damage + _unarmed_hitbox.activate() + + await get_tree().create_timer(unarmed_active).timeout + + # RECOVERY PHASE - Hitbox off + if _unarmed_hitbox and is_instance_valid(_unarmed_hitbox): + _unarmed_hitbox.deactivate() + func _on_weapon_area_entered(area: Area3D): # Check if the area belongs to a WorldWeapon var weapon = area.get_parent() diff --git a/level/scripts/weapon_data.gd b/level/scripts/weapon_data.gd index 66eef42..8145d38 100644 --- a/level/scripts/weapon_data.gd +++ b/level/scripts/weapon_data.gd @@ -18,6 +18,14 @@ enum Hand { MAIN_HAND, OFF_HAND, TWO_HAND } @export var attack_animation: String = "Attack1" # Animation to play when attacking @export var knockback_force: float = 8.0 # How much to push the target back +@export_category("Attack Timing") +## Time before hitbox activates (wind-up/anticipation) +@export var startup_time: float = 0.15 +## Duration hitbox stays active (the actual hit window) +@export var active_time: float = 0.2 +## Time after hitbox deactivates (follow-through, can't act) +## Note: recovery_time = attack_cooldown - startup_time - active_time (calculated automatically) + @export_category("Defense Stats") @export var can_block: bool = false @export_range(0.0, 1.0) var block_reduction: float = 0.5 # Percentage of damage blocked (0.5 = 50%) diff --git a/project.godot b/project.godot index b462b41..3fe638d 100644 --- a/project.godot +++ b/project.godot @@ -100,3 +100,5 @@ toggle_character_sheet={ 3d_physics/layer_1="player" 3d_physics/layer_2="world" 3d_physics/layer_3="weapon" +3d_physics/layer_4="hitbox" +3d_physics/layer_5="hurtbox"