Turns the sandbox into a survival roguelike loop: outfit your gladiator with banked gold, enter the arena, earn gold from kills and wave clears, die, keep 10% of your total value (floored at the 500 fresh-start). Economy (GameState autoload): - banked_gold, owned items, and loadout persist to user://save.cfg - Kill gold (basic 10g, armed 25g) credited server-side to the killer - Wave-clear bonus (25 + 5/wave) for all players; wave synced to peers - Death settlement liquidates gear, guarded against double-fire Character creation / shop: - Menu reframed: armory shop + live Character Sheet loadout preview - Character Sheet gains preview mode (renders from loadout, no player) - Buy/Equip with hand rules; two-handers and off-hands displace each other symmetrically (shop and in-game pickups) - Chosen loadout auto-equips on spawn via existing RPC path Combat feel: - Attacks snap to camera facing and lock direction for the swing - Dash commits to its direction for the full duration - Weapon slots show name text when no icon is set - Hitbox/hurtbox debug meshes hidden by default (H toggles) Death is final: players no longer respawn; a results screen shows waves, time, kills, and the settlement, then returns to camp. Armed enemies no longer respawn (were an infinite gold farm and respawned invisible).
910 lines
30 KiB
GDScript
910 lines
30 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
|
|
|
|
# Facing lock: while > 0 the body cannot turn (attacks aim at camera, dashes stay straight)
|
|
var _facing_locked_timer: float = 0.0
|
|
|
|
# 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
|
|
|
|
# Gladiator arena: death ends your run - no respawning
|
|
can_respawn = false
|
|
|
|
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 facing lock (attack/dash direction commitment)
|
|
if _facing_locked_timer > 0:
|
|
_facing_locked_timer -= delta
|
|
|
|
# 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 (facing already snapped and locked at dash start)
|
|
if _is_dashing:
|
|
velocity.x = _dash_direction.x * _current_speed * dash_speed_multiplier
|
|
velocity.z = _dash_direction.z * _current_speed * dash_speed_multiplier
|
|
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
|
|
# Body only turns toward movement while facing isn't locked by an attack/dash
|
|
if _body and _facing_locked_timer <= 0:
|
|
_body.apply_rotation(velocity)
|
|
return
|
|
|
|
velocity.x = move_toward(velocity.x, 0, _current_speed)
|
|
velocity.z = move_toward(velocity.z, 0, _current_speed)
|
|
|
|
## Horizontal direction the camera is looking (movement-space, matches _move math)
|
|
func _get_camera_forward() -> Vector3:
|
|
var fwd: Vector3 = transform.basis * Vector3(0, 0, -1)
|
|
if _spring_arm_offset:
|
|
fwd = fwd.rotated(Vector3.UP, _spring_arm_offset.rotation.y)
|
|
fwd.y = 0
|
|
return fwd.normalized()
|
|
|
|
## Snap the body to face a direction and hold it there for `duration` seconds
|
|
func _lock_facing(direction: Vector3, duration: float):
|
|
_facing_locked_timer = max(_facing_locked_timer, duration)
|
|
if _body and direction.length_squared() > 0.001:
|
|
if _body.has_method("snap_rotation"):
|
|
_body.snap_rotation(direction)
|
|
else:
|
|
_body.apply_rotation(direction)
|
|
|
|
## Attacks commit to where the camera looks, not where the character was walking
|
|
func _lock_facing_to_camera(duration: float):
|
|
_lock_facing(_get_camera_forward(), duration)
|
|
|
|
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():
|
|
# Aim the swing at the camera direction and commit to it
|
|
_lock_facing_to_camera(equipped_weapon.weapon_data.startup_time + equipped_weapon.weapon_data.active_time)
|
|
equipped_weapon.perform_attack()
|
|
return
|
|
|
|
# Or use off-hand weapon if available
|
|
if equipped_offhand and equipped_offhand.can_attack():
|
|
_lock_facing_to_camera(equipped_offhand.weapon_data.startup_time + equipped_offhand.weapon_data.active_time)
|
|
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
|
|
|
|
# Aim the punch at the camera direction and commit to it
|
|
_lock_facing_to_camera(total_duration)
|
|
|
|
# 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 - Run Over"
|
|
|
|
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
|
|
|
|
# Commit to the dash direction - no steering or turning mid-dash
|
|
_lock_facing(_dash_direction, dash_duration)
|
|
|
|
# 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)
|
|
|
|
# Two-handed weapons occupy both hands - clear the off-hand
|
|
if data.hand_type == WeaponData.Hand.TWO_HAND and equipped_offhand:
|
|
unequip_weapon(true)
|
|
|
|
# And symmetrically: an off-hand displaces a two-handed main weapon
|
|
if is_offhand and equipped_weapon and equipped_weapon.weapon_data \
|
|
and equipped_weapon.weapon_data.hand_type == WeaponData.Hand.TWO_HAND:
|
|
unequip_weapon(false)
|
|
|
|
# Unequip current weapon in that hand first
|
|
if is_offhand:
|
|
if equipped_offhand:
|
|
unequip_weapon(true)
|
|
else:
|
|
if equipped_weapon:
|
|
unequip_weapon(false)
|
|
|
|
# Determine 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())
|