2025-11-12 19:18:36 +00:00
extends BaseUnit
2025-11-12 18:41:45 +00:00
class_name Character
const NORMAL_SPEED = 6.0
const SPRINT_SPEED = 10.0
const JUMP_VELOCITY = 10
enum SkinColor { BLUE , YELLOW , GREEN , RED }
@onready var nickname : Label3D = $PlayerNick/Nickname
2025-11-12 19:18:36 +00:00
@onready var health_label : Label3D = null # Optional 3D health label
2025-11-12 18:41:45 +00:00
@export_category ( "Objects" )
@export var _body : Node3D = null
@export var _spring_arm_offset : Node3D = null
2025-11-15 16:44:58 +00:00
@export var _weapon_attachment : BoneAttachment3D = null # WeaponPoint bone attachment (main hand)
@export var _weapon_container : Node3D = null # Container node for main hand weapons
@export var _offhand_attachment : BoneAttachment3D = null # OffHandPoint bone attachment
@export var _offhand_container : Node3D = null # Container node for off-hand weapons
2025-11-12 18:41:45 +00:00
@export_category ( "Skin Colors" )
@export var blue_texture : CompressedTexture2D
@export var yellow_texture : CompressedTexture2D
@export var green_texture : CompressedTexture2D
@export var red_texture : CompressedTexture2D
2025-11-16 18:42:06 +00:00
@onready var _bottom_mesh : MeshInstance3D = get_node_or_null ( "3DGodotRobot/RobotArmature/Skeleton3D/Bottom" )
@onready var _chest_mesh : MeshInstance3D = get_node_or_null ( "3DGodotRobot/RobotArmature/Skeleton3D/Chest" )
@onready var _face_mesh : MeshInstance3D = get_node_or_null ( "3DGodotRobot/RobotArmature/Skeleton3D/Face" )
@onready var _limbs_head_mesh : MeshInstance3D = get_node_or_null ( "3DGodotRobot/RobotArmature/Skeleton3D/Llimbs and head" )
2025-11-12 18:41:45 +00:00
var _current_speed : float
var gravity = ProjectSettings . get_setting ( "physics/3d/default_gravity" )
2025-11-15 16:03:38 +00:00
# Weapon system
2025-11-15 16:44:58 +00:00
var equipped_weapon : BaseWeapon = null # Main hand weapon
var equipped_offhand : BaseWeapon = null # Off-hand weapon
2025-11-15 16:03:38 +00:00
var _nearby_weapons : Array [ WorldWeapon ] = []
2025-11-15 17:07:28 +00:00
var is_blocking : bool = false
2025-11-15 16:03:38 +00:00
2025-11-12 19:18:36 +00:00
# Attack system
@export var attack_damage : float = 10.0
@export var attack_range : float = 3.0
@export var attack_cooldown : float = 0.5
2025-11-21 23:53:28 +00:00
@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
2025-11-12 19:18:36 +00:00
var _attack_timer : float = 0.0
2025-11-21 23:53:28 +00:00
var _unarmed_hitbox : HitBox = null
2025-11-29 18:24:36 +00:00
var _is_unarmed_attacking : bool = false # Prevents overlapping unarmed attacks
2025-11-12 19:18:36 +00:00
2025-11-12 23:30:04 +00:00
# Dash system
@export var dash_speed_multiplier : float = 2.0
@export var dash_duration : float = 0.25
@export var dash_cooldown : float = 4.0
var _dash_timer : float = 0.0
var _dash_cooldown_timer : float = 0.0
var _is_dashing : bool = false
var _dash_direction : Vector3 = Vector3 . ZERO
2025-11-16 22:48:50 +00:00
# UI Signals
signal dash_cooldown_updated ( remaining : float , total : float )
signal attack_cooldown_updated ( remaining : float , total : float )
signal weapon_equipped_changed ()
2025-11-12 18:41:45 +00:00
func _enter_tree ():
2025-11-12 19:18:36 +00:00
super . _enter_tree ()
2025-11-12 18:41:45 +00:00
$SpringArmOffset/SpringArm3D/Camera3D . current = is_multiplayer_authority ()
func _ready ():
2026-06-30 22:57:11 +01:00
# Players passively regenerate health (set before super._ready so the regen timer is created)
if health_regen <= 0.0 :
health_regen = 3.0
regen_delay = 5.0
2025-11-12 19:18:36 +00:00
super . _ready ()
2025-11-21 23:53:28 +00:00
# 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 )
2025-11-12 19:18:36 +00:00
2025-11-16 22:48:50 +00:00
# Capture mouse for local player
if is_multiplayer_authority ():
Input . mouse_mode = Input . MOUSE_MODE_CAPTURED
2025-11-16 18:42:06 +00:00
# Auto-find body node (needed for instanced scenes where @export NodePath doesn't work reliably)
if _body == null :
2025-11-21 23:53:28 +00:00
# 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!" )
2025-11-16 18:42:06 +00:00
# Auto-find spring arm offset
if _spring_arm_offset == null :
if has_node ( "SpringArmOffset" ):
_spring_arm_offset = get_node ( "SpringArmOffset" )
print ( "Auto-found _spring_arm_offset" )
else :
push_error ( "Could not find SpringArmOffset!" )
# Auto-find weapon attachments if not set
if _weapon_attachment == null :
if has_node ( "Armature/Skeleton3D/WeaponPoint" ):
_weapon_attachment = get_node ( "Armature/Skeleton3D/WeaponPoint" )
print ( "Auto-found _weapon_attachment" )
elif has_node ( "3DGodotRobot/RobotArmature/Skeleton3D/BoneAttachment3D" ):
_weapon_attachment = get_node ( "3DGodotRobot/RobotArmature/Skeleton3D/BoneAttachment3D" )
print ( "Auto-found _weapon_attachment (robot)" )
else :
print ( "WARNING: WeaponPoint not found! Check if you've added BoneAttachment3D nodes." )
if has_node ( "Armature/Skeleton3D" ):
print ( "Skeleton3D children:" )
var skeleton = get_node ( "Armature/Skeleton3D" )
for child in skeleton . get_children ():
print ( " - " , child . name , " (" , child . get_class (), ")" )
# Auto-find weapon container
if _weapon_container == null and _weapon_attachment :
var container = _weapon_attachment . get_node_or_null ( "WeaponContainer" )
if container :
_weapon_container = container
print ( "Auto-found _weapon_container" )
# Auto-find offhand attachment
if _offhand_attachment == null :
if has_node ( "Armature/Skeleton3D/OffhandPoint" ):
_offhand_attachment = get_node ( "Armature/Skeleton3D/OffhandPoint" )
print ( "Auto-found _offhand_attachment" )
elif has_node ( "3DGodotRobot/RobotArmature/Skeleton3D/OffHandPoint" ):
_offhand_attachment = get_node ( "3DGodotRobot/RobotArmature/Skeleton3D/OffHandPoint" )
print ( "Auto-found _offhand_attachment (robot)" )
else :
print ( "WARNING: OffhandPoint not found!" )
# Auto-find offhand container
if _offhand_container == null and _offhand_attachment :
var container = _offhand_attachment . get_node_or_null ( "OffhandContainer" )
if container :
_offhand_container = container
print ( "Auto-found _offhand_container" )
2025-11-12 19:18:36 +00:00
# Try to get optional health label
if has_node ( "PlayerNick/HealthLabel" ):
health_label = get_node ( "PlayerNick/HealthLabel" )
# Connect health signals
health_changed . connect ( _on_health_changed )
died . connect ( _on_died )
respawned . connect ( _on_respawned )
# Update health display
_update_health_display ()
2025-11-15 16:03:38 +00:00
# Setup weapon pickup detection area
_setup_weapon_pickup_area ()
2025-11-21 23:53:28 +00:00
# Setup unarmed attack hitbox (deferred to avoid multiplayer timing issues)
call_deferred ( "_setup_unarmed_hitbox" )
2025-11-15 16:03:38 +00:00
# Auto-find weapon attachment if not set
if _weapon_attachment == null :
var bone_attach = get_node_or_null ( "3DGodotRobot/RobotArmature/Skeleton3D/BoneAttachment3D" )
if bone_attach :
_weapon_attachment = bone_attach
print ( "Auto-found weapon attachment point" )
else :
push_warning ( "Could not find BoneAttachment3D for weapons!" )
2025-11-15 16:44:58 +00:00
# Auto-find weapon container if not set
if _weapon_container == null and _weapon_attachment :
var container = _weapon_attachment . get_node_or_null ( "WeaponContainer" )
if container :
_weapon_container = container
print ( "Auto-found main hand weapon container" )
else :
push_warning ( "Could not find WeaponContainer! Weapons will attach directly to BoneAttachment3D." )
# Auto-find off-hand attachment if not set
if _offhand_attachment == null :
var offhand_attach = get_node_or_null ( "3DGodotRobot/RobotArmature/Skeleton3D/OffHandPoint" )
if offhand_attach :
_offhand_attachment = offhand_attach
print ( "Auto-found off-hand attachment point" )
# Auto-find off-hand container if not set
if _offhand_container == null and _offhand_attachment :
var container = _offhand_attachment . get_node_or_null ( "OffHandContainer" )
if container :
_offhand_container = container
print ( "Auto-found off-hand weapon container" )
2025-11-12 18:41:45 +00:00
func _physics_process ( delta ):
2025-11-12 19:18:36 +00:00
# Check if multiplayer is ready
if multiplayer . multiplayer_peer == null :
return
if not is_multiplayer_authority ():
return
2025-11-12 18:41:45 +00:00
var current_scene = get_tree () . get_current_scene ()
if current_scene and current_scene . has_method ( "is_chat_visible" ) and current_scene . is_chat_visible () and is_on_floor ():
freeze ()
return
2025-11-21 23:53:28 +00:00
# Apply gravity when not on floor
2025-11-12 18:41:45 +00:00
if not is_on_floor ():
velocity . y -= gravity * delta
2025-11-21 23:53:28 +00:00
# Handle jump
if is_on_floor () and Input . is_action_just_pressed ( "jump" ):
velocity . y = JUMP_VELOCITY
2025-11-12 18:41:45 +00:00
_move ()
move_and_slide ()
2025-11-21 23:53:28 +00:00
if _body :
_body . animate ( velocity )
2025-11-12 18:41:45 +00:00
2025-11-12 19:18:36 +00:00
func _process ( delta ):
# Check if multiplayer is ready
if multiplayer . multiplayer_peer == null :
return
if not is_multiplayer_authority ():
return
2025-11-12 18:41:45 +00:00
_check_fall_and_respawn ()
2025-11-12 19:18:36 +00:00
# Update attack cooldown
if _attack_timer > 0 :
_attack_timer -= delta
2025-11-16 22:48:50 +00:00
attack_cooldown_updated . emit ( _attack_timer , attack_cooldown )
2025-11-12 19:18:36 +00:00
2025-11-12 23:30:04 +00:00
# Update dash timers
if _dash_timer > 0 :
_dash_timer -= delta
if _dash_timer <= 0 :
_is_dashing = false
if _dash_cooldown_timer > 0 :
_dash_cooldown_timer -= delta
2025-11-16 22:48:50 +00:00
dash_cooldown_updated . emit ( _dash_cooldown_timer , dash_cooldown )
2025-11-12 23:30:04 +00:00
# Handle dash input
if Input . is_action_just_pressed ( "dash" ) and _dash_cooldown_timer <= 0 and not is_dead and is_on_floor ():
_perform_dash ()
2025-11-15 17:07:28 +00:00
# Handle block input
if Input . is_action_pressed ( "block" ) and not is_dead :
_try_block ()
else :
is_blocking = false
2025-11-12 19:18:36 +00:00
# Handle attack input
2025-11-15 17:07:28 +00:00
if Input . is_action_just_pressed ( "attack" ) and not is_dead and not _is_dashing and not is_blocking :
2025-11-12 19:18:36 +00:00
_perform_attack ()
2025-11-15 16:03:38 +00:00
# Handle weapon pickup/drop
if Input . is_action_just_pressed ( "pickup" ) and not is_dead :
2025-11-15 16:44:58 +00:00
print ( "Pickup pressed! Main: " , equipped_weapon != null , " OffHand: " , equipped_offhand != null , " Nearby: " , _nearby_weapons . size ())
# Try to pickup nearby weapon
if _nearby_weapons . size () > 0 :
_pickup_nearest_weapon ()
# If no nearby weapons, drop what we're holding (main hand first, then off-hand)
elif equipped_weapon :
2025-11-15 16:03:38 +00:00
# Tell server to drop (server will handle spawning)
if multiplayer . is_server ():
2025-11-15 16:44:58 +00:00
drop_weapon ( false ) # main hand
2025-11-15 16:03:38 +00:00
else :
2025-11-15 16:44:58 +00:00
rpc_id ( 1 , "drop_weapon" , false ) # Only send to server
elif equipped_offhand :
# Drop off-hand
if multiplayer . is_server ():
drop_weapon ( true ) # off-hand
else :
rpc_id ( 1 , "drop_weapon" , true )
2025-11-15 16:03:38 +00:00
else :
print ( "No weapons nearby to pick up" )
2025-11-12 18:41:45 +00:00
func freeze ():
velocity . x = 0
velocity . z = 0
_current_speed = 0
2025-11-21 23:53:28 +00:00
if _body :
_body . animate ( Vector3 . ZERO )
2025-11-12 18:41:45 +00:00
func _move () -> void :
2025-11-12 23:30:04 +00:00
# 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
2025-11-21 23:53:28 +00:00
if _body :
_body . apply_rotation ( velocity )
2025-11-12 23:30:04 +00:00
return
2025-11-12 18:41:45 +00:00
var _input_direction : Vector2 = Vector2 . ZERO
if is_multiplayer_authority ():
_input_direction = Input . get_vector (
"move_left" , "move_right" ,
"move_forward" , "move_backward"
)
var _direction : Vector3 = transform . basis * Vector3 ( _input_direction . x , 0 , _input_direction . y ) . normalized ()
is_running ()
2025-11-21 23:53:28 +00:00
if _spring_arm_offset :
_direction = _direction . rotated ( Vector3 . UP , _spring_arm_offset . rotation . y )
2025-11-12 18:41:45 +00:00
if _direction :
velocity . x = _direction . x * _current_speed
velocity . z = _direction . z * _current_speed
2025-11-21 23:53:28 +00:00
if _body :
_body . apply_rotation ( velocity )
2025-11-12 18:41:45 +00:00
return
velocity . x = move_toward ( velocity . x , 0 , _current_speed )
velocity . z = move_toward ( velocity . z , 0 , _current_speed )
func is_running () -> bool :
if Input . is_action_pressed ( "shift" ):
_current_speed = SPRINT_SPEED
return true
else :
_current_speed = NORMAL_SPEED
return false
func _check_fall_and_respawn ():
2025-11-12 19:18:36 +00:00
if global_transform . origin . y < - 15.0 and multiplayer . is_server ():
# Use BaseUnit's respawn system
2025-11-12 18:41:45 +00:00
_respawn ()
@rpc ( "any_peer" , "reliable" )
func change_nick ( new_nick : String ):
if nickname :
nickname . text = new_nick
func get_texture_from_name ( skin_color : SkinColor ) -> CompressedTexture2D :
match skin_color :
SkinColor . BLUE : return blue_texture
SkinColor . GREEN : return green_texture
SkinColor . RED : return red_texture
SkinColor . YELLOW : return yellow_texture
_ : return blue_texture
@rpc ( "any_peer" , "reliable" )
func set_player_skin ( skin_name : SkinColor ) -> void :
2025-11-29 18:10:23 +00:00
# Check if we're using the LilguyRigged model
if _body is LilguyBody :
# Use hue-based color system for Lilguy
var hue = _get_hue_from_skin_color ( skin_name )
_body . set_character_color ( hue )
else :
# Use texture-based system for 3DGodotRobot
var texture = get_texture_from_name ( skin_name )
set_mesh_texture ( _bottom_mesh , texture )
set_mesh_texture ( _chest_mesh , texture )
set_mesh_texture ( _face_mesh , texture )
set_mesh_texture ( _limbs_head_mesh , texture )
2025-11-12 18:41:45 +00:00
2025-11-29 18:10:23 +00:00
## Convert SkinColor enum to hue value (0.0 to 1.0)
func _get_hue_from_skin_color ( skin_color : SkinColor ) -> float :
match skin_color :
SkinColor . BLUE : return 0.6 # Blue hue
SkinColor . GREEN : return 0.33 # Green hue
SkinColor . RED : return 0.0 # Red hue
SkinColor . YELLOW : return 0.16 # Yellow hue
_ : return 0.6 # Default to blue
2025-11-12 18:41:45 +00:00
func set_mesh_texture ( mesh_instance : MeshInstance3D , texture : CompressedTexture2D ) -> void :
if mesh_instance :
var material := mesh_instance . get_surface_override_material ( 0 )
if material and material is StandardMaterial3D :
var new_material := material
new_material . albedo_texture = texture
mesh_instance . set_surface_override_material ( 0 , new_material )
2025-11-12 19:18:36 +00:00
2025-11-15 17:07:28 +00:00
## Block system
func _try_block ():
# Check if we have a weapon that can block
var can_block_weapon = false
if equipped_weapon and equipped_weapon . can_block ():
can_block_weapon = true
elif equipped_offhand and equipped_offhand . can_block ():
can_block_weapon = true
is_blocking = can_block_weapon
# Could add blocking animation here
# if is_blocking and _body:
# _body.play_block_animation()
## Get block reduction amount (0.0 to 1.0)
func get_block_reduction () -> float :
if not is_blocking :
return 0.0
var reduction = 0.0
# Use the highest block reduction from equipped weapons
if equipped_weapon and equipped_weapon . can_block ():
reduction = max ( reduction , equipped_weapon . get_block_reduction ())
if equipped_offhand and equipped_offhand . can_block ():
reduction = max ( reduction , equipped_offhand . get_block_reduction ())
return reduction
2025-11-12 19:18:36 +00:00
## Attack system
func _perform_attack ():
if not is_multiplayer_authority () or is_dead :
return
2025-11-15 16:44:58 +00:00
# Use main hand weapon if available
2025-11-15 16:03:38 +00:00
if equipped_weapon and equipped_weapon . can_attack ():
equipped_weapon . perform_attack ()
return
2025-11-15 16:44:58 +00:00
# Or use off-hand weapon if available
if equipped_offhand and equipped_offhand . can_attack ():
equipped_offhand . perform_attack ()
return
2025-11-15 16:03:38 +00:00
# Fallback to default unarmed attack
2025-11-12 19:51:47 +00:00
# Don't attack if already attacking
2025-11-21 22:59:41 +00:00
if _body and _body . animation_player and _body . animation_player . current_animation . begins_with ( "Attack" ):
2025-11-12 19:51:47 +00:00
return
2025-11-29 18:24:36 +00:00
if _attack_timer > 0 or _is_unarmed_attacking :
2025-11-15 16:03:38 +00:00
return
2025-11-29 18:24:36 +00:00
# Calculate total attack duration and ensure cooldown covers it
var total_duration = unarmed_startup + unarmed_active
var cooldown = max ( attack_cooldown , total_duration )
_attack_timer = cooldown
_is_unarmed_attacking = true
2025-11-12 19:18:36 +00:00
2025-11-12 19:51:47 +00:00
# Play attack animation once
if _body :
2025-11-21 22:59:41 +00:00
_body . play_attack ( "Attack_OneHand" )
# Sync animation to other clients
_sync_attack_animation . rpc ( "Attack_OneHand" )
2025-11-12 19:51:47 +00:00
2025-11-21 23:53:28 +00:00
# Activate unarmed hitbox for damage detection
_activate_unarmed_hitbox ()
2025-11-12 19:18:36 +00:00
## Server-side damage application
@rpc ( "any_peer" , "reliable" )
2025-11-15 17:17:32 +00:00
func _server_apply_damage ( target_name : String , damage : float , attacker_id : int , knockback : float = 0.0 , attacker_pos : Vector3 = Vector3 . ZERO ):
2025-11-12 19:18:36 +00:00
if not multiplayer . is_server ():
return
var level = get_tree () . get_current_scene ()
2025-11-29 18:10:23 +00:00
if not level :
2025-11-12 19:18:36 +00:00
return
2025-11-29 18:10:23 +00:00
var target = null
2025-11-12 19:18:36 +00:00
2025-11-29 18:10:23 +00:00
# Check players container first
if level . has_node ( "PlayersContainer" ):
var players_container = level . get_node ( "PlayersContainer" )
if players_container . has_node ( target_name ):
target = players_container . get_node ( target_name )
# If not found in players, check enemies container
if not target and level . has_node ( "EnemiesContainer" ):
var enemies_container = level . get_node ( "EnemiesContainer" )
if enemies_container . has_node ( target_name ):
target = enemies_container . get_node ( target_name )
# Apply damage if target found
2025-11-12 19:18:36 +00:00
if target and target is BaseUnit :
2025-11-15 17:17:32 +00:00
target . take_damage ( damage , attacker_id , knockback , attacker_pos )
2025-11-12 19:18:36 +00:00
## Health display and callbacks
2025-11-16 22:48:50 +00:00
# Old 2D health UI - now handled by HUD Manager
# Kept for reference, but no longer used
2025-11-12 19:18:36 +00:00
func _update_health_display ():
2025-11-16 22:48:50 +00:00
# Update 3D label if it exists (for other players to see)
2025-11-12 19:18:36 +00:00
if health_label :
health_label . text = "HP: %d / %d " % [ int ( current_health ), int ( max_health )]
2025-11-16 22:48:50 +00:00
# 2D UI is now handled by HUD Manager autoload
2025-11-12 19:18:36 +00:00
func _on_health_changed ( _old_health : float , _new_health : float ):
_update_health_display ()
func _on_died ( killer_id : int ):
2025-11-21 23:53:28 +00:00
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 )
2025-11-12 19:18:36 +00:00
2025-11-21 23:53:28 +00:00
# Visual feedback - runs on all peers
2025-11-12 19:18:36 +00:00
if _body :
_body . visible = false
# Print death message
var killer_name = "Unknown"
if Network . players . has ( killer_id ):
killer_name = Network . players [ killer_id ][ "nick" ]
if is_multiplayer_authority ():
print ( "You were killed by " , killer_name )
# Show death message on UI
if has_node ( "HealthUI/HealthText" ):
get_node ( "HealthUI/HealthText" ) . text = "DEAD - Respawning..."
func _on_respawned ():
2025-11-21 23:53:28 +00:00
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" )
2025-11-12 19:18:36 +00:00
2025-11-21 23:53:28 +00:00
# Visual feedback - runs on all peers
2025-11-12 19:18:36 +00:00
if _body :
_body . visible = true
_update_health_display ()
2025-11-12 23:30:04 +00:00
## Dash system
func _perform_dash ():
if not is_multiplayer_authority () or is_dead or not is_on_floor ():
return
# Get current movement direction or use forward if no input
var _input_direction : Vector2 = Input . get_vector (
"move_left" , "move_right" ,
"move_forward" , "move_backward"
)
var _direction : Vector3
if _input_direction . length () > 0 :
# Dash in input direction
_direction = transform . basis * Vector3 ( _input_direction . x , 0 , _input_direction . y ) . normalized ()
_direction = _direction . rotated ( Vector3 . UP , _spring_arm_offset . rotation . y )
else :
# Dash forward if no input
_direction = - transform . basis . z
_direction = _direction . rotated ( Vector3 . UP , _spring_arm_offset . rotation . y )
# Set dash parameters
_dash_direction = _direction
_is_dashing = true
_dash_timer = dash_duration
_dash_cooldown_timer = dash_cooldown
2025-11-16 22:22:24 +00:00
# Animation is handled by the Body's animate function (Jump animation plays during dash)
2025-11-15 16:03:38 +00:00
2025-11-21 22:59:41 +00:00
## Sync attack animation to all clients
@rpc ( "any_peer" , "call_remote" , "unreliable" )
func _sync_attack_animation ( anim_name : String ):
if _body :
_body . play_attack ( anim_name )
2025-11-15 17:17:32 +00:00
## Override hurt animation from BaseUnit
func _play_hurt_animation ():
if _body and _body . animation_player :
# Try to play a hurt/hit animation if it exists
if _body . animation_player . has_animation ( "Hurt" ):
_body . animation_player . play ( "Hurt" )
elif _body . animation_player . has_animation ( "Hit" ):
_body . animation_player . play ( "Hit" )
elif _body . animation_player . has_animation ( "TakeDamage" ):
_body . animation_player . play ( "TakeDamage" )
else :
# Fallback: briefly flash the character red
_flash_hurt ()
## Flash effect when no hurt animation exists
func _flash_hurt ():
if not _body :
return
2025-11-16 18:42:06 +00:00
# Only works if _body has a modulate property (CanvasItem or some Node3D with visual children)
if "modulate" in _body :
# Store original modulate
var original_modulate = _body . modulate
2025-11-15 17:17:32 +00:00
2025-11-16 18:42:06 +00:00
# Flash red
_body . modulate = Color ( 1.5 , 0.5 , 0.5 , 1.0 )
2025-11-15 17:17:32 +00:00
2025-11-16 18:42:06 +00:00
# Return to normal after a brief moment
await get_tree () . create_timer ( 0.15 ) . timeout
if _body :
_body . modulate = original_modulate
# If no modulate, just skip the visual effect
2025-11-15 17:17:32 +00:00
2025-11-15 16:03:38 +00:00
## Weapon System
func _setup_weapon_pickup_area ():
# Create an Area3D to detect nearby weapons
var pickup_area = Area3D . new ()
pickup_area . name = "WeaponPickupArea"
pickup_area . collision_layer = 0
pickup_area . collision_mask = 4 # Will detect WorldWeapons (we'll use layer 3)
add_child ( pickup_area )
# Create collision shape for pickup range
var pickup_collision = CollisionShape3D . new ()
var sphere = SphereShape3D . new ()
sphere . radius = 2.0 # Pickup range
pickup_collision . shape = sphere
pickup_area . add_child ( pickup_collision )
# Connect signals to track nearby weapons
pickup_area . area_entered . connect ( _on_weapon_area_entered )
pickup_area . area_exited . connect ( _on_weapon_area_exited )
2025-11-21 23:53:28 +00:00
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 )
2026-02-17 22:21:17 +00:00
# Add collision shape BEFORE adding hitbox to tree (so _ready can find it)
2025-11-21 23:53:28 +00:00
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 )
2026-02-17 22:21:17 +00:00
# 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 )
2025-11-21 23:53:28 +00:00
# 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 :
2025-11-29 18:24:36 +00:00
_is_unarmed_attacking = false
2025-11-21 23:53:28 +00:00
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 ):
2025-11-29 18:24:36 +00:00
_is_unarmed_attacking = false
2025-11-21 23:53:28 +00:00
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 ()
2025-11-29 18:24:36 +00:00
# Attack complete
_is_unarmed_attacking = false
2025-11-15 16:03:38 +00:00
func _on_weapon_area_entered ( area : Area3D ):
# Check if the area belongs to a WorldWeapon
var weapon = area . get_parent ()
if weapon is WorldWeapon and weapon not in _nearby_weapons :
_nearby_weapons . append ( weapon )
if is_multiplayer_authority ():
print ( "Weapon nearby: " , weapon . weapon_data . weapon_name if weapon . weapon_data else "Unknown" )
func _on_weapon_area_exited ( area : Area3D ):
# Remove weapon from nearby list
var weapon = area . get_parent ()
if weapon is WorldWeapon and weapon in _nearby_weapons :
_nearby_weapons . erase ( weapon )
if is_multiplayer_authority ():
print ( "Weapon left range: " , weapon . weapon_data . weapon_name if weapon . weapon_data else "Unknown" )
## Equip a weapon from WorldWeapon data (receives resource path)
@rpc ( "any_peer" , "call_local" , "reliable" )
func equip_weapon_from_world ( weapon_data_path : String ):
2025-11-16 18:42:06 +00:00
print ( "[Client " , multiplayer . get_unique_id (), "] equip_weapon_from_world called for: " , weapon_data_path )
2025-11-15 16:03:38 +00:00
var data = load ( weapon_data_path ) as WeaponData
if data :
equip_weapon ( data )
2025-11-16 18:42:06 +00:00
print ( "[Client " , multiplayer . get_unique_id (), "] Equipped weapon: " , data . weapon_name )
2025-11-15 16:03:38 +00:00
else :
push_error ( "Failed to load weapon data from: " + weapon_data_path )
## Equip a weapon with given data
func equip_weapon ( data : WeaponData ):
2025-11-15 16:44:58 +00:00
# Determine which hand based on weapon type
var is_offhand = ( data . hand_type == WeaponData . Hand . OFF_HAND )
2025-11-15 16:03:38 +00:00
2025-11-15 16:44:58 +00:00
# Unequip current weapon in that hand first
if is_offhand :
if equipped_offhand :
unequip_weapon ( true )
else :
if equipped_weapon :
unequip_weapon ( false )
# Determine where to attach the weapon
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 ( "No weapon attachment point found for " , "off-hand" if is_offhand else "main hand" )
2025-11-15 16:03:38 +00:00
return
# Create new weapon instance
var weapon = BaseWeapon . new ()
weapon . weapon_data = data
2025-11-15 16:44:58 +00:00
weapon . name = "EquippedOffHand" if is_offhand else "EquippedWeapon"
2025-11-15 16:03:38 +00:00
weapon . set_owner_character ( self )
2025-11-15 16:44:58 +00:00
# Attach to weapon container (or bone attachment if no container)
attach_point . add_child ( weapon )
# Store reference
if is_offhand :
equipped_offhand = weapon
else :
equipped_weapon = weapon
2025-11-15 16:03:38 +00:00
if is_multiplayer_authority ():
2025-11-15 16:44:58 +00:00
print ( "Equipped: " , data . weapon_name , " to " , attach_point . name )
2025-11-16 22:48:50 +00:00
weapon_equipped_changed . emit ()
2025-11-15 16:03:38 +00:00
## Unequip current weapon (local only)
2025-11-15 16:44:58 +00:00
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
2025-11-15 16:03:38 +00:00
2025-11-16 22:48:50 +00:00
if is_multiplayer_authority ():
weapon_equipped_changed . emit ()
2025-11-15 16:03:38 +00:00
## Sync unequip across all clients
@rpc ( "any_peer" , "call_local" , "reliable" )
2025-11-15 16:44:58 +00:00
func _unequip_weapon_sync ( is_offhand : bool = false ):
unequip_weapon ( is_offhand )
2025-11-15 16:03:38 +00:00
## Drop currently equipped weapon
@rpc ( "any_peer" , "reliable" )
2025-11-15 16:44:58 +00:00
func drop_weapon ( is_offhand : bool = false ):
var has_weapon = equipped_offhand if is_offhand else equipped_weapon
print ( "drop_weapon called on peer " , multiplayer . get_unique_id (), " is_server: " , multiplayer . is_server (), " has weapon: " , has_weapon != null , " offhand: " , is_offhand )
2025-11-15 16:03:38 +00:00
2025-11-15 16:44:58 +00:00
if not has_weapon :
print ( "No weapon equipped in " , "off-hand" if is_offhand else "main hand" , ", cannot drop" )
2025-11-15 16:03:38 +00:00
return
# Only server spawns the world weapon
if multiplayer . is_server ():
print ( "Server spawning dropped weapon" )
2025-11-15 16:44:58 +00:00
_spawn_world_weapon ( has_weapon . weapon_data )
2025-11-15 16:03:38 +00:00
# Tell all clients to unequip
2025-11-15 16:44:58 +00:00
rpc ( "_unequip_weapon_sync" , is_offhand )
2025-11-15 16:03:38 +00:00
# Unequip locally
2025-11-15 16:44:58 +00:00
unequip_weapon ( is_offhand )
2025-11-15 16:03:38 +00:00
if is_multiplayer_authority ():
2025-11-15 16:44:58 +00:00
print ( "Dropped weapon from " , "off-hand" if is_offhand else "main hand" )
2025-11-15 16:03:38 +00:00
## Spawn a weapon in the world (server only)
func _spawn_world_weapon ( data : WeaponData ):
if not multiplayer . is_server ():
return
# Get the resource path
var resource_path = data . resource_path
if resource_path == "" :
push_error ( "WeaponData has no resource path! Make sure to save it as a .tres file" )
return
# Position in front of player
var spawn_pos = global_position + ( - transform . basis . z * 2.0 )
spawn_pos . y += 1.0 # Spawn at chest height
# Calculate forward velocity
var velocity = - transform . basis . z * 3.0
# Tell level to spawn weapon on all clients
var level = get_tree () . get_current_scene ()
if level and level . has_method ( "spawn_world_weapon" ):
# Increment the level's weapon counter
level . _weapon_spawn_counter += 1
level . rpc ( "spawn_world_weapon" , resource_path , spawn_pos , velocity , level . _weapon_spawn_counter )
## Pick up nearest weapon
func _pickup_nearest_weapon ():
if _nearby_weapons . size () == 0 :
return
# Find closest weapon
var closest_weapon : WorldWeapon = null
var closest_distance : float = INF
for weapon in _nearby_weapons :
if not is_instance_valid ( weapon ):
continue
var distance = global_position . distance_to ( weapon . global_position )
if distance < closest_distance :
closest_distance = distance
closest_weapon = weapon
if closest_weapon :
2025-11-15 16:44:58 +00:00
# Check what hand this weapon goes in
var weapon_hand_type = closest_weapon . weapon_data . hand_type if closest_weapon . weapon_data else WeaponData . Hand . MAIN_HAND
var is_offhand = ( weapon_hand_type == WeaponData . Hand . OFF_HAND )
var slot_occupied = equipped_offhand if is_offhand else equipped_weapon
# If the slot is occupied, drop it first
if slot_occupied :
print ( "Slot occupied, dropping current weapon before picking up" )
if multiplayer . is_server ():
drop_weapon ( is_offhand )
else :
rpc_id ( 1 , "drop_weapon" , is_offhand )
# Wait a tiny bit for the drop to complete
await get_tree () . create_timer ( 0.1 ) . timeout
2025-11-15 16:03:38 +00:00
# Request server to pickup (server validates)
if multiplayer . is_server ():
closest_weapon . try_pickup ( multiplayer . get_unique_id ())
else :
closest_weapon . rpc_id ( 1 , "try_pickup" , multiplayer . get_unique_id ())