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 var _attack_timer: float = 0.0 # 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(): super._ready() set_respawn_point(Vector3(0, 5, 0)) # 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: for child in get_children(): if child.name == "Armature" or child.name == "3DGodotRobot": _body = child print("Auto-found _body: ", child.name) break if _body == null: push_error("Could not find body node (Armature or 3DGodotRobot)!") # 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() # 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 if not is_on_floor(): velocity.y -= gravity * delta _body.animate(velocity) if is_on_floor(): if Input.is_action_just_pressed("jump"): velocity.y = JUMP_VELOCITY else: velocity.y -= gravity * delta _move() move_and_slide() _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 _body.animate(Vector3.ZERO) func _move() -> void: # If dashing, use dash movement if _is_dashing: velocity.x = _dash_direction.x * _current_speed * dash_speed_multiplier velocity.z = _dash_direction.z * _current_speed * dash_speed_multiplier _body.apply_rotation(velocity) 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() _direction = _direction.rotated(Vector3.UP, _spring_arm_offset.rotation.y) if _direction: velocity.x = _direction.x * _current_speed velocity.z = _direction.z * _current_speed _body.apply_rotation(velocity) 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: 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) 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: return _attack_timer = attack_cooldown # Play attack animation once if _body: _body.play_attack("Attack_OneHand") # Sync animation to other clients _sync_attack_animation.rpc("Attack_OneHand") # Find nearest enemy in range var space_state = get_world_3d().direct_space_state var query = PhysicsShapeQueryParameters3D.new() var sphere = SphereShape3D.new() sphere.radius = attack_range query.shape = sphere query.transform = global_transform query.collision_mask = 1 # Player layer var results = space_state.intersect_shape(query) for result in results: var hit_body = result["collider"] if hit_body != self and hit_body is BaseUnit: var attacker_id = multiplayer.get_unique_id() # If we're the server, apply damage directly (default unarmed knockback) if multiplayer.is_server(): _server_apply_damage(hit_body.name, attack_damage, attacker_id, 5.0, global_position) else: # Otherwise, request server to apply damage rpc_id(1, "_server_apply_damage", hit_body.name, attack_damage, attacker_id, 5.0, global_position) break # Only hit one target per attack ## 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 # Get the target from the players container var level = get_tree().get_current_scene() if not level or not level.has_node("PlayersContainer"): return var players_container = level.get_node("PlayersContainer") if not players_container.has_node(target_name): return var target = players_container.get_node(target_name) 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): # Disable player when dead set_physics_process(false) set_process(false) # Visual feedback - could add death animation here 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(): # Re-enable player set_physics_process(true) set_process(true) if _body: _body.visible = true _update_health_display() if is_multiplayer_authority(): print("You respawned!") ## Dash system func _perform_dash(): if not is_multiplayer_authority() or is_dead or not is_on_floor(): 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 _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())