862 lines
28 KiB
GDScript
862 lines
28 KiB
GDScript
extends BaseUnit
|
|
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
|
|
@onready var health_label: Label3D = null # Optional 3D health label
|
|
|
|
@export_category("Objects")
|
|
@export var _body: Node3D = null
|
|
@export var _spring_arm_offset: Node3D = null
|
|
@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
|
|
|
|
@export_category("Skin Colors")
|
|
@export var blue_texture : CompressedTexture2D
|
|
@export var yellow_texture : CompressedTexture2D
|
|
@export var green_texture : CompressedTexture2D
|
|
@export var red_texture : CompressedTexture2D
|
|
|
|
@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")
|
|
|
|
var _current_speed: float
|
|
var gravity = ProjectSettings.get_setting("physics/3d/default_gravity")
|
|
|
|
# Weapon system
|
|
var equipped_weapon: BaseWeapon = null # Main hand weapon
|
|
var equipped_offhand: BaseWeapon = null # Off-hand weapon
|
|
var _nearby_weapons: Array[WorldWeapon] = []
|
|
var is_blocking: bool = false
|
|
|
|
# Attack system
|
|
@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
|
|
var _is_unarmed_attacking: bool = false # Prevents overlapping unarmed attacks
|
|
|
|
# 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
|
|
|
|
# UI Signals
|
|
signal dash_cooldown_updated(remaining: float, total: float)
|
|
signal attack_cooldown_updated(remaining: float, total: float)
|
|
signal weapon_equipped_changed()
|
|
|
|
func _enter_tree():
|
|
super._enter_tree()
|
|
$SpringArmOffset/SpringArm3D/Camera3D.current = is_multiplayer_authority()
|
|
|
|
func _ready():
|
|
# 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
|
|
|
|
super._ready()
|
|
# 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():
|
|
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
|
|
|
# Auto-find body node (needed for instanced scenes where @export NodePath doesn't work reliably)
|
|
if _body == null:
|
|
# 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:
|
|
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")
|
|
|
|
# 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()
|
|
|
|
# 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")
|
|
if bone_attach:
|
|
_weapon_attachment = bone_attach
|
|
print("Auto-found weapon attachment point")
|
|
else:
|
|
push_warning("Could not find BoneAttachment3D for weapons!")
|
|
|
|
# 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")
|
|
|
|
func _physics_process(delta):
|
|
# Check if multiplayer is ready
|
|
if multiplayer.multiplayer_peer == null:
|
|
return
|
|
|
|
if not is_multiplayer_authority():
|
|
return
|
|
|
|
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
|
|
|
|
# Apply gravity when not on floor
|
|
if not is_on_floor():
|
|
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()
|
|
|
|
if _body:
|
|
_body.animate(velocity)
|
|
|
|
func _process(delta):
|
|
# Check if multiplayer is ready
|
|
if multiplayer.multiplayer_peer == null:
|
|
return
|
|
|
|
if not is_multiplayer_authority():
|
|
return
|
|
|
|
_check_fall_and_respawn()
|
|
|
|
# Update attack cooldown
|
|
if _attack_timer > 0:
|
|
_attack_timer -= delta
|
|
attack_cooldown_updated.emit(_attack_timer, attack_cooldown)
|
|
|
|
# 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
|
|
dash_cooldown_updated.emit(_dash_cooldown_timer, dash_cooldown)
|
|
|
|
# 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()
|
|
|
|
# Handle block input
|
|
if Input.is_action_pressed("block") and not is_dead:
|
|
_try_block()
|
|
else:
|
|
is_blocking = false
|
|
|
|
# Handle attack input
|
|
if Input.is_action_just_pressed("attack") and not is_dead and not _is_dashing and not is_blocking:
|
|
_perform_attack()
|
|
|
|
# Handle weapon pickup/drop
|
|
if Input.is_action_just_pressed("pickup") and not is_dead:
|
|
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:
|
|
# Tell server to drop (server will handle spawning)
|
|
if multiplayer.is_server():
|
|
drop_weapon(false) # main hand
|
|
else:
|
|
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)
|
|
else:
|
|
print("No weapons nearby to pick up")
|
|
|
|
func freeze():
|
|
velocity.x = 0
|
|
velocity.z = 0
|
|
_current_speed = 0
|
|
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
|
|
if _body:
|
|
_body.apply_rotation(velocity)
|
|
return
|
|
|
|
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()
|
|
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
|
|
if _body:
|
|
_body.apply_rotation(velocity)
|
|
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():
|
|
if global_transform.origin.y < -15.0 and multiplayer.is_server():
|
|
# Use BaseUnit's respawn system
|
|
_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:
|
|
# 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)
|
|
|
|
## 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
|
|
|
|
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)
|
|
|
|
## 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
|
|
|
|
## Attack system
|
|
func _perform_attack():
|
|
if not is_multiplayer_authority() or is_dead:
|
|
return
|
|
|
|
# Use main hand weapon if available
|
|
if equipped_weapon and equipped_weapon.can_attack():
|
|
equipped_weapon.perform_attack()
|
|
return
|
|
|
|
# Or use off-hand weapon if available
|
|
if equipped_offhand and equipped_offhand.can_attack():
|
|
equipped_offhand.perform_attack()
|
|
return
|
|
|
|
# Fallback to default unarmed attack
|
|
# Don't attack if already attacking
|
|
if _body and _body.animation_player and _body.animation_player.current_animation.begins_with("Attack"):
|
|
return
|
|
|
|
if _attack_timer > 0 or _is_unarmed_attacking:
|
|
return
|
|
|
|
# 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
|
|
|
|
# Play attack animation once
|
|
if _body:
|
|
_body.play_attack("Attack_OneHand")
|
|
# Sync animation to other clients
|
|
_sync_attack_animation.rpc("Attack_OneHand")
|
|
|
|
# Activate unarmed hitbox for damage detection
|
|
_activate_unarmed_hitbox()
|
|
|
|
## Server-side damage application
|
|
@rpc("any_peer", "reliable")
|
|
func _server_apply_damage(target_name: String, damage: float, attacker_id: int, knockback: float = 0.0, attacker_pos: Vector3 = Vector3.ZERO):
|
|
if not multiplayer.is_server():
|
|
return
|
|
|
|
var level = get_tree().get_current_scene()
|
|
if not level:
|
|
return
|
|
|
|
var target = null
|
|
|
|
# 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
|
|
if target and target is BaseUnit:
|
|
target.take_damage(damage, attacker_id, knockback, attacker_pos)
|
|
|
|
## Health display and callbacks
|
|
# Old 2D health UI - now handled by HUD Manager
|
|
# Kept for reference, but no longer used
|
|
|
|
func _update_health_display():
|
|
# Update 3D label if it exists (for other players to see)
|
|
if health_label:
|
|
health_label.text = "HP: %d/%d" % [int(current_health), int(max_health)]
|
|
|
|
# 2D UI is now handled by HUD Manager autoload
|
|
|
|
func _on_health_changed(_old_health: float, _new_health: float):
|
|
_update_health_display()
|
|
|
|
func _on_died(killer_id: int):
|
|
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 - runs on all peers
|
|
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():
|
|
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()
|
|
|
|
## 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
|
|
|
|
# Animation is handled by the Body's animate function (Jump animation plays during dash)
|
|
|
|
## 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)
|
|
|
|
## 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
|
|
|
|
# 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
|
|
|
|
# Flash red
|
|
_body.modulate = Color(1.5, 0.5, 0.5, 1.0)
|
|
|
|
# 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
|
|
|
|
## 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)
|
|
|
|
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)
|
|
|
|
# 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
|
|
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)
|
|
|
|
# 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)
|
|
|
|
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:
|
|
_is_unarmed_attacking = false
|
|
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):
|
|
_is_unarmed_attacking = false
|
|
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()
|
|
|
|
# Attack complete
|
|
_is_unarmed_attacking = false
|
|
|
|
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):
|
|
print("[Client ", multiplayer.get_unique_id(), "] equip_weapon_from_world called for: ", weapon_data_path)
|
|
var data = load(weapon_data_path) as WeaponData
|
|
if data:
|
|
equip_weapon(data)
|
|
print("[Client ", multiplayer.get_unique_id(), "] Equipped weapon: ", data.weapon_name)
|
|
else:
|
|
push_error("Failed to load weapon data from: " + weapon_data_path)
|
|
|
|
## Equip a weapon with given data
|
|
func equip_weapon(data: WeaponData):
|
|
# Determine which hand based on weapon type
|
|
var is_offhand = (data.hand_type == WeaponData.Hand.OFF_HAND)
|
|
|
|
# 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")
|
|
return
|
|
|
|
# Create new weapon instance
|
|
var weapon = BaseWeapon.new()
|
|
weapon.weapon_data = data
|
|
weapon.name = "EquippedOffHand" if is_offhand else "EquippedWeapon"
|
|
weapon.set_owner_character(self)
|
|
|
|
# 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
|
|
|
|
if is_multiplayer_authority():
|
|
print("Equipped: ", data.weapon_name, " to ", attach_point.name)
|
|
weapon_equipped_changed.emit()
|
|
|
|
## Unequip current weapon (local only)
|
|
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
|
|
|
|
if is_multiplayer_authority():
|
|
weapon_equipped_changed.emit()
|
|
|
|
## Sync unequip across all clients
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func _unequip_weapon_sync(is_offhand: bool = false):
|
|
unequip_weapon(is_offhand)
|
|
|
|
## Drop currently equipped weapon
|
|
@rpc("any_peer", "reliable")
|
|
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)
|
|
|
|
if not has_weapon:
|
|
print("No weapon equipped in ", "off-hand" if is_offhand else "main hand", ", cannot drop")
|
|
return
|
|
|
|
# Only server spawns the world weapon
|
|
if multiplayer.is_server():
|
|
print("Server spawning dropped weapon")
|
|
_spawn_world_weapon(has_weapon.weapon_data)
|
|
# Tell all clients to unequip
|
|
rpc("_unequip_weapon_sync", is_offhand)
|
|
|
|
# Unequip locally
|
|
unequip_weapon(is_offhand)
|
|
|
|
if is_multiplayer_authority():
|
|
print("Dropped weapon from ", "off-hand" if is_offhand else "main hand")
|
|
|
|
## 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:
|
|
# 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
|
|
|
|
# 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())
|