MultiplayerFighter
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

592 lines
17 KiB

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
@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("3DGodotRobot/RobotArmature/Skeleton3D/Bottom")
@onready var _chest_mesh: MeshInstance3D = get_node("3DGodotRobot/RobotArmature/Skeleton3D/Chest")
@onready var _face_mesh: MeshInstance3D = get_node("3DGodotRobot/RobotArmature/Skeleton3D/Face")
@onready var _limbs_head_mesh: MeshInstance3D = get_node("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
var _nearby_weapons: Array[WorldWeapon] = []
# 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
func _enter_tree():
super._enter_tree()
$SpringArmOffset/SpringArm3D/Camera3D.current = is_multiplayer_authority()
func _ready():
super._ready()
set_respawn_point(Vector3(0, 5, 0))
# 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()
# Create 2D UI health bar for local player
if is_multiplayer_authority():
_create_health_ui()
# 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!")
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
# 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
# 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 attack input
if Input.is_action_just_pressed("attack") and not is_dead and not _is_dashing:
_perform_attack()
# Handle weapon pickup/drop
if Input.is_action_just_pressed("pickup") and not is_dead:
print("Pickup pressed! Equipped: ", equipped_weapon != null, " Nearby: ", _nearby_weapons.size())
if equipped_weapon:
# Tell server to drop (server will handle spawning)
if multiplayer.is_server():
drop_weapon()
else:
rpc_id(1, "drop_weapon") # Only send to server
elif _nearby_weapons.size() > 0:
_pickup_nearest_weapon()
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)
## Attack system
func _perform_attack():
if not is_multiplayer_authority() or is_dead:
return
# Use equipped weapon if available
if equipped_weapon and equipped_weapon.can_attack():
equipped_weapon.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 == "Attack1":
return
if _attack_timer > 0:
return
_attack_timer = attack_cooldown
# Play attack animation once
if _body:
_body.play_attack()
# 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
if multiplayer.is_server():
_server_apply_damage(hit_body.name, attack_damage, attacker_id)
else:
# Otherwise, request server to apply damage
rpc_id(1, "_server_apply_damage", hit_body.name, attack_damage, attacker_id)
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):
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)
## Health display and callbacks
func _create_health_ui():
# Create a 2D UI for the local player's health
var canvas = CanvasLayer.new()
canvas.name = "HealthUI"
add_child(canvas)
# Health bar background
var health_bg = ColorRect.new()
health_bg.name = "HealthBG"
health_bg.color = Color(0.2, 0.2, 0.2, 0.8)
health_bg.position = Vector2(20, 20)
health_bg.size = Vector2(200, 30)
canvas.add_child(health_bg)
# Health bar (current health)
var health_bar = ColorRect.new()
health_bar.name = "HealthBar"
health_bar.color = Color(0.0, 0.8, 0.0, 1.0) # Green
health_bar.position = Vector2(22, 22)
health_bar.size = Vector2(196, 26)
canvas.add_child(health_bar)
# Health text
var health_text = Label.new()
health_text.name = "HealthText"
health_text.position = Vector2(20, 20)
health_text.size = Vector2(200, 30)
health_text.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
health_text.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
health_text.add_theme_color_override("font_color", Color.WHITE)
health_text.add_theme_color_override("font_outline_color", Color.BLACK)
health_text.add_theme_constant_override("outline_size", 2)
health_text.text = "HP: %d/%d" % [int(current_health), int(max_health)]
canvas.add_child(health_text)
func _update_health_display():
# Update 3D label if it exists
if health_label:
health_label.text = "HP: %d/%d" % [int(current_health), int(max_health)]
# Update 2D UI for local player
if is_multiplayer_authority() and has_node("HealthUI"):
var health_bar = get_node_or_null("HealthUI/HealthBar")
var health_text = get_node_or_null("HealthUI/HealthText")
if health_bar:
var health_percent = get_health_percent()
health_bar.size.x = 196 * health_percent
# Change color based on health
if health_percent > 0.6:
health_bar.color = Color(0.0, 0.8, 0.0) # Green
elif health_percent > 0.3:
health_bar.color = Color(1.0, 0.8, 0.0) # Yellow
else:
health_bar.color = Color(0.8, 0.0, 0.0) # Red
if health_text:
health_text.text = "HP: %d/%d" % [int(current_health), int(max_health)]
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
# Rotate character slightly forward during dash
if _body:
_body.rotation.x = -0.3 # Tilt forward slightly
# Animation is handled by the Body's animate function
# Reset rotation after dash
var tween = create_tween()
tween.tween_method(_reset_dash_rotation, -0.3, 0.0, dash_duration)
func _reset_dash_rotation(rotation_value: float):
if _body:
_body.rotation.x = rotation_value
## 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):
var data = load(weapon_data_path) as WeaponData
if data:
equip_weapon(data)
else:
push_error("Failed to load weapon data from: " + weapon_data_path)
## Equip a weapon with given data
func equip_weapon(data: WeaponData):
# Unequip current weapon first
if equipped_weapon:
unequip_weapon()
if not _weapon_attachment:
push_error("No weapon attachment point found!")
return
# Create new weapon instance
var weapon = BaseWeapon.new()
weapon.weapon_data = data
weapon.name = "EquippedWeapon"
weapon.set_owner_character(self)
# Attach to bone
_weapon_attachment.add_child(weapon)
equipped_weapon = weapon
if is_multiplayer_authority():
print("Equipped: ", data.weapon_name)
## Unequip current weapon (local only)
func unequip_weapon():
if equipped_weapon:
equipped_weapon.queue_free()
equipped_weapon = null
## Sync unequip across all clients
@rpc("any_peer", "call_local", "reliable")
func _unequip_weapon_sync():
unequip_weapon()
## Drop currently equipped weapon
@rpc("any_peer", "reliable")
func drop_weapon():
print("drop_weapon called on peer ", multiplayer.get_unique_id(), " is_server: ", multiplayer.is_server(), " has weapon: ", equipped_weapon != null)
if not equipped_weapon:
print("No weapon equipped, cannot drop")
return
# Only server spawns the world weapon
if multiplayer.is_server():
print("Server spawning dropped weapon")
_spawn_world_weapon(equipped_weapon.weapon_data)
# Tell all clients to unequip
rpc("_unequip_weapon_sync")
# Unequip locally
unequip_weapon()
if is_multiplayer_authority():
print("Dropped weapon")
## 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:
# 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())